From 0bd634520b5ba77b57c082ccac5ddf88b8bae77f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 10:25:37 +0400 Subject: [PATCH 01/80] feat(profiles): hermes-style profile homes, file-backed SOUL.md, dedicated memory/workspace Add per-profile "home" materialization and two new opt-in AgentProfile fields, plus RPC path enrichment (spec sections A, B, C, E). - home.rs (new): profile_home / profile_action_workspace path helpers, validate_profile_id (^[a-z0-9][a-z0-9_-]{0,63}$, enforced only on new custom ids so legacy ids keep loading), ensure_profile_home (idempotent seed of personalities//SOUL.md + MEMORY.md, and /profiles/ when dedicated_workspace), and dedicated_workspace_dir (the section-D seam). - paths.rs: resolve_personality_soul now checks personalities//SOUL.md first (re-read each prompt, hermes-style; skipped for ids that fail validation), before the existing soul_md_path / inline / root fallbacks. New effective_memory_suffix: legacy numeric memory_dir_suffix wins, else - when dedicated_memory (valid id), else shared "". PersonalityContext::from_profile now derives its suffix through it. - types.rs: add dedicated_memory + dedicated_workspace (serde default false, camelCase over the wire); back-compat + round-trip tests. - ops.rs: materialize the home on upsert (non-built-in) and select (covers built-ins on first activation); list/select/upsert/delete now return payloads enriched with read-only soulMdFile + workspaceDir resolved per profile. - store.rs: expose normalise_profile_id so ops can locate the persisted profile. Backward compatible: existing agent_profiles.json, numeric memory_dir_suffix, and inline soul_md / soul_md_path all keep working (proven by extended tests). Home seeding never overwrites a user's edited files. --- src/openhuman/profiles/home.rs | 385 ++++++++++++++++++ src/openhuman/profiles/mod.rs | 37 +- src/openhuman/profiles/ops.rs | 201 ++++++++- src/openhuman/profiles/paths.rs | 203 ++++++++- src/openhuman/profiles/schemas.rs | 10 +- src/openhuman/profiles/store.rs | 39 ++ src/openhuman/profiles/types.rs | 35 ++ tests/personality_e2e.rs | 2 + .../inference_agent_raw_coverage_e2e.rs | 6 + 9 files changed, 892 insertions(+), 26 deletions(-) create mode 100644 src/openhuman/profiles/home.rs diff --git a/src/openhuman/profiles/home.rs b/src/openhuman/profiles/home.rs new file mode 100644 index 0000000000..756835a3d2 --- /dev/null +++ b/src/openhuman/profiles/home.rs @@ -0,0 +1,385 @@ +//! Per-profile "home" materialization — hermes-agent-style agent homes. +//! +//! Each profile can own an identity file (`SOUL.md`, re-read on every prompt +//! build), a curated per-profile memory file (`MEMORY.md`), an optional +//! dedicated memory subtree, and an optional agent-writable workspace. The two +//! roots are deliberately split (see [`crate::openhuman::config`]): +//! +//! ```text +//! /personalities//SOUL.md identity (hot-read each prompt) +//! /personalities//MEMORY.md curated per-profile memory +//! /profiles// agent-writable workspace +//! ``` +//! +//! Identity + memory files live under `workspace_dir` (core-managed reads only — +//! the agent's write tools cannot reach there, enforced fail-closed by +//! `is_workspace_internal_path`). The agent's *writable* working dir lives under +//! `action_dir`, which acting tools (shell/file/git) are allowed to touch. + +use std::io; +use std::path::{Path, PathBuf}; + +use super::types::AgentProfile; + +/// Directory holding a profile's core-managed identity + memory files: +/// `/personalities//`. +pub fn profile_home(workspace_dir: &Path, profile_id: &str) -> PathBuf { + workspace_dir.join("personalities").join(profile_id) +} + +/// The agent-writable per-profile workspace: `/profiles//`. +/// +/// Because this lives under `action_dir`, the `SecurityPolicy` already permits +/// acting tools to read/write here — no hardening change is needed. +pub fn profile_action_workspace(action_dir: &Path, profile_id: &str) -> PathBuf { + action_dir.join("profiles").join(profile_id) +} + +/// Validate a profile id against the hermes-style name grammar +/// `^[a-z0-9][a-z0-9_-]{0,63}$`. +/// +/// Only enforced when creating a *new* custom profile — legacy / built-in ids +/// (which may not satisfy the grammar) keep loading, so this is never applied on +/// the read/resolve paths. +pub fn validate_profile_id(id: &str) -> Result<(), String> { + if id.is_empty() { + return Err("profile id must not be empty".to_string()); + } + if id.len() > 64 { + return Err(format!( + "profile id '{id}' is too long (max 64 characters)" + )); + } + let mut chars = id.chars(); + let first = chars.next().expect("non-empty checked above"); + if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { + return Err(format!( + "profile id '{id}' must start with a lowercase letter or digit" + )); + } + for c in id.chars() { + let ok = c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-'; + if !ok { + return Err(format!( + "profile id '{id}' may only contain lowercase letters, digits, '_' or '-'" + )); + } + } + Ok(()) +} + +/// Render the default seed persona for a profile lacking an inline `soul_md`. +/// +/// Kept intentionally short — a real persona is authored by the user by editing +/// the seeded `SOUL.md`. Never contains PII or secrets. +fn default_soul_template(profile: &AgentProfile) -> String { + let name = if profile.name.trim().is_empty() { + profile.id.as_str() + } else { + profile.name.trim() + }; + let description = profile.description.trim(); + let mut out = format!("# {name}\n\n"); + if !description.is_empty() { + out.push_str(description); + out.push_str("\n\n"); + } + out.push_str(&format!( + "You are {name}. Keep your own voice, working style, and memory distinct \ + from other profiles. Edit this file to shape your identity.\n" + )); + out +} + +/// Materialize a profile's home on disk. Idempotent — existing files are never +/// overwritten, so a user's edited `SOUL.md` / `MEMORY.md` survive re-runs. +/// +/// Creates: +/// - `/personalities//` (always), +/// - `SOUL.md` seeded from `profile.soul_md` when non-empty, else a short +/// default persona template (only when the file is absent), +/// - an empty `MEMORY.md` (only when absent), +/// - `/profiles//` when `profile.dedicated_workspace`. +pub fn ensure_profile_home( + workspace_dir: &Path, + action_dir: &Path, + profile: &AgentProfile, +) -> io::Result<()> { + let home = profile_home(workspace_dir, &profile.id); + tracing::debug!( + profile_id = %profile.id, + home = %home.display(), + dedicated_memory = profile.dedicated_memory, + dedicated_workspace = profile.dedicated_workspace, + "[profiles][home] ensure_profile_home entry" + ); + std::fs::create_dir_all(&home).map_err(|e| { + tracing::debug!( + profile_id = %profile.id, + home = %home.display(), + error = %e, + "[profiles][home] create_dir_all home failed" + ); + e + })?; + + let soul_path = home.join("SOUL.md"); + if soul_path.exists() { + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] SOUL.md already present, not overwriting" + ); + } else { + let contents = profile + .soul_md + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| { + // Persist inline souls with a trailing newline for tidy files. + if s.ends_with('\n') { + s.to_string() + } else { + format!("{s}\n") + } + }) + .unwrap_or_else(|| default_soul_template(profile)); + let seeded_from_inline = profile + .soul_md + .as_ref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + std::fs::write(&soul_path, contents.as_bytes()).map_err(|e| { + tracing::debug!( + profile_id = %profile.id, + soul_path = %soul_path.display(), + error = %e, + "[profiles][home] seed SOUL.md failed" + ); + e + })?; + tracing::debug!( + profile_id = %profile.id, + seeded_from_inline, + "[profiles][home] SOUL.md seeded" + ); + } + + let memory_path = home.join("MEMORY.md"); + if memory_path.exists() { + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] MEMORY.md already present, not overwriting" + ); + } else { + std::fs::write(&memory_path, b"").map_err(|e| { + tracing::debug!( + profile_id = %profile.id, + memory_path = %memory_path.display(), + error = %e, + "[profiles][home] create empty MEMORY.md failed" + ); + e + })?; + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] MEMORY.md created (empty)" + ); + } + + if let Some(ws) = dedicated_workspace_dir(action_dir, profile) { + std::fs::create_dir_all(&ws).map_err(|e| { + tracing::debug!( + profile_id = %profile.id, + workspace = %ws.display(), + error = %e, + "[profiles][home] create dedicated workspace failed" + ); + e + })?; + tracing::debug!( + profile_id = %profile.id, + workspace = %ws.display(), + "[profiles][home] dedicated workspace ensured" + ); + } + + tracing::debug!(profile_id = %profile.id, "[profiles][home] ensure_profile_home ok"); + Ok(()) +} + +/// Resolve the agent-writable workspace directory for a profile *iff* it opts +/// into a dedicated workspace and its id passes [`validate_profile_id`]. +/// +/// Returns `None` for shared-workspace profiles (the common case) and for +/// legacy ids that would fail id validation — in both cases the caller falls +/// back to the shared `action_dir`. This is the seam the session builder uses to +/// derive a per-profile default cwd (section D): a `WorkspaceDescriptor` rooted +/// at this path is threaded into the top-level chat turn. +pub fn dedicated_workspace_dir(action_dir: &Path, profile: &AgentProfile) -> Option { + if !profile.dedicated_workspace { + return None; + } + if let Err(e) = validate_profile_id(&profile.id) { + tracing::warn!( + profile_id = %profile.id, + error = %e, + "[profiles][home] dedicated_workspace requested but id fails validation, \ + falling back to shared action_dir" + ); + return None; + } + Some(profile_action_workspace(action_dir, &profile.id)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn test_profile(id: &str) -> AgentProfile { + let mut p = crate::openhuman::profiles::store::built_in_default_profile(); + p.id = id.to_string(); + p.name = id.to_string(); + p.built_in = false; + p.is_master = false; + p.memory_dir_suffix = None; + p.soul_md = None; + p.dedicated_memory = false; + p.dedicated_workspace = false; + p + } + + #[test] + fn profile_home_and_action_workspace_paths() { + let ws = Path::new("/tmp/ws"); + let action = Path::new("/tmp/act"); + assert_eq!( + profile_home(ws, "alice"), + Path::new("/tmp/ws/personalities/alice") + ); + assert_eq!( + profile_action_workspace(action, "alice"), + Path::new("/tmp/act/profiles/alice") + ); + } + + #[test] + fn validate_profile_id_matrix() { + // Valid. + let max_len = "x".repeat(64); + for id in [ + "a", + "a1", + "alice", + "alice-bob", + "alice_bob", + "0", + max_len.as_str(), + ] { + assert!(validate_profile_id(id).is_ok(), "expected ok: {id}"); + } + // Invalid. + for id in [ + "", // empty + "-alice", // leading dash + "_alice", // leading underscore + "Alice", // uppercase + "alice bob", // space + "alice.bob", // dot + "alice/bob", // slash + "über", // non-ascii + ] { + assert!(validate_profile_id(id).is_err(), "expected err: {id}"); + } + // Too long (65). + assert!(validate_profile_id(&"a".repeat(65)).is_err()); + } + + #[test] + fn ensure_profile_home_creates_and_seeds() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + let mut profile = test_profile("alice"); + profile.description = "A tidy writer.".to_string(); + + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + + let home = profile_home(ws.path(), "alice"); + assert!(home.is_dir()); + let soul = std::fs::read_to_string(home.join("SOUL.md")).unwrap(); + // Default template used (no inline soul_md). + assert!(soul.contains("alice")); + assert!(soul.contains("A tidy writer.")); + assert!(home.join("MEMORY.md").exists()); + // No dedicated workspace requested. + assert!(!profile_action_workspace(action.path(), "alice").exists()); + } + + #[test] + fn ensure_profile_home_seeds_soul_from_inline() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + let mut profile = test_profile("bob"); + profile.soul_md = Some("I am Bob, terse and exact.".to_string()); + + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + let soul = + std::fs::read_to_string(profile_home(ws.path(), "bob").join("SOUL.md")).unwrap(); + assert_eq!(soul, "I am Bob, terse and exact.\n"); + } + + #[test] + fn ensure_profile_home_is_idempotent_and_preserves_edits() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + let profile = test_profile("carol"); + + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure 1"); + let home = profile_home(ws.path(), "carol"); + // User edits both files. + std::fs::write(home.join("SOUL.md"), "EDITED SOUL").unwrap(); + std::fs::write(home.join("MEMORY.md"), "EDITED MEMORY").unwrap(); + + // Second run must not clobber. + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure 2"); + assert_eq!( + std::fs::read_to_string(home.join("SOUL.md")).unwrap(), + "EDITED SOUL" + ); + assert_eq!( + std::fs::read_to_string(home.join("MEMORY.md")).unwrap(), + "EDITED MEMORY" + ); + } + + #[test] + fn ensure_profile_home_creates_dedicated_workspace_when_opted_in() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + let mut profile = test_profile("dave"); + profile.dedicated_workspace = true; + + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + assert!(profile_action_workspace(action.path(), "dave").is_dir()); + } + + #[test] + fn dedicated_workspace_dir_gates_on_flag_and_id() { + let action = Path::new("/tmp/act"); + let mut shared = test_profile("eve"); + assert_eq!(dedicated_workspace_dir(action, &shared), None); + + shared.dedicated_workspace = true; + assert_eq!( + dedicated_workspace_dir(action, &shared), + Some(Path::new("/tmp/act/profiles/eve").to_path_buf()) + ); + + // Legacy invalid id + dedicated_workspace → None (falls back to shared). + let mut legacy = test_profile("Legacy Id"); + legacy.id = "Legacy Id".to_string(); + legacy.dedicated_workspace = true; + assert_eq!(dedicated_workspace_dir(action, &legacy), None); + } +} diff --git a/src/openhuman/profiles/mod.rs b/src/openhuman/profiles/mod.rs index cce146a6d9..1e6abd4fdf 100644 --- a/src/openhuman/profiles/mod.rs +++ b/src/openhuman/profiles/mod.rs @@ -8,7 +8,34 @@ //! //! Relocated from `openhuman::agent::profiles` / `::personality_paths` so the //! domain is addressable on its own (`openhuman::profiles`). +//! +//! # Profile home layout (hermes-agent style) +//! +//! Beyond the shared JSON store, each profile can own an on-disk "home" — an +//! identity file, curated memory, an optional dedicated memory subtree, and an +//! optional agent-writable workspace. The two path roots are deliberately split +//! (see [`crate::openhuman::config`]): core-managed identity/memory files live +//! under `workspace_dir` (which the agent's write tools cannot reach), while the +//! agent's writable working dir lives under `action_dir`. +//! +//! ```text +//! /personalities//SOUL.md identity (hot-read each prompt) +//! /personalities//MEMORY.md curated per-profile memory +//! /{memory,memory_tree,session_raw}-/ dedicated memory subtree (opt-in) +//! /profiles// agent-writable workspace (opt-in) +//! ``` +//! +//! - `SOUL.md` is re-read on every prompt build (see +//! [`resolve_personality_soul`]) so identity edits take effect live. +//! - `dedicated_memory` derives a `-` memory suffix (see +//! [`effective_memory_suffix`]); a legacy numeric `memory_dir_suffix` still +//! wins for back-compat. +//! - `dedicated_workspace` roots a per-profile default cwd for acting tools (see +//! [`dedicated_workspace_dir`] and the session builder's section-D wiring). +//! - [`ensure_profile_home`] materializes the home idempotently (never +//! overwriting a user's edited files) on upsert/select. +pub mod home; pub mod ops; pub mod paths; pub mod prompt_section; @@ -16,10 +43,14 @@ mod schemas; pub mod store; pub mod types; +pub use home::{ + dedicated_workspace_dir, ensure_profile_home, profile_action_workspace, profile_home, + validate_profile_id, +}; pub use paths::{ - filter_integrations, memory_subdir_for_suffix, memory_tree_subdir_for_suffix, - resolve_personality_memory_md, resolve_personality_soul, session_raw_subdir_for_suffix, - HasToolkit, PersonalityContext, + effective_memory_suffix, filter_integrations, memory_subdir_for_suffix, + memory_tree_subdir_for_suffix, resolve_personality_memory_md, resolve_personality_soul, + session_raw_subdir_for_suffix, HasToolkit, PersonalityContext, }; pub use prompt_section::AgentProfilePromptSection; pub use store::{built_in_profiles, load_profiles, AgentProfileStore}; diff --git a/src/openhuman/profiles/ops.rs b/src/openhuman/profiles/ops.rs index 2dbe176a31..bf57fa3a68 100644 --- a/src/openhuman/profiles/ops.rs +++ b/src/openhuman/profiles/ops.rs @@ -6,31 +6,90 @@ //! payload the controller emits. The persistence itself is owned by //! [`AgentProfileStore`](super::store::AgentProfileStore). -use serde_json::Value; +use std::path::{Path, PathBuf}; +use serde_json::{Map, Value}; + +use super::home::{dedicated_workspace_dir, ensure_profile_home, profile_home}; use super::store::AgentProfileStore; use super::types::{AgentProfile, AgentProfilesState}; use crate::openhuman::config::rpc as config_rpc; -/// Shape the `{ profiles, activeProfileId }` payload every profiles RPC returns. -fn state_payload(state: &AgentProfilesState) -> Value { +/// Enrich one stored profile with resolved, read-only path info the UI shows but +/// the [`AgentProfile`] struct never persists (section E): +/// - `soulMdFile`: absolute path of `personalities//SOUL.md` when it exists; +/// - `workspaceDir`: absolute path of the dedicated workspace when opted in. +/// +/// Derived on read from the two path roots — never round-tripped back into the +/// store, so the stored payload stays clean. +fn enrich_profile(workspace_dir: &Path, action_dir: &Path, profile: &AgentProfile) -> Value { + let mut obj: Map = match serde_json::to_value(profile) { + Ok(Value::Object(map)) => map, + _ => Map::new(), + }; + let soul = profile_home(workspace_dir, &profile.id).join("SOUL.md"); + if soul.exists() { + obj.insert( + "soulMdFile".to_string(), + Value::String(soul.to_string_lossy().into_owned()), + ); + } + if let Some(ws) = dedicated_workspace_dir(action_dir, profile) { + obj.insert( + "workspaceDir".to_string(), + Value::String(ws.to_string_lossy().into_owned()), + ); + } + Value::Object(obj) +} + +/// Shape the `{ profiles, activeProfileId }` payload every profiles RPC returns, +/// enriching each profile with its resolved read-only path info. +fn enriched_state_payload( + workspace_dir: &Path, + action_dir: &Path, + state: &AgentProfilesState, +) -> Value { + let profiles: Vec = state + .profiles + .iter() + .map(|p| enrich_profile(workspace_dir, action_dir, p)) + .collect(); serde_json::json!({ - "profiles": state.profiles, + "profiles": profiles, "activeProfileId": state.active_profile_id, }) } -/// Resolve the workspace-scoped profile store from the loaded config. -async fn store() -> Result { +/// Resolve the workspace-scoped profile store plus the `(workspace, action)` +/// path roots from a single config load, so the id-scoped store call, the home +/// materialization, and payload enrichment all share one load. +async fn store_and_roots() -> Result<(AgentProfileStore, PathBuf, PathBuf), String> { let config = config_rpc::load_config_with_timeout().await?; - Ok(AgentProfileStore::new(config.workspace_dir)) + let store = AgentProfileStore::new(config.workspace_dir.clone()); + Ok((store, config.workspace_dir, config.action_dir)) +} + +/// Best-effort materialization of a profile's home directory. Logs and swallows +/// filesystem errors — a failed home seed must never fail the RPC (the profile +/// is already persisted; the identity/memory files are lazily re-created on the +/// next select/upsert or read). +fn materialize_home(workspace_dir: &Path, action_dir: &Path, profile: &AgentProfile) { + if let Err(e) = ensure_profile_home(workspace_dir, action_dir, profile) { + tracing::warn!( + profile_id = %profile.id, + error = %e, + "[profiles][ops] ensure_profile_home failed (non-fatal)" + ); + } } /// List all persistent profiles and the active id. pub async fn list() -> Result { let request_id = format!("profiles-list-{}", uuid::Uuid::new_v4()); tracing::debug!(request_id = %request_id, "[profiles][ops] list entry"); - let state = store().await?.load().map_err(|e| { + let (store, workspace_dir, action_dir) = store_and_roots().await?; + let state = store.load().map_err(|e| { tracing::debug!(request_id = %request_id, error = %e, "[profiles][ops] list error"); e })?; @@ -40,24 +99,30 @@ pub async fn list() -> Result { profile_count = state.profiles.len(), "[profiles][ops] list ok" ); - Ok(state_payload(&state)) + Ok(enriched_state_payload(&workspace_dir, &action_dir, &state)) } /// Select the active profile by id. pub async fn select(profile_id: &str) -> Result { let request_id = format!("profile-select-{}", uuid::Uuid::new_v4()); tracing::debug!(request_id = %request_id, profile_id, "[profiles][ops] select entry"); - let state = store().await?.select(profile_id).map_err(|e| { + let (store, workspace_dir, action_dir) = store_and_roots().await?; + let state = store.select(profile_id).map_err(|e| { tracing::debug!(request_id = %request_id, profile_id, error = %e, "[profiles][ops] select error"); e })?; + // Materialize the selected profile's home (covers built-ins, which are only + // seeded when a user first activates them). + if let Some(profile) = state.profiles.iter().find(|p| p.id == state.active_profile_id) { + materialize_home(&workspace_dir, &action_dir, profile); + } tracing::debug!( request_id = %request_id, profile_id, active_profile_id = %state.active_profile_id, "[profiles][ops] select ok" ); - Ok(state_payload(&state)) + Ok(enriched_state_payload(&workspace_dir, &action_dir, &state)) } /// Create or update a profile. @@ -104,24 +169,39 @@ pub async fn upsert(profile: AgentProfile) -> Result { } } } - let state = store().await?.upsert(profile).map_err(|e| { + let upserted_id = profile.id.clone(); + let (store, workspace_dir, action_dir) = store_and_roots().await?; + let state = store.upsert(profile).map_err(|e| { tracing::debug!(request_id = %request_id, error = %e, "[profiles][ops] upsert error"); e })?; + // Materialize the home for the just-upserted profile. The store normalises + // the id (slugify), so resolve the persisted profile from the returned state + // rather than trusting the raw input id. Built-ins are seeded on select, not + // here, matching the spec. + if let Some(persisted) = state + .profiles + .iter() + .find(|p| p.id == super::store::normalise_profile_id(&upserted_id)) + .filter(|p| !p.built_in) + { + materialize_home(&workspace_dir, &action_dir, persisted); + } tracing::debug!( request_id = %request_id, active_profile_id = %state.active_profile_id, profile_count = state.profiles.len(), "[profiles][ops] upsert ok" ); - Ok(state_payload(&state)) + Ok(enriched_state_payload(&workspace_dir, &action_dir, &state)) } /// Delete a custom profile by id. pub async fn delete(profile_id: &str) -> Result { let request_id = format!("profile-delete-{}", uuid::Uuid::new_v4()); tracing::debug!(request_id = %request_id, profile_id, "[profiles][ops] delete entry"); - let state = store().await?.delete(profile_id).map_err(|e| { + let (store, workspace_dir, action_dir) = store_and_roots().await?; + let state = store.delete(profile_id).map_err(|e| { tracing::debug!(request_id = %request_id, profile_id, error = %e, "[profiles][ops] delete error"); e })?; @@ -132,7 +212,7 @@ pub async fn delete(profile_id: &str) -> Result { profile_count = state.profiles.len(), "[profiles][ops] delete ok" ); - Ok(state_payload(&state)) + Ok(enriched_state_payload(&workspace_dir, &action_dir, &state)) } /// The implicit orchestrator agent that requires no registry entry — the @@ -168,6 +248,27 @@ mod tests { } } + struct ActionDirEnvGuard { + previous: Option, + } + impl ActionDirEnvGuard { + fn set(path: &std::path::Path) -> Self { + let previous = std::env::var_os("OPENHUMAN_ACTION_DIR"); + unsafe { + std::env::set_var("OPENHUMAN_ACTION_DIR", path); + } + Self { previous } + } + } + impl Drop for ActionDirEnvGuard { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => unsafe { std::env::set_var("OPENHUMAN_ACTION_DIR", value) }, + None => unsafe { std::env::remove_var("OPENHUMAN_ACTION_DIR") }, + } + } + } + fn profile(id: &str, agent_id: &str) -> AgentProfile { let mut p = super::super::store::built_in_default_profile(); p.id = id.to_string(); @@ -179,6 +280,76 @@ mod tests { p } + #[tokio::test] + async fn upsert_materializes_home_and_list_enriches_paths() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let ws = tempfile::tempdir().expect("ws tempdir"); + let action = tempfile::tempdir().expect("action tempdir"); + let _env_ws = WorkspaceEnvGuard::set(ws.path()); + let _env_action = ActionDirEnvGuard::set(action.path()); + + let mut p = profile("writer", "orchestrator"); + p.dedicated_workspace = true; + let out = upsert(p).await.expect("upsert"); + + // The enriched payload surfaces resolved read-only paths. `soulMdFile` is + // only inserted when the file exists on disk, so its presence proves the + // home was materialized (workspace_dir is config-derived, so we assert on + // the resolved suffix rather than the raw temp root). `workspaceDir` is + // present only for a `dedicated_workspace` profile, proving the opt-in + // dir path was resolved and created. + let writer = out["profiles"] + .as_array() + .expect("profiles array") + .iter() + .find(|p| p["id"] == "writer") + .expect("writer profile present"); + let soul_file = writer["soulMdFile"] + .as_str() + .expect("soulMdFile present (SOUL.md was seeded on disk)"); + assert!( + soul_file.ends_with("personalities/writer/SOUL.md"), + "soulMdFile should end at the profile home, got {soul_file}" + ); + // The resolved SOUL.md really exists on disk. + assert!(std::path::Path::new(soul_file).exists()); + let workspace_dir = writer["workspaceDir"] + .as_str() + .expect("workspaceDir present for dedicated_workspace profile"); + assert!( + workspace_dir.ends_with("profiles/writer"), + "workspaceDir should end at the profile workspace, got {workspace_dir}" + ); + // The dedicated workspace dir was actually created. + assert!(std::path::Path::new(workspace_dir).is_dir()); + assert_eq!(writer["dedicatedWorkspace"], json!(true)); + } + + #[tokio::test] + async fn shared_profile_has_no_workspace_dir_in_payload() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let ws = tempfile::tempdir().expect("ws tempdir"); + let action = tempfile::tempdir().expect("action tempdir"); + let _env_ws = WorkspaceEnvGuard::set(ws.path()); + let _env_action = ActionDirEnvGuard::set(action.path()); + + // A shared-workspace profile: soulMdFile resolves (home is seeded) but no + // workspaceDir key is present. + let out = upsert(profile("shared", "orchestrator")) + .await + .expect("upsert"); + let shared = out["profiles"] + .as_array() + .unwrap() + .iter() + .find(|p| p["id"] == "shared") + .expect("shared profile present"); + // A shared-workspace profile never surfaces workspaceDir, but its home + // (SOUL.md) is still seeded, so soulMdFile resolves. + assert!(shared.get("workspaceDir").is_none()); + assert!(shared["soulMdFile"].as_str().is_some()); + } + #[tokio::test] async fn upsert_default_agent_allowed_without_registry() { let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/src/openhuman/profiles/paths.rs b/src/openhuman/profiles/paths.rs index f6ab476c39..bf2571a739 100644 --- a/src/openhuman/profiles/paths.rs +++ b/src/openhuman/profiles/paths.rs @@ -2,6 +2,7 @@ use std::path::{Component, Path}; +use super::home::validate_profile_id; use super::types::AgentProfile; /// Reject path strings that could escape the workspace: absolute paths, @@ -46,11 +47,55 @@ pub fn session_raw_subdir_for_suffix(suffix: &str) -> String { /// Resolve the SOUL.md content for a personality. /// -/// Resolution order: -/// 1. `soul_md_path` — read the file at that relative path under workspace. -/// 2. `soul_md` — inline content from the profile. -/// 3. `None` — caller falls back to the workspace root `SOUL.md`. +/// Resolution order (hermes-style — the per-profile identity file wins and is +/// re-read on every prompt build): +/// 1. `personalities//SOUL.md` — the canonical per-profile identity file +/// (skipped when the profile id fails [`validate_profile_id`], so legacy +/// profiles with arbitrary ids can't construct an unexpected path). +/// 2. `soul_md_path` — read the file at that relative path under workspace. +/// 3. `soul_md` — inline content from the profile. +/// 4. `None` — caller falls back to the workspace root `SOUL.md`. pub fn resolve_personality_soul(workspace_dir: &Path, profile: &AgentProfile) -> Option { + // Step 1: the per-profile home SOUL.md. Only attempted for ids that pass the + // hermes name grammar — a legacy/built-in id that fails validation skips + // straight to the existing (2)/(3)/(4) resolution below, unchanged. + match validate_profile_id(&profile.id) { + Ok(()) => { + let home_soul = super::home::profile_home(workspace_dir, &profile.id).join("SOUL.md"); + match std::fs::read_to_string(&home_soul) { + Ok(content) if !content.trim().is_empty() => { + tracing::debug!( + path = %home_soul.display(), + profile_id = %profile.id, + "[personality] soul_md loaded from profile home" + ); + return Some(content); + } + Ok(_) => { + tracing::debug!( + profile_id = %profile.id, + "[personality] profile-home SOUL.md empty, trying soul_md_path/inline" + ); + } + Err(e) => { + tracing::debug!( + path = %home_soul.display(), + profile_id = %profile.id, + error = %e, + "[personality] profile-home SOUL.md absent, trying soul_md_path/inline" + ); + } + } + } + Err(e) => { + tracing::debug!( + profile_id = %profile.id, + error = %e, + "[personality] profile id fails validation, skipping profile-home SOUL.md" + ); + } + } + if let Some(ref rel_path) = profile.soul_md_path { let rel = Path::new(rel_path); if !is_safe_relative_path(rel) { @@ -160,6 +205,61 @@ pub fn resolve_personality_memory_md( } } +/// Derive the effective memory directory suffix for a profile. +/// +/// Precedence: +/// 1. an explicit `memory_dir_suffix` (the legacy auto-assigned numeric suffix, +/// e.g. `"-1"`) always wins — pre-existing profiles keep their directories; +/// 2. else, when `dedicated_memory` is set, derive `"-"` from the profile id +/// (id must pass [`validate_profile_id`], else fall back to the shared `""` +/// and warn — a legacy id can't mint an unexpected directory name); +/// 3. else `""` (the shared/global memory tree). +/// +/// The returned suffix feeds the existing +/// [`memory_subdir_for_suffix`] / [`memory_tree_subdir_for_suffix`] / +/// [`session_raw_subdir_for_suffix`] helpers unchanged. +pub fn effective_memory_suffix(profile: &AgentProfile) -> String { + if let Some(suffix) = profile + .memory_dir_suffix + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + tracing::debug!( + profile_id = %profile.id, + suffix = %suffix, + "[personality] effective_memory_suffix using legacy numeric suffix" + ); + return suffix.to_string(); + } + if profile.dedicated_memory { + match validate_profile_id(&profile.id) { + Ok(()) => { + let suffix = format!("-{}", profile.id); + tracing::debug!( + profile_id = %profile.id, + suffix = %suffix, + "[personality] effective_memory_suffix derived from dedicated_memory" + ); + return suffix; + } + Err(e) => { + tracing::warn!( + profile_id = %profile.id, + error = %e, + "[personality] dedicated_memory requested but id fails validation, \ + falling back to shared memory tree" + ); + } + } + } + tracing::debug!( + profile_id = %profile.id, + "[personality] effective_memory_suffix using shared memory tree" + ); + String::new() +} + /// All personality-resolved overrides needed to build a scoped agent session. #[derive(Debug, Clone)] pub struct PersonalityContext { @@ -174,7 +274,7 @@ pub struct PersonalityContext { impl PersonalityContext { /// Build from a resolved `AgentProfile`, reading personality files from the workspace. pub fn from_profile(workspace_dir: &Path, profile: AgentProfile) -> Self { - let memory_suffix = profile.memory_dir_suffix.clone().unwrap_or_default(); + let memory_suffix = effective_memory_suffix(&profile); let soul_md_override = resolve_personality_soul(workspace_dir, &profile); let memory_md_override = resolve_personality_memory_md(workspace_dir, &profile); let composio_allowlist = profile.composio_integrations.clone(); @@ -253,6 +353,8 @@ mod tests { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, } } @@ -276,6 +378,97 @@ mod tests { assert_eq!(session_raw_subdir_for_suffix("-1"), "session_raw-1"); } + #[test] + fn resolve_soul_prefers_profile_home_file() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("personalities").join("alice"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("SOUL.md"), "Home identity for Alice").unwrap(); + + let mut profile = test_profile("alice"); + // Both inline and soul_md_path present — the profile-home file still wins. + profile.soul_md = Some("Inline soul".to_string()); + let result = resolve_personality_soul(tmp.path(), &profile); + assert_eq!(result.as_deref(), Some("Home identity for Alice")); + } + + #[test] + fn resolve_soul_skips_home_file_for_invalid_legacy_id() { + let tmp = TempDir::new().unwrap(); + // A legacy id that fails validate_profile_id (space + uppercase). + let legacy_id = "Legacy Id"; + let home = tmp.path().join("personalities").join(legacy_id); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("SOUL.md"), "SHOULD BE SKIPPED").unwrap(); + + let mut profile = test_profile("placeholder"); + profile.id = legacy_id.to_string(); + profile.soul_md = Some("Legacy inline soul".to_string()); + // Step 1 (profile-home SOUL.md) is skipped for the invalid id; resolution + // falls through to the inline value. + let result = resolve_personality_soul(tmp.path(), &profile); + assert_eq!(result.as_deref(), Some("Legacy inline soul")); + } + + #[test] + fn resolve_soul_empty_home_file_falls_through_to_inline() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("personalities").join("alice"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("SOUL.md"), " \n").unwrap(); // whitespace only + + let mut profile = test_profile("alice"); + profile.soul_md = Some("Inline wins over empty home file".to_string()); + let result = resolve_personality_soul(tmp.path(), &profile); + assert_eq!(result.as_deref(), Some("Inline wins over empty home file")); + } + + #[test] + fn effective_memory_suffix_legacy_numeric_wins() { + let mut profile = test_profile("alice"); + profile.memory_dir_suffix = Some("-3".to_string()); + // Even with dedicated_memory set, the persisted legacy suffix wins so the + // existing memory directory is never orphaned. + profile.dedicated_memory = true; + assert_eq!(effective_memory_suffix(&profile), "-3"); + } + + #[test] + fn effective_memory_suffix_dedicated_derives_from_id() { + let mut profile = test_profile("alice"); + profile.memory_dir_suffix = None; + profile.dedicated_memory = true; + assert_eq!(effective_memory_suffix(&profile), "-alice"); + } + + #[test] + fn effective_memory_suffix_shared_default() { + let mut profile = test_profile("alice"); + profile.memory_dir_suffix = None; + profile.dedicated_memory = false; + assert_eq!(effective_memory_suffix(&profile), ""); + } + + #[test] + fn effective_memory_suffix_invalid_id_falls_back_to_shared() { + let mut profile = test_profile("placeholder"); + profile.id = "Bad Id".to_string(); + profile.memory_dir_suffix = None; + profile.dedicated_memory = true; + // Invalid id cannot mint a directory name — fall back to shared "". + assert_eq!(effective_memory_suffix(&profile), ""); + } + + #[test] + fn effective_memory_suffix_empty_string_suffix_is_not_legacy() { + let mut profile = test_profile("alice"); + // The default profile stores Some("") — treated as "no legacy suffix", so + // dedicated_memory (if set) still derives, else shared. + profile.memory_dir_suffix = Some(String::new()); + profile.dedicated_memory = false; + assert_eq!(effective_memory_suffix(&profile), ""); + } + #[test] fn resolve_soul_inline_fallback() { let tmp = TempDir::new().unwrap(); diff --git a/src/openhuman/profiles/schemas.rs b/src/openhuman/profiles/schemas.rs index 5b7ed0f631..e562712d94 100644 --- a/src/openhuman/profiles/schemas.rs +++ b/src/openhuman/profiles/schemas.rs @@ -46,7 +46,10 @@ pub fn schemas(function: &str) -> ControllerSchema { "list" => ControllerSchema { namespace: "profiles", function: "list", - description: "List persistent agent profiles and the active profile id.", + description: "List persistent agent profiles and the active profile id. Each \ + profile is enriched with resolved read-only path info: soulMdFile \ + (personalities//SOUL.md if present) and workspaceDir (the \ + dedicated workspace when opted in).", inputs: vec![], outputs: vec![json_output("profiles", "Agent profile state payload.")], }, @@ -65,8 +68,9 @@ pub fn schemas(function: &str) -> ControllerSchema { function: "upsert", description: "Create or update an agent profile. The `profile` payload may include \ memory_sources, includeAgentConversations, allowedSkills, \ - allowedMcpServers, composioIntegrations, allowedTools, and soulMd; \ - an omitted/empty allowlist means \"all\".", + allowedMcpServers, composioIntegrations, allowedTools, soulMd, \ + dedicatedMemory (own memory subtree), and dedicatedWorkspace (own \ + working dir under action_dir); an omitted/empty allowlist means \"all\".", inputs: vec![FieldSchema { name: "profile", ty: TypeSchema::Json, diff --git a/src/openhuman/profiles/store.rs b/src/openhuman/profiles/store.rs index ad391e9743..5a242e0ad3 100644 --- a/src/openhuman/profiles/store.rs +++ b/src/openhuman/profiles/store.rs @@ -332,6 +332,8 @@ pub fn built_in_profiles() -> Vec { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, }, AgentProfile { id: "research".to_string(), @@ -358,6 +360,8 @@ pub fn built_in_profiles() -> Vec { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, }, AgentProfile { id: "planner".to_string(), @@ -384,6 +388,8 @@ pub fn built_in_profiles() -> Vec { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, }, AgentProfile { id: "review".to_string(), @@ -410,6 +416,8 @@ pub fn built_in_profiles() -> Vec { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, }, ] } @@ -437,6 +445,8 @@ pub(crate) fn built_in_default_profile() -> AgentProfile { memory_dir_suffix: Some("".into()), is_master: true, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, } } @@ -575,6 +585,14 @@ fn next_available_suffix(existing: &std::collections::HashSet) -> String } } +/// Normalise a raw profile id into its persisted slug form, mirroring the +/// transformation `normalise_profile` applies on upsert. Exposed so callers +/// (e.g. `ops::upsert`) can locate the persisted profile by its stored id after +/// the store has slugified it. +pub(crate) fn normalise_profile_id(input: &str) -> String { + slugify_profile_id(input) +} + fn slugify_profile_id(input: &str) -> String { let mut out = String::new(); let mut last_was_sep = false; @@ -629,6 +647,8 @@ mod tests { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, } } @@ -823,6 +843,25 @@ mod tests { assert_eq!(charlie.memory_dir_suffix.as_deref(), Some("-1")); } + #[test] + fn upsert_roundtrips_dedicated_home_fields() { + let dir = tempdir().expect("tempdir"); + let store = AgentProfileStore::new(dir.path().to_path_buf()); + let mut profile = custom("iso", "Iso", "orchestrator"); + profile.dedicated_memory = true; + profile.dedicated_workspace = true; + store.upsert(profile).expect("upsert"); + + let loaded = store.load().expect("load"); + let iso = loaded + .profiles + .iter() + .find(|p| p.id == "iso") + .expect("iso profile"); + assert!(iso.dedicated_memory); + assert!(iso.dedicated_workspace); + } + #[test] fn default_profile_has_master_and_memory_suffix() { let default = built_in_default_profile(); diff --git a/src/openhuman/profiles/types.rs b/src/openhuman/profiles/types.rs index 319faf9b53..a335c10be2 100644 --- a/src/openhuman/profiles/types.rs +++ b/src/openhuman/profiles/types.rs @@ -66,6 +66,16 @@ pub struct AgentProfile { /// Display order (lower = shown first). #[serde(default, skip_serializing_if = "Option::is_none")] pub sort_order: Option, + /// Give this profile its own memory subtree (`memory-`, + /// `memory_tree-`, `session_raw-`) instead of the shared/global one. + /// Default false. See [`super::paths::effective_memory_suffix`]. + #[serde(default)] + pub dedicated_memory: bool, + /// Give this profile its own working directory under `action_dir` + /// (`/profiles/`) used as the default cwd for its tool runs. + /// Default false. See [`super::home::dedicated_workspace_dir`]. + #[serde(default)] + pub dedicated_workspace: bool, } /// serde default for `include_agent_conversations` (true). @@ -115,6 +125,8 @@ mod tests { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, }; assert!(profile_signature(&profile).contains("\"planner\"")); } @@ -144,5 +156,28 @@ mod tests { // Defaults to true so existing users keep cross-chat recall. assert!(profile.include_agent_conversations); assert!(!profile.is_master); + // New home fields default to false so legacy payloads keep behaving. + assert!(!profile.dedicated_memory); + assert!(!profile.dedicated_workspace); + } + + #[test] + fn new_home_fields_roundtrip_over_camelcase() { + let json = json!({ + "id": "alice", + "name": "Alice", + "description": "", + "agentId": "orchestrator", + "builtIn": false, + "dedicatedMemory": true, + "dedicatedWorkspace": true + }); + let profile: AgentProfile = serde_json::from_value(json).expect("deserialize"); + assert!(profile.dedicated_memory); + assert!(profile.dedicated_workspace); + // Serializes back out as camelCase. + let out = serde_json::to_value(&profile).expect("serialize"); + assert_eq!(out["dedicatedMemory"], serde_json::json!(true)); + assert_eq!(out["dedicatedWorkspace"], serde_json::json!(true)); } } diff --git a/tests/personality_e2e.rs b/tests/personality_e2e.rs index de6dd9d6c9..5cfe19e676 100644 --- a/tests/personality_e2e.rs +++ b/tests/personality_e2e.rs @@ -64,6 +64,8 @@ fn make_profile(id: &str, name: &str) -> AgentProfile { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, } } diff --git a/tests/raw_coverage/inference_agent_raw_coverage_e2e.rs b/tests/raw_coverage/inference_agent_raw_coverage_e2e.rs index 765235d5e2..ecb81410db 100644 --- a/tests/raw_coverage/inference_agent_raw_coverage_e2e.rs +++ b/tests/raw_coverage/inference_agent_raw_coverage_e2e.rs @@ -1377,6 +1377,8 @@ fn agent_profile_store_and_personality_helpers_cover_normalisation_edges() { memory_dir_suffix: None, is_master: true, sort_order: Some(50), + dedicated_memory: false, + dedicated_workspace: false, }) .expect("upsert first"); let writing = first @@ -1415,6 +1417,8 @@ fn agent_profile_store_and_personality_helpers_cover_normalisation_edges() { memory_dir_suffix: None, is_master: false, sort_order: None, + dedicated_memory: false, + dedicated_workspace: false, }) .expect("upsert second"); let second_profile = second @@ -1752,6 +1756,8 @@ fn agent_personality_paths_cover_safe_fallbacks_and_integration_filters() { memory_dir_suffix: Some("-7".into()), is_master: false, sort_order: Some(10), + dedicated_memory: false, + dedicated_workspace: false, }; assert_eq!( From cf2d29fb9f31f2d143666963f113b5e5a5a297ea Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 10:25:51 +0400 Subject: [PATCH 02/80] feat(profiles): per-profile dedicated workspace as default tool cwd (section D) When a session's active profile opts into dedicated_workspace, acting tools (shell/file/git) default their cwd to /profiles/ instead of the shared action_dir. Seam chosen: thread a tinyagents WorkspaceDescriptor into the top-level chat turn. investigation: acting tools already resolve cwd from ToolExecutionContext.workspace (a WorkspaceDescriptor) when present, else fall back to security.action_dir (tools/impl/system/shell.rs effective_action_dir_for_context). The top-level chat turn previously passed `None` for that descriptor at turn/graph.rs (subagents already use it for worktree isolation). So the smallest correct seam is to fill that existing slot: compute the descriptor in the session builder from profiles::dedicated_workspace_dir, carry it on Agent -> ChatTurnGraph -> run_turn_via_tinyagents_shared. Why not narrow SecurityPolicy.action_dir (the other candidate): that changes the agent's *write root*, i.e. a cross-profile write guard, which the spec defers to a follow-up. The descriptor sets only the default cwd; the profile dir lives under action_dir so SecurityPolicy already permits writes there and no hardening code is touched. `None` (the common shared case) is byte-for-byte unchanged. - factory.rs: build the descriptor (ensures the dir exists) and set it on the builder; + section-D seam unit tests. - session/types.rs + builder/setters.rs: Agent/AgentBuilder workspace_descriptor field + setter, defaulting to None. - turn/graph.rs + turn/core.rs: carry it onto ChatTurnGraph and pass it where the top-level turn used to hardcode None. --- .../agent/harness/session/builder/factory.rs | 98 +++++++++++++++++++ .../agent/harness/session/builder/setters.rs | 13 +++ .../agent/harness/session/turn/core.rs | 4 + .../agent/harness/session/turn/graph.rs | 12 ++- src/openhuman/agent/harness/session/types.rs | 9 ++ 5 files changed, 134 insertions(+), 2 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index a711f138b9..9d71872ee4 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -326,6 +326,41 @@ impl Agent { "[profiles] applying per-profile session gate" ); } + + // Section D — per-profile dedicated workspace. When the active profile + // opts into `dedicated_workspace` (and its id passes validation), derive + // a `WorkspaceDescriptor` rooted at `/profiles/` and + // thread it into the top-level chat turn so acting tools (shell/file/git) + // resolve their default cwd there. Because the dir is under `action_dir`, + // `SecurityPolicy` already permits it — no hardening change, and the + // agent's broad write root is left intact (cross-profile write guarding + // is a deliberate follow-up). `None` (the common case) preserves the + // shared-`action_dir` cwd behaviour byte-for-byte. + let profile_workspace_descriptor: Option = + profile + .and_then(|p| { + crate::openhuman::profiles::dedicated_workspace_dir(&config.action_dir, p) + .map(|dir| (p.id.clone(), dir)) + }) + .map(|(profile_id, dir)| { + if let Err(e) = std::fs::create_dir_all(&dir) { + tracing::warn!( + profile_id = %profile_id, + dir = %dir.display(), + error = %e, + "[profiles] failed to create dedicated workspace dir; \ + tools will still target it as cwd" + ); + } + tracing::debug!( + profile_id = %profile_id, + dir = %dir.display(), + "[profiles] session bound to dedicated workspace as default cwd" + ); + tinyagents::harness::workspace::WorkspaceDescriptor::new(dir) + .with_policy_id(format!("openhuman.profile:{profile_id}")) + }); + let runtime: Arc = Arc::from( host_runtime::create_runtime(&config.runtime, config.shell.hide_window)?, ); @@ -1186,6 +1221,7 @@ impl Agent { .temperature(effective_temperature) .workspace_dir(config.workspace_dir.clone()) .action_dir(config.action_dir.clone()) + .workspace_descriptor(profile_workspace_descriptor) .workflows(crate::openhuman::skills::load_workflow_metadata( &config.workspace_dir, )) @@ -1430,3 +1466,65 @@ mod provider_role_tests { ); } } + +/// Section D — per-profile dedicated-workspace descriptor seam. +/// +/// The session builder derives the top-level chat turn's workspace descriptor +/// from `profiles::dedicated_workspace_dir` exactly as the tests below assert, +/// then wraps it in a `WorkspaceDescriptor`. These tests pin that the descriptor +/// root points at `/profiles/` for an opted-in profile, and that +/// shared/legacy profiles produce no descriptor (so the shared `action_dir` cwd +/// is preserved). +#[cfg(test)] +mod profile_workspace_descriptor_tests { + use crate::openhuman::profiles::{dedicated_workspace_dir, store::built_in_default_profile}; + use std::path::Path; + + fn profile(id: &str, dedicated_workspace: bool) -> crate::openhuman::profiles::AgentProfile { + let mut p = built_in_default_profile(); + p.id = id.to_string(); + p.name = id.to_string(); + p.built_in = false; + p.is_master = false; + p.memory_dir_suffix = None; + p.dedicated_workspace = dedicated_workspace; + p + } + + /// Mirror the exact expression `build_session_agent_inner` uses to build the + /// descriptor, so this test tracks the production seam. + fn descriptor_for( + action_dir: &Path, + profile: &crate::openhuman::profiles::AgentProfile, + ) -> Option { + dedicated_workspace_dir(action_dir, profile).map(|dir| { + tinyagents::harness::workspace::WorkspaceDescriptor::new(dir) + .with_policy_id(format!("openhuman.profile:{}", profile.id)) + }) + } + + #[test] + fn dedicated_workspace_profile_roots_descriptor_at_profile_dir() { + let action = Path::new("/tmp/action"); + let desc = descriptor_for(action, &profile("alice", true)) + .expect("dedicated_workspace profile yields a descriptor"); + assert_eq!( + desc.root.as_path(), + Path::new("/tmp/action/profiles/alice") + ); + } + + #[test] + fn shared_profile_yields_no_descriptor() { + let action = Path::new("/tmp/action"); + assert!(descriptor_for(action, &profile("bob", false)).is_none()); + } + + #[test] + fn legacy_invalid_id_yields_no_descriptor_even_when_opted_in() { + let action = Path::new("/tmp/action"); + // An id that fails validation can't mint a workspace path → no descriptor, + // so the session falls back to the shared action_dir cwd. + assert!(descriptor_for(action, &profile("Bad Id", true)).is_none()); + } +} diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs index 0ebd629e7a..803f9fac5e 100644 --- a/src/openhuman/agent/harness/session/builder/setters.rs +++ b/src/openhuman/agent/harness/session/builder/setters.rs @@ -33,6 +33,7 @@ impl AgentBuilder { temperature: None, workspace_dir: None, action_dir: None, + workspace_descriptor: None, workflows: None, auto_save: None, post_turn_hooks: Vec::new(), @@ -188,6 +189,17 @@ impl AgentBuilder { self } + /// Sets the per-profile workspace descriptor (section D of agent-profile + /// homes). When set, the top-level chat turn threads it through so acting + /// tools resolve their default cwd to the profile's dedicated workspace. + pub fn workspace_descriptor( + mut self, + descriptor: Option, + ) -> Self { + self.workspace_descriptor = descriptor; + self + } + /// Sets the skills available to the agent. pub fn workflows(mut self, skills: Vec) -> Self { self.workflows = Some(skills); @@ -491,6 +503,7 @@ impl AgentBuilder { temperature: self.temperature.unwrap_or(0.7), workspace_dir, action_dir, + workspace_descriptor: self.workspace_descriptor, workflows: self.workflows.unwrap_or_default(), auto_save: self.auto_save.unwrap_or(false), last_memory_context: None, diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index f6756db6e3..f1e24ef441 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -1322,6 +1322,10 @@ impl Agent { channel: self.event_channel().to_string(), agent_definition_id: self.agent_definition_id.clone(), }), + // Section D: forward the session's per-profile workspace + // descriptor (if any) so the top-level chat turn's acting + // tools default their cwd to the profile's dedicated dir. + workspace_descriptor: self.workspace_descriptor.clone(), }), ) .await; diff --git a/src/openhuman/agent/harness/session/turn/graph.rs b/src/openhuman/agent/harness/session/turn/graph.rs index 95afaa8f68..464098cb9f 100644 --- a/src/openhuman/agent/harness/session/turn/graph.rs +++ b/src/openhuman/agent/harness/session/turn/graph.rs @@ -72,6 +72,12 @@ pub(crate) struct ChatTurnGraph { /// The agent's builder-configured tool policy + session context, enforced at /// the tool boundary. `None` when the session has no explicit policy. pub tool_policy: Option, + /// Optional per-profile workspace descriptor (section D of agent-profile + /// homes). `Some` when the session's active profile opted into a dedicated + /// workspace — acting tools then resolve their default cwd to + /// `/profiles/` via `ToolExecutionContext.workspace`. `None` + /// (the common case) keeps the shared-`action_dir` cwd behaviour. + pub workspace_descriptor: Option, } /// Drive the chat turn graph: a thin wrapper over the shared tinyagents seam @@ -121,8 +127,10 @@ pub(crate) async fn run_chat_turn_graph(graph: ChatTurnGraph) -> Result/profiles/` for a `dedicated_workspace` profile. + graph.workspace_descriptor, // Interactive chat turn — response caching MUST stay off so a live user // turn is never served a cached model response (correctness/safety). false, diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index c6a6bd71f6..0047556d30 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -64,6 +64,12 @@ pub struct Agent { pub(super) temperature: f64, pub(super) workspace_dir: std::path::PathBuf, pub(super) action_dir: std::path::PathBuf, + /// Optional per-profile workspace descriptor. When set (a profile with + /// `dedicated_workspace` opted in), it is threaded into the top-level chat + /// turn so acting tools (shell/file/git) resolve their default cwd to + /// `/profiles/` instead of the shared `action_dir`. `None` + /// (the common case) preserves the shared-cwd behaviour unchanged. + pub(super) workspace_descriptor: Option, pub(super) workflows: Vec, /// Agent workflows discovered at session start. pub(super) auto_save: bool, @@ -350,6 +356,9 @@ pub struct AgentBuilder { pub(super) temperature: Option, pub(super) workspace_dir: Option, pub(super) action_dir: Option, + /// Optional per-profile workspace descriptor forwarded to [`Agent`] at build + /// time. Defaults to `None` (shared `action_dir` cwd). + pub(super) workspace_descriptor: Option, pub(super) workflows: Option>, /// Agent workflows to surface in the prompt. Populated from `load_workflows` /// at session start; defaults to empty when not explicitly set. From 6f8c7874b2b777a3e70a7e457194aa7b3da8c354 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 10:26:19 +0400 Subject: [PATCH 03/80] docs(profiles): describe multi-profile homes in the about_app catalog Update the Persona Pack capability description to cover multiple agent profiles with isolated identity (personalities//SOUL.md, re-read each message), optional dedicated memory subtree, and optional dedicated working directory. The profile home layout itself is documented in the profiles module doc comment (shipped with the domain commit). --- src/openhuman/about_app/catalog_data.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index cc08c6003a..dc0c044de5 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -1435,7 +1435,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ name: "Persona Pack", domain: "settings", category: CapabilityCategory::Settings, - description: "Personalize the assistant as one identity: set a display name and description, edit or reset the SOUL.md personality prompt, and reach mascot avatar and voice settings — all from a single Persona surface.", + description: "Personalize the assistant across one or more agent profiles: set a display name and description, edit or reset each profile's SOUL.md identity (kept in its own home under personalities// and re-read every message), give a profile its own dedicated memory subtree or its own working directory, and reach mascot avatar and voice settings. Multiple profiles can run with isolated identity, memory, and workspace state.", how_to: "Settings > Persona", status: CapabilityStatus::Beta, privacy: GITHUB_MASCOT_MANIFEST, From 71967be935dc17bc6193dbdb06d2bb454b3f3654 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 10:30:46 +0400 Subject: [PATCH 04/80] test(profiles): json-rpc e2e for dedicated-memory profile lifecycle Drive the profiles RPC surface end to end over the core HTTP router: upsert a custom profile with dedicatedMemory, assert the field round-trips and that list surfaces the resolved read-only soulMdFile (present only when the home was materialized on disk) with no workspaceDir for a memory-only profile, then select/delete and assert the active id falls back to default. --- tests/json_rpc_e2e.rs | 122 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index ade98ad76e..f5ea0f6915 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -7828,6 +7828,128 @@ async fn json_rpc_local_ai_device_profile_and_presets() { rpc_join.abort(); } +// --------------------------------------------------------------------------- +// Agent profiles — home materialization + dedicated-memory field lifecycle +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn json_rpc_profiles_dedicated_memory_lifecycle() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _action_guard = EnvVarGuard::unset("OPENHUMAN_ACTION_DIR"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{}", rpc_addr); + tokio::time::sleep(Duration::from_millis(100)).await; + + // --- upsert a custom profile opting into dedicated memory --- + // agentId "orchestrator" is always admitted without a registry (the implicit + // default), so this upsert persists even in the bare e2e core. + let upsert = post_json_rpc( + &rpc_base, + 60, + "openhuman.profiles_upsert", + json!({ + "profile": { + "id": "writer", + "name": "Writer", + "description": "Drafts crisp copy.", + "agentId": "orchestrator", + "builtIn": false, + "dedicatedMemory": true + } + }), + ) + .await; + let upsert_result = assert_no_jsonrpc_error(&upsert, "profiles_upsert"); + let upsert_payload = upsert_result.get("result").unwrap_or(upsert_result); + let writer = upsert_payload + .get("profiles") + .and_then(Value::as_array) + .expect("profiles array") + .iter() + .find(|p| p.get("id").and_then(Value::as_str) == Some("writer")) + .expect("writer profile present after upsert"); + assert_eq!( + writer.get("dedicatedMemory").and_then(Value::as_bool), + Some(true), + "dedicatedMemory should round-trip: {upsert_payload}" + ); + + // --- list must surface the field + the resolved soulMdFile path --- + let list = post_json_rpc(&rpc_base, 61, "openhuman.profiles_list", json!({})).await; + let list_result = assert_no_jsonrpc_error(&list, "profiles_list"); + let list_payload = list_result.get("result").unwrap_or(list_result); + let listed_writer = list_payload + .get("profiles") + .and_then(Value::as_array) + .expect("profiles array") + .iter() + .find(|p| p.get("id").and_then(Value::as_str) == Some("writer")) + .expect("writer present in list"); + assert_eq!( + listed_writer.get("dedicatedMemory").and_then(Value::as_bool), + Some(true) + ); + // The home was materialized on upsert, so SOUL.md exists and its path is + // surfaced read-only. A shared-memory profile carries no workspaceDir. + assert!( + listed_writer + .get("soulMdFile") + .and_then(Value::as_str) + .is_some_and(|s| s.ends_with("personalities/writer/SOUL.md")), + "soulMdFile should resolve: {listed_writer}" + ); + assert!( + listed_writer.get("workspaceDir").is_none(), + "dedicated_memory (not workspace) must not surface workspaceDir" + ); + + // --- select then delete round-trips the active id --- + let select = post_json_rpc( + &rpc_base, + 62, + "openhuman.profiles_select", + json!({"profile_id": "writer"}), + ) + .await; + let select_result = assert_no_jsonrpc_error(&select, "profiles_select"); + let select_payload = select_result.get("result").unwrap_or(select_result); + assert_eq!( + select_payload.get("activeProfileId").and_then(Value::as_str), + Some("writer") + ); + + let delete = post_json_rpc( + &rpc_base, + 63, + "openhuman.profiles_delete", + json!({"profile_id": "writer"}), + ) + .await; + let delete_result = assert_no_jsonrpc_error(&delete, "profiles_delete"); + let delete_payload = delete_result.get("result").unwrap_or(delete_result); + assert_eq!( + delete_payload.get("activeProfileId").and_then(Value::as_str), + Some("default"), + "deleting the active custom profile falls back to default" + ); + + mock_join.abort(); + rpc_join.abort(); +} + #[tokio::test] async fn json_rpc_local_ai_lm_studio_config_diagnostics_and_prompt() { let _env_lock = json_rpc_e2e_env_lock(); From 7b6b25f795bea475022548453dc9db8428e321de Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 11:00:46 +0400 Subject: [PATCH 05/80] feat(profiles-ui): dedicated memory/workspace toggles + resolved paths Add the profile home fields to the frontend: AgentProfile gains dedicatedMemory/dedicatedWorkspace (upsertable) and read-only soulMdFile/workspaceDir (resolved by the core, verified against enrich_profile in src/openhuman/profiles/ops.rs). ProfileEditorPage gets two toggles under a new "Isolation" section plus monospace read-only rows for the resolved paths when present. Adds Vitest coverage for the toggles' render/dispatch, edit-mode hydration of the read-only paths, and the api-layer field round-trip. --- .../panels/ProfileEditorPage.test.tsx | 42 +++++++++++++++ .../settings/panels/ProfileEditorPage.tsx | 54 +++++++++++++++++++ app/src/services/api/agentProfilesApi.test.ts | 31 +++++++++++ app/src/types/agentProfile.ts | 14 +++++ 4 files changed, 141 insertions(+) diff --git a/app/src/components/settings/panels/ProfileEditorPage.test.tsx b/app/src/components/settings/panels/ProfileEditorPage.test.tsx index c50b1ba208..30f6ce24e1 100644 --- a/app/src/components/settings/panels/ProfileEditorPage.test.tsx +++ b/app/src/components/settings/panels/ProfileEditorPage.test.tsx @@ -140,4 +140,46 @@ describe('ProfileEditorPage', () => { await waitFor(() => expect(mockUpsert).toHaveBeenCalled()); expect(mockUpsert.mock.calls[0][0].includeAgentConversations).toBe(false); }); + + it('defaults the dedicated memory/workspace toggles to off and dispatches them true when flipped', async () => { + renderAt('/settings/profiles/new'); + const dedicatedMemory = screen.getByLabelText('Dedicated memory'); + const dedicatedWorkspace = screen.getByLabelText('Dedicated workspace'); + expect(dedicatedMemory).toHaveAttribute('aria-checked', 'false'); + expect(dedicatedWorkspace).toHaveAttribute('aria-checked', 'false'); + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Isolated' } }); + fireEvent.click(dedicatedMemory); + fireEvent.click(dedicatedWorkspace); + expect(screen.getByLabelText('Dedicated memory')).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByLabelText('Dedicated workspace')).toHaveAttribute('aria-checked', 'true'); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => expect(mockUpsert).toHaveBeenCalled()); + expect(mockUpsert.mock.calls[0][0].dedicatedMemory).toBe(true); + expect(mockUpsert.mock.calls[0][0].dedicatedWorkspace).toBe(true); + }); + + it('edit mode hydrates the dedicated toggles and shows the resolved read-only paths', () => { + renderAt('/settings/profiles/edit/writer', [ + profile({ + id: 'writer', + name: 'Writer', + dedicatedMemory: true, + dedicatedWorkspace: true, + soulMdFile: '/workspace/personalities/writer/SOUL.md', + workspaceDir: '/action/profiles/writer', + }), + ]); + expect(screen.getByLabelText('Dedicated memory')).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByLabelText('Dedicated workspace')).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByText('/workspace/personalities/writer/SOUL.md')).toBeInTheDocument(); + expect(screen.getByText('/action/profiles/writer')).toBeInTheDocument(); + }); + + it('hides the resolved read-only path rows when the profile has none', () => { + renderAt('/settings/profiles/edit/writer', [profile({ id: 'writer' })]); + expect(screen.queryByText('Identity file')).not.toBeInTheDocument(); + expect(screen.queryByText('Workspace directory')).not.toBeInTheDocument(); + }); }); diff --git a/app/src/components/settings/panels/ProfileEditorPage.tsx b/app/src/components/settings/panels/ProfileEditorPage.tsx index 200c85772f..a696e184d6 100644 --- a/app/src/components/settings/panels/ProfileEditorPage.tsx +++ b/app/src/components/settings/panels/ProfileEditorPage.tsx @@ -77,6 +77,8 @@ const ProfileEditorPage = () => { const [composioIntegrations, setComposioIntegrations] = useState(null); const [allowedSkills, setAllowedSkills] = useState(null); const [allowedMcpServers, setAllowedMcpServers] = useState(null); + const [dedicatedMemory, setDedicatedMemory] = useState(false); + const [dedicatedWorkspace, setDedicatedWorkspace] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -120,6 +122,8 @@ const ProfileEditorPage = () => { setComposioIntegrations(existing.composioIntegrations ?? null); setAllowedSkills(existing.allowedSkills ?? null); setAllowedMcpServers(existing.allowedMcpServers ?? null); + setDedicatedMemory(existing.dedicatedMemory ?? false); + setDedicatedWorkspace(existing.dedicatedWorkspace ?? false); }, [existing, isCreate, profiles.length]); const handleName = (value: string) => { @@ -159,6 +163,8 @@ const ProfileEditorPage = () => { composioIntegrations, allowedSkills, allowedMcpServers, + dedicatedMemory, + dedicatedWorkspace, builtIn: existing?.builtIn ?? false, }; try { @@ -348,6 +354,54 @@ const ProfileEditorPage = () => { /> + {/* Isolation */} + + + } + /> + + } + /> + {existing?.soulMdFile && ( + + {existing.soulMdFile} + + } + /> + )} + {existing?.workspaceDir && ( + + {existing.workspaceDir} + + } + /> + )} + + {/* Capabilities */} { }); }); + it('forwards dedicatedMemory/dedicatedWorkspace on upsert and round-trips the read-only resolved paths on list', async () => { + const profile = { + id: 'writer', + name: 'Writer', + description: 'Drafts copy.', + agentId: 'orchestrator', + builtIn: false, + dedicatedMemory: true, + dedicatedWorkspace: true, + }; + const enriched = { + ...profile, + soulMdFile: '/workspace/personalities/writer/SOUL.md', + workspaceDir: '/action/profiles/writer', + }; + const response = { profiles: [enriched], activeProfileId: 'writer' }; + + mockCallCoreRpc.mockResolvedValueOnce({ data: response }); + const { agentProfilesApi } = await import('./agentProfilesApi'); + await expect(agentProfilesApi.upsert(profile)).resolves.toEqual(response); + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.profiles_upsert', + params: { profile }, + }); + + mockCallCoreRpc.mockResolvedValueOnce({ data: response }); + const listed = await agentProfilesApi.list(); + expect(listed.profiles[0].soulMdFile).toBe('/workspace/personalities/writer/SOUL.md'); + expect(listed.profiles[0].workspaceDir).toBe('/action/profiles/writer'); + }); + it('rejects malformed envelopes with undefined data', async () => { mockCallCoreRpc.mockResolvedValueOnce({ data: undefined }); diff --git a/app/src/types/agentProfile.ts b/app/src/types/agentProfile.ts index 1ced4110ca..d163a42b54 100644 --- a/app/src/types/agentProfile.ts +++ b/app/src/types/agentProfile.ts @@ -25,6 +25,20 @@ export interface AgentProfile { memoryDirSuffix?: string | null; isMaster?: boolean | null; sortOrder?: number | null; + /** Give this profile its own memory subtree instead of the shared one. Default false. */ + dedicatedMemory?: boolean; + /** Give this profile its own working directory under action_dir. Default false. */ + dedicatedWorkspace?: boolean; + /** + * Read-only, resolved by the core on read (never sent on upsert): absolute + * path of `personalities//SOUL.md` when it exists. + */ + soulMdFile?: string; + /** + * Read-only, resolved by the core on read (never sent on upsert): absolute + * path of the dedicated workspace directory when `dedicatedWorkspace` is set. + */ + workspaceDir?: string; } export interface AgentProfilesResponse { From 8c74c7a9448f19b8edfe39b81daeeac9b1c4d2ea Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 11:02:27 +0400 Subject: [PATCH 06/80] feat(profiles-ui): i18n strings for dedicated memory/workspace toggles --- app/src/lib/i18n/ar.ts | 8 ++++++++ app/src/lib/i18n/bn.ts | 8 ++++++++ app/src/lib/i18n/de.ts | 8 ++++++++ app/src/lib/i18n/en.ts | 8 ++++++++ app/src/lib/i18n/es.ts | 8 ++++++++ app/src/lib/i18n/fr.ts | 8 ++++++++ app/src/lib/i18n/hi.ts | 8 ++++++++ app/src/lib/i18n/id.ts | 8 ++++++++ app/src/lib/i18n/it.ts | 8 ++++++++ app/src/lib/i18n/ko.ts | 8 ++++++++ app/src/lib/i18n/pl.ts | 8 ++++++++ app/src/lib/i18n/pt.ts | 8 ++++++++ app/src/lib/i18n/ru.ts | 8 ++++++++ app/src/lib/i18n/zh-CN.ts | 6 ++++++ 14 files changed, 110 insertions(+) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index c525953353..6f7b6c8436 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -7116,6 +7116,14 @@ const messages: TranslationMap = { 'settings.profiles.editor.skillsHint': 'سير العمل الذي يمكن لهذا الملف سرده وتشغيله.', 'settings.profiles.editor.mcpServers': 'خوادم MCP', 'settings.profiles.editor.mcpServersHint': 'خوادم MCP التي يمكن لهذا الملف الوصول إليها.', + 'settings.profiles.editor.dedicatedMemory': 'ذاكرة مخصصة', + 'settings.profiles.editor.dedicatedMemoryHint': + 'امنح هذا الملف ذاكرته الخاصة بدلاً من مشاركة الذاكرة الافتراضية.', + 'settings.profiles.editor.dedicatedWorkspace': 'مساحة عمل مخصصة', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'امنح هذا الملف مجلد عمل خاص به لعمليات الملفات والأدوات.', + 'settings.profiles.editor.soulMdFile': 'ملف الهوية', + 'settings.profiles.editor.workspaceDir': 'مجلد مساحة العمل', 'settings.profiles.editor.all': 'الكل', 'settings.profiles.editor.selected': 'محدّد', 'settings.profiles.editor.addPlaceholder': 'اكتب معرّفًا ثم اضغط Enter', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 53bec88ce0..2817c8b5dd 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -7282,6 +7282,14 @@ const messages: TranslationMap = { 'settings.profiles.editor.skillsHint': 'এই প্রোফাইল যেসব ওয়ার্কফ্লো তালিকাভুক্ত ও চালাতে পারে।', 'settings.profiles.editor.mcpServers': 'MCP সার্ভার', 'settings.profiles.editor.mcpServersHint': 'এই প্রোফাইল যেসব MCP সার্ভারে পৌঁছাতে পারে।', + 'settings.profiles.editor.dedicatedMemory': 'নিবেদিত মেমরি', + 'settings.profiles.editor.dedicatedMemoryHint': + 'ডিফল্ট মেমরি ভাগ করার পরিবর্তে এই প্রোফাইলকে নিজস্ব মেমরি দিন।', + 'settings.profiles.editor.dedicatedWorkspace': 'নিবেদিত ওয়ার্কস্পেস', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'ফাইল ও টুল কাজের জন্য এই প্রোফাইলকে নিজস্ব ওয়ার্কিং ডিরেক্টরি দিন।', + 'settings.profiles.editor.soulMdFile': 'পরিচয় ফাইল', + 'settings.profiles.editor.workspaceDir': 'ওয়ার্কস্পেস ডিরেক্টরি', 'settings.profiles.editor.all': 'সব', 'settings.profiles.editor.selected': 'নির্বাচিত', 'settings.profiles.editor.addPlaceholder': 'একটি আইডি লিখে এন্টার চাপুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 3d08122ebb..e11195a482 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7498,6 +7498,14 @@ const messages: TranslationMap = { 'Workflows, die dieses Profil auflisten und ausführen kann.', 'settings.profiles.editor.mcpServers': 'MCP-Server', 'settings.profiles.editor.mcpServersHint': 'MCP-Server, die dieses Profil erreichen kann.', + 'settings.profiles.editor.dedicatedMemory': 'Dediziertes Gedächtnis', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Gib diesem Profil ein eigenes Gedächtnis, statt das Standardgedächtnis zu teilen.', + 'settings.profiles.editor.dedicatedWorkspace': 'Dedizierter Arbeitsbereich', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Gib diesem Profil ein eigenes Arbeitsverzeichnis für Datei- und Tool-Operationen.', + 'settings.profiles.editor.soulMdFile': 'Identitätsdatei', + 'settings.profiles.editor.workspaceDir': 'Arbeitsverzeichnis', 'settings.profiles.editor.all': 'Alle', 'settings.profiles.editor.selected': 'Ausgewählt', 'settings.profiles.editor.addPlaceholder': 'Kennung eingeben und Enter drücken', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 1369bcf3d0..2300e26b1c 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7424,6 +7424,14 @@ const en: TranslationMap = { 'settings.profiles.editor.skillsHint': 'Workflows this profile can list and run.', 'settings.profiles.editor.mcpServers': 'MCP servers', 'settings.profiles.editor.mcpServersHint': 'MCP servers this profile can reach.', + 'settings.profiles.editor.dedicatedMemory': 'Dedicated memory', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Give this profile its own memory instead of sharing the default one.', + 'settings.profiles.editor.dedicatedWorkspace': 'Dedicated workspace', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Give this profile its own working directory for file and tool operations.', + 'settings.profiles.editor.soulMdFile': 'Identity file', + 'settings.profiles.editor.workspaceDir': 'Workspace directory', 'settings.profiles.editor.all': 'All', 'settings.profiles.editor.selected': 'Selected', 'settings.profiles.editor.addPlaceholder': 'Type an id, press Enter', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 85b75a3016..04e8344930 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7434,6 +7434,14 @@ const messages: TranslationMap = { 'Flujos de trabajo que este perfil puede listar y ejecutar.', 'settings.profiles.editor.mcpServers': 'Servidores MCP', 'settings.profiles.editor.mcpServersHint': 'Servidores MCP a los que puede acceder este perfil.', + 'settings.profiles.editor.dedicatedMemory': 'Memoria dedicada', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Dale a este perfil su propia memoria en lugar de compartir la memoria predeterminada.', + 'settings.profiles.editor.dedicatedWorkspace': 'Espacio de trabajo dedicado', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Dale a este perfil su propio directorio de trabajo para operaciones de archivos y herramientas.', + 'settings.profiles.editor.soulMdFile': 'Archivo de identidad', + 'settings.profiles.editor.workspaceDir': 'Directorio de trabajo', 'settings.profiles.editor.all': 'Todos', 'settings.profiles.editor.selected': 'Seleccionados', 'settings.profiles.editor.addPlaceholder': 'Escribe un identificador y pulsa Intro', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index a9ffe08f41..12f23c8af3 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7467,6 +7467,14 @@ const messages: TranslationMap = { 'settings.profiles.editor.skillsHint': 'Flux de travail que ce profil peut lister et exécuter.', 'settings.profiles.editor.mcpServers': 'Serveurs MCP', 'settings.profiles.editor.mcpServersHint': 'Serveurs MCP que ce profil peut atteindre.', + 'settings.profiles.editor.dedicatedMemory': 'Mémoire dédiée', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Donner à ce profil sa propre mémoire au lieu de partager la mémoire par défaut.', + 'settings.profiles.editor.dedicatedWorkspace': 'Espace de travail dédié', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Donner à ce profil son propre répertoire de travail pour les opérations de fichiers et d’outils.', + 'settings.profiles.editor.soulMdFile': 'Fichier d’identité', + 'settings.profiles.editor.workspaceDir': 'Répertoire de travail', 'settings.profiles.editor.all': 'Tous', 'settings.profiles.editor.selected': 'Sélectionnés', 'settings.profiles.editor.addPlaceholder': 'Saisissez un identifiant, puis Entrée', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index ae6ae6c5bd..82def84d47 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7277,6 +7277,14 @@ const messages: TranslationMap = { 'इस प्रोफ़ाइल द्वारा सूचीबद्ध और चलाए जा सकने वाले वर्कफ़्लो।', 'settings.profiles.editor.mcpServers': 'MCP सर्वर', 'settings.profiles.editor.mcpServersHint': 'इस प्रोफ़ाइल द्वारा पहुँचने योग्य MCP सर्वर।', + 'settings.profiles.editor.dedicatedMemory': 'समर्पित मेमोरी', + 'settings.profiles.editor.dedicatedMemoryHint': + 'डिफ़ॉल्ट मेमोरी साझा करने के बजाय इस प्रोफ़ाइल को अपनी खुद की मेमोरी दें।', + 'settings.profiles.editor.dedicatedWorkspace': 'समर्पित वर्कस्पेस', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'फ़ाइल और टूल कार्यों के लिए इस प्रोफ़ाइल को अपनी खुद की वर्किंग डायरेक्टरी दें।', + 'settings.profiles.editor.soulMdFile': 'पहचान फ़ाइल', + 'settings.profiles.editor.workspaceDir': 'वर्कस्पेस डायरेक्टरी', 'settings.profiles.editor.all': 'सभी', 'settings.profiles.editor.selected': 'चयनित', 'settings.profiles.editor.addPlaceholder': 'आईडी टाइप करें, एंटर दबाएँ', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 99b05238e7..3a1d5ca9c4 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -7314,6 +7314,14 @@ const messages: TranslationMap = { 'Alur kerja yang dapat didaftar dan dijalankan profil ini.', 'settings.profiles.editor.mcpServers': 'Server MCP', 'settings.profiles.editor.mcpServersHint': 'Server MCP yang dapat dijangkau profil ini.', + 'settings.profiles.editor.dedicatedMemory': 'Memori khusus', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Berikan profil ini memorinya sendiri alih-alih berbagi memori bawaan.', + 'settings.profiles.editor.dedicatedWorkspace': 'Ruang kerja khusus', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Berikan profil ini direktori kerjanya sendiri untuk operasi file dan alat.', + 'settings.profiles.editor.soulMdFile': 'Berkas identitas', + 'settings.profiles.editor.workspaceDir': 'Direktori ruang kerja', 'settings.profiles.editor.all': 'Semua', 'settings.profiles.editor.selected': 'Terpilih', 'settings.profiles.editor.addPlaceholder': 'Ketik id, tekan Enter', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 3e953b892a..c7602d2a2e 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7419,6 +7419,14 @@ const messages: TranslationMap = { 'Flussi di lavoro che questo profilo può elencare ed eseguire.', 'settings.profiles.editor.mcpServers': 'Server MCP', 'settings.profiles.editor.mcpServersHint': 'Server MCP raggiungibili da questo profilo.', + 'settings.profiles.editor.dedicatedMemory': 'Memoria dedicata', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Assegna a questo profilo una memoria propria invece di condividere quella predefinita.', + 'settings.profiles.editor.dedicatedWorkspace': 'Area di lavoro dedicata', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Assegna a questo profilo una propria directory di lavoro per le operazioni su file e strumenti.', + 'settings.profiles.editor.soulMdFile': 'File di identità', + 'settings.profiles.editor.workspaceDir': 'Directory di lavoro', 'settings.profiles.editor.all': 'Tutti', 'settings.profiles.editor.selected': 'Selezionati', 'settings.profiles.editor.addPlaceholder': 'Digita un identificativo e premi Invio', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index ec43167301..7a92acdacc 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -7194,6 +7194,14 @@ const messages: TranslationMap = { 'settings.profiles.editor.skillsHint': '이 프로필이 나열하고 실행할 수 있는 워크플로.', 'settings.profiles.editor.mcpServers': 'MCP 서버', 'settings.profiles.editor.mcpServersHint': '이 프로필이 접근할 수 있는 MCP 서버.', + 'settings.profiles.editor.dedicatedMemory': '전용 메모리', + 'settings.profiles.editor.dedicatedMemoryHint': + '기본 메모리를 공유하는 대신 이 프로필에 전용 메모리를 부여합니다.', + 'settings.profiles.editor.dedicatedWorkspace': '전용 작업 공간', + 'settings.profiles.editor.dedicatedWorkspaceHint': + '파일 및 도구 작업을 위해 이 프로필에 전용 작업 디렉터리를 부여합니다.', + 'settings.profiles.editor.soulMdFile': '아이덴티티 파일', + 'settings.profiles.editor.workspaceDir': '작업 공간 디렉터리', 'settings.profiles.editor.all': '전체', 'settings.profiles.editor.selected': '선택됨', 'settings.profiles.editor.addPlaceholder': '식별자를 입력하고 Enter를 누르세요', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index caa78f3ea8..4548962af4 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7392,6 +7392,14 @@ const messages: TranslationMap = { 'Przepływy pracy, które ten profil może wyświetlać i uruchamiać.', 'settings.profiles.editor.mcpServers': 'Serwery MCP', 'settings.profiles.editor.mcpServersHint': 'Serwery MCP, do których ma dostęp ten profil.', + 'settings.profiles.editor.dedicatedMemory': 'Dedykowana pamięć', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Nadaj temu profilowi własną pamięć zamiast korzystać z pamięci współdzielonej.', + 'settings.profiles.editor.dedicatedWorkspace': 'Dedykowany obszar roboczy', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Nadaj temu profilowi własny katalog roboczy do operacji na plikach i narzędziach.', + 'settings.profiles.editor.soulMdFile': 'Plik tożsamości', + 'settings.profiles.editor.workspaceDir': 'Katalog roboczy', 'settings.profiles.editor.all': 'Wszystkie', 'settings.profiles.editor.selected': 'Wybrane', 'settings.profiles.editor.addPlaceholder': 'Wpisz identyfikator i naciśnij Enter', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index fe5ea7ef2b..5f49e2808a 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7401,6 +7401,14 @@ const messages: TranslationMap = { 'Fluxos de trabalho que este perfil pode listar e executar.', 'settings.profiles.editor.mcpServers': 'Servidores MCP', 'settings.profiles.editor.mcpServersHint': 'Servidores MCP que este perfil pode acessar.', + 'settings.profiles.editor.dedicatedMemory': 'Memória dedicada', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Dê a este perfil sua própria memória em vez de compartilhar a memória padrão.', + 'settings.profiles.editor.dedicatedWorkspace': 'Espaço de trabalho dedicado', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Dê a este perfil seu próprio diretório de trabalho para operações de arquivos e ferramentas.', + 'settings.profiles.editor.soulMdFile': 'Arquivo de identidade', + 'settings.profiles.editor.workspaceDir': 'Diretório de trabalho', 'settings.profiles.editor.all': 'Todos', 'settings.profiles.editor.selected': 'Selecionados', 'settings.profiles.editor.addPlaceholder': 'Digite um identificador e pressione Enter', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 43324a7a4b..210409eb8a 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -7365,6 +7365,14 @@ const messages: TranslationMap = { 'settings.profiles.editor.mcpServers': 'Серверы MCP', 'settings.profiles.editor.mcpServersHint': 'Серверы MCP, к которым может обращаться этот профиль.', + 'settings.profiles.editor.dedicatedMemory': 'Выделенная память', + 'settings.profiles.editor.dedicatedMemoryHint': + 'Выделить этому профилю собственную память вместо общей памяти по умолчанию.', + 'settings.profiles.editor.dedicatedWorkspace': 'Выделенное рабочее пространство', + 'settings.profiles.editor.dedicatedWorkspaceHint': + 'Выделить этому профилю собственный рабочий каталог для операций с файлами и инструментами.', + 'settings.profiles.editor.soulMdFile': 'Файл идентичности', + 'settings.profiles.editor.workspaceDir': 'Рабочий каталог', 'settings.profiles.editor.all': 'Все', 'settings.profiles.editor.selected': 'Выбранные', 'settings.profiles.editor.addPlaceholder': 'Введите идентификатор и нажмите Enter', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index acdc4cda0d..fa9208c8e0 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6884,6 +6884,12 @@ const messages: TranslationMap = { 'settings.profiles.editor.skillsHint': '此配置可列出并运行的工作流。', 'settings.profiles.editor.mcpServers': 'MCP 服务器', 'settings.profiles.editor.mcpServersHint': '此配置可访问的 MCP 服务器。', + 'settings.profiles.editor.dedicatedMemory': '专属记忆', + 'settings.profiles.editor.dedicatedMemoryHint': '为此配置分配专属记忆,而不是共享默认记忆。', + 'settings.profiles.editor.dedicatedWorkspace': '专属工作区', + 'settings.profiles.editor.dedicatedWorkspaceHint': '为此配置分配专属工作目录,用于文件和工具操作。', + 'settings.profiles.editor.soulMdFile': '身份文件', + 'settings.profiles.editor.workspaceDir': '工作目录', 'settings.profiles.editor.all': '全部', 'settings.profiles.editor.selected': '指定', 'settings.profiles.editor.addPlaceholder': '输入标识后按回车', From b14020864d9b6b3919b1df91bc4f6c9c6f7211c7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 11:01:04 +0400 Subject: [PATCH 07/80] fix(profiles): review fixes - real descriptor-seam test, invalid-id home guard, atomic seeds F1: extract the per-profile WorkspaceDescriptor expression into derive_profile_workspace_descriptor(), called by both build_session_agent_inner and its unit tests, so the tests can no longer drift from production. The descriptor's propagation to subagents is documented as deliberate profile isolation. F2: ensure_profile_home early-returns (with a [profiles] warn) when the id fails validate_profile_id, and enrich_profile skips soulMdFile/workspaceDir under the same check - keeping materialized homes and advertised paths symmetric with what the read paths (resolve_personality_soul/dedicated_workspace_dir/ effective_memory_suffix) will actually load. F3: enrich_profile warns (keeping the empty-map fallback) when a profile does not serialize to a JSON object instead of silently dropping every field. F4: SOUL.md/MEMORY.md first-time seeding is now atomic - write a temp file in the same dir then rename over the target (still only when absent), so a concurrent reader never observes a half-written seed. Idempotency semantics unchanged. --- .../agent/harness/session/builder/factory.rs | 137 +++++++++++------- src/openhuman/profiles/home.rs | 66 ++++++++- src/openhuman/profiles/ops.rs | 61 +++++++- 3 files changed, 206 insertions(+), 58 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 9d71872ee4..aa16e5443a 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -336,30 +336,21 @@ impl Agent { // agent's broad write root is left intact (cross-profile write guarding // is a deliberate follow-up). `None` (the common case) preserves the // shared-`action_dir` cwd behaviour byte-for-byte. - let profile_workspace_descriptor: Option = - profile - .and_then(|p| { - crate::openhuman::profiles::dedicated_workspace_dir(&config.action_dir, p) - .map(|dir| (p.id.clone(), dir)) - }) - .map(|(profile_id, dir)| { - if let Err(e) = std::fs::create_dir_all(&dir) { - tracing::warn!( - profile_id = %profile_id, - dir = %dir.display(), - error = %e, - "[profiles] failed to create dedicated workspace dir; \ - tools will still target it as cwd" - ); - } - tracing::debug!( - profile_id = %profile_id, - dir = %dir.display(), - "[profiles] session bound to dedicated workspace as default cwd" - ); - tinyagents::harness::workspace::WorkspaceDescriptor::new(dir) - .with_policy_id(format!("openhuman.profile:{profile_id}")) - }); + // + // NOTE (deliberate): this `ctx.workspace` descriptor propagates to + // subagents spawned from this session, so they too root under + // `/profiles/` rather than the bare `action_dir`. That + // propagation is *intended* profile isolation, not a leak — a profile's + // subagents should share its dedicated workspace. Do not "fix" it by + // clearing the descriptor for child sessions. + // + // The expression is extracted into [`derive_profile_workspace_descriptor`] + // so the unit tests exercise the *same* code path rather than a + // hand-copied mirror. + let profile_workspace_descriptor = derive_profile_workspace_descriptor( + &config.action_dir, + profile, + ); let runtime: Arc = Arc::from( host_runtime::create_runtime(&config.runtime, config.shell.hide_window)?, @@ -1467,18 +1458,57 @@ mod provider_role_tests { } } +/// Section D — derive the top-level chat turn's per-profile workspace +/// descriptor. Shared by [`Agent::build_session_agent_inner`] and its unit tests +/// so the two can never drift. +/// +/// Returns a [`WorkspaceDescriptor`](tinyagents::harness::workspace::WorkspaceDescriptor) +/// rooted at `/profiles/` when `profile` opts into +/// `dedicated_workspace` and its id passes validation (via +/// [`dedicated_workspace_dir`](crate::openhuman::profiles::dedicated_workspace_dir)), +/// creating the dir as a side effect; `None` for the shared-workspace common case +/// and for legacy ids that fail validation (both fall back to the shared +/// `action_dir` cwd). The returned descriptor propagates to subagents — see the +/// deliberate-isolation note at the call site. +pub(crate) fn derive_profile_workspace_descriptor( + action_dir: &std::path::Path, + profile: Option<&crate::openhuman::profiles::AgentProfile>, +) -> Option { + let (profile_id, dir) = profile.and_then(|p| { + crate::openhuman::profiles::dedicated_workspace_dir(action_dir, p) + .map(|dir| (p.id.clone(), dir)) + })?; + if let Err(e) = std::fs::create_dir_all(&dir) { + tracing::warn!( + profile_id = %profile_id, + dir = %dir.display(), + error = %e, + "[profiles] failed to create dedicated workspace dir; \ + tools will still target it as cwd" + ); + } + tracing::debug!( + profile_id = %profile_id, + dir = %dir.display(), + "[profiles] session bound to dedicated workspace as default cwd" + ); + Some( + tinyagents::harness::workspace::WorkspaceDescriptor::new(dir) + .with_policy_id(format!("openhuman.profile:{profile_id}")), + ) +} + /// Section D — per-profile dedicated-workspace descriptor seam. /// -/// The session builder derives the top-level chat turn's workspace descriptor -/// from `profiles::dedicated_workspace_dir` exactly as the tests below assert, -/// then wraps it in a `WorkspaceDescriptor`. These tests pin that the descriptor -/// root points at `/profiles/` for an opted-in profile, and that -/// shared/legacy profiles produce no descriptor (so the shared `action_dir` cwd -/// is preserved). +/// These tests exercise the **production** [`derive_profile_workspace_descriptor`] +/// directly (the same function the session builder calls), so they cannot drift +/// from the real seam. They pin that the descriptor root points at +/// `/profiles/` for an opted-in profile, and that shared/legacy +/// profiles produce no descriptor (so the shared `action_dir` cwd is preserved). #[cfg(test)] mod profile_workspace_descriptor_tests { - use crate::openhuman::profiles::{dedicated_workspace_dir, store::built_in_default_profile}; - use std::path::Path; + use super::derive_profile_workspace_descriptor; + use crate::openhuman::profiles::store::built_in_default_profile; fn profile(id: &str, dedicated_workspace: bool) -> crate::openhuman::profiles::AgentProfile { let mut p = built_in_default_profile(); @@ -1491,40 +1521,39 @@ mod profile_workspace_descriptor_tests { p } - /// Mirror the exact expression `build_session_agent_inner` uses to build the - /// descriptor, so this test tracks the production seam. - fn descriptor_for( - action_dir: &Path, - profile: &crate::openhuman::profiles::AgentProfile, - ) -> Option { - dedicated_workspace_dir(action_dir, profile).map(|dir| { - tinyagents::harness::workspace::WorkspaceDescriptor::new(dir) - .with_policy_id(format!("openhuman.profile:{}", profile.id)) - }) - } - #[test] fn dedicated_workspace_profile_roots_descriptor_at_profile_dir() { - let action = Path::new("/tmp/action"); - let desc = descriptor_for(action, &profile("alice", true)) + // Real temp action_dir: the production fn creates the profile dir as a + // side effect, so assert against the resolved path suffix. + let action = tempfile::tempdir().expect("action tempdir"); + let p = profile("alice", true); + let desc = derive_profile_workspace_descriptor(action.path(), Some(&p)) .expect("dedicated_workspace profile yields a descriptor"); - assert_eq!( - desc.root.as_path(), - Path::new("/tmp/action/profiles/alice") - ); + let expected = action.path().join("profiles").join("alice"); + assert_eq!(desc.root.as_path(), expected.as_path()); + // The production path really created the dir. + assert!(desc.root.is_dir()); } #[test] fn shared_profile_yields_no_descriptor() { - let action = Path::new("/tmp/action"); - assert!(descriptor_for(action, &profile("bob", false)).is_none()); + let action = tempfile::tempdir().expect("action tempdir"); + let p = profile("bob", false); + assert!(derive_profile_workspace_descriptor(action.path(), Some(&p)).is_none()); + } + + #[test] + fn none_profile_yields_no_descriptor() { + let action = tempfile::tempdir().expect("action tempdir"); + assert!(derive_profile_workspace_descriptor(action.path(), None).is_none()); } #[test] fn legacy_invalid_id_yields_no_descriptor_even_when_opted_in() { - let action = Path::new("/tmp/action"); + let action = tempfile::tempdir().expect("action tempdir"); // An id that fails validation can't mint a workspace path → no descriptor, // so the session falls back to the shared action_dir cwd. - assert!(descriptor_for(action, &profile("Bad Id", true)).is_none()); + let p = profile("Bad Id", true); + assert!(derive_profile_workspace_descriptor(action.path(), Some(&p)).is_none()); } } diff --git a/src/openhuman/profiles/home.rs b/src/openhuman/profiles/home.rs index 756835a3d2..b656b88a56 100644 --- a/src/openhuman/profiles/home.rs +++ b/src/openhuman/profiles/home.rs @@ -68,6 +68,29 @@ pub fn validate_profile_id(id: &str) -> Result<(), String> { Ok(()) } +/// Seed `target` with `contents` atomically: write a temp file in the same +/// directory, then `rename` it over `target`. Callers invoke this only when +/// `target` is absent, so the rename never clobbers a user's edited file; the +/// write-then-rename keeps any concurrent reader from ever observing a +/// half-written seed (a bare `write` can be seen mid-flush). Idempotency +/// semantics are identical to the previous check-then-`write`. +fn seed_file_atomic(dir: &Path, target: &Path, contents: &[u8]) -> io::Result<()> { + let base = target + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("seed"); + let tmp = dir.join(format!(".{base}.tmp-{}", std::process::id())); + std::fs::write(&tmp, contents)?; + match std::fs::rename(&tmp, target) { + Ok(()) => Ok(()), + Err(e) => { + // Best-effort cleanup so a failed rename doesn't leave a stray temp. + let _ = std::fs::remove_file(&tmp); + Err(e) + } + } +} + /// Render the default seed persona for a profile lacking an inline `soul_md`. /// /// Kept intentionally short — a real persona is authored by the user by editing @@ -105,6 +128,21 @@ pub fn ensure_profile_home( action_dir: &Path, profile: &AgentProfile, ) -> io::Result<()> { + // Guard: only materialize a home for ids the read paths will actually load. + // `resolve_personality_soul`, `dedicated_workspace_dir`, and + // `effective_memory_suffix` all skip ids that fail `validate_profile_id`, so + // seeding `personalities//` for such an id would leave a home nothing + // ever reads. Early-return (no dir, no seed) to keep write/read symmetric. + if let Err(e) = validate_profile_id(&profile.id) { + tracing::warn!( + profile_id = %profile.id, + error = %e, + "[profiles][home] ensure_profile_home skipped: id fails validation, \ + no home materialized (read paths would never load it)" + ); + return Ok(()); + } + let home = profile_home(workspace_dir, &profile.id); tracing::debug!( profile_id = %profile.id, @@ -149,7 +187,7 @@ pub fn ensure_profile_home( .as_ref() .map(|s| !s.trim().is_empty()) .unwrap_or(false); - std::fs::write(&soul_path, contents.as_bytes()).map_err(|e| { + seed_file_atomic(&home, &soul_path, contents.as_bytes()).map_err(|e| { tracing::debug!( profile_id = %profile.id, soul_path = %soul_path.display(), @@ -172,7 +210,7 @@ pub fn ensure_profile_home( "[profiles][home] MEMORY.md already present, not overwriting" ); } else { - std::fs::write(&memory_path, b"").map_err(|e| { + seed_file_atomic(&home, &memory_path, b"").map_err(|e| { tracing::debug!( profile_id = %profile.id, memory_path = %memory_path.display(), @@ -364,6 +402,30 @@ mod tests { assert!(profile_action_workspace(action.path(), "dave").is_dir()); } + #[test] + fn ensure_profile_home_skips_invalid_id() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + // An id that fails validate_profile_id must not materialize a home — the + // read paths would never load it, so a seeded dir would be dead weight. + let mut profile = test_profile("placeholder"); + profile.id = "Bad Id".to_string(); + profile.dedicated_workspace = true; + + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + + assert!( + !profile_home(ws.path(), "Bad Id").exists(), + "no home dir should be materialized for an invalid id" + ); + assert!( + !profile_action_workspace(action.path(), "Bad Id").exists(), + "no dedicated workspace should be materialized for an invalid id" + ); + // The `personalities/` root itself must not be created for it either. + assert!(!ws.path().join("personalities").join("Bad Id").exists()); + } + #[test] fn dedicated_workspace_dir_gates_on_flag_and_id() { let action = Path::new("/tmp/act"); diff --git a/src/openhuman/profiles/ops.rs b/src/openhuman/profiles/ops.rs index bf57fa3a68..a2fafa9d46 100644 --- a/src/openhuman/profiles/ops.rs +++ b/src/openhuman/profiles/ops.rs @@ -10,7 +10,9 @@ use std::path::{Path, PathBuf}; use serde_json::{Map, Value}; -use super::home::{dedicated_workspace_dir, ensure_profile_home, profile_home}; +use super::home::{ + dedicated_workspace_dir, ensure_profile_home, profile_home, validate_profile_id, +}; use super::store::AgentProfileStore; use super::types::{AgentProfile, AgentProfilesState}; use crate::openhuman::config::rpc as config_rpc; @@ -25,8 +27,27 @@ use crate::openhuman::config::rpc as config_rpc; fn enrich_profile(workspace_dir: &Path, action_dir: &Path, profile: &AgentProfile) -> Value { let mut obj: Map = match serde_json::to_value(profile) { Ok(Value::Object(map)) => map, - _ => Map::new(), + _ => { + // A profile must serialize to a JSON object; anything else drops every + // field. This should be unreachable for `AgentProfile`, so warn (but + // keep the empty-map fallback so the RPC still returns a value). + tracing::warn!( + profile_id = %profile.id, + "[profiles][ops] enrich_profile: profile did not serialize to a JSON \ + object; enrichment yields an empty object" + ); + Map::new() + } }; + // Skip the derived path enrichment for ids the read paths would never load + // (they fail `validate_profile_id`). `dedicated_workspace_dir` already gates + // on the same check, so `workspaceDir` was never emitted for them; matching + // that for `soulMdFile` keeps the advertised paths symmetric with what the + // core will actually read, and — paired with the `ensure_profile_home` guard + // — such an id has no SOUL.md on disk to advertise anyway. + if validate_profile_id(&profile.id).is_err() { + return Value::Object(obj); + } let soul = profile_home(workspace_dir, &profile.id).join("SOUL.md"); if soul.exists() { obj.insert( @@ -350,6 +371,42 @@ mod tests { assert!(shared["soulMdFile"].as_str().is_some()); } + #[test] + fn enrich_profile_skips_paths_for_invalid_id() { + let ws = tempfile::tempdir().expect("ws tempdir"); + let action = tempfile::tempdir().expect("action tempdir"); + let mut p = profile("placeholder", "orchestrator"); + p.id = "Bad Id".to_string(); + p.dedicated_workspace = true; + + // Even if a SOUL.md somehow exists at the would-be home, an invalid id must + // not advertise soulMdFile/workspaceDir — the read paths would never load + // it, so the enriched payload must stay symmetric with what core reads. + let home = super::profile_home(ws.path(), "Bad Id"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("SOUL.md"), "x").unwrap(); + + let enriched = super::enrich_profile(ws.path(), action.path(), &p); + assert!( + enriched.get("soulMdFile").is_none(), + "invalid id must not advertise soulMdFile even when the file exists" + ); + assert!( + enriched.get("workspaceDir").is_none(), + "invalid id must not advertise workspaceDir" + ); + + // Control: a valid id with the same on-disk SOUL.md does advertise it. + let mut valid = p.clone(); + valid.id = "goodid".to_string(); + let valid_home = super::profile_home(ws.path(), "goodid"); + std::fs::create_dir_all(&valid_home).unwrap(); + std::fs::write(valid_home.join("SOUL.md"), "x").unwrap(); + let enriched_valid = super::enrich_profile(ws.path(), action.path(), &valid); + assert!(enriched_valid["soulMdFile"].as_str().is_some()); + assert!(enriched_valid["workspaceDir"].as_str().is_some()); + } + #[tokio::test] async fn upsert_default_agent_allowed_without_registry() { let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); From 3d6a2a2faa746a9d4c1abb34262a30749b1cf296 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 11:59:08 +0400 Subject: [PATCH 08/80] feat(profiles): plumb active profile id to the tool + hook layers (1a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes the two carriers a profile-scoped turn needs so later slices (cross-profile write guard, experience scoping) can read "which profile is active" without inventing a new channel: - Tool layer: reuse the existing WorkspaceDescriptor.policy_id slot. The session builder already stamps `openhuman.profile:` on the dedicated- workspace descriptor that reaches shell/file tools via ToolExecutionContext. Centralize the encode/decode as `profiles::guard::{workspace_policy_id, profile_id_from_policy_id}` so the builder and the (1b) tool gates can never drift on the wire format; factory now uses the encoder. - Hook/session layer: add `Agent.active_profile_id: Option` (+ builder field/setter), set from the resolved AgentProfile. This carries ANY active profile (not just dedicated-workspace ones), which the (1c) experience retrieval/capture keys on. Seam choice: the WorkspaceDescriptor.policy_id already threads to both shell (effective_action_dir_for_context) and file tools (security_for_tool_context), so no new tool-boundary plumbing is needed for the guard. Considered a new ToolExecutionContext field and a session task-local; both were redundant given the descriptor already carries the identity, and a task-local would not survive the subagent boundary the descriptor already crosses. TurnContext was considered for the hook side but adding a field there churns ~35 struct literals for no gain — the capture hook is built where the profile is in scope (1c), and the retrieval side reads Agent.active_profile_id. None-path is byte-identical: no profile -> active_profile_id None, policy_id decodes to None. Pinned by builder tests (present/absent) + guard round-trip. --- .../harness/session/builder/builder_tests.rs | 51 ++++++++++++++++ .../agent/harness/session/builder/factory.rs | 7 ++- .../agent/harness/session/builder/setters.rs | 14 +++++ src/openhuman/agent/harness/session/types.rs | 16 +++++ src/openhuman/profiles/guard.rs | 59 +++++++++++++++++++ src/openhuman/profiles/mod.rs | 2 + 6 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 src/openhuman/profiles/guard.rs diff --git a/src/openhuman/agent/harness/session/builder/builder_tests.rs b/src/openhuman/agent/harness/session/builder/builder_tests.rs index 7e44eebcda..44be23c398 100644 --- a/src/openhuman/agent/harness/session/builder/builder_tests.rs +++ b/src/openhuman/agent/harness/session/builder/builder_tests.rs @@ -241,6 +241,57 @@ async fn build_session_agent_falls_back_to_global_default_when_no_definition() { ); } +// ── 1a: active profile id plumbed onto the built session ───────────────────── + +#[tokio::test] +async fn build_session_agent_carries_active_profile_id_when_profile_present() { + use crate::openhuman::agent::harness::session::types::Agent; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.id = "alice".to_string(); + profile.built_in = false; + profile.is_master = false; + + let agent = Agent::build_session_agent_inner( + &config, + "orchestrator", + None, + None, + None, + false, + Some(&profile), + ) + .expect("build_session_agent_inner with a profile should succeed"); + + assert_eq!( + agent.active_profile_id.as_deref(), + Some("alice"), + "an active profile must plumb its id onto the built session" + ); +} + +#[tokio::test] +async fn build_session_agent_leaves_active_profile_id_none_without_profile() { + use crate::openhuman::agent::harness::session::types::Agent; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + + // The profile-less path (the legacy default) must stay byte-identical: + // no active profile id is stamped. + let agent = + Agent::build_session_agent_inner(&config, "orchestrator", None, None, None, false, None) + .expect("build_session_agent_inner with no profile should succeed"); + + assert_eq!( + agent.active_profile_id, None, + "the profile-less session must not carry an active profile id" + ); +} + // ── #5050 Fix 1: shared `Arc` for the per-build tool config ────────── #[test] diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index aa16e5443a..ed445b1212 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -1213,6 +1213,11 @@ impl Agent { .workspace_dir(config.workspace_dir.clone()) .action_dir(config.action_dir.clone()) .workspace_descriptor(profile_workspace_descriptor) + // 1a — carry the active profile id (any active profile, not just + // dedicated-workspace ones) so profile-scoped post-turn hooks can + // see which profile the turn ran under. `None` for the profile-less + // session keeps every consumer byte-identical. + .active_profile_id(profile.map(|p| p.id.clone())) .workflows(crate::openhuman::skills::load_workflow_metadata( &config.workspace_dir, )) @@ -1494,7 +1499,7 @@ pub(crate) fn derive_profile_workspace_descriptor( ); Some( tinyagents::harness::workspace::WorkspaceDescriptor::new(dir) - .with_policy_id(format!("openhuman.profile:{profile_id}")), + .with_policy_id(crate::openhuman::profiles::workspace_policy_id(&profile_id)), ) } diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs index 803f9fac5e..30285e08be 100644 --- a/src/openhuman/agent/harness/session/builder/setters.rs +++ b/src/openhuman/agent/harness/session/builder/setters.rs @@ -42,6 +42,7 @@ impl AgentBuilder { event_session_id: None, event_channel: None, agent_definition_name: None, + active_profile_id: None, session_parent_prefix: None, omit_profile: None, omit_memory_md: None, @@ -200,6 +201,18 @@ impl AgentBuilder { self } + /// Sets the active agent-profile id for this session (1a plumbing). + /// + /// `None` (default) is the profile-less session. When set, the id is + /// carried on the built [`Agent`] and threaded into the post-turn + /// [`TurnContext`](crate::openhuman::agent::hooks::TurnContext) so + /// profile-scoped hooks (agent-experience capture) can stamp records with + /// it. A `None` here keeps every downstream consumer on its legacy path. + pub fn active_profile_id(mut self, profile_id: Option) -> Self { + self.active_profile_id = profile_id; + self + } + /// Sets the skills available to the agent. pub fn workflows(mut self, skills: Vec) -> Self { self.workflows = Some(skills); @@ -523,6 +536,7 @@ impl AgentBuilder { // `refresh_delegation_tools` to re-resolve the agent's // `subagents` declaration against the global registry. agent_definition_id: agent_definition_name.clone(), + active_profile_id: self.active_profile_id, session_transcript_path: None, persisted_transcript_messages: Vec::new(), session_key: { diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index 0047556d30..d2c164b7a6 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -127,6 +127,18 @@ pub struct Agent { /// /// [`AgentDefinitionRegistry`]: crate::openhuman::agent::harness::definition::AgentDefinitionRegistry pub(super) agent_definition_id: String, + /// Id of the agent profile this session runs under, when the turn was + /// launched with an active profile (`profiles` domain). `None` for the + /// default (profile-less) session — the byte-identical legacy path. + /// + /// Set once at build time from the resolved [`AgentProfile`] and never + /// rewritten. Consumed by the profile-scoped agent-experience capture + + /// retrieval (1c): records are stamped with this id and only records + /// matching it (plus unstamped legacy records) are recalled. Distinct from + /// the tool-layer guard identity, which rides the + /// [`WorkspaceDescriptor::policy_id`](tinyagents::harness::workspace::WorkspaceDescriptor) + /// and only exists for *dedicated-workspace* profiles. + pub(super) active_profile_id: Option, /// Resolved filesystem path for this session's transcript file. /// Set on first write, reused for subsequent **appends** within the /// same session. @@ -369,6 +381,10 @@ pub struct AgentBuilder { pub(super) event_session_id: Option, pub(super) event_channel: Option, pub(super) agent_definition_name: Option, + /// Forwarded to [`Agent::active_profile_id`] at `build()` time. `None` + /// (default) means the profile-less session; the profile-launching callers + /// (web chat, task dispatcher, cron) set the active profile id here. + pub(super) active_profile_id: Option, /// Directory chain of parent session keys for a sub-agent. `None` /// (default) means this is a root session — its transcript lands /// flat in `session_raw/DDMMYYYY/{session_key}.jsonl`. Populated diff --git a/src/openhuman/profiles/guard.rs b/src/openhuman/profiles/guard.rs new file mode 100644 index 0000000000..1daf63a1d7 --- /dev/null +++ b/src/openhuman/profiles/guard.rs @@ -0,0 +1,59 @@ +//! Active-profile identity plumbing (and, from 1b, the cross-profile write guard). +//! +//! When a dedicated-workspace profile is active, its id is carried to the tool +//! layer inside the +//! [`WorkspaceDescriptor`](tinyagents::harness::workspace::WorkspaceDescriptor)'s +//! `policy_id` field as `openhuman.profile:`. [`workspace_policy_id`] and +//! [`profile_id_from_policy_id`] are the single encode/decode pair so the +//! session builder and the tool gates can never drift on the wire format. + +/// Wire prefix for the per-profile `WorkspaceDescriptor::policy_id`. The suffix +/// is the profile id. Kept private-behind-helpers so the encode/decode pair is +/// the only way this string is produced or parsed. +const PROFILE_POLICY_ID_PREFIX: &str = "openhuman.profile:"; + +/// Encode a profile id as the `WorkspaceDescriptor::policy_id` the session +/// builder stamps onto a dedicated-workspace descriptor (`openhuman.profile:`). +/// +/// Paired with [`profile_id_from_policy_id`]; the two are the sole owners of the +/// wire format so the encode and decode sites can never disagree. +pub fn workspace_policy_id(profile_id: &str) -> String { + format!("{PROFILE_POLICY_ID_PREFIX}{profile_id}") +} + +/// Decode the active profile id from a `WorkspaceDescriptor::policy_id`. +/// +/// Returns `Some(id)` only for the `openhuman.profile:` shape +/// [`workspace_policy_id`] produces (and only when `` is non-empty); every +/// other policy_id — the worktree-isolation ids, test ids, or an empty string — +/// yields `None`, so a non-profile session reads as "no active profile" and the +/// tool gates stay on their shared-path behaviour. +pub fn profile_id_from_policy_id(policy_id: &str) -> Option<&str> { + policy_id + .strip_prefix(PROFILE_POLICY_ID_PREFIX) + .filter(|id| !id.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_policy_id_round_trips() { + let encoded = workspace_policy_id("alice"); + assert_eq!(encoded, "openhuman.profile:alice"); + assert_eq!(profile_id_from_policy_id(&encoded), Some("alice")); + } + + #[test] + fn profile_id_from_policy_id_rejects_non_profile_ids() { + // Worktree-isolation / test ids and empty strings are not profiles. + assert_eq!(profile_id_from_policy_id("test-worktree"), None); + assert_eq!(profile_id_from_policy_id(""), None); + assert_eq!(profile_id_from_policy_id("openhuman.profile:"), None); + assert_eq!( + profile_id_from_policy_id("openhuman.profile:bob"), + Some("bob") + ); + } +} diff --git a/src/openhuman/profiles/mod.rs b/src/openhuman/profiles/mod.rs index 1e6abd4fdf..2262fa6fc1 100644 --- a/src/openhuman/profiles/mod.rs +++ b/src/openhuman/profiles/mod.rs @@ -35,6 +35,7 @@ //! - [`ensure_profile_home`] materializes the home idempotently (never //! overwriting a user's edited files) on upsert/select. +pub mod guard; pub mod home; pub mod ops; pub mod paths; @@ -43,6 +44,7 @@ mod schemas; pub mod store; pub mod types; +pub use guard::{profile_id_from_policy_id, workspace_policy_id}; pub use home::{ dedicated_workspace_dir, ensure_profile_home, profile_action_workspace, profile_home, validate_profile_id, From 8821c9a1787dbcda073bcfcb2ff3d02966ded4d1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 12:20:08 +0400 Subject: [PATCH 09/80] feat(profiles): cross-profile write guard + prompt notice (1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a turn runs under a dedicated-workspace profile P, any tool write/command whose resolved target lands in a sibling profile's workspace `/profiles//` (Q != P) is blocked with a `[policy-blocked]` error naming both profiles. Mirrors hermes's classify_cross_profile_target. Guard: `profiles::guard::classify_cross_profile_target(action_dir, active_profile, target) -> Allow | Block{other_id}`. Symlink-safe — it canonicalizes the profiles root and the target's deepest existing ancestor, so a symlink inside P pointing at Q's dir, a `../Q/..` traversal, and a not-yet- created write target all classify correctly. Enforcement seams (single shared helper, two existing gate sites — this is why the guard context is carried, not passed): - File tools: `SecurityPolicy::{validate_path, validate_parent_path}` — the resolved-path gate every file write tool funnels through. Armed via a new optional `SecurityPolicy.active_profile` (ActiveProfileGuard{profile_id, action_dir}), set once at session build by `with_active_profile` ONLY when the active profile owns a dedicated workspace. It carries the broad action_dir because `security_for_tool_context` overrides `action_dir` to the profile dir per call, so the guard cannot re-derive the profiles root at check time. - Shell: `scan_command_for_cross_profile` at the `run_with_security_in_context` gate — shell never funnels through validate_path, so it scans path-shaped command tokens against the profile's own cwd. Best-effort containment backstop layered on the cwd (already the profile's dir); the airtight guarantee for file mutations is the validate_path site. Investigated alternatives: enforcing only in validate_path (misses shell); threading the profile id through ToolExecutionContext per call (the WorkspaceDescriptor.policy_id already carries it, but validate_path is a SecurityPolicy method, so a policy field is the least-invasive carrier); overriding WorkspaceDescriptor::allows in vendored tinyagents (too broad, edits the SDK). Chose the session-scoped policy field + the two existing gate sites. Prompt notice: `cross_profile_workspace_notice` renders one sentence naming the profile's workspace and stating other profiles' dirs are off-limits, injected via AgentProfilePromptSection (added even when the profile has no persona suffix) — the hermes system-prompt disclosure. Only TIGHTENS: `active_profile = None` (profile-less or shared-workspace session) leaves validate_path/shell byte-identical; is_workspace_internal_path and every other SecurityPolicy check are untouched. Pinned by the guard matrix (same/other/outside/traversal/symlink/nonexistent), the shell-scan cases, the validate_parent_path integration (block sibling / allow own / disarmed allows sibling), and the prompt-notice render tests. --- .../agent/harness/session/builder/factory.rs | 52 ++- src/openhuman/profiles/guard.rs | 335 +++++++++++++++++- src/openhuman/profiles/mod.rs | 7 +- src/openhuman/profiles/prompt_section.rs | 111 +++++- src/openhuman/security/policy/enforcement.rs | 31 ++ src/openhuman/security/policy/mod.rs | 5 +- src/openhuman/security/policy/path_checks.rs | 38 ++ src/openhuman/security/policy/policy_tests.rs | 76 ++++ src/openhuman/security/policy/types.rs | 25 ++ src/openhuman/tools/impl/system/shell.rs | 34 ++ 10 files changed, 687 insertions(+), 27 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index ed445b1212..91d8beb449 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -355,11 +355,26 @@ impl Agent { let runtime: Arc = Arc::from( host_runtime::create_runtime(&config.runtime, config.shell.hide_window)?, ); - let security = Arc::new(SecurityPolicy::from_config( + // 1b — arm the cross-profile write guard for the session iff the active + // profile owns a dedicated workspace (i.e. a descriptor was derived + // above). The guard blocks tool writes/commands that target a *sibling* + // profile's `/profiles/` dir. A profile-less session, or + // a shared-workspace profile, leaves it disarmed (`active_profile = + // None`) so path validation is byte-identical. The broad `action_dir` + // is captured so the guard survives the per-tool-call `action_dir` + // override `security_for_tool_context` applies. + let base_security = SecurityPolicy::from_config( &config.autonomy, &config.workspace_dir, &config.action_dir, - )); + ); + let security = Arc::new( + match profile.filter(|_| profile_workspace_descriptor.is_some()) { + Some(p) => base_security + .with_active_profile(p.id.clone(), config.action_dir.clone()), + None => base_security, + }, + ); // Phase 1 of #1401: see comment in channels/runtime/startup.rs. let audit = crate::openhuman::security::get_or_create_workspace_audit_logger( crate::openhuman::config::AuditConfig::default(), @@ -696,17 +711,34 @@ impl Agent { prompt_builder = prompt_builder.with_reflection_context(chunks); } } - if let Some(suffix) = profile_prompt_suffix + // Compose the profile prompt section: the persona suffix, plus (1b) the + // cross-profile workspace notice when a dedicated workspace is active. + // The notice discloses the boundary the guard enforces, so it is added + // even when the profile carries no persona suffix. + let profile_suffix = profile_prompt_suffix .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - { + .filter(|s| !s.is_empty()); + let workspace_notice = profile_workspace_descriptor.as_ref().and_then(|descriptor| { + profile.map(|p| { + crate::openhuman::profiles::cross_profile_workspace_notice( + &p.id, + &descriptor.root, + ) + }) + }); + if profile_suffix.is_some() || workspace_notice.is_some() { log::debug!( - "[agent:builder] profile prompt section injected suffix_chars={}", - suffix.chars().count() + "[agent:builder] profile prompt section injected suffix_chars={} workspace_notice={}", + profile_suffix.as_deref().map(|s| s.chars().count()).unwrap_or(0), + workspace_notice.is_some() ); - prompt_builder = prompt_builder.add_section(Box::new( - crate::openhuman::profiles::AgentProfilePromptSection::new(suffix), - )); + let mut section = crate::openhuman::profiles::AgentProfilePromptSection::new( + profile_suffix.unwrap_or_default(), + ); + if let Some(notice) = workspace_notice { + section = section.with_workspace_notice(notice); + } + prompt_builder = prompt_builder.add_section(Box::new(section)); } // Build post-turn hooks when learning is enabled diff --git a/src/openhuman/profiles/guard.rs b/src/openhuman/profiles/guard.rs index 1daf63a1d7..bea3156a12 100644 --- a/src/openhuman/profiles/guard.rs +++ b/src/openhuman/profiles/guard.rs @@ -1,11 +1,24 @@ -//! Active-profile identity plumbing (and, from 1b, the cross-profile write guard). +//! Active-profile identity plumbing + the cross-profile write guard. //! -//! When a dedicated-workspace profile is active, its id is carried to the tool -//! layer inside the -//! [`WorkspaceDescriptor`](tinyagents::harness::workspace::WorkspaceDescriptor)'s -//! `policy_id` field as `openhuman.profile:`. [`workspace_policy_id`] and -//! [`profile_id_from_policy_id`] are the single encode/decode pair so the -//! session builder and the tool gates can never drift on the wire format. +//! Two concerns, both keyed off *which* profile a turn runs under: +//! +//! 1. **Identity plumbing (1a).** When a dedicated-workspace profile is active, +//! its id is carried to the tool layer inside the +//! [`WorkspaceDescriptor`](tinyagents::harness::workspace::WorkspaceDescriptor)'s +//! `policy_id` field as `openhuman.profile:`. [`workspace_policy_id`] and +//! [`profile_id_from_policy_id`] are the single encode/decode pair so the +//! session builder and the tool gates can never drift on the wire format. +//! +//! 2. **Cross-profile write guard (1b).** A hermes `file_safety` equivalent: +//! while a turn runs under a dedicated-workspace profile `P`, any tool +//! write/command whose resolved target lands in a *sibling* profile's +//! workspace `/profiles//` (Q != P) is blocked. See +//! [`classify_cross_profile_target`] (file tools) and +//! [`scan_command_for_cross_profile`] (shell). The guard only ever +//! **tightens**: with no active profile the classifier is never consulted +//! and behaviour is byte-identical to today. + +use std::path::{Component, Path, PathBuf}; /// Wire prefix for the per-profile `WorkspaceDescriptor::policy_id`. The suffix /// is the profile id. Kept private-behind-helpers so the encode/decode pair is @@ -34,6 +47,163 @@ pub fn profile_id_from_policy_id(policy_id: &str) -> Option<&str> { .filter(|id| !id.is_empty()) } +/// Outcome of classifying a resolved write/command target against the active +/// profile's isolation boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CrossProfileDecision { + /// The target is not inside a *sibling* profile's workspace — permitted (the + /// active profile's own dir, the shared `action_dir`, or anywhere outside + /// `/profiles/` entirely). + Allow, + /// The target lands inside `/profiles//` with + /// `other_id != active_profile` — blocked. + Block { + /// The sibling profile whose workspace the target tried to reach. + other_id: String, + }, +} + +/// Classify a resolved target path against the active profile's cross-profile +/// isolation boundary (the hermes `classify_cross_profile_target` analogue). +/// +/// `action_dir` is the agent's **broad** action root; sibling profile +/// workspaces live under `/profiles//`. `active_profile` is the +/// id of the profile the turn runs under. `target` is the write/command target +/// — ideally already resolved to an absolute path, but relative inputs are +/// joined onto `action_dir` first so the classifier is robust to either. +/// +/// Returns [`CrossProfileDecision::Block`] iff the canonicalized `target` is +/// inside `/profiles//` for some `Q != active_profile`, otherwise +/// [`CrossProfileDecision::Allow`]. +/// +/// **Symlink safety.** The comparison is done on canonicalized paths: the +/// profiles root and the target's deepest *existing* ancestor are both resolved +/// through the filesystem, so a symlink inside profile `P` pointing at profile +/// `Q`'s dir still classifies as a cross-profile target. A not-yet-existing +/// target (a fresh write) canonicalizes its nearest existing ancestor and +/// re-appends the missing tail, matching the `validate_parent_path` strategy. +pub fn classify_cross_profile_target( + action_dir: &Path, + active_profile: &str, + target: &Path, +) -> CrossProfileDecision { + let profiles_root = action_dir.join("profiles"); + // Resolve the profiles root through the filesystem so a symlinked + // action_dir (macOS `/tmp` -> `/private/tmp`, sandbox bind-mounts) compares + // against the same canonical prefix the target resolves to. + let canon_profiles_root = canonicalize_best_effort(&profiles_root); + + let absolute_target = if target.is_absolute() { + target.to_path_buf() + } else { + action_dir.join(target) + }; + let canon_target = canonicalize_deepest_existing(&absolute_target); + + let Ok(relative) = canon_target.strip_prefix(&canon_profiles_root) else { + return CrossProfileDecision::Allow; + }; + // First component under `profiles/` is the owning profile id. + let Some(Component::Normal(owner)) = relative.components().next() else { + return CrossProfileDecision::Allow; + }; + let owner = owner.to_string_lossy(); + if owner == active_profile { + CrossProfileDecision::Allow + } else { + CrossProfileDecision::Block { + other_id: owner.into_owned(), + } + } +} + +/// Best-effort scan of a shell `command` for a token that targets a sibling +/// profile's workspace, given the command's working directory `cwd` (the active +/// profile's own dir). +/// +/// Shell commands do not funnel through the per-path `validate_path` gate that +/// file tools use, so this is the shell-side call site of the same guard. It +/// splits the command on whitespace and redirect operators, keeps only +/// path-shaped tokens (those containing a path separator or a leading `~`), +/// resolves each against `cwd`, and classifies it via +/// [`classify_cross_profile_target`]. Returns the first sibling profile id it +/// would write into, or `None` when the command stays clear of other profiles. +/// +/// This is deliberately a *containment* backstop layered on top of the cwd — +/// which is already rooted at the profile's own workspace — rather than a full +/// shell parser. It reliably catches the realistic escape vectors (an absolute +/// path into `profiles/` or a `..//…` traversal); the airtight guarantee +/// for file mutations lives in the file-tool `validate_path` call site. +pub fn scan_command_for_cross_profile( + command: &str, + cwd: &Path, + action_dir: &Path, + active_profile: &str, +) -> Option { + for raw in command.split(|c: char| c.is_whitespace() || matches!(c, '>' | '<' | '|' | ';')) { + let token = raw.trim_matches(|c| matches!(c, '"' | '\'' | '(' | ')' | '`')); + if token.is_empty() { + continue; + } + // Only path-shaped tokens can reach another directory: they contain a + // separator or start with `~`. A bare word (`ls`, `bob`) resolves under + // cwd (the profile's own dir) and is never a sibling. + let path_shaped = token.contains('/') || token.contains('\\') || token.starts_with('~'); + if !path_shaped { + continue; + } + let expanded = crate::openhuman::config::expand_tilde(token); + let candidate = Path::new(&expanded); + let absolute = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + cwd.join(candidate) + }; + if let CrossProfileDecision::Block { other_id } = + classify_cross_profile_target(action_dir, active_profile, &absolute) + { + return Some(other_id); + } + } + None +} + +/// Canonicalize `path`, falling back to the raw path when it does not exist. +fn canonicalize_best_effort(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +/// Canonicalize `path` when it exists; otherwise canonicalize its deepest +/// existing ancestor and re-append the missing tail. Mirrors +/// `SecurityPolicy::validate_parent_path`'s symlink-safe resolution so a fresh +/// (not-yet-created) write target is still classified against the real +/// filesystem layout. +fn canonicalize_deepest_existing(path: &Path) -> PathBuf { + if let Ok(canonical) = path.canonicalize() { + return canonical; + } + // Walk up to the deepest existing ancestor, collecting the non-existent tail. + let mut existing = path; + let mut tail: Vec> = Vec::new(); + loop { + if existing.exists() { + break; + } + match (existing.parent(), existing.components().next_back()) { + (Some(parent), Some(comp)) => { + tail.push(comp); + existing = parent; + } + _ => break, + } + } + let mut resolved = canonicalize_best_effort(existing); + for component in tail.into_iter().rev() { + resolved.push(component); + } + resolved +} + #[cfg(test)] mod tests { use super::*; @@ -56,4 +226,155 @@ mod tests { Some("bob") ); } + + // ── Cross-profile classifier (1b) ───────────────────────────────────── + + fn profiles_layout() -> (tempfile::TempDir, PathBuf) { + let action = tempfile::tempdir().expect("action tempdir"); + let profiles = action.path().join("profiles"); + for id in ["alice", "bob"] { + std::fs::create_dir_all(profiles.join(id)).unwrap(); + } + let action_dir = action.path().to_path_buf(); + (action, action_dir) + } + + #[test] + fn same_profile_target_is_allowed() { + let (_g, action) = profiles_layout(); + let target = action.join("profiles").join("alice").join("notes.txt"); + assert_eq!( + classify_cross_profile_target(&action, "alice", &target), + CrossProfileDecision::Allow + ); + } + + #[test] + fn other_profile_target_is_blocked() { + let (_g, action) = profiles_layout(); + let target = action.join("profiles").join("bob").join("secret.txt"); + assert_eq!( + classify_cross_profile_target(&action, "alice", &target), + CrossProfileDecision::Block { + other_id: "bob".into() + } + ); + } + + #[test] + fn target_outside_profiles_root_is_allowed() { + let (_g, action) = profiles_layout(); + // A plain file under action_dir (the shared workspace) — not under + // profiles/ at all. + let target = action.join("scratch.txt"); + assert_eq!( + classify_cross_profile_target(&action, "alice", &target), + CrossProfileDecision::Allow + ); + } + + #[test] + fn nonexistent_sibling_target_is_blocked_via_ancestor() { + let (_g, action) = profiles_layout(); + // File does not exist yet, but its parent (profiles/bob) does → the + // deepest-existing-ancestor resolution still classifies it as bob's. + let target = action + .join("profiles") + .join("bob") + .join("nested") + .join("fresh.txt"); + assert_eq!( + classify_cross_profile_target(&action, "alice", &target), + CrossProfileDecision::Block { + other_id: "bob".into() + } + ); + } + + #[test] + fn relative_traversal_into_sibling_is_blocked() { + let (_g, action) = profiles_layout(); + // A relative `../bob/x` composed from the active profile's own dir. + let target = action + .join("profiles") + .join("alice") + .join("..") + .join("bob") + .join("x.txt"); + assert_eq!( + classify_cross_profile_target(&action, "alice", &target), + CrossProfileDecision::Block { + other_id: "bob".into() + } + ); + } + + #[cfg(unix)] + #[test] + fn symlink_into_sibling_profile_is_blocked() { + use std::os::unix::fs::symlink; + let (_g, action) = profiles_layout(); + // Inside alice, a symlink `link -> ../bob`. Writing `link/hijack.txt` + // must resolve to bob's dir and block. + let alice = action.join("profiles").join("alice"); + let bob = action.join("profiles").join("bob"); + symlink(&bob, alice.join("link")).unwrap(); + let target = alice.join("link").join("hijack.txt"); + assert_eq!( + classify_cross_profile_target(&action, "alice", &target), + CrossProfileDecision::Block { + other_id: "bob".into() + } + ); + } + + #[test] + fn profiles_root_itself_is_allowed() { + // `profiles/` (no owner component) is not a sibling write. + let (_g, action) = profiles_layout(); + let target = action.join("profiles"); + assert_eq!( + classify_cross_profile_target(&action, "alice", &target), + CrossProfileDecision::Allow + ); + } + + // ── Shell command scan (1b) ─────────────────────────────────────────── + + #[test] + fn scan_command_allows_same_profile_and_bare_tokens() { + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + // Relative writes under cwd, plain words, and reads under cwd are fine. + assert_eq!( + scan_command_for_cross_profile("echo hi > notes.txt", &cwd, &action, "alice"), + None + ); + assert_eq!( + scan_command_for_cross_profile("ls -la sub/dir", &cwd, &action, "alice"), + None + ); + } + + #[test] + fn scan_command_blocks_absolute_sibling_target() { + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + let sibling = action.join("profiles").join("bob").join("loot.txt"); + let command = format!("cat secret > {}", sibling.display()); + assert_eq!( + scan_command_for_cross_profile(&command, &cwd, &action, "alice"), + Some("bob".to_string()) + ); + } + + #[test] + fn scan_command_blocks_relative_traversal_into_sibling() { + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + assert_eq!( + scan_command_for_cross_profile("cp x ../bob/y", &cwd, &action, "alice"), + Some("bob".to_string()) + ); + } } diff --git a/src/openhuman/profiles/mod.rs b/src/openhuman/profiles/mod.rs index 2262fa6fc1..dbdaebd886 100644 --- a/src/openhuman/profiles/mod.rs +++ b/src/openhuman/profiles/mod.rs @@ -44,7 +44,10 @@ mod schemas; pub mod store; pub mod types; -pub use guard::{profile_id_from_policy_id, workspace_policy_id}; +pub use guard::{ + classify_cross_profile_target, profile_id_from_policy_id, scan_command_for_cross_profile, + workspace_policy_id, CrossProfileDecision, +}; pub use home::{ dedicated_workspace_dir, ensure_profile_home, profile_action_workspace, profile_home, validate_profile_id, @@ -54,7 +57,7 @@ pub use paths::{ memory_tree_subdir_for_suffix, resolve_personality_memory_md, resolve_personality_soul, session_raw_subdir_for_suffix, HasToolkit, PersonalityContext, }; -pub use prompt_section::AgentProfilePromptSection; +pub use prompt_section::{cross_profile_workspace_notice, AgentProfilePromptSection}; pub use store::{built_in_profiles, load_profiles, AgentProfileStore}; pub use types::{profile_signature, AgentProfile, AgentProfilesState, DEFAULT_PROFILE_ID}; diff --git a/src/openhuman/profiles/prompt_section.rs b/src/openhuman/profiles/prompt_section.rs index 5257d4e00e..a752628a77 100644 --- a/src/openhuman/profiles/prompt_section.rs +++ b/src/openhuman/profiles/prompt_section.rs @@ -1,17 +1,50 @@ -//! Prompt section that injects a profile's free-form persona blurb. +//! Prompt section that injects a profile's free-form persona blurb, plus the +//! optional cross-profile workspace notice (1b) that tells the model where its +//! dedicated workspace is and that other profiles' directories are off-limits. + +use std::path::Path; use crate::openhuman::context::prompt::{PromptContext, PromptSection}; use anyhow::Result; +/// One-sentence system-prompt notice, mirroring hermes's cross-profile +/// disclosure: names the profile's dedicated workspace and states that other +/// profiles' directories are off-limits. Rendered only when a dedicated +/// workspace is active (the enforcement backstop is the guard in +/// [`crate::openhuman::profiles::guard`]). +pub fn cross_profile_workspace_notice(profile_id: &str, workspace_path: &Path) -> String { + format!( + "Your dedicated workspace for profile `{profile_id}` is `{}`. Work only there; the \ + directories of other profiles are off-limits.", + workspace_path.display() + ) +} + /// Renders a profile's `system_prompt_suffix` (or any free-form persona body) -/// as a `## Agent profile` block in the system prompt. +/// as a `## Agent profile` block in the system prompt, optionally followed by +/// the cross-profile workspace notice. pub struct AgentProfilePromptSection { body: String, + workspace_notice: Option, } impl AgentProfilePromptSection { pub fn new(body: String) -> Self { - Self { body } + Self { + body, + workspace_notice: None, + } + } + + /// Attach the cross-profile workspace notice (1b). Rendered under the + /// persona body — or on its own when the body is empty — so a + /// dedicated-workspace profile always discloses its boundary even without a + /// custom persona suffix. + #[must_use] + pub fn with_workspace_notice(mut self, notice: String) -> Self { + let notice = notice.trim().to_string(); + self.workspace_notice = (!notice.is_empty()).then_some(notice); + self } } @@ -21,10 +54,14 @@ impl PromptSection for AgentProfilePromptSection { } fn build(&self, _ctx: &PromptContext<'_>) -> Result { - if self.body.trim().is_empty() { - return Ok(String::new()); + let body = self.body.trim(); + let notice = self.workspace_notice.as_deref().unwrap_or_default(); + match (body.is_empty(), notice.is_empty()) { + (true, true) => Ok(String::new()), + (false, true) => Ok(format!("## Agent profile\n\n{body}")), + (true, false) => Ok(format!("## Agent profile\n\n{notice}")), + (false, false) => Ok(format!("## Agent profile\n\n{body}\n\n{notice}")), } - Ok(format!("## Agent profile\n\n{}", self.body.trim())) } } @@ -68,4 +105,66 @@ mod tests { let empty = AgentProfilePromptSection::new(" ".into()); assert_eq!(empty.build(&ctx).expect("empty profile section"), ""); } + + fn empty_ctx<'a>(visible: &'a HashSet) -> PromptContext<'a> { + PromptContext { + workspace_dir: std::path::Path::new("/tmp"), + model_name: "test-model", + agent_id: "orchestrator", + tools: &[], + workflows: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + connected_identities_md: String::new(), + include_profile: false, + include_memory_md: false, + curated_snapshot: None, + user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], + agents_md_global: None, + agents_md_local: None, + } + } + + #[test] + fn cross_profile_workspace_notice_names_path_and_boundary() { + let notice = + cross_profile_workspace_notice("alice", std::path::Path::new("/act/profiles/alice")); + assert!(notice.contains("alice")); + assert!(notice.contains("/act/profiles/alice")); + assert!(notice.contains("off-limits")); + } + + #[test] + fn workspace_notice_renders_with_and_without_body() { + let visible = HashSet::new(); + let ctx = empty_ctx(&visible); + + // Notice-only (no persona body) still emits the block + the notice. + let notice_only = AgentProfilePromptSection::new(String::new()) + .with_workspace_notice("boundary sentence.".into()); + let rendered = notice_only.build(&ctx).expect("render notice-only"); + assert!(rendered.starts_with("## Agent profile")); + assert!(rendered.contains("boundary sentence.")); + + // Body + notice: both present, body first. + let both = AgentProfilePromptSection::new("Be terse.".into()) + .with_workspace_notice("boundary sentence.".into()); + let rendered = both.build(&ctx).expect("render both"); + let body_at = rendered.find("Be terse.").expect("body present"); + let notice_at = rendered.find("boundary sentence.").expect("notice present"); + assert!(body_at < notice_at, "persona body must precede the notice"); + + // A blank notice is dropped, leaving only the body. + let blank_notice = + AgentProfilePromptSection::new("Be terse.".into()).with_workspace_notice(" ".into()); + let rendered = blank_notice.build(&ctx).expect("render blank notice"); + assert!(rendered.contains("Be terse.")); + assert!(!rendered.contains("off-limits")); + } } diff --git a/src/openhuman/security/policy/enforcement.rs b/src/openhuman/security/policy/enforcement.rs index 6e18944960..41b36caa44 100644 --- a/src/openhuman/security/policy/enforcement.rs +++ b/src/openhuman/security/policy/enforcement.rs @@ -168,9 +168,40 @@ impl SecurityPolicy { auto_approve_all: autonomy_config.auto_approve_all, tracker: ActionTracker::new(), canonical_workspace: Arc::new(OnceCell::new()), + // No active profile by default — armed explicitly by the session + // builder via [`with_active_profile`] when a dedicated-workspace + // profile is in play. Keeps every existing `from_config` caller on + // the byte-identical, guard-off path. + active_profile: None, } } + /// Return a copy of this policy with the cross-profile write guard armed for + /// `profile_id`, whose sibling workspaces live under + /// `/profiles//` (1b). + /// + /// Builder-style so the session builder reads as + /// `SecurityPolicy::from_config(..).with_active_profile(id, action_dir)`. + /// Only the dedicated-workspace path calls this; a profile-less session + /// never does, so the guard stays dormant and path validation is unchanged. + #[must_use] + pub fn with_active_profile( + mut self, + profile_id: String, + action_dir: std::path::PathBuf, + ) -> Self { + tracing::debug!( + profile_id = %profile_id, + action_dir = %action_dir.display(), + "[profiles][security] cross-profile write guard armed for session" + ); + self.active_profile = Some(super::types::ActiveProfileGuard { + profile_id, + action_dir, + }); + self + } + /// Return a copy of this policy with `privacy_mode` set. Used by the live- /// policy install / reload chokepoints to layer `config.privacy.mode` onto a /// policy that [`from_config`](Self::from_config) built with the `Standard` diff --git a/src/openhuman/security/policy/mod.rs b/src/openhuman/security/policy/mod.rs index a93791d83b..b4d4d155ed 100644 --- a/src/openhuman/security/policy/mod.rs +++ b/src/openhuman/security/policy/mod.rs @@ -10,8 +10,9 @@ mod types; pub use enforcement::validate_path_within_root; pub use enforcement::{ensure_openhuman_scratch_dir, openhuman_scratch_dir}; pub use types::{ - ActionTracker, AutonomyLevel, CommandClass, CommandRiskLevel, GateDecision, SecurityPolicy, - ToolOperation, TrustedAccess, TrustedRoot, POLICY_BLOCKED_MARKER, POLICY_DENIED_MARKER, + ActionTracker, ActiveProfileGuard, AutonomyLevel, CommandClass, CommandRiskLevel, GateDecision, + SecurityPolicy, ToolOperation, TrustedAccess, TrustedRoot, POLICY_BLOCKED_MARKER, + POLICY_DENIED_MARKER, }; #[cfg(test)] diff --git a/src/openhuman/security/policy/path_checks.rs b/src/openhuman/security/policy/path_checks.rs index fb930b05d2..40edb0e8fc 100644 --- a/src/openhuman/security/policy/path_checks.rs +++ b/src/openhuman/security/policy/path_checks.rs @@ -224,6 +224,7 @@ impl SecurityPolicy { } let workspace_root = self.workspace_root().await; self.check_resolved_against_forbidden(&resolved, &workspace_root)?; + self.check_cross_profile(&resolved)?; log::debug!( "[security] validate_path: '{}' resolved to '{}'", path, @@ -292,6 +293,7 @@ impl SecurityPolicy { let workspace_root = self.workspace_root().await; self.check_resolved_against_forbidden(&canonical_ancestor, &workspace_root)?; self.check_resolved_against_forbidden(&result, &workspace_root)?; + self.check_cross_profile(&result)?; log::debug!( "[security] validate_parent_path: '{}' resolved parent to '{}'", @@ -301,6 +303,42 @@ impl SecurityPolicy { Ok(result) } + /// Cross-profile write guard (1b). A no-op unless the session runs under a + /// dedicated-workspace profile (`active_profile` armed via + /// [`SecurityPolicy::with_active_profile`]). When armed, a resolved target + /// that lands inside a *sibling* profile's workspace + /// (`/profiles//`, `Q != active`) is refused with the + /// permanent `[policy-blocked]` marker so the harness halts instead of + /// retrying. This only ever tightens: `None` leaves every path check + /// byte-identical, and it never loosens `is_workspace_internal_path` or any + /// other `SecurityPolicy` check. + pub(super) fn check_cross_profile(&self, resolved: &Path) -> Result<(), String> { + let Some(guard) = self.active_profile.as_ref() else { + return Ok(()); + }; + if let crate::openhuman::profiles::CrossProfileDecision::Block { other_id } = + crate::openhuman::profiles::classify_cross_profile_target( + &guard.action_dir, + &guard.profile_id, + resolved, + ) + { + tracing::warn!( + active_profile = %guard.profile_id, + other_profile = %other_id, + target = %resolved.display(), + "[profiles] cross-profile write blocked" + ); + return Err(format!( + "{POLICY_BLOCKED_MARKER} Cross-profile access blocked: profile '{}' may not write \ + into profile '{}'s workspace. Stay within your own profile directory; do not \ + retry this path.", + guard.profile_id, other_id + )); + } + Ok(()) + } + /// Returns `true` if `path` falls under one of the internal-state /// subdirectories or files within `workspace_dir`. Agent tools must not /// write to these locations — they contain memory DBs, session transcripts, diff --git a/src/openhuman/security/policy/policy_tests.rs b/src/openhuman/security/policy/policy_tests.rs index 1978eee17c..e81f9a0a70 100644 --- a/src/openhuman/security/policy/policy_tests.rs +++ b/src/openhuman/security/policy/policy_tests.rs @@ -18,6 +18,82 @@ fn full_policy() -> SecurityPolicy { } } +// -- Cross-profile write guard (1b) ------------------------------- +// +// These drive the guard through the real `validate_parent_path` gate that every +// file write tool funnels through, proving the tightening lands at the shared +// call site (not just in the standalone classifier). `active_profile = None` +// keeps the exact same setup passing, pinning the byte-identical shared path. + +/// Build a `/projects/profiles/{alice,bob}` layout and a policy whose cwd +/// is scoped to alice (as `security_for_tool_context` would), with the guard +/// optionally armed for alice against the broad action root. +fn cross_profile_policy( + arm_for_alice: bool, +) -> (tempfile::TempDir, PathBuf, SecurityPolicy) { + let root = tempfile::tempdir().expect("root tempdir"); + let action_root = root.path().join("projects"); + let profiles = action_root.join("profiles"); + for id in ["alice", "bob"] { + std::fs::create_dir_all(profiles.join(id)).unwrap(); + } + let alice_dir = profiles.join("alice"); + let policy = SecurityPolicy { + autonomy: AutonomyLevel::Full, + // Everything under `root` is inside the workspace, so the sibling path + // clears containment and reaches the cross-profile check. + workspace_dir: root.path().to_path_buf(), + // Mirrors the per-tool-call override: cwd scoped to the profile dir. + action_dir: alice_dir.clone(), + workspace_only: false, + // Clear the default forbidden list (it blocks /tmp, /var, …, which the + // OS tempdir lives under) so the guard is what does the blocking. + forbidden_paths: Vec::new(), + active_profile: arm_for_alice.then(|| ActiveProfileGuard { + profile_id: "alice".to_string(), + action_dir: action_root.clone(), + }), + ..SecurityPolicy::default() + }; + (root, action_root, policy) +} + +#[tokio::test] +async fn cross_profile_guard_blocks_write_into_sibling_profile() { + let (_root, action_root, policy) = cross_profile_policy(true); + let sibling = action_root.join("profiles").join("bob").join("loot.txt"); + let err = policy + .validate_parent_path(sibling.to_str().unwrap()) + .await + .expect_err("write into sibling profile must be blocked"); + assert!(err.contains(POLICY_BLOCKED_MARKER), "err: {err}"); + assert!(err.contains("bob"), "error should name the sibling: {err}"); +} + +#[tokio::test] +async fn cross_profile_guard_allows_write_into_own_profile() { + let (_root, action_root, policy) = cross_profile_policy(true); + let own = action_root.join("profiles").join("alice").join("notes.txt"); + let resolved = policy + .validate_parent_path(own.to_str().unwrap()) + .await + .expect("write into own profile must be allowed"); + assert!(resolved.ends_with("notes.txt")); +} + +#[tokio::test] +async fn cross_profile_guard_disarmed_allows_sibling_write() { + // Same setup, guard OFF (active_profile None): the sibling write is allowed, + // proving the guard only tightens and the shared path is byte-identical. + let (_root, action_root, policy) = cross_profile_policy(false); + let sibling = action_root.join("profiles").join("bob").join("loot.txt"); + let resolved = policy + .validate_parent_path(sibling.to_str().unwrap()) + .await + .expect("with the guard disarmed the sibling write must be allowed"); + assert!(resolved.ends_with("loot.txt")); +} + // -- AutonomyLevel ------------------------------------------------ #[test] diff --git a/src/openhuman/security/policy/types.rs b/src/openhuman/security/policy/types.rs index a3a683ebd9..f916332578 100644 --- a/src/openhuman/security/policy/types.rs +++ b/src/openhuman/security/policy/types.rs @@ -206,6 +206,24 @@ pub(super) const WORKSPACE_INTERNAL_FILES: &[&str] = &[ "PROFILE.md", ]; +/// The active agent-profile context that arms the cross-profile write guard +/// (1b). Present **only** when a turn runs under a dedicated-workspace profile; +/// `None` (the common case) leaves every path check byte-identical to today. +/// +/// Carries the broad `action_dir` deliberately: `security_for_tool_context` +/// clones the policy and overrides its `action_dir` to the profile's own dir +/// for relative-path resolution, so the guard cannot derive the profiles root +/// from `SecurityPolicy::action_dir` at check time — it reads the immutable +/// `action_dir` captured here instead. +#[derive(Debug, Clone)] +pub struct ActiveProfileGuard { + /// Id of the profile the turn runs under (`P`). + pub profile_id: String, + /// The agent's broad action root; sibling profile workspaces live under + /// `/profiles//`. + pub action_dir: PathBuf, +} + /// Security policy enforced on all tool executions #[derive(Debug, Clone)] pub struct SecurityPolicy { @@ -281,6 +299,12 @@ pub struct SecurityPolicy { /// default. `pub(crate)` was an over-tight first cut that broke /// `examples/mouse_smoke.rs` with E0451. pub canonical_workspace: Arc>, + /// Arms the cross-profile write guard (1b) when a dedicated-workspace + /// profile is active. `None` (default) = no active profile → the guard is + /// never consulted and path validation is byte-identical to today. Set once + /// at session build; carried through the per-tool-call clone made by + /// `security_for_tool_context` so file tools see it too. + pub active_profile: Option, } impl Default for SecurityPolicy { @@ -381,6 +405,7 @@ impl Default for SecurityPolicy { auto_approve_all: false, tracker: ActionTracker::new(), canonical_workspace: Arc::new(OnceCell::new()), + active_profile: None, } } } diff --git a/src/openhuman/tools/impl/system/shell.rs b/src/openhuman/tools/impl/system/shell.rs index ab1bb745e3..2b1879e3e2 100644 --- a/src/openhuman/tools/impl/system/shell.rs +++ b/src/openhuman/tools/impl/system/shell.rs @@ -349,6 +349,40 @@ impl ShellTool { return (false, ToolResult::error(reason)); } + // Cross-profile write guard (1b), shell call site. File tools enforce + // the same boundary per-path in `SecurityPolicy::validate_path`; shell + // commands never funnel through that, so scan the command's path-shaped + // tokens against the profile's own workspace (its cwd). No-op unless the + // session runs under a dedicated-workspace profile. See + // `profiles::guard::scan_command_for_cross_profile` for the containment + // rationale (the cwd is already rooted at the profile's own dir). + if let Some(guard) = self.security.active_profile.as_ref() { + let cwd = self.effective_action_dir_for_context(context); + if let Some(other_id) = crate::openhuman::profiles::scan_command_for_cross_profile( + command, + &cwd, + &guard.action_dir, + &guard.profile_id, + ) { + tracing::warn!( + active_profile = %guard.profile_id, + other_profile = %other_id, + "[profiles] cross-profile shell command blocked" + ); + return ( + false, + ToolResult::error(format!( + "{} Cross-profile access blocked: profile '{}' may not touch profile \ + '{}'s workspace. Stay within your own profile directory; do not retry \ + this command.", + crate::openhuman::security::POLICY_BLOCKED_MARKER, + guard.profile_id, + other_id + )), + ); + } + } + if self.security.is_rate_limited() { return ( false, From e671d990151e5dca75ab97985c866fff9b28c678 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 12:20:39 +0400 Subject: [PATCH 10/80] feat(agent_experience): scope procedural experience by profile (1c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent_experience pool was global — one shared namespace and an unused agent_id retrieval filter. Partition it by the active profile so a profile only recalls its own operating lessons plus the shared/legacy pool, and never a sibling profile's. - Record: `AgentExperience.profile_id: Option` (serde-defaulted; legacy stored records have none). Capture: `AgentExperienceCaptureHook::with_profile` stamps every captured record with the session's active profile (built from the resolved profile in the session builder); the profile-less session leaves it None. - Retrieval/injection + RPC: `ExperienceQuery.profile_id`, `RetrieveParams.profile_id`, and a new `ListParams.profile_id` all flow through the shared `experience_matches_profile` predicate. The turn's experience injection passes `Agent.active_profile_id` (1a) as the query profile. - Partition rule (documented + pinned): a profiled query sees records stamped with that profile PLUS unstamped legacy records, and excludes records stamped with a different profile. A profile-less query (None) sees EVERYTHING — the default session historically owns the whole shared pool and must keep recalling every record it and prior versions wrote, so narrowing it would silently drop guidance the default agent still relies on. None-path is byte-identical: no active profile -> records unstamped, queries recall the whole pool exactly as before. Pinned by the capture-stamp test, the retrieve/list partition matrix (P sees P+legacy not Q; None sees all), and the `experience_matches_profile` unit rules. --- .../agent/harness/session/builder/factory.rs | 6 +- .../agent/harness/session/turn/core.rs | 4 + src/openhuman/agent_experience/capture.rs | 58 ++++++- src/openhuman/agent_experience/ops.rs | 16 +- src/openhuman/agent_experience/prompt.rs | 1 + src/openhuman/agent_experience/schemas.rs | 26 ++- src/openhuman/agent_experience/store.rs | 158 ++++++++++++++++++ src/openhuman/agent_experience/types.rs | 7 + 8 files changed, 267 insertions(+), 9 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 91d8beb449..ba46b5f55d 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -800,10 +800,14 @@ impl Agent { } if config.learning.tool_memory_capture_enabled { + // 1c — stamp captured experiences with the active profile id so + // retrieval can partition them. `None` for the profile-less + // session leaves records unstamped (shared/legacy). post_turn_hooks.push(Arc::new( - crate::openhuman::agent_experience::AgentExperienceCaptureHook::new( + crate::openhuman::agent_experience::AgentExperienceCaptureHook::with_profile( memory.clone(), true, + profile.map(|p| p.id.clone()), ), )); log::info!("[learning] agent_experience_capture hook registered"); diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index f1e24ef441..7015ceb337 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -1676,6 +1676,10 @@ impl Agent { agent_id: Some(self.agent_definition_id.clone()).filter(|id| !id.trim().is_empty()), entrypoint: Some(self.event_channel.clone()) .filter(|entrypoint| !entrypoint.trim().is_empty()), + // 1c — partition recall by the active profile: this turn sees records + // stamped with its profile plus unstamped legacy records, and never a + // sibling profile's. `None` (profile-less) recalls the whole pool. + profile_id: self.active_profile_id.clone(), max_hits: MAX_EXPERIENCE_HITS, }; diff --git a/src/openhuman/agent_experience/capture.rs b/src/openhuman/agent_experience/capture.rs index bf5aa7c625..b97c72347f 100644 --- a/src/openhuman/agent_experience/capture.rs +++ b/src/openhuman/agent_experience/capture.rs @@ -14,18 +14,37 @@ const MAX_SUMMARY_CHARS: usize = 280; pub struct AgentExperienceCaptureHook { store: AgentExperienceStore, enabled: bool, + /// Profile the session runs under (1c). Stamped onto every captured record + /// so retrieval can partition by profile. `None` for the profile-less + /// session — those records stay unstamped (shared/legacy). + profile_id: Option, } impl AgentExperienceCaptureHook { pub fn new(memory: Arc, enabled: bool) -> Self { + Self::with_profile(memory, enabled, None) + } + + /// [`Self::new`] carrying the active profile id (1c). The session builder + /// passes the resolved profile so captured records are stamped with it. + pub fn with_profile( + memory: Arc, + enabled: bool, + profile_id: Option, + ) -> Self { Self { store: AgentExperienceStore::new(memory), enabled, + profile_id, } } pub fn from_store(store: AgentExperienceStore, enabled: bool) -> Self { - Self { store, enabled } + Self { + store, + enabled, + profile_id: None, + } } pub fn extract_candidates(ctx: &TurnContext) -> Vec { @@ -64,7 +83,11 @@ impl PostTurnHook for AgentExperienceCaptureHook { return Ok(()); } - for candidate in Self::extract_candidates(ctx) { + for mut candidate in Self::extract_candidates(ctx) { + // Stamp the active profile (1c) so retrieval can partition. Left as + // `None` for the profile-less session — unstamped records read as + // shared/legacy and surface under any profile. + candidate.profile_id = self.profile_id.clone(); if let Err(err) = self.store.put(candidate).await { log::warn!("[agent-experience] failed to capture turn experience: {err}"); } @@ -217,6 +240,10 @@ fn build_experience( source: ExperienceSource::ToolLoop, agent_id: clean_optional(agent_id), entrypoint: clean_optional(entrypoint), + // Stamped by the hook's `on_turn_complete` from the active profile; + // `extract_candidates` stays profile-agnostic so its unit tests need no + // profile context. + profile_id: None, task_fingerprint: stable_task_fingerprint(&task_summary), task_summary, tools_used, @@ -370,5 +397,32 @@ mod tests { assert_eq!(stored[0].outcome, ExperienceOutcome::Success); assert_eq!(stored[0].agent_id.as_deref(), Some("orchestrator")); assert_eq!(stored[0].entrypoint.as_deref(), Some("web_channel")); + // Profile-less hook leaves records unstamped (shared/legacy). + assert_eq!(stored[0].profile_id, None); + } + + #[tokio::test] + async fn on_turn_complete_stamps_active_profile() { + let memory: Arc = Arc::new(MockMemory::default()); + let hook = AgentExperienceCaptureHook::with_profile( + memory.clone(), + true, + Some("alice".to_string()), + ); + + hook.on_turn_complete(&ctx_with(vec![ + call("grep", true, "grep: ok (20 chars)"), + call("file_read", true, "file_read: ok (100 chars)"), + ])) + .await + .unwrap(); + + let stored = AgentExperienceStore::new(memory).list().await.unwrap(); + assert_eq!(stored.len(), 1); + assert_eq!( + stored[0].profile_id.as_deref(), + Some("alice"), + "captured record must be stamped with the active profile id" + ); } } diff --git a/src/openhuman/agent_experience/ops.rs b/src/openhuman/agent_experience/ops.rs index abfde6d143..9a445256d5 100644 --- a/src/openhuman/agent_experience/ops.rs +++ b/src/openhuman/agent_experience/ops.rs @@ -21,10 +21,21 @@ pub struct RetrieveParams { pub agent_id: Option, #[serde(default)] pub entrypoint: Option, + /// Profile partition filter (1c). `None` (omitted) recalls the whole pool; + /// `Some(P)` recalls records stamped `P` plus unstamped legacy records. + #[serde(default)] + pub profile_id: Option, #[serde(default)] pub max_hits: Option, } +#[derive(Debug, Deserialize, Default)] +pub struct ListParams { + /// Profile partition filter (1c), same semantics as `RetrieveParams`. + #[serde(default)] + pub profile_id: Option, +} + #[derive(Debug, Deserialize)] pub struct DismissParams { pub id: String, @@ -64,15 +75,16 @@ pub async fn retrieve(params: RetrieveParams) -> Result Result>, String> { +pub async fn list(params: ListParams) -> Result>, String> { let store = open_store().await?; - let experiences = store.list().await?; + let experiences = store.list_for_profile(params.profile_id.as_deref()).await?; Ok(RpcOutcome::single_log( experiences, "agent experiences listed", diff --git a/src/openhuman/agent_experience/prompt.rs b/src/openhuman/agent_experience/prompt.rs index 6357beeae0..73fbce82a1 100644 --- a/src/openhuman/agent_experience/prompt.rs +++ b/src/openhuman/agent_experience/prompt.rs @@ -123,6 +123,7 @@ mod tests { source: ExperienceSource::ToolLoop, agent_id: Some("orchestrator".into()), entrypoint: Some("chat".into()), + profile_id: None, task_fingerprint: "fp".into(), task_summary: "search docs".into(), tools_used: vec!["grep".into(), "file_read".into()], diff --git a/src/openhuman/agent_experience/schemas.rs b/src/openhuman/agent_experience/schemas.rs index 9cce34ba6b..332540baf4 100644 --- a/src/openhuman/agent_experience/schemas.rs +++ b/src/openhuman/agent_experience/schemas.rs @@ -3,7 +3,9 @@ use serde_json::{Map, Value}; use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; -use crate::openhuman::agent_experience::ops::{CaptureParams, DismissParams, RetrieveParams}; +use crate::openhuman::agent_experience::ops::{ + CaptureParams, DismissParams, ListParams, RetrieveParams, +}; use crate::rpc::RpcOutcome; pub fn all_controller_schemas() -> Vec { @@ -91,6 +93,13 @@ pub fn schemas(function: &str) -> ControllerSchema { comment: "Optional turn entrypoint or event channel.", required: false, }, + FieldSchema { + name: "profile_id", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Optional profile partition: returns records stamped with this \ + profile plus unstamped legacy records; omit to recall the whole pool.", + required: false, + }, FieldSchema { name: "max_hits", ty: TypeSchema::Option(Box::new(TypeSchema::U64)), @@ -109,7 +118,13 @@ pub fn schemas(function: &str) -> ControllerSchema { namespace: "agent_experience", function: "list", description: "List locally stored procedural operating experiences.", - inputs: vec![], + inputs: vec![FieldSchema { + name: "profile_id", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Optional profile partition: lists records stamped with this profile plus \ + unstamped legacy records; omit to list the whole pool.", + required: false, + }], outputs: vec![FieldSchema { name: "experiences", ty: TypeSchema::Array(Box::new(TypeSchema::Ref("AgentExperience"))), @@ -183,8 +198,11 @@ fn handle_retrieve(params: Map) -> ControllerFuture { }) } -fn handle_list(_params: Map) -> ControllerFuture { - Box::pin(async move { to_json(crate::openhuman::agent_experience::ops::list().await?) }) +fn handle_list(params: Map) -> ControllerFuture { + Box::pin(async move { + let params = read_params::(params)?; + to_json(crate::openhuman::agent_experience::ops::list(params).await?) + }) } fn handle_dismiss(params: Map) -> ControllerFuture { diff --git a/src/openhuman/agent_experience/store.rs b/src/openhuman/agent_experience/store.rs index e2acea34a6..219569c5e6 100644 --- a/src/openhuman/agent_experience/store.rs +++ b/src/openhuman/agent_experience/store.rs @@ -16,9 +16,33 @@ pub struct ExperienceQuery { pub tags: Vec, pub agent_id: Option, pub entrypoint: Option, + /// Profile partition (1c). When `Some(P)`, retrieval returns records stamped + /// `P` plus unstamped legacy records and excludes records stamped with a + /// different profile. When `None` (the profile-less session), every record + /// is in scope — see [`experience_matches_profile`]. + pub profile_id: Option, pub max_hits: usize, } +/// Profile partition predicate shared by retrieval and RPC list (1c). +/// +/// - A **profile-less** query (`query_profile == None`) sees **everything**: +/// the default session historically owns the whole shared experience pool and +/// must keep recalling every record it and prior versions wrote, so narrowing +/// it would silently drop guidance the default agent still relies on. +/// - A **profiled** query (`Some(P)`) sees records stamped `P` plus unstamped +/// legacy/shared records (`record_profile == None`), and excludes records +/// stamped with a different profile `Q` — the isolation the feature adds. +pub fn experience_matches_profile(record_profile: Option<&str>, query_profile: Option<&str>) -> bool { + match query_profile { + None => true, + Some(active) => match record_profile { + None => true, + Some(owner) => owner == active, + }, + } +} + #[derive(Clone)] pub struct AgentExperienceStore { memory: Arc, @@ -100,6 +124,24 @@ impl AgentExperienceStore { Ok(experiences) } + /// [`Self::list`] narrowed to a profile partition (1c). `profile_id == None` + /// returns everything (the profile-less view); `Some(P)` returns records + /// stamped `P` plus unstamped legacy records. Shares + /// [`experience_matches_profile`] with retrieval so the two never diverge. + pub async fn list_for_profile( + &self, + profile_id: Option<&str>, + ) -> Result, String> { + Ok(self + .list() + .await? + .into_iter() + .filter(|experience| { + experience_matches_profile(experience.profile_id.as_deref(), profile_id) + }) + .collect()) + } + pub async fn dismiss(&self, id: &str) -> Result { let key = storage_key(id); let Some(mut experience) = self.fetch(&key).await? else { @@ -125,6 +167,12 @@ impl AgentExperienceStore { .await? .into_iter() .filter(|experience| !experience.dismissed) + .filter(|experience| { + experience_matches_profile( + experience.profile_id.as_deref(), + query.profile_id.as_deref(), + ) + }) .filter_map(|experience| { let (score, match_reasons) = score_experience( &experience, @@ -289,6 +337,7 @@ mod tests { source: ExperienceSource::ToolLoop, agent_id: Some("orchestrator".into()), entrypoint: Some("chat".into()), + profile_id: None, task_fingerprint: format!("fp-{id}"), task_summary: task_summary.to_string(), tools_used: tools.iter().map(|tool| (*tool).to_string()).collect(), @@ -371,6 +420,7 @@ mod tests { tags: vec!["docs".into()], agent_id: Some("orchestrator".into()), entrypoint: Some("chat".into()), + profile_id: None, max_hits: 2, }) .await @@ -383,6 +433,113 @@ mod tests { assert!(hits[0].match_reasons.contains(&"query_overlap".into())); } + #[test] + fn experience_matches_profile_partition_rules() { + // Profile-less query sees everything. + assert!(experience_matches_profile(None, None)); + assert!(experience_matches_profile(Some("p"), None)); + // Profiled query: own + legacy in, sibling out. + assert!(experience_matches_profile(Some("p"), Some("p"))); + assert!(experience_matches_profile(None, Some("p"))); + assert!(!experience_matches_profile(Some("q"), Some("p"))); + } + + async fn seed_partitioned(store: &AgentExperienceStore) { + let mut own = sample_experience("exp_p", "task p", vec!["grep"], vec!["docs"], 0.8); + own.profile_id = Some("p".into()); + store.put(own).await.unwrap(); + + let mut sibling = sample_experience("exp_q", "task q", vec!["grep"], vec!["docs"], 0.8); + sibling.profile_id = Some("q".into()); + store.put(sibling).await.unwrap(); + + // Unstamped legacy record. + store + .put(sample_experience( + "exp_legacy", + "task legacy", + vec!["grep"], + vec!["docs"], + 0.8, + )) + .await + .unwrap(); + } + + #[tokio::test] + async fn retrieve_partitions_by_profile() { + let (store, _) = fresh_store(); + seed_partitioned(&store).await; + + // Profile P: sees P + legacy, never Q. + let hits = store + .retrieve(ExperienceQuery { + query: "task".into(), + tools: vec!["grep".into()], + tags: vec!["docs".into()], + profile_id: Some("p".into()), + max_hits: 10, + ..Default::default() + }) + .await + .unwrap(); + let ids: BTreeSet<_> = hits.iter().map(|h| h.experience.id.clone()).collect(); + assert!(ids.contains("exp_p"), "profile P must see its own record"); + assert!(ids.contains("exp_legacy"), "profile P must see legacy records"); + assert!(!ids.contains("exp_q"), "profile P must not see sibling Q"); + + // Profile-less: sees everything. + let all = store + .retrieve(ExperienceQuery { + query: "task".into(), + tools: vec!["grep".into()], + tags: vec!["docs".into()], + profile_id: None, + max_hits: 10, + ..Default::default() + }) + .await + .unwrap(); + let all_ids: BTreeSet<_> = all.iter().map(|h| h.experience.id.clone()).collect(); + assert!(all_ids.contains("exp_p")); + assert!(all_ids.contains("exp_q")); + assert!(all_ids.contains("exp_legacy")); + } + + #[tokio::test] + async fn list_for_profile_partitions() { + let (store, _) = fresh_store(); + seed_partitioned(&store).await; + + let p_ids: BTreeSet<_> = store + .list_for_profile(Some("p")) + .await + .unwrap() + .into_iter() + .map(|e| e.id) + .collect(); + assert_eq!( + p_ids, + BTreeSet::from(["exp_legacy".to_string(), "exp_p".to_string()]) + ); + + let all_ids: BTreeSet<_> = store + .list_for_profile(None) + .await + .unwrap() + .into_iter() + .map(|e| e.id) + .collect(); + assert_eq!( + all_ids, + BTreeSet::from([ + "exp_legacy".to_string(), + "exp_p".to_string(), + "exp_q".to_string() + ]) + ); + } + #[tokio::test] async fn retrieve_ignores_dismissed_records() { let (store, _) = fresh_store(); @@ -405,6 +562,7 @@ mod tests { tags: vec!["docs".into()], agent_id: None, entrypoint: None, + profile_id: None, max_hits: 5, }) .await diff --git a/src/openhuman/agent_experience/types.rs b/src/openhuman/agent_experience/types.rs index 9d4bd7e061..5456240f5f 100644 --- a/src/openhuman/agent_experience/types.rs +++ b/src/openhuman/agent_experience/types.rs @@ -28,6 +28,13 @@ pub struct AgentExperience { pub source: ExperienceSource, pub agent_id: Option, pub entrypoint: Option, + /// Id of the agent profile the turn ran under when this experience was + /// captured (1c). `None` for the default profile-less session and for every + /// record written before profile scoping existed (legacy). Serde-defaulted + /// so older stored payloads deserialize unchanged; retrieval treats `None` + /// records as shared/legacy and surfaces them under any profile. + #[serde(default)] + pub profile_id: Option, pub task_fingerprint: String, pub task_summary: String, pub tools_used: Vec, From 3a32bf44edccd549294b0c0dcbe994bc8fd9fa14 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 12:43:59 +0400 Subject: [PATCH 11/80] feat(profiles): per-profile skills directory (2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `/personalities//skills/` as a per-profile skill discovery root, surfaced ONLY for turns running under that profile. Seam: the discovery/list layer, not the execution engine. - `WorkflowScope::Profile` (highest collision precedence) marks bundles found under a profile-local root; `discover_filtered` scans that root last so a profile-local skill shadows a same-named global one for its owner. New public entry points `discover_workflows_with_profile` / `load_workflow_metadata_for_profile` take the root; the existing `discover_workflows` / `load_workflow_metadata` delegate with `None`, so the profile-less session and every other profile are byte-identical. - `profiles::{profile_skills_dir, profile_skills_root}` resolve the root (root gated on `validate_profile_id`); `ensure_profile_home` seeds the empty `skills/` dir; `enrich_profile` advertises `skillsDir` when it exists on disk. - Harness catalog (factory `.workflows()`) + mid-session refresh (`turn::tools::refresh_workflows`) + the `list_workflows` tool pass the active profile's root; profile-local skills are implicitly allowed for their owner (bypass the `allowed_skills` allowlist). Alternatives considered: threading the root through describe/read/run tools too, but those resolve via the global registry/skill_runtime (`get_workflow`/`read_workflow_resource`/spawn) — the execution engine the spec says not to fork. Left as follow-up; profile-local skills are discoverable/listed/in-prompt but resolve for run/describe/read only if also globally installed. `skills` gate: new fns mirrored in `skills/stub.rs`; `all_tools_with_runtime` gains a `profile_skills_root` param (unread when the feature is off). Disabled build (`--no-default-features --features tokenjuice-treesitter`) is green. Tests: discovery scoping matrix (owner sees profile+global, not another profile's; None sees neither), collision precedence (profile wins + tagged Profile + resolves under the profile root), None==plain back-compat, skills-dir creation, skillsDir enrichment. --- .../agent/harness/session/builder/factory.rs | 54 +++-- .../agent/harness/session/turn/tools.rs | 12 +- src/openhuman/channels/runtime/startup.rs | 1 + src/openhuman/profiles/home.rs | 103 +++++++- src/openhuman/profiles/mod.rs | 2 +- src/openhuman/profiles/ops.rs | 26 +- src/openhuman/runtime_node/ops.rs | 1 + src/openhuman/skills/ops.rs | 5 +- src/openhuman/skills/ops_create.rs | 12 +- src/openhuman/skills/ops_discover.rs | 222 +++++++++++++++++- src/openhuman/skills/ops_install.rs | 4 +- src/openhuman/skills/ops_tests.rs | 2 +- src/openhuman/skills/ops_types.rs | 6 + src/openhuman/skills/stub.rs | 12 + src/openhuman/skills/tools.rs | 34 ++- src/openhuman/tools/ops.rs | 13 +- 16 files changed, 456 insertions(+), 53 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index ba46b5f55d..2afe256ff0 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -347,10 +347,8 @@ impl Agent { // The expression is extracted into [`derive_profile_workspace_descriptor`] // so the unit tests exercise the *same* code path rather than a // hand-copied mirror. - let profile_workspace_descriptor = derive_profile_workspace_descriptor( - &config.action_dir, - profile, - ); + let profile_workspace_descriptor = + derive_profile_workspace_descriptor(&config.action_dir, profile); let runtime: Arc = Arc::from( host_runtime::create_runtime(&config.runtime, config.shell.hide_window)?, @@ -370,8 +368,9 @@ impl Agent { ); let security = Arc::new( match profile.filter(|_| profile_workspace_descriptor.is_some()) { - Some(p) => base_security - .with_active_profile(p.id.clone(), config.action_dir.clone()), + Some(p) => { + base_security.with_active_profile(p.id.clone(), config.action_dir.clone()) + } None => base_security, }, ); @@ -404,6 +403,21 @@ impl Agent { let profile_mcp_allowlist: Option> = profile.and_then(|p| p.allowed_mcp_servers.clone()); + // 2a — profile-local skills root (`/personalities//skills/`). + // Threaded into the harness workflow catalog AND the discovery/list tools + // so a turn running under this profile sees its private skills (implicitly + // allowed for their owner, winning same-name collisions). `None` for the + // profile-less session / legacy ids keeps discovery byte-identical. + let profile_skills_root: Option = profile.and_then(|p| { + crate::openhuman::profiles::profile_skills_root(&config.workspace_dir, &p.id) + }); + if let Some(root) = profile_skills_root.as_deref() { + tracing::debug!( + skills_root = %root.display(), + "[profiles] profile-local skills root active for this session" + ); + } + // Load the user's persisted tool preferences once. They drive two // things below: granting the App UI Control / App Automation mutation // opt-in (#3762) and filtering the tool set to the enabled snapshot. @@ -454,6 +468,7 @@ impl Agent { &tool_config, profile_skill_allowlist.as_ref(), profile_mcp_allowlist.as_deref(), + profile_skills_root.as_deref(), ); // Filter tools by the user preference loaded above. @@ -718,14 +733,16 @@ impl Agent { let profile_suffix = profile_prompt_suffix .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); - let workspace_notice = profile_workspace_descriptor.as_ref().and_then(|descriptor| { - profile.map(|p| { - crate::openhuman::profiles::cross_profile_workspace_notice( - &p.id, - &descriptor.root, - ) - }) - }); + let workspace_notice = profile_workspace_descriptor + .as_ref() + .and_then(|descriptor| { + profile.map(|p| { + crate::openhuman::profiles::cross_profile_workspace_notice( + &p.id, + &descriptor.root, + ) + }) + }); if profile_suffix.is_some() || workspace_notice.is_some() { log::debug!( "[agent:builder] profile prompt section injected suffix_chars={} workspace_notice={}", @@ -1254,9 +1271,12 @@ impl Agent { // see which profile the turn ran under. `None` for the profile-less // session keeps every consumer byte-identical. .active_profile_id(profile.map(|p| p.id.clone())) - .workflows(crate::openhuman::skills::load_workflow_metadata( - &config.workspace_dir, - )) + .workflows( + crate::openhuman::skills::load_workflow_metadata_for_profile( + &config.workspace_dir, + profile_skills_root.as_deref(), + ), + ) .auto_save(config.memory.auto_save) .post_turn_hooks(post_turn_hooks) .learning_enabled(config.learning.enabled) diff --git a/src/openhuman/agent/harness/session/turn/tools.rs b/src/openhuman/agent/harness/session/turn/tools.rs index 1dd30deab7..3e485619a4 100644 --- a/src/openhuman/agent/harness/session/turn/tools.rs +++ b/src/openhuman/agent/harness/session/turn/tools.rs @@ -326,7 +326,17 @@ impl Agent { w.dir_name.clone() } }; - let latest = crate::openhuman::skills::load_workflow_metadata(&self.workspace_dir); + // Keep the mid-session refresh consistent with the initial catalog + // (built in the session factory): include the active profile's private + // skills root so profile-local installs are tracked/announced too. `None` + // for the profile-less session reproduces the prior behaviour. + let profile_skills_root = self.active_profile_id.as_deref().and_then(|id| { + crate::openhuman::profiles::profile_skills_root(&self.workspace_dir, id) + }); + let latest = crate::openhuman::skills::load_workflow_metadata_for_profile( + &self.workspace_dir, + profile_skills_root.as_deref(), + ); let current_ids: std::collections::HashSet = self.workflows.iter().map(&id_of).collect(); let latest_ids: std::collections::HashSet = latest.iter().map(&id_of).collect(); diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index 7e98177ab2..79232906aa 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -345,6 +345,7 @@ pub async fn start_channels(mut config: Config) -> Result<()> { &config, None, None, + None, )); let skills = crate::openhuman::skills::load_workflow_metadata(&workspace); diff --git a/src/openhuman/profiles/home.rs b/src/openhuman/profiles/home.rs index b656b88a56..b4007ab8ab 100644 --- a/src/openhuman/profiles/home.rs +++ b/src/openhuman/profiles/home.rs @@ -35,6 +35,38 @@ pub fn profile_action_workspace(action_dir: &Path, profile_id: &str) -> PathBuf action_dir.join("profiles").join(profile_id) } +/// A profile's private skills directory: `/personalities//skills/`. +/// +/// SKILL.md / WORKFLOW.md bundles placed here are discovered ONLY for turns +/// running under this profile (see +/// `skills::discover_workflows_with_profile`). Seeded empty by +/// [`ensure_profile_home`]. +pub fn profile_skills_dir(workspace_dir: &Path, profile_id: &str) -> PathBuf { + profile_home(workspace_dir, profile_id).join("skills") +} + +/// The profile-local skills discovery root for `profile_id`, iff the id passes +/// [`validate_profile_id`]. +/// +/// The discovery/list seam (harness catalog build, `list_workflows` / +/// `describe_workflow` / resource-read tools) passes this into +/// `skills::discover_workflows_with_profile` / +/// `skills::load_workflow_metadata_for_profile`. Returns `None` for legacy ids +/// that fail validation — matching [`profile_skills_dir`]'s companion guards on +/// the home/workspace paths, so a home the read paths would never load never +/// contributes skills either. +pub fn profile_skills_root(workspace_dir: &Path, profile_id: &str) -> Option { + if let Err(e) = validate_profile_id(profile_id) { + tracing::debug!( + profile_id = %profile_id, + error = %e, + "[profiles][home] profile_skills_root: id fails validation, no profile-local skills root" + ); + return None; + } + Some(profile_skills_dir(workspace_dir, profile_id)) +} + /// Validate a profile id against the hermes-style name grammar /// `^[a-z0-9][a-z0-9_-]{0,63}$`. /// @@ -46,9 +78,7 @@ pub fn validate_profile_id(id: &str) -> Result<(), String> { return Err("profile id must not be empty".to_string()); } if id.len() > 64 { - return Err(format!( - "profile id '{id}' is too long (max 64 characters)" - )); + return Err(format!("profile id '{id}' is too long (max 64 characters)")); } let mut chars = id.chars(); let first = chars.next().expect("non-empty checked above"); @@ -225,6 +255,25 @@ pub fn ensure_profile_home( ); } + // Profile-local skills root: `/personalities//skills/`. + // Created empty so the user has an obvious place to drop private SKILL.md + // bundles; discovery surfaces them only for this profile's turns. + let skills_dir = profile_skills_dir(workspace_dir, &profile.id); + std::fs::create_dir_all(&skills_dir).map_err(|e| { + tracing::debug!( + profile_id = %profile.id, + skills_dir = %skills_dir.display(), + error = %e, + "[profiles][home] create profile skills dir failed" + ); + e + })?; + tracing::debug!( + profile_id = %profile.id, + skills_dir = %skills_dir.display(), + "[profiles][home] profile skills dir ensured" + ); + if let Some(ws) = dedicated_workspace_dir(action_dir, profile) { std::fs::create_dir_all(&ws).map_err(|e| { tracing::debug!( @@ -319,14 +368,14 @@ mod tests { } // Invalid. for id in [ - "", // empty - "-alice", // leading dash - "_alice", // leading underscore - "Alice", // uppercase - "alice bob", // space - "alice.bob", // dot - "alice/bob", // slash - "über", // non-ascii + "", // empty + "-alice", // leading dash + "_alice", // leading underscore + "Alice", // uppercase + "alice bob", // space + "alice.bob", // dot + "alice/bob", // slash + "über", // non-ascii ] { assert!(validate_profile_id(id).is_err(), "expected err: {id}"); } @@ -362,8 +411,7 @@ mod tests { profile.soul_md = Some("I am Bob, terse and exact.".to_string()); ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); - let soul = - std::fs::read_to_string(profile_home(ws.path(), "bob").join("SOUL.md")).unwrap(); + let soul = std::fs::read_to_string(profile_home(ws.path(), "bob").join("SOUL.md")).unwrap(); assert_eq!(soul, "I am Bob, terse and exact.\n"); } @@ -391,6 +439,35 @@ mod tests { ); } + #[test] + fn ensure_profile_home_creates_empty_skills_dir() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + let profile = test_profile("frank"); + + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + + let skills = profile_skills_dir(ws.path(), "frank"); + assert!(skills.is_dir(), "profile skills dir must be created"); + // Empty — the user drops private SKILL.md bundles here. + assert_eq!(std::fs::read_dir(&skills).unwrap().count(), 0); + } + + #[test] + fn profile_skills_dir_and_root_paths() { + let ws = Path::new("/tmp/ws"); + assert_eq!( + profile_skills_dir(ws, "alice"), + Path::new("/tmp/ws/personalities/alice/skills") + ); + // Valid id → Some(root); invalid id → None (read paths never load it). + assert_eq!( + profile_skills_root(ws, "alice"), + Some(Path::new("/tmp/ws/personalities/alice/skills").to_path_buf()) + ); + assert_eq!(profile_skills_root(ws, "Bad Id"), None); + } + #[test] fn ensure_profile_home_creates_dedicated_workspace_when_opted_in() { let ws = TempDir::new().unwrap(); diff --git a/src/openhuman/profiles/mod.rs b/src/openhuman/profiles/mod.rs index dbdaebd886..39b424fda0 100644 --- a/src/openhuman/profiles/mod.rs +++ b/src/openhuman/profiles/mod.rs @@ -50,7 +50,7 @@ pub use guard::{ }; pub use home::{ dedicated_workspace_dir, ensure_profile_home, profile_action_workspace, profile_home, - validate_profile_id, + profile_skills_dir, profile_skills_root, validate_profile_id, }; pub use paths::{ effective_memory_suffix, filter_integrations, memory_subdir_for_suffix, diff --git a/src/openhuman/profiles/ops.rs b/src/openhuman/profiles/ops.rs index a2fafa9d46..d0b53aa9c6 100644 --- a/src/openhuman/profiles/ops.rs +++ b/src/openhuman/profiles/ops.rs @@ -55,6 +55,16 @@ fn enrich_profile(workspace_dir: &Path, action_dir: &Path, profile: &AgentProfil Value::String(soul.to_string_lossy().into_owned()), ); } + // 2a — advertise the profile-local skills dir when it exists on disk (seeded + // by `ensure_profile_home`). Read-only, derived, never persisted. The UI + // (Phase 3) surfaces it as "skills placed here are private to this profile". + let skills_dir = super::home::profile_skills_dir(workspace_dir, &profile.id); + if skills_dir.is_dir() { + obj.insert( + "skillsDir".to_string(), + Value::String(skills_dir.to_string_lossy().into_owned()), + ); + } if let Some(ws) = dedicated_workspace_dir(action_dir, profile) { obj.insert( "workspaceDir".to_string(), @@ -134,7 +144,11 @@ pub async fn select(profile_id: &str) -> Result { })?; // Materialize the selected profile's home (covers built-ins, which are only // seeded when a user first activates them). - if let Some(profile) = state.profiles.iter().find(|p| p.id == state.active_profile_id) { + if let Some(profile) = state + .profiles + .iter() + .find(|p| p.id == state.active_profile_id) + { materialize_home(&workspace_dir, &action_dir, profile); } tracing::debug!( @@ -344,6 +358,16 @@ mod tests { // The dedicated workspace dir was actually created. assert!(std::path::Path::new(workspace_dir).is_dir()); assert_eq!(writer["dedicatedWorkspace"], json!(true)); + // 2a — the profile-local skills dir is seeded by `ensure_profile_home` + // and advertised read-only as `skillsDir`. + let skills_dir = writer["skillsDir"] + .as_str() + .expect("skillsDir present (skills dir was seeded on disk)"); + assert!( + skills_dir.ends_with("personalities/writer/skills"), + "skillsDir should end at the profile skills dir, got {skills_dir}" + ); + assert!(std::path::Path::new(skills_dir).is_dir()); } #[tokio::test] diff --git a/src/openhuman/runtime_node/ops.rs b/src/openhuman/runtime_node/ops.rs index 0bc43c19ce..c5b935797a 100644 --- a/src/openhuman/runtime_node/ops.rs +++ b/src/openhuman/runtime_node/ops.rs @@ -127,6 +127,7 @@ fn build_runtime_tools(config: &Config) -> Result>, String> { config, None, None, + None, ); debug!( tool_count = built.len(), diff --git a/src/openhuman/skills/ops.rs b/src/openhuman/skills/ops.rs index d6ad883101..e55c466c0b 100644 --- a/src/openhuman/skills/ops.rs +++ b/src/openhuman/skills/ops.rs @@ -31,8 +31,9 @@ // callers are unaffected. pub use super::ops_create::{create_workflow, CreateWorkflowParams, WorkflowCreateInputDef}; pub use super::ops_discover::{ - discover_automations, discover_workflows, init_workflows_dir, is_workspace_trusted, - load_workflow_metadata, read_workflow_resource, + discover_automations, discover_workflows, discover_workflows_with_profile, init_workflows_dir, + is_workspace_trusted, load_workflow_metadata, load_workflow_metadata_for_profile, + read_workflow_resource, }; pub use super::ops_install::{ install_workflow_from_url, uninstall_workflow, validate_install_url, validate_resolved_host, diff --git a/src/openhuman/skills/ops_create.rs b/src/openhuman/skills/ops_create.rs index b271dff224..587116f12c 100644 --- a/src/openhuman/skills/ops_create.rs +++ b/src/openhuman/skills/ops_create.rs @@ -149,7 +149,10 @@ fn legacy_workflow_dir( workspace_dir.join(".openhuman").join("skills"), workspace_dir.join(".agents").join("skills"), ], - WorkflowScope::Legacy => return None, + // Profile-local skills are placed by hand under + // `/personalities//skills/`, never scaffolded through + // the create path; treat them like Legacy here (no create target). + WorkflowScope::Legacy | WorkflowScope::Profile => return None, }; for root in roots { let canonical_root = match std::fs::canonicalize(&root) { @@ -214,9 +217,10 @@ pub(crate) fn create_workflow_inner( } workspace_dir.join(".openhuman").join("workflows") } - WorkflowScope::Legacy => { + WorkflowScope::Legacy | WorkflowScope::Profile => { return Err( - "cannot create skill in legacy scope; choose 'user' or 'project'".to_string(), + "cannot create skill in legacy or profile scope; choose 'user' or 'project'" + .to_string(), ); } }; @@ -378,7 +382,7 @@ pub(crate) fn create_workflow_inner( ); let trusted = is_workspace_trusted(workspace_dir); - let created = discover_workflows_inner(home_dir, Some(workspace_dir), trusted) + let created = discover_workflows_inner(home_dir, Some(workspace_dir), None, trusted) .into_iter() .find(|s| s.name == slug) .ok_or_else(|| format!("created skill '{slug}' but failed to re-discover"))?; diff --git a/src/openhuman/skills/ops_discover.rs b/src/openhuman/skills/ops_discover.rs index 0038c1732c..d45f6a26c3 100644 --- a/src/openhuman/skills/ops_discover.rs +++ b/src/openhuman/skills/ops_discover.rs @@ -66,7 +66,30 @@ pub fn init_workflows_dir(workspace_dir: &Path) -> Result<(), String> { pub fn load_workflow_metadata(workspace_dir: &Path) -> Vec { let trusted = is_workspace_trusted(workspace_dir); let home = dirs::home_dir(); - discover_workflows_inner(home.as_deref(), Some(workspace_dir), trusted) + discover_workflows_inner(home.as_deref(), Some(workspace_dir), None, trusted) +} + +/// Like [`load_workflow_metadata`], but additionally scans a profile-local +/// skills root (`/personalities//skills/`) when one is supplied. +/// +/// Callers pass the active profile's root (resolved via +/// `profiles::profile_skills_root`) so the returned catalog carries that +/// profile's private skills. `None` reproduces [`load_workflow_metadata`] +/// byte-for-byte, so the profile-less session and every other profile are +/// unaffected. Profile-local skills win same-name collisions against global +/// scopes (see [`WorkflowScope::Profile`]). +pub fn load_workflow_metadata_for_profile( + workspace_dir: &Path, + profile_skills_root: Option<&Path>, +) -> Vec { + let trusted = is_workspace_trusted(workspace_dir); + let home = dirs::home_dir(); + discover_workflows_inner( + home.as_deref(), + Some(workspace_dir), + profile_skills_root, + trusted, + ) } /// Discover skills from every supported location. @@ -84,7 +107,26 @@ pub fn discover_workflows( workspace_dir: Option<&Path>, trusted: bool, ) -> Vec { - discover_workflows_inner(home_dir, workspace_dir, trusted) + discover_workflows_inner(home_dir, workspace_dir, None, trusted) +} + +/// Discover skills including a profile-local root, for a turn running under a +/// specific agent profile. +/// +/// `profile_skills_root` is `/personalities//skills/` (resolved +/// via `profiles::profile_skills_root`, which validates the id). It is scanned +/// unconditionally — no trust marker is required, since the directory is +/// core-managed under `workspace_dir` — and its bundles win same-name collisions +/// against every global scope for this profile. `None` is identical to +/// [`discover_workflows`], so other profiles and the default session never see +/// these skills. +pub fn discover_workflows_with_profile( + home_dir: Option<&Path>, + workspace_dir: Option<&Path>, + profile_skills_root: Option<&Path>, + trusted: bool, +) -> Vec { + discover_workflows_inner(home_dir, workspace_dir, profile_skills_root, trusted) } /// Whether the workspace has opted into loading project-scope skills. @@ -116,9 +158,16 @@ const WORKFLOW_ROOT_KINDS: &[RootKind] = &[RootKind::Workflow]; pub(crate) fn discover_workflows_inner( home_dir: Option<&Path>, workspace_dir: Option<&Path>, + profile_skills_root: Option<&Path>, trusted: bool, ) -> Vec { - discover_filtered(home_dir, workspace_dir, trusted, ALL_ROOT_KINDS) + discover_filtered( + home_dir, + workspace_dir, + profile_skills_root, + trusted, + ALL_ROOT_KINDS, + ) } /// Discover only *automation* bundles — those under the `workflows/` roots — @@ -143,7 +192,7 @@ pub fn discover_automations( has_workspace = workspace_dir.is_some(), "[workflows] discover:automations:enter" ); - discover_filtered(home_dir, workspace_dir, trusted, WORKFLOW_ROOT_KINDS) + discover_filtered(home_dir, workspace_dir, None, trusted, WORKFLOW_ROOT_KINDS) } /// Shared discovery core. `kinds` selects which root categories to scan, @@ -152,6 +201,7 @@ pub fn discover_automations( fn discover_filtered( home_dir: Option<&Path>, workspace_dir: Option<&Path>, + profile_skills_root: Option<&Path>, trusted: bool, kinds: &[RootKind], ) -> Vec { @@ -159,6 +209,7 @@ fn discover_filtered( trusted, has_home = home_dir.is_some(), has_workspace = workspace_dir.is_some(), + has_profile_root = profile_skills_root.is_some(), include_skills = kinds.contains(&RootKind::Skill), include_workflows = kinds.contains(&RootKind::Workflow), "[workflows] discover:enter" @@ -210,6 +261,33 @@ fn discover_filtered( } } + // Profile-local skills (`/personalities//skills/`) are a skill + // root scoped to the *active* profile: scanned last and at the highest + // precedence so a profile-local bundle wins any same-name collision against + // the global scopes for its owner (see [`precedence`]). Excluded from the + // automations-only view for the same reason as the legacy skill root. No + // trust marker is consulted — the directory is core-managed under + // `workspace_dir`, seeded by `ensure_profile_home`. + if let Some(profile_root) = profile_skills_root { + if kinds.contains(&RootKind::Skill) { + tracing::debug!( + root = %profile_root.display(), + scope = ?WorkflowScope::Profile, + "[profiles] discover:branch:profile-local skills" + ); + let before = by_name.len(); + absorb( + &mut by_name, + scan_root(profile_root, WorkflowScope::Profile), + ); + tracing::debug!( + names_before = before, + names_after = by_name.len(), + "[profiles] profile-local skills absorbed (profile scope wins same-name collisions)" + ); + } + } + let mut out: Vec = by_name.into_values().collect(); out.sort_by(|a, b| a.name.cmp(&b.name)); tracing::debug!(discovered_count = out.len(), "[workflows] discover:exit"); @@ -284,6 +362,8 @@ fn precedence(scope: WorkflowScope) -> u8 { WorkflowScope::Legacy => 0, WorkflowScope::User => 1, WorkflowScope::Project => 2, + // Profile-local skills win against every global scope for their owner. + WorkflowScope::Profile => 3, } } @@ -613,3 +693,137 @@ mod include_skills_tests { ); } } + +#[cfg(test)] +mod profile_scope_tests { + use super::*; + + /// Write a minimal `WORKFLOW.md` bundle under `root/slug/`. + fn seed_bundle(root: &Path, slug: &str) { + let dir = root.join(slug); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("WORKFLOW.md"), + format!("---\nname: {slug}\ndescription: {slug} desc\n---\n\n{slug} body\n"), + ) + .unwrap(); + } + + /// Profile-local skills appear ONLY when their root is passed, and never for + /// the profile-less session or a *different* profile's root (2a scoping + /// matrix). + #[test] + fn profile_local_skills_scoped_to_their_owner() { + let home = tempfile::TempDir::new().unwrap(); + // A global user-scope skill everyone sees. + seed_bundle( + &home.path().join(".openhuman").join("skills"), + "global-skill", + ); + + // Two distinct profile roots (alice / bob), each with a private skill. + let alice_root = tempfile::TempDir::new().unwrap(); + seed_bundle(alice_root.path(), "alice-only"); + let bob_root = tempfile::TempDir::new().unwrap(); + seed_bundle(bob_root.path(), "bob-only"); + + let names = |workflows: Vec| { + let mut n: Vec = workflows.into_iter().map(|w| w.name).collect(); + n.sort(); + n + }; + + // No profile: only the global skill. + let none = names(discover_workflows_with_profile( + Some(home.path()), + None, + None, + false, + )); + assert_eq!(none, vec!["global-skill"]); + + // Alice's turn: global + alice-only, never bob-only. + let alice = names(discover_workflows_with_profile( + Some(home.path()), + None, + Some(alice_root.path()), + false, + )); + assert_eq!(alice, vec!["alice-only", "global-skill"]); + + // Bob's turn: global + bob-only, never alice-only. + let bob = names(discover_workflows_with_profile( + Some(home.path()), + None, + Some(bob_root.path()), + false, + )); + assert_eq!(bob, vec!["bob-only", "global-skill"]); + } + + /// A profile-local skill named the same as a global skill wins for its owner + /// (highest precedence) and is tagged `WorkflowScope::Profile` (2a collision + /// precedence). + #[test] + fn profile_local_wins_same_name_collision() { + let home = tempfile::TempDir::new().unwrap(); + seed_bundle( + &home.path().join(".openhuman").join("skills"), + "shared-name", + ); + let profile_root = tempfile::TempDir::new().unwrap(); + seed_bundle(profile_root.path(), "shared-name"); + + let workflows = discover_workflows_with_profile( + Some(home.path()), + None, + Some(profile_root.path()), + false, + ); + let winner = workflows + .iter() + .find(|w| w.name == "shared-name") + .expect("shared-name resolved"); + // Exactly one entry for the name, and it is the profile-local copy. + assert_eq!( + workflows.iter().filter(|w| w.name == "shared-name").count(), + 1, + "collision must collapse to a single winner" + ); + assert_eq!( + winner.scope, + WorkflowScope::Profile, + "profile-local skill must win the same-name collision" + ); + // The winner resolves under the profile root, not the global one. + let canon_profile = std::fs::canonicalize(profile_root.path()).unwrap(); + let loc = std::fs::canonicalize(winner.location.as_ref().unwrap()).unwrap(); + assert!( + loc.starts_with(&canon_profile), + "winning skill must live under the profile root, got {}", + loc.display() + ); + } + + /// `WorkflowScope::Profile` outranks every global scope in the precedence + /// ladder (the mechanism the collision test relies on). + #[test] + fn profile_scope_has_highest_precedence() { + assert!(precedence(WorkflowScope::Profile) > precedence(WorkflowScope::Project)); + assert!(precedence(WorkflowScope::Profile) > precedence(WorkflowScope::User)); + assert!(precedence(WorkflowScope::Profile) > precedence(WorkflowScope::Legacy)); + } + + /// A `None` profile root reproduces `load_workflow_metadata` byte-for-byte — + /// the back-compat guarantee for the profile-less session. + #[test] + fn none_profile_root_matches_plain_discovery() { + let home = tempfile::TempDir::new().unwrap(); + seed_bundle(&home.path().join(".openhuman").join("skills"), "a-skill"); + let with_none = discover_workflows_with_profile(Some(home.path()), None, None, false); + let plain = discover_workflows(Some(home.path()), None, false); + let names: Vec<&str> = with_none.iter().map(|w| w.name.as_str()).collect(); + let plain_names: Vec<&str> = plain.iter().map(|w| w.name.as_str()).collect(); + assert_eq!(names, plain_names); + } +} diff --git a/src/openhuman/skills/ops_install.rs b/src/openhuman/skills/ops_install.rs index c24cfe02cd..3f628aaac2 100644 --- a/src/openhuman/skills/ops_install.rs +++ b/src/openhuman/skills/ops_install.rs @@ -168,7 +168,7 @@ pub(crate) async fn install_workflow_from_url_with_home( let trusted_before = is_workspace_trusted(workspace_dir); let before: std::collections::HashSet = - discover_workflows_inner(home, Some(workspace_dir), trusted_before) + discover_workflows_inner(home, Some(workspace_dir), None, trusted_before) .into_iter() .map(|s| s.name) .collect(); @@ -371,7 +371,7 @@ pub(crate) async fn install_workflow_from_url_with_home( } let trusted_after = is_workspace_trusted(workspace_dir); - let after = discover_workflows_inner(home, Some(workspace_dir), trusted_after); + let after = discover_workflows_inner(home, Some(workspace_dir), None, trusted_after); let new_skills: Vec = after .into_iter() .map(|s| s.name) diff --git a/src/openhuman/skills/ops_tests.rs b/src/openhuman/skills/ops_tests.rs index 75e2deb6bc..9a82efb2f8 100644 --- a/src/openhuman/skills/ops_tests.rs +++ b/src/openhuman/skills/ops_tests.rs @@ -15,7 +15,7 @@ fn write(path: &Path, content: &str) { /// [`discover_workflows`] explicitly (see `load_skills_surfaces_user_scope`). fn load_skills_ws(workspace_dir: &Path) -> Vec { let trusted = is_workspace_trusted(workspace_dir); - discover_workflows_inner(None, Some(workspace_dir), trusted) + discover_workflows_inner(None, Some(workspace_dir), None, trusted) } #[test] diff --git a/src/openhuman/skills/ops_types.rs b/src/openhuman/skills/ops_types.rs index ff92ae1f5d..828e0d8926 100644 --- a/src/openhuman/skills/ops_types.rs +++ b/src/openhuman/skills/ops_types.rs @@ -51,6 +51,12 @@ pub enum WorkflowScope { Project, /// Workflow discovered under the legacy `/skills/` layout. Legacy, + /// Workflow private to the active agent profile, discovered under + /// `/personalities//skills/` and surfaced ONLY for turns + /// running under that profile. Highest collision precedence — a + /// profile-local skill shadows a same-named global one for its owner. See + /// `ops_discover::discover_workflows_with_profile`. + Profile, } /// Parsed frontmatter of a `SKILL.md` file. diff --git a/src/openhuman/skills/stub.rs b/src/openhuman/skills/stub.rs index c09d9b9bcc..c63de3046b 100644 --- a/src/openhuman/skills/stub.rs +++ b/src/openhuman/skills/stub.rs @@ -44,6 +44,18 @@ pub fn load_workflow_metadata(_workspace_dir: &Path) -> Vec { Vec::new() } +/// Always empty: with skills compiled out there is nothing to discover, whether +/// or not a profile-local root is supplied. Mirrors +/// [`super::ops_discover::load_workflow_metadata_for_profile`] so the harness's +/// per-profile catalog call site needs no `#[cfg]`. +pub fn load_workflow_metadata_for_profile( + _workspace_dir: &Path, + _profile_skills_root: Option<&Path>, +) -> Vec { + log::debug!("[skills-stub] load_workflow_metadata_for_profile -> [] (skills disabled)"); + Vec::new() +} + /// No-op success: with skills compiled out there is no skills directory to /// provision, and workspace bootstrap must not fail because of it. pub fn init_workflows_dir(_workspace_dir: &Path) -> Result<(), String> { diff --git a/src/openhuman/skills/tools.rs b/src/openhuman/skills/tools.rs index 144a0b3bbd..4f1ce5960a 100644 --- a/src/openhuman/skills/tools.rs +++ b/src/openhuman/skills/tools.rs @@ -24,11 +24,14 @@ use crate::openhuman::config::Config; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; use super::ops_create::{create_workflow, CreateWorkflowParams}; -use super::ops_discover::{discover_workflows, is_workspace_trusted, read_workflow_resource}; +use super::ops_discover::{ + discover_workflows_with_profile, is_workspace_trusted, read_workflow_resource, +}; use super::ops_install::{ install_workflow_from_url, uninstall_workflow, InstallWorkflowFromUrlParams, UninstallWorkflowParams, }; +use super::ops_types::WorkflowScope; use super::registry::get_workflow; use super::run_log::{find_run_log_path, read_run_log_slice, scan_runs}; @@ -65,6 +68,10 @@ fn skill_allowed(allowlist: &SkillAllowlist, dir_name: &str) -> bool { pub struct WorkflowListTool { workspace_dir: PathBuf, skill_allowlist: SkillAllowlist, + /// 2a — the active profile's private skills root + /// (`/personalities//skills/`). `None` for the profile-less + /// session and other profiles, so the listed set is byte-identical to today. + profile_skills_root: Option, } impl WorkflowListTool { @@ -72,6 +79,7 @@ impl WorkflowListTool { Self { workspace_dir: config.workspace_dir.clone(), skill_allowlist: None, + profile_skills_root: None, } } @@ -81,6 +89,15 @@ impl WorkflowListTool { self.skill_allowlist = allowlist; self } + + /// Surface the active profile's private skills + /// (`/personalities//skills/`) in this list. Profile-local + /// skills are implicitly allowed for their owner (they bypass the + /// `skill_allowlist`) and win same-name collisions against global skills. + pub fn with_profile_skills_root(mut self, root: Option) -> Self { + self.profile_skills_root = root; + self + } } #[async_trait] @@ -104,10 +121,21 @@ impl Tool for WorkflowListTool { log::debug!("[tool][workflows] list invoked"); let home = dirs::home_dir(); let trusted = is_workspace_trusted(&self.workspace_dir); - let mut workflows = discover_workflows(home.as_deref(), Some(&self.workspace_dir), trusted); + let mut workflows = discover_workflows_with_profile( + home.as_deref(), + Some(&self.workspace_dir), + self.profile_skills_root.as_deref(), + trusted, + ); if self.skill_allowlist.is_some() { let before = workflows.len(); - workflows.retain(|w| skill_allowed(&self.skill_allowlist, &w.dir_name)); + // Profile-local skills are implicitly allowed for their owner — they + // bypass the `allowed_skills` allowlist (which scopes only global + // skills). Keep any skill whose scope is `Profile`. + workflows.retain(|w| { + w.scope == WorkflowScope::Profile + || skill_allowed(&self.skill_allowlist, &w.dir_name) + }); log::debug!( "[profiles] list_workflows scoped to profile allowlist: before={before} after={}", workflows.len() diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index a892e90310..f886525d28 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -78,6 +78,7 @@ pub fn all_tools( root_config, None, None, + None, ) } @@ -100,11 +101,13 @@ pub fn all_tools_with_runtime( root_config: &crate::openhuman::config::Config, skill_allowlist: Option<&std::collections::HashSet>, mcp_allowlist: Option<&[String]>, + profile_skills_root: Option<&std::path::Path>, ) -> Vec> { - // `skill_allowlist` scopes only the `skills`-gated tool registrations - // below, so it is genuinely unread when that feature is compiled out. + // `skill_allowlist` / `profile_skills_root` scope only the `skills`-gated + // tool registrations below, so they are genuinely unread when that feature + // is compiled out. #[cfg(not(feature = "skills"))] - let _ = skill_allowlist; + let _ = (skill_allowlist, profile_skills_root); // Build a session-scoped managed Node.js bootstrap once, so ShellTool, // NodeExecTool, and NpmExecTool all share the same memoised resolution @@ -511,7 +514,9 @@ pub fn all_tools_with_runtime( // `tools::user_filter` (install also fetches remote content). #[cfg(feature = "skills")] Box::new( - WorkflowListTool::new(config.clone()).with_skill_allowlist(skill_allowlist.cloned()), + WorkflowListTool::new(config.clone()) + .with_skill_allowlist(skill_allowlist.cloned()) + .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), ), #[cfg(feature = "skills")] Box::new( From ec2831a0b78e2e0513bc7e58abc87d910a2864e2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 12:54:14 +0400 Subject: [PATCH 12/80] feat(cron): per-profile cron job attribution (2b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional `profile_id` to cron jobs so a scheduled agent run can be attributed to an agent profile and inherit its SOUL, memory scope, dedicated-workspace descriptor, and tool/skill/MCP allowlists. - `CronJob.profile_id: Option` (`#[serde(default)]`) and `CronJobPatch.profile_id: Option>` (None = no change, Some(None) = clear), mirroring the existing `agent_id` shapes. - Store: `add_column_if_missing("profile_id", "TEXT")` migration; the column is threaded through the agent-job INSERT, all SELECT lists, the row mapper, and the UPDATE patch. Legacy DBs/rows load with `profile_id = None` — byte-identical. - Run construction: `build_agent_for_cron_job` resolves the attributed profile (`resolve_cron_profile`) and, when it still exists, builds the run via `Agent::from_config_for_agent_with_profile` — the SAME profile-aware path the task dispatcher uses — so the run is not a fork of the execution engine. A deleted profile (or load error) warns and falls through to the profile-less build; a profile-aware build error also falls back. A job may still pin a built-in `agent_id`; otherwise the profile's own `agent_id` is used. - RPC: `cron.add` accepts `profile_id` (snake_case, matching the domain's existing `agent_id`/`session_target` wire convention — NOT camelCase `profileId`; the frontend cron client is snake_case too); `cron.update` accepts it via `CronJobPatch`; `cron.list` returns it (serde). Schema advertises the new input field. Seed jobs (morning briefing, tinyplace autopilot, legacy welcome) pass `None` — unattributed, unchanged. Tests: schema back-compat (CronJob deserializes without the field; CronJobPatch default None + explicit-None clearing), store round-trip (create -> reload -> repoint -> clear -> untouched), shell-job has no attribution, `resolve_cron_profile` present-vs-deleted fallback, and a json_rpc_e2e round-trip (cron_add + cron_list + cron_update over /rpc inside the profiles lifecycle test). Deviation from spec wording: the spec said `profileId`; used snake_case `profile_id` for consistency with the cron domain's existing wire fields. --- src/openhuman/cron/scheduler.rs | 75 ++++++++++++++++++++++++++ src/openhuman/cron/scheduler_tests.rs | 35 ++++++++++++ src/openhuman/cron/schemas.rs | 14 +++++ src/openhuman/cron/seed.rs | 3 ++ src/openhuman/cron/store.rs | 23 +++++--- src/openhuman/cron/store_tests.rs | 78 +++++++++++++++++++++++++++ src/openhuman/cron/types.rs | 62 +++++++++++++++++++++ tests/json_rpc_e2e.rs | 60 +++++++++++++++++++++ 8 files changed, 343 insertions(+), 7 deletions(-) diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index 623bbf09c6..7f964f8687 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -1014,7 +1014,82 @@ fn run_flow_schedule_job(job: &CronJob) -> (bool, String) { /// no text. Never delivered to chat — used only for the run-history record. const EMPTY_AGENT_OUTPUT: &str = "agent job executed"; +/// Resolve the agent profile a cron job is attributed to, if any. +/// +/// Returns `Some(profile)` only when `job.profile_id` is set AND that profile +/// still exists in the store. A deleted profile (or a load error) yields `None` +/// so the caller runs the job without a profile rather than failing it (2b). +fn resolve_cron_profile( + config: &Config, + job: &CronJob, +) -> Option { + let profile_id = job.profile_id.as_deref()?; + match crate::openhuman::profiles::load_profiles(&config.workspace_dir) { + Ok(state) => { + let found = state.profiles.into_iter().find(|p| p.id == profile_id); + if found.is_none() { + tracing::warn!( + job_id = %job.id, + profile_id = %profile_id, + "[cron] attributed profile no longer exists — running job without a profile" + ); + } + found + } + Err(e) => { + tracing::warn!( + job_id = %job.id, + profile_id = %profile_id, + error = %e, + "[cron] failed to load profiles for cron attribution — running without a profile" + ); + None + } + } +} + fn build_agent_for_cron_job(config: &Config, job: &CronJob) -> anyhow::Result { + // 2b — profile attribution. When the job names a profile that still exists, + // build the run under it via the SAME profile-aware session path the task + // dispatcher uses (`from_config_for_agent_with_profile`), so the run inherits + // the profile's SOUL, memory scope, dedicated-workspace descriptor, and + // tool/skill/MCP allowlists. A deleted profile falls through (warned in + // `resolve_cron_profile`) to the profile-less path below. + if let Some(profile) = resolve_cron_profile(config, job) { + // A job may pin a built-in `agent_id`; otherwise the profile picks its + // own agent definition. + let agent_id = job + .agent_id + .clone() + .unwrap_or_else(|| profile.agent_id.clone()); + match Agent::from_config_for_agent_with_profile( + config, + &agent_id, + None, + None, + Some(&profile), + ) { + Ok(agent) => { + tracing::debug!( + job_id = %job.id, + profile_id = %profile.id, + agent_id = %agent_id, + "[cron] built scheduled job agent under attributed profile" + ); + return Ok(agent); + } + Err(e) => { + tracing::warn!( + job_id = %job.id, + profile_id = %profile.id, + agent_id = %agent_id, + error = %e, + "[cron] profile-aware agent build failed; falling back to profile-less build" + ); + } + } + } + if let Some(agent_id) = job.agent_id.as_deref() { match Agent::from_config_for_agent(config, agent_id) { Ok(agent) => { diff --git a/src/openhuman/cron/scheduler_tests.rs b/src/openhuman/cron/scheduler_tests.rs index dff55e92ed..8ca0fb0618 100644 --- a/src/openhuman/cron/scheduler_tests.rs +++ b/src/openhuman/cron/scheduler_tests.rs @@ -39,6 +39,7 @@ fn test_job(command: &str) -> CronJob { session_target: SessionTarget::Isolated, model: None, agent_id: None, + profile_id: None, enabled: true, delivery: DeliveryConfig::default(), delete_after_run: false, @@ -50,6 +51,40 @@ fn test_job(command: &str) -> CronJob { } } +#[tokio::test] +async fn resolve_cron_profile_present_and_deleted_fallback() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp).await; + + // A job attributed to profile "alice". + let mut job = test_job(""); + job.job_type = JobType::Agent; + job.profile_id = Some("alice".into()); + + // Profile does not exist yet → None (the deleted-profile fallback path; + // the scheduler runs the job without a profile rather than failing it). + assert!( + resolve_cron_profile(&config, &job).is_none(), + "missing profile must resolve to None" + ); + + // Seed the profile → it now resolves. + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.id = "alice".into(); + profile.name = "Alice".into(); + profile.built_in = false; + profile.is_master = false; + crate::openhuman::profiles::store::AgentProfileStore::new(config.workspace_dir.clone()) + .upsert(profile) + .expect("seed profile"); + let resolved = resolve_cron_profile(&config, &job).expect("profile resolves"); + assert_eq!(resolved.id, "alice"); + + // A job with no attribution is always None. + let plain = test_job(""); + assert!(resolve_cron_profile(&config, &plain).is_none()); +} + #[test] fn agent_failure_copy_mentions_retry_reporting_and_discord() { assert!(AGENT_JOB_USER_FAILURE_MESSAGE.contains("Something went wrong. Please try again.")); diff --git a/src/openhuman/cron/schemas.rs b/src/openhuman/cron/schemas.rs index c4d7a981cf..13d8c8f85d 100644 --- a/src/openhuman/cron/schemas.rs +++ b/src/openhuman/cron/schemas.rs @@ -115,6 +115,13 @@ pub fn schemas(function: &str) -> ControllerSchema { comment: "Built-in agent or skill definition ID.", required: false, }, + FieldSchema { + name: "profile_id", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Agent profile id to run the job under (soul, memory scope, \ + workspace, allowlists). Ignored if the profile is deleted.", + required: false, + }, FieldSchema { name: "delivery", ty: TypeSchema::Option(Box::new(TypeSchema::Ref("DeliveryConfig"))), @@ -311,6 +318,12 @@ fn handle_add(params: Map) -> ControllerFuture { .get("agent_id") .and_then(|v| v.as_str()) .map(|s| s.to_string()); + // 2b — optional agent-profile attribution. Snake_case `profile_id` matches + // the existing cron wire convention (`agent_id`, `session_target`). + let profile_id = params + .get("profile_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); let delivery: Option = match params.get("delivery") { None | Some(Value::Null) => None, @@ -358,6 +371,7 @@ fn handle_add(params: Map) -> ControllerFuture { agent_id, // RPC-created jobs default to enabled (current behaviour). true, + profile_id, ) .map_err(|e| e.to_string())? } diff --git a/src/openhuman/cron/seed.rs b/src/openhuman/cron/seed.rs index e80f6fc4d5..dfd8f75c95 100644 --- a/src/openhuman/cron/seed.rs +++ b/src/openhuman/cron/seed.rs @@ -195,6 +195,7 @@ fn seed_morning_briefing(config: &Config) -> Result<()> { false, // recurring — do not delete after run Some(MORNING_BRIEFING_JOB_NAME.to_string()), false, // enabled=false — opt-in, created disabled atomically + None, // no profile attribution for the seeded briefing )?; tracing::debug!( @@ -255,6 +256,7 @@ fn seed_tinyplace_autopilot(config: &Config) -> Result<()> { // Runs the single tiny.place agent autonomously (no dedicated agent def). Some("tinyplace_agent".to_string()), false, // enabled=false — opt-in, created disabled atomically + None, // no profile attribution for the seeded autopilot )?; tracing::debug!( @@ -488,6 +490,7 @@ mod tests { true, Some(LEGACY_WELCOME_JOB_NAME.to_string()), true, // enabled + None, // no profile attribution ) .expect("seed legacy welcome"); assert_eq!(list_jobs(&config).unwrap().len(), 1); diff --git a/src/openhuman/cron/store.rs b/src/openhuman/cron/store.rs index 7f4e4ae139..07c26dfe55 100644 --- a/src/openhuman/cron/store.rs +++ b/src/openhuman/cron/store.rs @@ -79,6 +79,7 @@ pub fn add_agent_job( delete_after_run, None, true, + None, ) } @@ -97,6 +98,7 @@ pub fn add_agent_job_with_definition( delete_after_run: bool, agent_id: Option, enabled: bool, + profile_id: Option, ) -> Result { let now = Utc::now(); validate_schedule(&schedule, now)?; @@ -114,8 +116,8 @@ pub fn add_agent_job_with_definition( conn.execute( "INSERT INTO cron_jobs ( id, expression, command, schedule, job_type, prompt, name, session_target, model, - enabled, delivery, delete_after_run, created_at, next_run, agent_id - ) VALUES (?1, ?2, '', ?3, 'agent', ?4, ?5, ?6, ?7, ?13, ?8, ?9, ?10, ?11, ?12)", + enabled, delivery, delete_after_run, created_at, next_run, agent_id, profile_id + ) VALUES (?1, ?2, '', ?3, 'agent', ?4, ?5, ?6, ?7, ?13, ?8, ?9, ?10, ?11, ?12, ?14)", params![ id, expression, @@ -130,6 +132,7 @@ pub fn add_agent_job_with_definition( next_run.to_rfc3339(), agent_id, if enabled { 1 } else { 0 }, + profile_id, ], ) .context("Failed to insert cron agent job")?; @@ -219,7 +222,7 @@ pub fn find_flow_schedule_job(config: &Config, flow_id: &str) -> Result Result> { let mut stmt = conn.prepare( "SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model, enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output, - agent_id + agent_id, profile_id FROM cron_jobs ORDER BY next_run ASC", )?; @@ -254,7 +257,7 @@ pub fn get_job(config: &Config, job_id: &str) -> Result { let mut stmt = conn.prepare( "SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model, enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output, - agent_id + agent_id, profile_id FROM cron_jobs WHERE id = ?1", )?; @@ -376,7 +379,7 @@ pub fn due_jobs(config: &Config, now: DateTime) -> Result> { let mut stmt = conn.prepare( "SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model, enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output, - agent_id + agent_id, profile_id FROM cron_jobs WHERE enabled = 1 AND next_run <= ?1 ORDER BY next_run ASC @@ -431,6 +434,9 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result< if let Some(agent_id) = patch.agent_id { job.agent_id = agent_id; } + if let Some(profile_id) = patch.profile_id { + job.profile_id = profile_id; + } if schedule_changed { job.next_run = next_run_for_schedule(&job.schedule, Utc::now())?; @@ -459,7 +465,7 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result< "UPDATE cron_jobs SET expression = ?1, command = ?2, schedule = ?3, job_type = ?4, prompt = ?5, name = ?6, session_target = ?7, model = ?8, enabled = ?9, delivery = ?10, delete_after_run = ?11, - next_run = ?12, agent_id = ?14 + next_run = ?12, agent_id = ?14, profile_id = ?15 WHERE id = ?13", params![ job.expression, @@ -476,6 +482,7 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result< job.next_run.to_rfc3339(), job.id, job.agent_id, + job.profile_id, ], ) .context("Failed to update cron job")?; @@ -683,6 +690,7 @@ fn map_cron_job_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { session_target: SessionTarget::parse(&row.get::<_, String>(7)?), model: row.get(8)?, agent_id: row.get(17)?, + profile_id: row.get(18)?, enabled: row.get::<_, i64>(9)? != 0, delivery, delete_after_run: row.get::<_, i64>(11)? != 0, @@ -828,6 +836,7 @@ fn with_connection(config: &Config, f: impl FnOnce(&Connection) -> Result) add_column_if_missing(&conn, "delivery", "TEXT")?; add_column_if_missing(&conn, "delete_after_run", "INTEGER NOT NULL DEFAULT 0")?; add_column_if_missing(&conn, "agent_id", "TEXT")?; + add_column_if_missing(&conn, "profile_id", "TEXT")?; f(&conn) } diff --git a/src/openhuman/cron/store_tests.rs b/src/openhuman/cron/store_tests.rs index 87f67269a5..04f17f6ee1 100644 --- a/src/openhuman/cron/store_tests.rs +++ b/src/openhuman/cron/store_tests.rs @@ -103,6 +103,84 @@ fn due_jobs_filters_by_timestamp_and_enabled() { assert!(due_after_disable.is_empty()); } +#[test] +fn agent_job_round_trips_profile_id_and_patch_clears_it() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + // Create an agent job attributed to a profile. + let job = add_agent_job_with_definition( + &config, + Some("attributed".into()), + Schedule::Cron { + expr: "0 9 * * *".into(), + tz: None, + active_hours: None, + }, + "do the thing", + SessionTarget::Isolated, + None, + None, + false, + None, + true, + Some("alice".into()), + ) + .unwrap(); + assert_eq!(job.profile_id.as_deref(), Some("alice")); + + // Reload from disk — the column round-trips. + let stored = get_job(&config, &job.id).unwrap(); + assert_eq!(stored.profile_id.as_deref(), Some("alice")); + + // Patch to a different profile. + let repointed = update_job( + &config, + &job.id, + CronJobPatch { + profile_id: Some(Some("bob".into())), + ..CronJobPatch::default() + }, + ) + .unwrap(); + assert_eq!(repointed.profile_id.as_deref(), Some("bob")); + + // Patch with `Some(None)` clears the attribution; `None` leaves it untouched. + let cleared = update_job( + &config, + &job.id, + CronJobPatch { + profile_id: Some(None), + ..CronJobPatch::default() + }, + ) + .unwrap(); + assert_eq!(cleared.profile_id, None); + + let untouched = update_job( + &config, + &job.id, + CronJobPatch { + name: Some("renamed".into()), + ..CronJobPatch::default() + }, + ) + .unwrap(); + assert_eq!(untouched.profile_id, None); + assert_eq!(untouched.name.as_deref(), Some("renamed")); +} + +#[test] +fn shell_job_has_no_profile_attribution() { + // Back-compat: shell jobs (and any job created without profile_id) load with + // profile_id = None. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let job = add_job(&config, "*/5 * * * *", "echo ok").unwrap(); + assert_eq!(job.profile_id, None); + assert_eq!(get_job(&config, &job.id).unwrap().profile_id, None); +} + #[test] fn enabling_stale_disabled_job_refreshes_next_run() { let tmp = TempDir::new().unwrap(); diff --git a/src/openhuman/cron/types.rs b/src/openhuman/cron/types.rs index db7d0652a0..368f5652ce 100644 --- a/src/openhuman/cron/types.rs +++ b/src/openhuman/cron/types.rs @@ -232,6 +232,15 @@ pub struct CronJob { /// definition's prompt, tool allowlist, iteration cap, and model hint /// instead of the generic `Agent::from_config` path. pub agent_id: Option, + /// Optional agent-profile id (`profiles::AgentProfile::id`) this job runs + /// under. When set and the profile still exists, the triggered run is built + /// via the profile-aware session path so it inherits the profile's SOUL, + /// memory scope, workspace descriptor, and allowlists. When the profile was + /// deleted, the scheduler warns and runs without a profile (never fails the + /// job). `#[serde(default)]` keeps legacy rows / payloads without the field + /// deserializing unchanged. + #[serde(default)] + pub profile_id: Option, pub enabled: bool, pub delivery: DeliveryConfig, pub delete_after_run: bool, @@ -265,6 +274,9 @@ pub struct CronJobPatch { pub session_target: Option, pub delete_after_run: Option, pub agent_id: Option>, + /// `Option>` distinguishes "no change" (`None`) from + /// "clear the profile" (`Some(None)`) — same shape as `agent_id`. + pub profile_id: Option>, } #[cfg(test)] @@ -507,6 +519,56 @@ mod tests { assert!(p.agent_id.is_none()); } + #[test] + fn cron_job_deserializes_without_profile_id() { + // A pre-2b serialized CronJob (no `profile_id` key) must still + // deserialize, with the field defaulting to None. + let raw = json!({ + "id": "j1", + "expression": "0 9 * * *", + "schedule": { "kind": "cron", "expr": "0 9 * * *" }, + "command": "", + "prompt": "hi", + "name": "briefing", + "job_type": "agent", + "session_target": "isolated", + "model": null, + "agent_id": null, + "enabled": true, + "delivery": {}, + "delete_after_run": false, + "created_at": "2027-01-15T12:00:00Z", + "next_run": "2027-01-16T09:00:00Z", + "last_run": null, + "last_status": null, + "last_output": null + }); + let job: CronJob = serde_json::from_value(raw).unwrap(); + assert_eq!(job.profile_id, None); + } + + #[test] + fn cron_job_patch_default_leaves_profile_id_none() { + assert!(CronJobPatch::default().profile_id.is_none()); + } + + #[test] + fn cron_job_patch_profile_id_supports_explicit_none_clearing() { + // Option>: None = no change, Some(None) = clear. + let clear = CronJobPatch { + profile_id: Some(None), + ..Default::default() + }; + assert!(clear.profile_id.is_some()); + assert!(clear.profile_id.as_ref().unwrap().is_none()); + + let set = CronJobPatch { + profile_id: Some(Some("alice".into())), + ..Default::default() + }; + assert_eq!(set.profile_id, Some(Some("alice".to_string()))); + } + #[test] fn cron_job_patch_agent_id_supports_explicit_none_clearing() { // Option> lets callers distinguish "no change" diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index f5ea0f6915..e5b5b18e93 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -7916,6 +7916,66 @@ async fn json_rpc_profiles_dedicated_memory_lifecycle() { "dedicated_memory (not workspace) must not surface workspaceDir" ); + // --- 2b: a cron agent job can be attributed to this profile, and the + // attribution round-trips through cron_add and cron_list (snake_case + // `profile_id`, matching the cron domain's wire convention). --- + let cron_add = post_json_rpc( + &rpc_base, + 64, + "openhuman.cron_add", + json!({ + "schedule": "0 9 * * *", + "prompt": "draft the daily note", + "profile_id": "writer" + }), + ) + .await; + let cron_add_result = assert_no_jsonrpc_error(&cron_add, "cron_add"); + // `RpcOutcome::single_log` wraps the job as `{ "result": , "logs": [..] }`. + let added_job = cron_add_result.get("result").unwrap_or(cron_add_result); + let job_id = added_job + .get("id") + .and_then(Value::as_str) + .expect("cron job id present") + .to_string(); + assert_eq!( + added_job.get("profile_id").and_then(Value::as_str), + Some("writer"), + "cron_add must round-trip profile_id: {cron_add_result}" + ); + + let cron_list = post_json_rpc(&rpc_base, 65, "openhuman.cron_list", json!({})).await; + let cron_list_result = assert_no_jsonrpc_error(&cron_list, "cron_list"); + let jobs = cron_list_result + .get("result") + .and_then(Value::as_array) + .expect("cron_list jobs array"); + let listed_job = jobs + .iter() + .find(|j| j.get("id").and_then(Value::as_str) == Some(job_id.as_str())) + .expect("added cron job present in cron_list"); + assert_eq!( + listed_job.get("profile_id").and_then(Value::as_str), + Some("writer"), + "cron_list must surface profile_id: {listed_job}" + ); + + // cron_update can repoint the attribution (Some(Some) over the wire). + let cron_update = post_json_rpc( + &rpc_base, + 66, + "openhuman.cron_update", + json!({ "job_id": job_id, "patch": { "profile_id": "editor" } }), + ) + .await; + let cron_update_result = assert_no_jsonrpc_error(&cron_update, "cron_update"); + let updated_job = cron_update_result.get("result").unwrap_or(cron_update_result); + assert_eq!( + updated_job.get("profile_id").and_then(Value::as_str), + Some("editor"), + "cron_update must repoint profile_id: {cron_update_result}" + ); + // --- select then delete round-trips the active id --- let select = post_json_rpc( &rpc_base, From 4bdb96ab9a9c8beed3b22e0e6b739639f25f8485 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 13:00:44 +0400 Subject: [PATCH 13/80] docs(profiles): document per-profile skills dir + cron attribution (2c) - profiles/mod.rs home-layout doc: add the `personalities//skills/` row (owner-only skill discovery, implicitly allowed, wins same-name collisions, surfaced as `skillsDir`) and a "Cron attribution" section describing `CronJob.profile_id` and the deleted-profile fallback. - about_app catalog (Persona Pack): mention private per-profile skills and cron-job profile attribution in the capability description. --- src/openhuman/about_app/catalog_data.rs | 2 +- src/openhuman/profiles/mod.rs | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index dc0c044de5..3ce035a55a 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -1435,7 +1435,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ name: "Persona Pack", domain: "settings", category: CapabilityCategory::Settings, - description: "Personalize the assistant across one or more agent profiles: set a display name and description, edit or reset each profile's SOUL.md identity (kept in its own home under personalities// and re-read every message), give a profile its own dedicated memory subtree or its own working directory, and reach mascot avatar and voice settings. Multiple profiles can run with isolated identity, memory, and workspace state.", + description: "Personalize the assistant across one or more agent profiles: set a display name and description, edit or reset each profile's SOUL.md identity (kept in its own home under personalities// and re-read every message), give a profile its own dedicated memory subtree or its own working directory, drop private skills under personalities//skills/ that only that profile can discover, attribute a scheduled cron job to a profile so it runs with that profile's identity, memory, and permissions, and reach mascot avatar and voice settings. Multiple profiles can run with isolated identity, memory, skills, and workspace state.", how_to: "Settings > Persona", status: CapabilityStatus::Beta, privacy: GITHUB_MASCOT_MANIFEST, diff --git a/src/openhuman/profiles/mod.rs b/src/openhuman/profiles/mod.rs index 39b424fda0..9509dd7d00 100644 --- a/src/openhuman/profiles/mod.rs +++ b/src/openhuman/profiles/mod.rs @@ -21,6 +21,7 @@ //! ```text //! /personalities//SOUL.md identity (hot-read each prompt) //! /personalities//MEMORY.md curated per-profile memory +//! /personalities//skills/ private skills (owner-only discovery) //! /{memory,memory_tree,session_raw}-/ dedicated memory subtree (opt-in) //! /profiles// agent-writable workspace (opt-in) //! ``` @@ -32,8 +33,22 @@ //! wins for back-compat. //! - `dedicated_workspace` roots a per-profile default cwd for acting tools (see //! [`dedicated_workspace_dir`] and the session builder's section-D wiring). +//! - `skills/` (see [`profile_skills_dir`]) holds SKILL.md/WORKFLOW.md bundles +//! private to this profile: discovered ONLY for turns running under it, +//! implicitly allowed for their owner, and winning same-name collisions +//! against global skills (`skills::discover_workflows_with_profile`). Advertised +//! read-only as `skillsDir` in the enriched RPC payload when present. //! - [`ensure_profile_home`] materializes the home idempotently (never -//! overwriting a user's edited files) on upsert/select. +//! overwriting a user's edited files) on upsert/select — including the empty +//! `skills/` dir. +//! +//! # Cron attribution +//! +//! A cron job may carry a `profile_id` (`cron::CronJob::profile_id`). When it is +//! set and the profile still exists, the scheduled run is built under that +//! profile (soul, memory scope, dedicated workspace, allowlists) via the same +//! profile-aware session path the task dispatcher uses; a deleted profile falls +//! back to a profile-less run rather than failing the job. pub mod guard; pub mod home; From 9ddbb84b526d843284262fa0dfe72935b9bc7ba2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 13:12:44 +0400 Subject: [PATCH 14/80] feat(profiles): resolve profile-local skills for describe/read/run (2a follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the "listed but unrunnable" gap from 2a: a profile-local skill that appears in list_workflows / the prompt catalog can now also be described, read, and run by the session that owns it — same precedence and owner-only visibility as discovery. Seam: extend the existing resolution functions with `_with_profile` variants that thread the profile skills root down to `discover_*`, and keep the old signatures delegating with `None` (byte-identical for the profile-less session and other profiles). No global registry state is mutated, so concurrent sessions under different profiles never see each other's private skills. - registry: `load_workflows_with_profile` / `get_workflow_with_profile` (used by describe + run) scan the profile root via `discover_workflows_with_profile`; `load_workflows` / `get_workflow` delegate with None. - ops_discover: `read_workflow_resource_with_profile` (used by read) resolves via `load_workflow_metadata_for_profile`; `read_workflow_resource` delegates with None. New `profile_local_skill_ids` returns the owner's private skill ids for the implicit-allow check. - skill_runtime: `spawn_workflow_run_background_with_profile` passes the root (owned, crosses the spawn boundary) into `get_workflow_with_profile`; the old fn delegates with None. - tools: `WorkflowDescribeTool` / `WorkflowReadResourceTool` / `RunWorkflowTool` gain `with_profile_skills_root`, wired from `all_tools_with_runtime`'s existing `profile_skills_root` channel (the same one 2a added for the list tool). Profile-local skills are implicitly allowed for their owner (bypass `allowed_skills`), consistent with `list_workflows`. Precedence: profile-local wins same-name collisions for the owner via the existing `WorkflowScope::Profile` ordering — the resolver already dedups by that precedence, so describe/read/run resolve the profile-local copy while everyone else resolves the global one. skills gate: all new code lives inside `#[cfg(feature = "skills")]` modules (registry/ops_discover/run_machinery/tools) whose callers are gated by the same feature, so no stub changes are needed; the disabled build (`--no-default-features --features tokenjuice-treesitter`) stays green. Tests: `get_workflow_with_profile` matrix (describe + run resolution) and `read_workflow_resource_with_profile` matrix (read) each pin owner-resolves, collision-resolves-to-profile-local, other-profile/profile-less cannot resolve, and global-only resolves everywhere; plus `profile_local_skill_ids` lists only the profile root. Skills + skill_runtime suites: 186 passed. --- src/openhuman/agent/tools/run_workflow.rs | 39 +++++- src/openhuman/skill_runtime/mod.rs | 5 +- src/openhuman/skill_runtime/run_machinery.rs | 23 +++- src/openhuman/skills/ops.rs | 2 +- src/openhuman/skills/ops_discover.rs | 130 ++++++++++++++++++- src/openhuman/skills/registry.rs | 118 ++++++++++++++++- src/openhuman/skills/tools.rs | 62 +++++++-- src/openhuman/tools/ops.rs | 12 +- 8 files changed, 364 insertions(+), 27 deletions(-) diff --git a/src/openhuman/agent/tools/run_workflow.rs b/src/openhuman/agent/tools/run_workflow.rs index f148854f0c..56592b79ed 100644 --- a/src/openhuman/agent/tools/run_workflow.rs +++ b/src/openhuman/agent/tools/run_workflow.rs @@ -33,7 +33,9 @@ use async_trait::async_trait; use serde_json::json; -use crate::openhuman::skill_runtime::{await_run_outcome, spawn_workflow_run_background}; +use crate::openhuman::skill_runtime::{ + await_run_outcome, spawn_workflow_run_background_with_profile, +}; use crate::openhuman::skills::schemas::resolve_workspace_dir; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; @@ -211,6 +213,10 @@ pub struct RunWorkflowTool { /// Per-profile allowlist of runnable workflow `dir_name` slugs. `None` /// (the default) means every installed workflow may be run. skill_allowlist: Option>, + /// Active profile's private skills root + /// (`/personalities//skills/`). Resolves + implicitly allows + /// the owner's profile-local skills. `None` = byte-identical to today. + profile_skills_root: Option, } impl Default for RunWorkflowTool { @@ -223,6 +229,7 @@ impl RunWorkflowTool { pub fn new() -> Self { Self { skill_allowlist: None, + profile_skills_root: None, } } @@ -234,6 +241,13 @@ impl RunWorkflowTool { self.skill_allowlist = allowlist; self } + + /// Resolve (and implicitly allow) the active profile's private skills so a + /// turn under profile P can run P's own skills. + pub fn with_profile_skills_root(mut self, root: Option) -> Self { + self.profile_skills_root = root; + self + } } #[async_trait] @@ -308,7 +322,12 @@ impl Tool for RunWorkflowTool { } }; if let Some(allow) = &self.skill_allowlist { - if !allow.contains(&workflow_id) { + // Profile-local skills are implicitly allowed for their owner (they + // bypass `allowed_skills`), mirroring `list_workflows`. + let profile_local = crate::openhuman::skills::profile_local_skill_ids( + self.profile_skills_root.as_deref(), + ); + if !profile_local.contains(&workflow_id) && !allow.contains(&workflow_id) { log::debug!("[profiles] run_workflow blocked by profile allowlist: {workflow_id}"); return Ok(ToolResult::error(format!( "run_workflow: workflow `{workflow_id}` is not available to the active agent profile" @@ -321,7 +340,13 @@ impl Tool for RunWorkflowTool { // Fire-and-forget: only the spawn backstop applies — no await, so no // re-entrancy/nesting slot to take. if wait_seconds == 0 { - return match spawn_workflow_run_background(workflow_id.clone(), inputs).await { + return match spawn_workflow_run_background_with_profile( + workflow_id.clone(), + inputs, + self.profile_skills_root.clone(), + ) + .await + { // Count only spawns that actually start against the backstop — // unknown-workflow / bad-input rejections (the Err arm) must not // burn the budget, or rejected calls accumulate and trip the @@ -355,7 +380,13 @@ impl Tool for RunWorkflowTool { Err(e) => return Ok(ToolResult::error(format!("run_workflow: {e}"))), }; - let started = match spawn_workflow_run_background(workflow_id.clone(), inputs).await { + let started = match spawn_workflow_run_background_with_profile( + workflow_id.clone(), + inputs, + self.profile_skills_root.clone(), + ) + .await + { Ok(s) => { if let Err(e) = guard::account_spawn() { return Ok(ToolResult::error(format!("run_workflow: {e}"))); diff --git a/src/openhuman/skill_runtime/mod.rs b/src/openhuman/skill_runtime/mod.rs index 4d0dfedb5a..6143eaea8a 100644 --- a/src/openhuman/skill_runtime/mod.rs +++ b/src/openhuman/skill_runtime/mod.rs @@ -27,7 +27,10 @@ pub mod schemas; pub mod tools; #[cfg(feature = "skills")] -pub use run_machinery::{await_run_outcome, spawn_workflow_run_background, WorkflowRunStarted}; +pub use run_machinery::{ + await_run_outcome, spawn_workflow_run_background, spawn_workflow_run_background_with_profile, + WorkflowRunStarted, +}; #[cfg(feature = "skills")] pub use schemas::{ all_skill_runtime_controller_schemas, all_skill_runtime_registered_controllers, diff --git a/src/openhuman/skill_runtime/run_machinery.rs b/src/openhuman/skill_runtime/run_machinery.rs index f69bba0f1b..7cacfe21b3 100644 --- a/src/openhuman/skill_runtime/run_machinery.rs +++ b/src/openhuman/skill_runtime/run_machinery.rs @@ -41,10 +41,29 @@ pub struct WorkflowRunStarted { pub async fn spawn_workflow_run_background( skill_id_param: String, inputs_param: Option, +) -> Result { + spawn_workflow_run_background_with_profile(skill_id_param, inputs_param, None).await +} + +/// Like [`spawn_workflow_run_background`], but resolves the target skill against +/// the active profile's private skills root too +/// (`/personalities//skills/`) when `profile_skills_root` is +/// supplied — so `run_workflow` under profile P can run P's private skills, with +/// profile-local winning same-name collisions. `None` is byte-identical to +/// [`spawn_workflow_run_background`]. The root is passed by value (owned) so it +/// can cross the resolution boundary without borrowing across the spawn. +pub async fn spawn_workflow_run_background_with_profile( + skill_id_param: String, + inputs_param: Option, + profile_skills_root: Option, ) -> Result { let workspace = resolve_workspace_dir().await; - let skill = registry::get_workflow(&workspace, &skill_id_param) - .ok_or_else(|| format!("workflow_run: unknown skill '{skill_id_param}'"))?; + let skill = registry::get_workflow_with_profile( + &workspace, + &skill_id_param, + profile_skills_root.as_deref(), + ) + .ok_or_else(|| format!("workflow_run: unknown skill '{skill_id_param}'"))?; let inputs = inputs_param.unwrap_or(Value::Null); let missing = registry::missing_required_inputs(&skill.inputs, &inputs); if !missing.is_empty() { diff --git a/src/openhuman/skills/ops.rs b/src/openhuman/skills/ops.rs index e55c466c0b..28a7d09e07 100644 --- a/src/openhuman/skills/ops.rs +++ b/src/openhuman/skills/ops.rs @@ -33,7 +33,7 @@ pub use super::ops_create::{create_workflow, CreateWorkflowParams, WorkflowCreat pub use super::ops_discover::{ discover_automations, discover_workflows, discover_workflows_with_profile, init_workflows_dir, is_workspace_trusted, load_workflow_metadata, load_workflow_metadata_for_profile, - read_workflow_resource, + profile_local_skill_ids, read_workflow_resource, read_workflow_resource_with_profile, }; pub use super::ops_install::{ install_workflow_from_url, uninstall_workflow, validate_install_url, validate_resolved_host, diff --git a/src/openhuman/skills/ops_discover.rs b/src/openhuman/skills/ops_discover.rs index d45f6a26c3..f489ac2680 100644 --- a/src/openhuman/skills/ops_discover.rs +++ b/src/openhuman/skills/ops_discover.rs @@ -478,11 +478,50 @@ pub fn read_workflow_resource( workspace_dir: &Path, skill_id: &str, relative_path: &Path, +) -> Result { + read_workflow_resource_with_profile(workspace_dir, skill_id, relative_path, None) +} + +/// The dir_name/name set of skills discovered under a profile-local skills root. +/// +/// Used by the `describe_workflow` / `read_workflow_resource` / `run_workflow` +/// tools to treat a profile's private skills as implicitly allowed for their +/// owner (they bypass the `allowed_skills` allowlist, mirroring `list_workflows`). +/// Empty when no profile root is active, so the profile-less session and other +/// profiles are unaffected. +pub fn profile_local_skill_ids( + profile_skills_root: Option<&Path>, +) -> std::collections::HashSet { + let Some(root) = profile_skills_root else { + return std::collections::HashSet::new(); + }; + scan_root(root, WorkflowScope::Profile) + .into_iter() + .map(|w| { + if w.dir_name.is_empty() { + w.name + } else { + w.dir_name + } + }) + .collect() +} + +/// Like [`read_workflow_resource`], but resolves the skill against the active +/// profile's private skills root too (`/personalities//skills/`) +/// when `profile_skills_root` is supplied. `None` is byte-identical to +/// [`read_workflow_resource`]. +pub fn read_workflow_resource_with_profile( + workspace_dir: &Path, + skill_id: &str, + relative_path: &Path, + profile_skills_root: Option<&Path>, ) -> Result { tracing::debug!( skill_id = %skill_id, relative_path = %relative_path.display(), workspace = %workspace_dir.display(), + has_profile_root = profile_skills_root.is_some(), "[skills] read_workflow_resource: entry" ); @@ -514,10 +553,14 @@ pub fn read_workflow_resource( } // Resolve the skill by running the standard discovery pipeline. We reuse - // `load_workflow_metadata` (which honors both user and workspace roots plus the - // trust marker) so the resource read is scoped to the exact same set of - // skills the UI would already have shown the user. - let skill = resolve_workflow_for_resource(load_workflow_metadata(workspace_dir), skill_id)?; + // `load_workflow_metadata_for_profile` (which honors both user and workspace + // roots plus the trust marker, and the active profile's private root when + // supplied) so the resource read is scoped to the exact same set of skills + // the owner would already have seen listed. + let skill = resolve_workflow_for_resource( + load_workflow_metadata_for_profile(workspace_dir, profile_skills_root), + skill_id, + )?; let skill_root = skill .location .as_deref() @@ -814,6 +857,85 @@ mod profile_scope_tests { assert!(precedence(WorkflowScope::Profile) > precedence(WorkflowScope::Legacy)); } + /// Seed a runnable bundle with a bundled resource under `references/`. + fn seed_bundle_with_resource(root: &Path, slug: &str, resource_body: &str) { + let dir = root.join(slug); + std::fs::create_dir_all(dir.join("references")).unwrap(); + std::fs::write( + dir.join("WORKFLOW.md"), + format!("---\nname: {slug}\ndescription: {slug} desc\n---\n\n{slug} body\n"), + ) + .unwrap(); + std::fs::write(dir.join("references").join("note.md"), resource_body).unwrap(); + } + + /// `read_workflow_resource_with_profile` (the `read_workflow_resource` tool's + /// seam) resolves a profile's private skill resources for the owner only, + /// resolves collisions to the profile-local copy, hides them from other + /// profiles / the profile-less session, and leaves global-only resources + /// readable everywhere. + #[test] + fn read_workflow_resource_with_profile_resolution_matrix() { + let ws = tempfile::TempDir::new().unwrap(); + let profile_root = tempfile::TempDir::new().unwrap(); + let other_root = tempfile::TempDir::new().unwrap(); + + // Global (legacy) skill + resource, private skill + resource, and a + // collision under both. + seed_bundle_with_resource(&ws.path().join("skills"), "resglobal7788", "GLOBAL_RES"); + seed_bundle_with_resource(profile_root.path(), "reslocal7788", "LOCAL_RES"); + seed_bundle_with_resource(&ws.path().join("skills"), "rescollide7788", "GLOBAL_RES"); + seed_bundle_with_resource(profile_root.path(), "rescollide7788", "PROFILE_RES"); + + let rel = Path::new("references/note.md"); + let read = |id: &str, root: Option<&Path>| { + read_workflow_resource_with_profile(ws.path(), id, rel, root) + }; + + // Owner reads its private skill's resource. + assert_eq!( + read("reslocal7788", Some(profile_root.path())).unwrap(), + "LOCAL_RES" + ); + // Profile-less + other profile cannot resolve the private skill at all. + assert!(read("reslocal7788", None).is_err()); + assert!(read("reslocal7788", Some(other_root.path())).is_err()); + + // Global-only resource is readable with or without a profile root. + assert_eq!(read("resglobal7788", None).unwrap(), "GLOBAL_RES"); + assert_eq!( + read("resglobal7788", Some(profile_root.path())).unwrap(), + "GLOBAL_RES" + ); + + // Collision: owner reads the profile-local resource; everyone else the global. + assert_eq!( + read("rescollide7788", Some(profile_root.path())).unwrap(), + "PROFILE_RES" + ); + assert_eq!(read("rescollide7788", None).unwrap(), "GLOBAL_RES"); + } + + /// `profile_local_skill_ids` returns exactly the dir_names under the profile + /// root (the implicit-allow set the describe/read/run tools consult), and is + /// empty for the profile-less session. + #[test] + fn profile_local_skill_ids_lists_only_the_profile_root() { + let profile_root = tempfile::TempDir::new().unwrap(); + seed_bundle(profile_root.path(), "priv-a"); + seed_bundle(profile_root.path(), "priv-b"); + + let ids = profile_local_skill_ids(Some(profile_root.path())); + assert_eq!(ids.len(), 2); + assert!(ids.contains("priv-a")); + assert!(ids.contains("priv-b")); + + assert!( + profile_local_skill_ids(None).is_empty(), + "profile-less session has no implicitly-allowed profile-local ids" + ); + } + /// A `None` profile root reproduces `load_workflow_metadata` byte-for-byte — /// the back-compat guarantee for the profile-less session. #[test] diff --git a/src/openhuman/skills/registry.rs b/src/openhuman/skills/registry.rs index dc374b71c4..36fc6cf789 100644 --- a/src/openhuman/skills/registry.rs +++ b/src/openhuman/skills/registry.rs @@ -153,6 +153,24 @@ pub fn prune_legacy_default_workflows(workspace_dir: &Path) { /// Without `skill.toml`, a synthesized SKILL.md-only definition means a bare workflow is /// still runnable. A bad `skill.toml` falls back to the SKILL.md-only form. pub fn load_workflows(workspace_dir: &Path) -> Vec { + load_workflows_with_profile(workspace_dir, None) +} + +/// Like [`load_workflows`], but additionally resolves the active profile's +/// private skills (`/personalities//skills/`) when +/// `profile_skills_root` is supplied. +/// +/// The profile root is threaded straight into +/// [`super::ops_discover::discover_workflows_with_profile`], so profile-local +/// skills become runnable/describable for their owner and win same-name +/// collisions against global skills (via [`WorkflowScope::Profile`] precedence). +/// `None` reproduces [`load_workflows`] byte-for-byte — other profiles and the +/// profile-less session never see these skills. No global registry state is +/// mutated, so concurrent sessions under different profiles stay isolated. +pub fn load_workflows_with_profile( + workspace_dir: &Path, + profile_skills_root: Option<&Path>, +) -> Vec { // Prune any legacy bundled skills an older build left behind so discover's // legacy scan no longer surfaces them (idempotent). prune_legacy_default_workflows(workspace_dir); @@ -173,8 +191,12 @@ pub fn load_workflows(workspace_dir: &Path) -> Vec { // discovery the create/list path uses, then load each one's definition. let home = dirs::home_dir(); let trusted = super::ops_discover::is_workspace_trusted(workspace_dir); - for wf in super::ops_discover::discover_workflows(home.as_deref(), Some(workspace_dir), trusted) - { + for wf in super::ops_discover::discover_workflows_with_profile( + home.as_deref(), + Some(workspace_dir), + profile_skills_root, + trusted, + ) { let Some(skill_md) = wf.location.as_ref() else { continue; }; @@ -251,7 +273,20 @@ fn load_workflow_definition( /// Look up one skill by id across the registry. pub fn get_workflow(workspace_dir: &Path, id: &str) -> Option { - load_workflows(workspace_dir) + get_workflow_with_profile(workspace_dir, id, None) +} + +/// Like [`get_workflow`], but resolves the active profile's private skills too +/// (`/personalities//skills/`) when `profile_skills_root` is +/// supplied. This is the resolution seam behind `describe_workflow` / +/// `run_workflow`: a profile-local skill is runnable/describable for its owner +/// and wins same-name collisions; `None` is byte-identical to [`get_workflow`]. +pub fn get_workflow_with_profile( + workspace_dir: &Path, + id: &str, + profile_skills_root: Option<&Path>, +) -> Option { + load_workflows_with_profile(workspace_dir, profile_skills_root) .into_iter() .find(|s| s.definition.id == id) } @@ -320,6 +355,83 @@ mod tests { assert!(i.required); } + /// Seed a runnable WORKFLOW.md bundle under `root/slug/` with a distinct + /// body marker so the resolved definition can be traced back to its source. + fn seed_runnable(root: &std::path::Path, slug: &str, body_marker: &str) { + let dir = root.join(slug); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("WORKFLOW.md"), + format!("---\nname: {slug}\ndescription: {slug} desc\n---\n\n{body_marker}\n"), + ) + .unwrap(); + } + + fn resolved_body(def: &WorkflowDefinition) -> String { + match &def.definition.system_prompt { + PromptSource::Inline(p) => p.clone(), + other => panic!("expected inline prompt, got {other:?}"), + } + } + + /// The resolution seam behind `describe_workflow` / `run_workflow` + /// (`get_workflow_with_profile`) resolves a profile's private skills for the + /// owner only, resolves collisions to the profile-local copy, keeps + /// profile-local skills invisible to other profiles / the profile-less + /// session, and leaves global-only skills resolvable everywhere. + #[test] + fn get_workflow_with_profile_resolution_matrix() { + // Unique-ish ids so a developer's real ~/.openhuman/skills can't collide. + let ws = tempfile::TempDir::new().unwrap(); + let profile_root = tempfile::TempDir::new().unwrap(); + let other_root = tempfile::TempDir::new().unwrap(); // a different profile + + // A global skill under the legacy `/skills/` root (no trust marker + // needed), and a private skill under the profile root. + seed_runnable(&ws.path().join("skills"), "zzglobalonly7788", "GLOBAL_BODY"); + seed_runnable(profile_root.path(), "zzlocalonly7788", "LOCAL_BODY"); + // Collision: same id in both the global legacy root and the profile root. + seed_runnable(&ws.path().join("skills"), "zzcollide7788", "GLOBAL_COLLIDE"); + seed_runnable(profile_root.path(), "zzcollide7788", "PROFILE_COLLIDE"); + + let get = |id: &str, root: Option<&std::path::Path>| { + get_workflow_with_profile(ws.path(), id, root) + }; + + // Owner resolves its profile-local skill. + assert!( + get("zzlocalonly7788", Some(profile_root.path())).is_some(), + "owner must resolve its profile-local skill" + ); + // Profile-less session and a different profile cannot resolve it. + assert!( + get("zzlocalonly7788", None).is_none(), + "profile-less session must not resolve a profile-local skill" + ); + assert!( + get("zzlocalonly7788", Some(other_root.path())).is_none(), + "a different profile must not resolve another profile's private skill" + ); + + // Global-only skill resolves everywhere (with/without a profile root). + assert!(get("zzglobalonly7788", None).is_some()); + assert!(get("zzglobalonly7788", Some(profile_root.path())).is_some()); + assert!(get("zzglobalonly7788", Some(other_root.path())).is_some()); + + // Collision: the owner resolves the profile-local copy; everyone else + // resolves the global copy. + assert_eq!( + resolved_body(&get("zzcollide7788", Some(profile_root.path())).unwrap()), + "---\nname: zzcollide7788\ndescription: zzcollide7788 desc\n---\n\nPROFILE_COLLIDE\n", + "owner must resolve the profile-local copy on collision" + ); + assert_eq!( + resolved_body(&get("zzcollide7788", None).unwrap()), + "---\nname: zzcollide7788\ndescription: zzcollide7788 desc\n---\n\nGLOBAL_COLLIDE\n", + "profile-less session resolves the global copy on collision" + ); + } + #[test] fn load_skills_reads_runtime_skill_prompt_and_inputs() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/src/openhuman/skills/tools.rs b/src/openhuman/skills/tools.rs index 4f1ce5960a..670e8ec60c 100644 --- a/src/openhuman/skills/tools.rs +++ b/src/openhuman/skills/tools.rs @@ -25,14 +25,15 @@ use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; use super::ops_create::{create_workflow, CreateWorkflowParams}; use super::ops_discover::{ - discover_workflows_with_profile, is_workspace_trusted, read_workflow_resource, + discover_workflows_with_profile, is_workspace_trusted, profile_local_skill_ids, + read_workflow_resource_with_profile, }; use super::ops_install::{ install_workflow_from_url, uninstall_workflow, InstallWorkflowFromUrlParams, UninstallWorkflowParams, }; use super::ops_types::WorkflowScope; -use super::registry::get_workflow; +use super::registry::get_workflow_with_profile; use super::run_log::{find_run_log_path, read_run_log_slice, scan_runs}; fn read_required_str(args: &serde_json::Value, key: &str) -> anyhow::Result { @@ -64,6 +65,19 @@ fn skill_allowed(allowlist: &SkillAllowlist, dir_name: &str) -> bool { } } +/// Whether `skill_id` is usable given the profile's allowlist AND its private +/// skills. A profile's own (profile-local) skills are implicitly allowed for +/// their owner — they bypass the `allowed_skills` allowlist, mirroring +/// `list_workflows`. `profile_local_ids` is empty for the profile-less session +/// and other profiles, so this reduces to [`skill_allowed`] there. +fn skill_allowed_including_profile( + allowlist: &SkillAllowlist, + profile_local_ids: &std::collections::HashSet, + skill_id: &str, +) -> bool { + profile_local_ids.contains(skill_id) || skill_allowed(allowlist, skill_id) +} + /// List installed skills. pub struct WorkflowListTool { workspace_dir: PathBuf, @@ -156,6 +170,9 @@ impl Tool for WorkflowListTool { pub struct WorkflowDescribeTool { workspace_dir: PathBuf, skill_allowlist: SkillAllowlist, + /// Active profile's private skills root — resolves + implicitly allows the + /// owner's profile-local skills. `None` = byte-identical to today. + profile_skills_root: Option, } impl WorkflowDescribeTool { @@ -163,6 +180,7 @@ impl WorkflowDescribeTool { Self { workspace_dir: config.workspace_dir.clone(), skill_allowlist: None, + profile_skills_root: None, } } @@ -171,6 +189,12 @@ impl WorkflowDescribeTool { self.skill_allowlist = allowlist; self } + + /// Resolve (and implicitly allow) the active profile's private skills. + pub fn with_profile_skills_root(mut self, root: Option) -> Self { + self.profile_skills_root = root; + self + } } #[async_trait] @@ -197,14 +221,19 @@ impl Tool for WorkflowDescribeTool { async fn execute(&self, args: serde_json::Value) -> anyhow::Result { log::debug!("[tool][workflows] describe invoked"); let skill_id = read_workflow_id(&args)?; - if !skill_allowed(&self.skill_allowlist, &skill_id) { + let profile_local = profile_local_skill_ids(self.profile_skills_root.as_deref()); + if !skill_allowed_including_profile(&self.skill_allowlist, &profile_local, &skill_id) { log::debug!("[profiles] describe_workflow blocked by profile allowlist: {skill_id}"); return Ok(ToolResult::error(format!( "describe_workflow: workflow `{skill_id}` is not available to the active agent profile" ))); } - let def = get_workflow(&self.workspace_dir, &skill_id) - .ok_or_else(|| anyhow::anyhow!("describe_workflow: workflow `{skill_id}` not found"))?; + let def = get_workflow_with_profile( + &self.workspace_dir, + &skill_id, + self.profile_skills_root.as_deref(), + ) + .ok_or_else(|| anyhow::anyhow!("describe_workflow: workflow `{skill_id}` not found"))?; Ok(ToolResult::success(serde_json::to_string(&json!({ "definition": def.definition, "inputs": def.inputs, @@ -221,6 +250,9 @@ impl Tool for WorkflowDescribeTool { pub struct WorkflowReadResourceTool { workspace_dir: PathBuf, skill_allowlist: SkillAllowlist, + /// Active profile's private skills root — resolves + implicitly allows the + /// owner's profile-local skills. `None` = byte-identical to today. + profile_skills_root: Option, } impl WorkflowReadResourceTool { @@ -228,6 +260,7 @@ impl WorkflowReadResourceTool { Self { workspace_dir: config.workspace_dir.clone(), skill_allowlist: None, + profile_skills_root: None, } } @@ -238,6 +271,12 @@ impl WorkflowReadResourceTool { self.skill_allowlist = allowlist; self } + + /// Resolve (and implicitly allow) the active profile's private skills. + pub fn with_profile_skills_root(mut self, root: Option) -> Self { + self.profile_skills_root = root; + self + } } #[async_trait] @@ -267,7 +306,8 @@ impl Tool for WorkflowReadResourceTool { async fn execute(&self, args: serde_json::Value) -> anyhow::Result { log::debug!("[tool][workflows] read_resource invoked"); let skill_id = read_workflow_id(&args)?; - if !skill_allowed(&self.skill_allowlist, &skill_id) { + let profile_local = profile_local_skill_ids(self.profile_skills_root.as_deref()); + if !skill_allowed_including_profile(&self.skill_allowlist, &profile_local, &skill_id) { log::debug!( "[profiles] read_workflow_resource blocked by profile allowlist: {skill_id}" ); @@ -276,9 +316,13 @@ impl Tool for WorkflowReadResourceTool { ))); } let relative_path = read_required_str(&args, "relative_path")?; - let content = - read_workflow_resource(&self.workspace_dir, &skill_id, Path::new(&relative_path)) - .map_err(|e| anyhow::anyhow!("read_workflow_resource: {e}"))?; + let content = read_workflow_resource_with_profile( + &self.workspace_dir, + &skill_id, + Path::new(&relative_path), + self.profile_skills_root.as_deref(), + ) + .map_err(|e| anyhow::anyhow!("read_workflow_resource: {e}"))?; Ok(ToolResult::success(serde_json::to_string(&json!({ "workflow_id": skill_id, "relative_path": relative_path, diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index f886525d28..42f746a15e 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -229,7 +229,11 @@ pub fn all_tools_with_runtime( // `await_run_outcome` — the same spawn path `openhuman.skills_run` // JSON-RPC uses, so RPC and tool callers stay in sync. #[cfg(feature = "skills")] - Box::new(RunWorkflowTool::new().with_skill_allowlist(skill_allowlist.cloned())), + Box::new( + RunWorkflowTool::new() + .with_skill_allowlist(skill_allowlist.cloned()) + .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), + ), #[cfg(feature = "skills")] Box::new(AwaitWorkflowTool::new()), Box::new(CurrentTimeTool::new()), @@ -521,7 +525,8 @@ pub fn all_tools_with_runtime( #[cfg(feature = "skills")] Box::new( WorkflowDescribeTool::new(config.clone()) - .with_skill_allowlist(skill_allowlist.cloned()), + .with_skill_allowlist(skill_allowlist.cloned()) + .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), ), // Skill registry tools — browse/search/install from remote registries. // Browse and search are read-only (default-ON); install is a write @@ -543,7 +548,8 @@ pub fn all_tools_with_runtime( #[cfg(feature = "skills")] Box::new( WorkflowReadResourceTool::new(config.clone()) - .with_skill_allowlist(skill_allowlist.cloned()), + .with_skill_allowlist(skill_allowlist.cloned()) + .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), ), #[cfg(feature = "skills")] Box::new(WorkflowRecentRunsTool::new(config.clone())), From ceaadca16849b84f418667b17f1d906c503ba669 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 13:30:53 +0400 Subject: [PATCH 15/80] feat(profiles-ui): surface per-profile skills directory + i18n Add a read-only skills directory path row (with a hint that SKILL.md workflows there are private to the profile) to ProfileEditorPage, backed by the enriched `skillsDir` field on AgentProfile. Includes the new en.ts strings and real translations across all 13 locales for both the skills-directory rows and the cron attribution picker (wired next). --- .../settings/panels/ProfileEditorPage.test.tsx | 16 ++++++++++++++++ .../settings/panels/ProfileEditorPage.tsx | 12 ++++++++++++ app/src/lib/i18n/ar.ts | 8 ++++++++ app/src/lib/i18n/bn.ts | 8 ++++++++ app/src/lib/i18n/de.ts | 8 ++++++++ app/src/lib/i18n/en.ts | 8 ++++++++ app/src/lib/i18n/es.ts | 8 ++++++++ app/src/lib/i18n/fr.ts | 8 ++++++++ app/src/lib/i18n/hi.ts | 8 ++++++++ app/src/lib/i18n/id.ts | 8 ++++++++ app/src/lib/i18n/it.ts | 8 ++++++++ app/src/lib/i18n/ko.ts | 8 ++++++++ app/src/lib/i18n/pl.ts | 8 ++++++++ app/src/lib/i18n/pt.ts | 8 ++++++++ app/src/lib/i18n/ru.ts | 8 ++++++++ app/src/lib/i18n/zh-CN.ts | 9 ++++++++- app/src/types/agentProfile.ts | 6 ++++++ 17 files changed, 146 insertions(+), 1 deletion(-) diff --git a/app/src/components/settings/panels/ProfileEditorPage.test.tsx b/app/src/components/settings/panels/ProfileEditorPage.test.tsx index 30f6ce24e1..2a938fa00e 100644 --- a/app/src/components/settings/panels/ProfileEditorPage.test.tsx +++ b/app/src/components/settings/panels/ProfileEditorPage.test.tsx @@ -181,5 +181,21 @@ describe('ProfileEditorPage', () => { renderAt('/settings/profiles/edit/writer', [profile({ id: 'writer' })]); expect(screen.queryByText('Identity file')).not.toBeInTheDocument(); expect(screen.queryByText('Workspace directory')).not.toBeInTheDocument(); + expect(screen.queryByText('Skills directory')).not.toBeInTheDocument(); + }); + + it('shows the resolved skills directory path and hint when present', () => { + renderAt('/settings/profiles/edit/writer', [ + profile({ + id: 'writer', + name: 'Writer', + skillsDir: '/workspace/personalities/writer/skills', + }), + ]); + expect(screen.getByText('Skills directory')).toBeInTheDocument(); + expect(screen.getByText('/workspace/personalities/writer/skills')).toBeInTheDocument(); + expect( + screen.getByText('SKILL.md workflows placed here are private to this profile.') + ).toBeInTheDocument(); }); }); diff --git a/app/src/components/settings/panels/ProfileEditorPage.tsx b/app/src/components/settings/panels/ProfileEditorPage.tsx index a696e184d6..e7cde2d684 100644 --- a/app/src/components/settings/panels/ProfileEditorPage.tsx +++ b/app/src/components/settings/panels/ProfileEditorPage.tsx @@ -400,6 +400,18 @@ const ProfileEditorPage = () => { } /> )} + {existing?.skillsDir && ( + + {existing.skillsDir} + + } + /> + )} {/* Capabilities */} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 6f7b6c8436..9c50aaf324 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -5025,6 +5025,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'معزول (موصى به)', 'settings.cron.jobs.formSessionMain': 'الجلسة الرئيسية', 'settings.cron.jobs.formSessionTarget': 'هدف الجلسة', + 'settings.cron.jobs.formProfile': 'ملف الوكيل', + 'settings.cron.jobs.formProfileNone': 'بدون ملف', + 'settings.cron.jobs.formProfileHint': + 'شغّل هذه المهمة بالملف المحدد، مستخدمًا روحه وذاكرته ومساحة عمله.', + 'settings.cron.jobs.profile': 'الملف', 'settings.cron.jobs.lastStatus': 'آخر حالة', 'settings.cron.jobs.loading': 'جارٍ تحميل مهام cron...', 'settings.cron.jobs.loadingRuns': 'جارٍ تحميل عمليات التشغيل', @@ -7124,6 +7129,9 @@ const messages: TranslationMap = { 'امنح هذا الملف مجلد عمل خاص به لعمليات الملفات والأدوات.', 'settings.profiles.editor.soulMdFile': 'ملف الهوية', 'settings.profiles.editor.workspaceDir': 'مجلد مساحة العمل', + 'settings.profiles.editor.skillsDir': 'دليل المهارات', + 'settings.profiles.editor.skillsDirHint': + 'ملفات سير عمل SKILL.md الموضوعة هنا خاصة بهذا الملف وحده.', 'settings.profiles.editor.all': 'الكل', 'settings.profiles.editor.selected': 'محدّد', 'settings.profiles.editor.addPlaceholder': 'اكتب معرّفًا ثم اضغط Enter', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 2817c8b5dd..6aa8104c20 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -5145,6 +5145,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'বিচ্ছিন্ন (প্রস্তাবিত)', 'settings.cron.jobs.formSessionMain': 'প্রধান সেশন', 'settings.cron.jobs.formSessionTarget': 'সেশন টার্গেট', + 'settings.cron.jobs.formProfile': 'এজেন্ট প্রোফাইল', + 'settings.cron.jobs.formProfileNone': 'কোনো প্রোফাইল নয়', + 'settings.cron.jobs.formProfileHint': + 'নির্বাচিত প্রোফাইল হিসেবে এই কাজটি চালান, তার পরিচয়, মেমরি ও ওয়ার্কস্পেস ব্যবহার করে।', + 'settings.cron.jobs.profile': 'প্রোফাইল', 'settings.cron.jobs.lastStatus': 'শেষ স্ট্যাটাস', 'settings.cron.jobs.loading': 'ক্রন জব লোড হচ্ছে...', 'settings.cron.jobs.loadingRuns': 'রান লোড হচ্ছে', @@ -7290,6 +7295,9 @@ const messages: TranslationMap = { 'ফাইল ও টুল কাজের জন্য এই প্রোফাইলকে নিজস্ব ওয়ার্কিং ডিরেক্টরি দিন।', 'settings.profiles.editor.soulMdFile': 'পরিচয় ফাইল', 'settings.profiles.editor.workspaceDir': 'ওয়ার্কস্পেস ডিরেক্টরি', + 'settings.profiles.editor.skillsDir': 'স্কিল ডিরেক্টরি', + 'settings.profiles.editor.skillsDirHint': + 'এখানে রাখা SKILL.md ওয়ার্কফ্লোগুলি শুধু এই প্রোফাইলের জন্য ব্যক্তিগত।', 'settings.profiles.editor.all': 'সব', 'settings.profiles.editor.selected': 'নির্বাচিত', 'settings.profiles.editor.addPlaceholder': 'একটি আইডি লিখে এন্টার চাপুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index e11195a482..51df33dc2a 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -5293,6 +5293,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'Isoliert (empfohlen)', 'settings.cron.jobs.formSessionMain': 'Hauptsitzung', 'settings.cron.jobs.formSessionTarget': 'Sitzungsziel', + 'settings.cron.jobs.formProfile': 'Agentenprofil', + 'settings.cron.jobs.formProfileNone': 'Kein Profil', + 'settings.cron.jobs.formProfileHint': + 'Diesen Auftrag als ausgewähltes Profil ausführen, mit dessen Seele, Gedächtnis und Arbeitsbereich.', + 'settings.cron.jobs.profile': 'Profil', 'settings.cron.jobs.lastStatus': 'Letzter Stand', 'settings.cron.jobs.loading': 'Cron-Jobs werden geladen...', 'settings.cron.jobs.loadingRuns': 'Ladeläufe', @@ -7506,6 +7511,9 @@ const messages: TranslationMap = { 'Gib diesem Profil ein eigenes Arbeitsverzeichnis für Datei- und Tool-Operationen.', 'settings.profiles.editor.soulMdFile': 'Identitätsdatei', 'settings.profiles.editor.workspaceDir': 'Arbeitsverzeichnis', + 'settings.profiles.editor.skillsDir': 'Skill-Verzeichnis', + 'settings.profiles.editor.skillsDirHint': + 'Hier abgelegte SKILL.md-Workflows sind privat für dieses Profil.', 'settings.profiles.editor.all': 'Alle', 'settings.profiles.editor.selected': 'Ausgewählt', 'settings.profiles.editor.addPlaceholder': 'Kennung eingeben und Enter drücken', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 2300e26b1c..0c28924562 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -5853,9 +5853,14 @@ const en: TranslationMap = { 'settings.cron.jobs.formScheduleRequired': 'Schedule is required', 'settings.cron.jobs.formScheduleType': 'Schedule type', 'settings.cron.jobs.formSessionIsolated': 'Isolated (recommended)', + 'settings.cron.jobs.formProfile': 'Agent profile', + 'settings.cron.jobs.formProfileNone': 'No profile', + 'settings.cron.jobs.formProfileHint': + 'Run this job as the selected profile, using its soul, memory, and workspace.', 'settings.cron.jobs.formSessionMain': 'Main session', 'settings.cron.jobs.formSessionTarget': 'Session target', 'settings.cron.jobs.lastStatus': 'Last status', + 'settings.cron.jobs.profile': 'Profile', 'settings.cron.jobs.loading': 'Loading cron jobs...', 'settings.cron.jobs.loadingRuns': 'Loading runs…', 'settings.cron.jobs.nextRun': 'Next run', @@ -7432,6 +7437,9 @@ const en: TranslationMap = { 'Give this profile its own working directory for file and tool operations.', 'settings.profiles.editor.soulMdFile': 'Identity file', 'settings.profiles.editor.workspaceDir': 'Workspace directory', + 'settings.profiles.editor.skillsDir': 'Skills directory', + 'settings.profiles.editor.skillsDirHint': + 'SKILL.md workflows placed here are private to this profile.', 'settings.profiles.editor.all': 'All', 'settings.profiles.editor.selected': 'Selected', 'settings.profiles.editor.addPlaceholder': 'Type an id, press Enter', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 04e8344930..46135c449f 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -5239,6 +5239,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'Aislada (recomendado)', 'settings.cron.jobs.formSessionMain': 'Sesión principal', 'settings.cron.jobs.formSessionTarget': 'Sesión de destino', + 'settings.cron.jobs.formProfile': 'Perfil del agente', + 'settings.cron.jobs.formProfileNone': 'Sin perfil', + 'settings.cron.jobs.formProfileHint': + 'Ejecuta esta tarea como el perfil seleccionado, usando su alma, memoria y espacio de trabajo.', + 'settings.cron.jobs.profile': 'Perfil', 'settings.cron.jobs.lastStatus': 'Último estado', 'settings.cron.jobs.loading': 'Cargando tareas cron...', 'settings.cron.jobs.loadingRuns': 'Cargando ejecuciones', @@ -7442,6 +7447,9 @@ const messages: TranslationMap = { 'Dale a este perfil su propio directorio de trabajo para operaciones de archivos y herramientas.', 'settings.profiles.editor.soulMdFile': 'Archivo de identidad', 'settings.profiles.editor.workspaceDir': 'Directorio de trabajo', + 'settings.profiles.editor.skillsDir': 'Directorio de habilidades', + 'settings.profiles.editor.skillsDirHint': + 'Los flujos de SKILL.md colocados aquí son privados de este perfil.', 'settings.profiles.editor.all': 'Todos', 'settings.profiles.editor.selected': 'Seleccionados', 'settings.profiles.editor.addPlaceholder': 'Escribe un identificador y pulsa Intro', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 12f23c8af3..ca19da1625 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -5268,6 +5268,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'Isolée (recommandé)', 'settings.cron.jobs.formSessionMain': 'Session principale', 'settings.cron.jobs.formSessionTarget': 'Session cible', + 'settings.cron.jobs.formProfile': "Profil de l'agent", + 'settings.cron.jobs.formProfileNone': 'Aucun profil', + 'settings.cron.jobs.formProfileHint': + 'Exécuter cette tâche avec le profil sélectionné, en utilisant son âme, sa mémoire et son espace de travail.', + 'settings.cron.jobs.profile': 'Profil', 'settings.cron.jobs.lastStatus': 'Dernier statut', 'settings.cron.jobs.loading': 'Chargement des tâches cron…', 'settings.cron.jobs.loadingRuns': 'Chargement des exécutions', @@ -7475,6 +7480,9 @@ const messages: TranslationMap = { 'Donner à ce profil son propre répertoire de travail pour les opérations de fichiers et d’outils.', 'settings.profiles.editor.soulMdFile': 'Fichier d’identité', 'settings.profiles.editor.workspaceDir': 'Répertoire de travail', + 'settings.profiles.editor.skillsDir': 'Répertoire des compétences', + 'settings.profiles.editor.skillsDirHint': + 'Les workflows SKILL.md placés ici sont privés à ce profil.', 'settings.profiles.editor.all': 'Tous', 'settings.profiles.editor.selected': 'Sélectionnés', 'settings.profiles.editor.addPlaceholder': 'Saisissez un identifiant, puis Entrée', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 82def84d47..9e167f5346 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -5145,6 +5145,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'पृथक (अनुशंसित)', 'settings.cron.jobs.formSessionMain': 'मुख्य सत्र', 'settings.cron.jobs.formSessionTarget': 'सत्र लक्ष्य', + 'settings.cron.jobs.formProfile': 'एजेंट प्रोफ़ाइल', + 'settings.cron.jobs.formProfileNone': 'कोई प्रोफ़ाइल नहीं', + 'settings.cron.jobs.formProfileHint': + 'इस कार्य को चयनित प्रोफ़ाइल के रूप में चलाएँ, उसकी पहचान, स्मृति और कार्यस्थान का उपयोग करते हुए।', + 'settings.cron.jobs.profile': 'प्रोफ़ाइल', 'settings.cron.jobs.lastStatus': 'आखिरी स्टेटस', 'settings.cron.jobs.loading': 'Cron jobs लोड हो रही हैं...', 'settings.cron.jobs.loadingRuns': 'रन लोड हो रहे हैं', @@ -7285,6 +7290,9 @@ const messages: TranslationMap = { 'फ़ाइल और टूल कार्यों के लिए इस प्रोफ़ाइल को अपनी खुद की वर्किंग डायरेक्टरी दें।', 'settings.profiles.editor.soulMdFile': 'पहचान फ़ाइल', 'settings.profiles.editor.workspaceDir': 'वर्कस्पेस डायरेक्टरी', + 'settings.profiles.editor.skillsDir': 'स्किल निर्देशिका', + 'settings.profiles.editor.skillsDirHint': + 'यहाँ रखे गए SKILL.md वर्कफ़्लो केवल इस प्रोफ़ाइल के लिए निजी हैं।', 'settings.profiles.editor.all': 'सभी', 'settings.profiles.editor.selected': 'चयनित', 'settings.profiles.editor.addPlaceholder': 'आईडी टाइप करें, एंटर दबाएँ', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 3a1d5ca9c4..903770499a 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -5167,6 +5167,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'Terisolasi (disarankan)', 'settings.cron.jobs.formSessionMain': 'Sesi utama', 'settings.cron.jobs.formSessionTarget': 'Target sesi', + 'settings.cron.jobs.formProfile': 'Profil agen', + 'settings.cron.jobs.formProfileNone': 'Tanpa profil', + 'settings.cron.jobs.formProfileHint': + 'Jalankan tugas ini sebagai profil yang dipilih, menggunakan jiwa, memori, dan ruang kerjanya.', + 'settings.cron.jobs.profile': 'Profil', 'settings.cron.jobs.lastStatus': 'Status terakhir', 'settings.cron.jobs.loading': 'Memuat cron job...', 'settings.cron.jobs.loadingRuns': 'Memuat run', @@ -7322,6 +7327,9 @@ const messages: TranslationMap = { 'Berikan profil ini direktori kerjanya sendiri untuk operasi file dan alat.', 'settings.profiles.editor.soulMdFile': 'Berkas identitas', 'settings.profiles.editor.workspaceDir': 'Direktori ruang kerja', + 'settings.profiles.editor.skillsDir': 'Direktori keterampilan', + 'settings.profiles.editor.skillsDirHint': + 'Alur kerja SKILL.md yang ditempatkan di sini bersifat pribadi untuk profil ini.', 'settings.profiles.editor.all': 'Semua', 'settings.profiles.editor.selected': 'Terpilih', 'settings.profiles.editor.addPlaceholder': 'Ketik id, tekan Enter', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index c7602d2a2e..225073ad6e 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -5232,6 +5232,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'Isolata (consigliata)', 'settings.cron.jobs.formSessionMain': 'Sessione principale', 'settings.cron.jobs.formSessionTarget': 'Sessione di destinazione', + 'settings.cron.jobs.formProfile': "Profilo dell'agente", + 'settings.cron.jobs.formProfileNone': 'Nessun profilo', + 'settings.cron.jobs.formProfileHint': + 'Esegui questo lavoro come il profilo selezionato, usando la sua anima, memoria e spazio di lavoro.', + 'settings.cron.jobs.profile': 'Profilo', 'settings.cron.jobs.lastStatus': 'Ultimo stato', 'settings.cron.jobs.loading': 'Caricamento cron job...', 'settings.cron.jobs.loadingRuns': 'Caricamento esecuzioni', @@ -7427,6 +7432,9 @@ const messages: TranslationMap = { 'Assegna a questo profilo una propria directory di lavoro per le operazioni su file e strumenti.', 'settings.profiles.editor.soulMdFile': 'File di identità', 'settings.profiles.editor.workspaceDir': 'Directory di lavoro', + 'settings.profiles.editor.skillsDir': 'Cartella delle competenze', + 'settings.profiles.editor.skillsDirHint': + 'I flussi SKILL.md inseriti qui sono privati per questo profilo.', 'settings.profiles.editor.all': 'Tutti', 'settings.profiles.editor.selected': 'Selezionati', 'settings.profiles.editor.addPlaceholder': 'Digita un identificativo e premi Invio', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 7a92acdacc..11e8c3f0bb 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -5086,6 +5086,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': '격리됨 (권장)', 'settings.cron.jobs.formSessionMain': '메인 세션', 'settings.cron.jobs.formSessionTarget': '세션 대상', + 'settings.cron.jobs.formProfile': '에이전트 프로필', + 'settings.cron.jobs.formProfileNone': '프로필 없음', + 'settings.cron.jobs.formProfileHint': + '선택한 프로필로 이 작업을 실행하며, 해당 프로필의 정체성, 메모리, 작업 공간을 사용합니다.', + 'settings.cron.jobs.profile': '프로필', 'settings.cron.jobs.lastStatus': '마지막 상태', 'settings.cron.jobs.loading': 'cron 작업 불러오는 중...', 'settings.cron.jobs.loadingRuns': '실행 기록 불러오는 중', @@ -7202,6 +7207,9 @@ const messages: TranslationMap = { '파일 및 도구 작업을 위해 이 프로필에 전용 작업 디렉터리를 부여합니다.', 'settings.profiles.editor.soulMdFile': '아이덴티티 파일', 'settings.profiles.editor.workspaceDir': '작업 공간 디렉터리', + 'settings.profiles.editor.skillsDir': '기술 디렉터리', + 'settings.profiles.editor.skillsDirHint': + '여기에 넣은 SKILL.md 워크플로는 이 프로필에만 적용됩니다.', 'settings.profiles.editor.all': '전체', 'settings.profiles.editor.selected': '선택됨', 'settings.profiles.editor.addPlaceholder': '식별자를 입력하고 Enter를 누르세요', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 4548962af4..2598b575c5 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -5227,6 +5227,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'Izolowana (zalecane)', 'settings.cron.jobs.formSessionMain': 'Sesja główna', 'settings.cron.jobs.formSessionTarget': 'Docelowa sesja', + 'settings.cron.jobs.formProfile': 'Profil agenta', + 'settings.cron.jobs.formProfileNone': 'Brak profilu', + 'settings.cron.jobs.formProfileHint': + 'Uruchom to zadanie jako wybrany profil, korzystając z jego duszy, pamięci i przestrzeni roboczej.', + 'settings.cron.jobs.profile': 'Profil', 'settings.cron.jobs.lastStatus': 'Ostatni status', 'settings.cron.jobs.loading': 'Wczytywanie zadań cron...', 'settings.cron.jobs.loadingRuns': 'Wczytywanie uruchomień', @@ -7400,6 +7405,9 @@ const messages: TranslationMap = { 'Nadaj temu profilowi własny katalog roboczy do operacji na plikach i narzędziach.', 'settings.profiles.editor.soulMdFile': 'Plik tożsamości', 'settings.profiles.editor.workspaceDir': 'Katalog roboczy', + 'settings.profiles.editor.skillsDir': 'Katalog umiejętności', + 'settings.profiles.editor.skillsDirHint': + 'Przepływy SKILL.md umieszczone tutaj są prywatne dla tego profilu.', 'settings.profiles.editor.all': 'Wszystkie', 'settings.profiles.editor.selected': 'Wybrane', 'settings.profiles.editor.addPlaceholder': 'Wpisz identyfikator i naciśnij Enter', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 5f49e2808a..44ee2721be 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -5221,6 +5221,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'Isolada (recomendada)', 'settings.cron.jobs.formSessionMain': 'Sessão principal', 'settings.cron.jobs.formSessionTarget': 'Sessão de destino', + 'settings.cron.jobs.formProfile': 'Perfil do agente', + 'settings.cron.jobs.formProfileNone': 'Sem perfil', + 'settings.cron.jobs.formProfileHint': + 'Execute esta tarefa como o perfil selecionado, usando sua alma, memória e espaço de trabalho.', + 'settings.cron.jobs.profile': 'Perfil', 'settings.cron.jobs.lastStatus': 'Último status', 'settings.cron.jobs.loading': 'Carregando tarefas cron...', 'settings.cron.jobs.loadingRuns': 'Carregando execuções', @@ -7409,6 +7414,9 @@ const messages: TranslationMap = { 'Dê a este perfil seu próprio diretório de trabalho para operações de arquivos e ferramentas.', 'settings.profiles.editor.soulMdFile': 'Arquivo de identidade', 'settings.profiles.editor.workspaceDir': 'Diretório de trabalho', + 'settings.profiles.editor.skillsDir': 'Diretório de habilidades', + 'settings.profiles.editor.skillsDirHint': + 'Os fluxos SKILL.md colocados aqui são privados deste perfil.', 'settings.profiles.editor.all': 'Todos', 'settings.profiles.editor.selected': 'Selecionados', 'settings.profiles.editor.addPlaceholder': 'Digite um identificador e pressione Enter', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 210409eb8a..3340726453 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -5196,6 +5196,11 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': 'Изолированная (рекомендуется)', 'settings.cron.jobs.formSessionMain': 'Основная сессия', 'settings.cron.jobs.formSessionTarget': 'Целевая сессия', + 'settings.cron.jobs.formProfile': 'Профиль агента', + 'settings.cron.jobs.formProfileNone': 'Без профиля', + 'settings.cron.jobs.formProfileHint': + 'Запускать эту задачу от выбранного профиля, используя его душу, память и рабочее пространство.', + 'settings.cron.jobs.profile': 'Профиль', 'settings.cron.jobs.lastStatus': 'Последний статус', 'settings.cron.jobs.loading': 'Загрузка заданий...', 'settings.cron.jobs.loadingRuns': 'Загрузка запусков', @@ -7373,6 +7378,9 @@ const messages: TranslationMap = { 'Выделить этому профилю собственный рабочий каталог для операций с файлами и инструментами.', 'settings.profiles.editor.soulMdFile': 'Файл идентичности', 'settings.profiles.editor.workspaceDir': 'Рабочий каталог', + 'settings.profiles.editor.skillsDir': 'Каталог навыков', + 'settings.profiles.editor.skillsDirHint': + 'Рабочие процессы SKILL.md, размещённые здесь, доступны только этому профилю.', 'settings.profiles.editor.all': 'Все', 'settings.profiles.editor.selected': 'Выбранные', 'settings.profiles.editor.addPlaceholder': 'Введите идентификатор и нажмите Enter', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index fa9208c8e0..451bae832b 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4872,6 +4872,10 @@ const messages: TranslationMap = { 'settings.cron.jobs.formSessionIsolated': '隔离(推荐)', 'settings.cron.jobs.formSessionMain': '主会话', 'settings.cron.jobs.formSessionTarget': '会话目标', + 'settings.cron.jobs.formProfile': '智能体配置', + 'settings.cron.jobs.formProfileNone': '不使用配置', + 'settings.cron.jobs.formProfileHint': '以所选配置运行此任务,使用其灵魂、记忆和工作空间。', + 'settings.cron.jobs.profile': '配置', 'settings.cron.jobs.lastStatus': '上次状态', 'settings.cron.jobs.loading': '正在加载定时任务...', 'settings.cron.jobs.loadingRuns': '加载运行记录中', @@ -6887,9 +6891,12 @@ const messages: TranslationMap = { 'settings.profiles.editor.dedicatedMemory': '专属记忆', 'settings.profiles.editor.dedicatedMemoryHint': '为此配置分配专属记忆,而不是共享默认记忆。', 'settings.profiles.editor.dedicatedWorkspace': '专属工作区', - 'settings.profiles.editor.dedicatedWorkspaceHint': '为此配置分配专属工作目录,用于文件和工具操作。', + 'settings.profiles.editor.dedicatedWorkspaceHint': + '为此配置分配专属工作目录,用于文件和工具操作。', 'settings.profiles.editor.soulMdFile': '身份文件', 'settings.profiles.editor.workspaceDir': '工作目录', + 'settings.profiles.editor.skillsDir': '技能目录', + 'settings.profiles.editor.skillsDirHint': '放在此处的 SKILL.md 工作流仅对该配置私有。', 'settings.profiles.editor.all': '全部', 'settings.profiles.editor.selected': '指定', 'settings.profiles.editor.addPlaceholder': '输入标识后按回车', diff --git a/app/src/types/agentProfile.ts b/app/src/types/agentProfile.ts index d163a42b54..19679044cf 100644 --- a/app/src/types/agentProfile.ts +++ b/app/src/types/agentProfile.ts @@ -39,6 +39,12 @@ export interface AgentProfile { * path of the dedicated workspace directory when `dedicatedWorkspace` is set. */ workspaceDir?: string; + /** + * Read-only, resolved by the core on read (never sent on upsert): absolute + * path of the profile's private `skills/` directory when it exists on disk. + * SKILL.md workflows placed there are scoped to this profile only. + */ + skillsDir?: string; } export interface AgentProfilesResponse { From 233c1604558bf25532840fbc53a3aaedf20ae178 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 13:31:01 +0400 Subject: [PATCH 16/80] feat(profiles-ui): per-profile cron attribution picker Add an agent-profile picker to the cron create/edit form (agent jobs only) wired to the snake_case `profile_id` wire field: create sends the id when set and omits it for "no profile"; edit sends the id or `null` to clear (double-option contract). A deleted-but-still-attributed profile stays selectable by its raw id. The job list shows the resolved profile name, falling back to the raw id when the profile is gone. CronJobsPanel loads profiles from the agentProfile slice. --- .../settings/panels/CronJobsPanel.test.tsx | 11 +++ .../settings/panels/CronJobsPanel.tsx | 12 +++ .../settings/panels/cron/CoreJobList.test.tsx | 45 ++++++++++ .../settings/panels/cron/CoreJobList.tsx | 17 ++++ .../panels/cron/CronJobFormModal.test.tsx | 86 +++++++++++++++++++ .../settings/panels/cron/CronJobFormModal.tsx | 45 ++++++++++ app/src/utils/tauriCommands/cron.ts | 4 + 7 files changed, 220 insertions(+) diff --git a/app/src/components/settings/panels/CronJobsPanel.test.tsx b/app/src/components/settings/panels/CronJobsPanel.test.tsx index d4ccc17e4c..8338b4a6ed 100644 --- a/app/src/components/settings/panels/CronJobsPanel.test.tsx +++ b/app/src/components/settings/panels/CronJobsPanel.test.tsx @@ -13,6 +13,17 @@ vi.mock('../hooks/useSettingsNavigation', () => ({ useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }), })); +// ── Mock Redux store hooks ────────────────────────────────────────────── +// The panel dispatches loadAgentProfiles + reads the agentProfile slice to +// feed the attribution picker / job-list labels. These tests render the panel +// without a Provider, so stub the hooks (profile UI is covered by the +// CronJobFormModal / CoreJobList component tests). +const noopDispatch = vi.fn(); +vi.mock('../../../store/hooks', () => ({ + useAppDispatch: () => noopDispatch, + useAppSelector: () => [], +})); + // ── Mock SettingsHeader ───────────────────────────────────────────────── vi.mock('../components/SettingsHeader', () => ({ default: ({ title }: { title: string }) =>
{title}
, diff --git a/app/src/components/settings/panels/CronJobsPanel.tsx b/app/src/components/settings/panels/CronJobsPanel.tsx index a10872f74c..0c5d0048fa 100644 --- a/app/src/components/settings/panels/CronJobsPanel.tsx +++ b/app/src/components/settings/panels/CronJobsPanel.tsx @@ -2,6 +2,8 @@ import createDebug from 'debug'; import { useCallback, useEffect, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; +import { loadAgentProfiles, selectAgentProfiles } from '../../../store/agentProfileSlice'; +import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { type CoreCronJob, type CoreCronRun, @@ -23,6 +25,8 @@ const loadCronJobsLog = createDebug('app:settings:CronJobsPanel:loadCronSkills') const CronJobsPanel = () => { const { t } = useT(); + const dispatch = useAppDispatch(); + const profiles = useAppSelector(selectAgentProfiles); const formatCronError = useCallback( (key: string, message: string) => t(key).replace('{message}', message), [t] @@ -70,6 +74,11 @@ const CronJobsPanel = () => { void loadCoreCronJobsOnly(); }, [loadCoreCronJobsOnly]); + // Populate the agent-profile attribution picker + job-list labels. + useEffect(() => { + void dispatch(loadAgentProfiles()); + }, [dispatch]); + const toggleCoreJob = async (job: CoreCronJob) => { const key = `core-toggle:${job.id}`; setCoreBusyKey(key); @@ -207,6 +216,7 @@ const CronJobsPanel = () => { void toggleCoreJob(job)} @@ -235,6 +245,7 @@ const CronJobsPanel = () => { key="cron-form-create" mode="create" open={true} + profiles={profiles} onClose={() => setFormOpen(false)} onCreate={params => handleCreate(params)} onUpdate={handleUpdate} @@ -248,6 +259,7 @@ const CronJobsPanel = () => { mode="edit" job={editingJob} open={true} + profiles={profiles} onClose={() => setEditingJob(null)} onCreate={handleCreate} onUpdate={handleUpdate} diff --git a/app/src/components/settings/panels/cron/CoreJobList.test.tsx b/app/src/components/settings/panels/cron/CoreJobList.test.tsx index 8cdcecf342..93eb5a0ca8 100644 --- a/app/src/components/settings/panels/cron/CoreJobList.test.tsx +++ b/app/src/components/settings/panels/cron/CoreJobList.test.tsx @@ -1,9 +1,14 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; +import type { AgentProfile } from '../../../../types/agentProfile'; import type { CoreCronJob, CoreCronRun } from '../../../../utils/tauriCommands'; import CoreJobList from './CoreJobList'; +const profiles: AgentProfile[] = [ + { id: 'writer', name: 'Writer', description: '', agentId: 'orchestrator', builtIn: false }, +]; + vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => @@ -15,6 +20,7 @@ vi.mock('../../../../lib/i18n/I18nContext', () => ({ 'settings.cron.jobs.lastStatus': 'Last status', 'settings.cron.jobs.nextRun': 'Next run', 'settings.cron.jobs.pause': 'Pause', + 'settings.cron.jobs.profile': 'Profile', 'settings.cron.jobs.recentRuns': 'Recent runs', 'settings.cron.jobs.saving': 'Saving…', 'settings.cron.jobs.schedule': 'Schedule', @@ -108,6 +114,45 @@ describe('CoreJobList stable test hooks', () => { expect(onEditCoreJob).toHaveBeenCalledWith(job); }); + test('shows the attributed profile name when it resolves', () => { + render( + + ); + expect(screen.getByTestId(`cron-job-profile-${job.id}`)).toHaveTextContent('Writer'); + }); + + test('falls back to the raw profile id when the profile was deleted', () => { + render( + + ); + expect(screen.getByTestId(`cron-job-profile-${job.id}`)).toHaveTextContent('ghost'); + }); + + test('omits the profile row when the job has no attribution', () => { + renderList(); + expect(screen.queryByTestId(`cron-job-profile-${job.id}`)).not.toBeInTheDocument(); + }); + test('toggle button shows saving label when coreBusyKey targets the toggle', () => { render( void; /** Optional: when provided, an Edit button is rendered per row. */ onEditCoreJob?: (job: CoreCronJob) => void; + /** Agent profiles, used to resolve a job's attributed profile name. */ + profiles?: AgentProfile[]; } const CoreJobList = ({ @@ -25,9 +28,15 @@ const CoreJobList = ({ onLoadCoreRuns, onRemoveCoreJob, onEditCoreJob, + profiles = [], }: CoreJobListProps) => { const { t } = useT(); + // Resolve a job's attributed profile to a display name, falling back to the + // raw id when the profile has since been deleted. + const profileLabel = (profileId: string): string => + profiles.find(p => p.id === profileId)?.name || profileId; + const toggleButtonLabel = (job: CoreCronJob) => { if (coreBusyKey === `core-toggle:${job.id}`) { return t('settings.cron.jobs.saving'); @@ -103,6 +112,14 @@ const CoreJobList = ({ {new Date(job.next_run).toLocaleString()} + {job.profile_id && ( +
+ {t('settings.cron.jobs.profile')}{' '} + + {profileLabel(job.profile_id)} + +
+ )} {job.last_status && (
{t('settings.cron.jobs.lastStatus')}{' '} diff --git a/app/src/components/settings/panels/cron/CronJobFormModal.test.tsx b/app/src/components/settings/panels/cron/CronJobFormModal.test.tsx index 3879486a96..3ce88d5475 100644 --- a/app/src/components/settings/panels/cron/CronJobFormModal.test.tsx +++ b/app/src/components/settings/panels/cron/CronJobFormModal.test.tsx @@ -1,9 +1,21 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AgentProfile } from '../../../../types/agentProfile'; import type { CoreCronJob } from '../../../../utils/tauriCommands'; import CronJobFormModal, { type CronJobFormModalProps } from './CronJobFormModal'; +const sampleProfiles: AgentProfile[] = [ + { id: 'writer', name: 'Writer', description: '', agentId: 'orchestrator', builtIn: false }, + { + id: 'researcher', + name: 'Researcher', + description: '', + agentId: 'orchestrator', + builtIn: false, + }, +]; + // ── Mock i18n ────────────────────────────────────────────────────────── vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ @@ -34,6 +46,9 @@ vi.mock('../../../../lib/i18n/I18nContext', () => ({ 'settings.cron.jobs.formSessionTarget': 'Session target', 'settings.cron.jobs.formSessionIsolated': 'Isolated (recommended)', 'settings.cron.jobs.formSessionMain': 'Main session', + 'settings.cron.jobs.formProfile': 'Agent profile', + 'settings.cron.jobs.formProfileNone': 'No profile', + 'settings.cron.jobs.formProfileHint': 'Run this job as the selected profile.', 'settings.cron.jobs.formDelivery': 'Delivery mode', 'settings.cron.jobs.formDeliveryNone': 'None (output only)', 'settings.cron.jobs.formDeliveryProactive': 'Proactive (push notification)', @@ -435,4 +450,75 @@ describe('', () => { fireEvent.click(screen.getByTestId('cron-form-job-type-agent')); expect(screen.getByTestId('cron-form-prompt')).toBeInTheDocument(); }); + + // ── Agent profile attribution picker ───────────────────────────────── + it('renders a profile picker with a "no profile" default plus each profile for agent jobs', () => { + render(); + const picker = screen.getByTestId('cron-form-profile') as HTMLSelectElement; + const optionValues = Array.from(picker.options).map(o => o.value); + expect(optionValues).toEqual(['', 'writer', 'researcher']); + // Defaults to "no profile". + expect(picker.value).toBe(''); + }); + + it('hides the profile picker for shell jobs', () => { + render(); + fireEvent.click(screen.getByTestId('cron-form-job-type-shell')); + expect(screen.queryByTestId('cron-form-profile')).not.toBeInTheDocument(); + }); + + it('omits profile_id from create params when "no profile" is selected', async () => { + const onCreate = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'go' } }); + fireEvent.click(screen.getByTestId('cron-form-submit')); + await waitFor(() => expect(onCreate).toHaveBeenCalledOnce()); + const [params] = onCreate.mock.calls[0]; + expect(params).not.toHaveProperty('profile_id'); + }); + + it('includes profile_id in create params when a profile is selected', async () => { + const onCreate = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'go' } }); + fireEvent.change(screen.getByTestId('cron-form-profile'), { target: { value: 'researcher' } }); + fireEvent.click(screen.getByTestId('cron-form-submit')); + await waitFor(() => expect(onCreate).toHaveBeenCalledOnce()); + const [params] = onCreate.mock.calls[0]; + expect(params.profile_id).toBe('researcher'); + }); + + it('prefills the picker from job.profile_id and patches the same id on edit', async () => { + const onUpdate = vi.fn().mockResolvedValue(undefined); + const job: CoreCronJob = { ...sampleJob, profile_id: 'writer' }; + render( + + ); + expect((screen.getByTestId('cron-form-profile') as HTMLSelectElement).value).toBe('writer'); + fireEvent.click(screen.getByTestId('cron-form-submit')); + await waitFor(() => expect(onUpdate).toHaveBeenCalledOnce()); + const [, patch] = onUpdate.mock.calls[0]; + expect(patch.profile_id).toBe('writer'); + }); + + it('sends profile_id: null on edit when the attribution is cleared to "no profile"', async () => { + const onUpdate = vi.fn().mockResolvedValue(undefined); + const job: CoreCronJob = { ...sampleJob, profile_id: 'writer' }; + render( + + ); + fireEvent.change(screen.getByTestId('cron-form-profile'), { target: { value: '' } }); + fireEvent.click(screen.getByTestId('cron-form-submit')); + await waitFor(() => expect(onUpdate).toHaveBeenCalledOnce()); + const [, patch] = onUpdate.mock.calls[0]; + expect(patch.profile_id).toBeNull(); + }); + + it('keeps a deleted attributed profile selectable by its raw id', () => { + const job: CoreCronJob = { ...sampleJob, profile_id: 'ghost' }; + render(); + const picker = screen.getByTestId('cron-form-profile') as HTMLSelectElement; + expect(picker.value).toBe('ghost'); + expect(Array.from(picker.options).map(o => o.value)).toContain('ghost'); + }); }); diff --git a/app/src/components/settings/panels/cron/CronJobFormModal.tsx b/app/src/components/settings/panels/cron/CronJobFormModal.tsx index 278088b16f..0189cd13bc 100644 --- a/app/src/components/settings/panels/cron/CronJobFormModal.tsx +++ b/app/src/components/settings/panels/cron/CronJobFormModal.tsx @@ -10,6 +10,7 @@ import { useState } from 'react'; import { cronToHuman } from '../../../../lib/cron/cronToHuman'; import { SCHEDULE_PRESET_VALUES, SCHEDULE_PRESETS } from '../../../../lib/cron/schedulePresets'; import { useT } from '../../../../lib/i18n/I18nContext'; +import type { AgentProfile } from '../../../../types/agentProfile'; import type { CoreCronJob, CoreCronSchedule, @@ -33,6 +34,8 @@ export interface CronJobFormModalProps { onClose: () => void; onCreate: (params: CronAddParams) => Promise; onUpdate: (jobId: string, patch: Record) => Promise; + /** Agent profiles offered in the attribution picker (agent jobs only). */ + profiles?: AgentProfile[]; } // ── Helpers ──────────────────────────────────────────────────────────── @@ -104,6 +107,8 @@ interface CronJobFormInitialState { sessionTarget: SessionTarget; delivery: DeliveryMode; deleteAfterRun: boolean; + /** '' means "no profile" / cleared attribution. */ + profileId: string; } function getInitialFormState(mode: 'create' | 'edit', job?: CoreCronJob): CronJobFormInitialState { @@ -126,6 +131,7 @@ function getInitialFormState(mode: 'create' | 'edit', job?: CoreCronJob): CronJo sessionTarget: job.session_target === 'main' ? 'main' : 'isolated', delivery: getInitialDelivery(job), deleteAfterRun: job.delete_after_run, + profileId: job.profile_id ?? '', }; } @@ -142,6 +148,7 @@ function getInitialFormState(mode: 'create' | 'edit', job?: CoreCronJob): CronJo sessionTarget: 'isolated', delivery: 'proactive', deleteAfterRun: false, + profileId: '', }; } @@ -154,6 +161,7 @@ const CronJobFormModal = ({ onClose, onCreate, onUpdate, + profiles = [], }: CronJobFormModalProps) => { const { t } = useT(); const initialState = getInitialFormState(mode, job); @@ -172,6 +180,7 @@ const CronJobFormModal = ({ const [sessionTarget, setSessionTarget] = useState(initialState.sessionTarget); const [delivery, setDelivery] = useState(initialState.delivery); const [deleteAfterRun, setDeleteAfterRun] = useState(initialState.deleteAfterRun); + const [profileId, setProfileId] = useState(initialState.profileId); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -220,6 +229,9 @@ const CronJobFormModal = ({ ...(jobType === 'agent' ? { delivery: { mode: delivery, best_effort: true } } : { delivery: { mode: 'none', best_effort: false } }), + // Attribute the run to an agent profile (agent jobs only). Omit the + // key entirely for "no profile" so the core leaves it unset. + ...(jobType === 'agent' && profileId ? { profile_id: profileId } : {}), delete_after_run: deleteAfterRun, }; log('[CronJobFormModal] calling onCreate metadata=%o', { @@ -242,6 +254,9 @@ const CronJobFormModal = ({ ...(jobType === 'agent' ? { delivery: { mode: delivery, best_effort: true } } : { delivery: { mode: 'none', best_effort: false } }), + // Double-option attribution: send the id to (re)attribute, or `null` + // to clear. Only meaningful for agent jobs. + ...(jobType === 'agent' ? { profile_id: profileId || null } : {}), delete_after_run: deleteAfterRun, }; const patchSchedule = patch.schedule as { kind?: string } | undefined; @@ -571,6 +586,36 @@ const CronJobFormModal = ({
)} + {/* Agent profile attribution (agent only) */} + {jobType === 'agent' && ( +
+ + +

+ {t('settings.cron.jobs.formProfileHint')} +

+
+ )} + {/* Delivery mode (agent only) */} {jobType === 'agent' && (
diff --git a/app/src/utils/tauriCommands/cron.ts b/app/src/utils/tauriCommands/cron.ts index 6379c1093d..4ae27a1939 100644 --- a/app/src/utils/tauriCommands/cron.ts +++ b/app/src/utils/tauriCommands/cron.ts @@ -32,6 +32,8 @@ export interface CoreCronJob { job_type: 'shell' | 'agent' | string; session_target: 'isolated' | 'main' | string; model?: string | null; + /** Agent profile this job runs as, when attributed (snake_case on the wire). */ + profile_id?: string | null; enabled: boolean; delivery: { mode: string; channel?: string | null; to?: string | null; best_effort: boolean }; delete_after_run: boolean; @@ -61,6 +63,8 @@ export interface CronAddParams { session_target?: 'isolated' | 'main'; model?: string; agent_id?: string; + /** Agent profile to attribute this job to (snake_case on the wire). Omit for none. */ + profile_id?: string; delivery?: { mode: string; channel?: string | null; to?: string | null; best_effort?: boolean }; delete_after_run?: boolean; } From 981b7a55bb059e2a0ddf7576a4fdf0966674ff51 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 13:40:26 +0400 Subject: [PATCH 17/80] fix(cron): honor wire null as clear for CronJobPatch double-option fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CronJobPatch.profile_id: Option>` with plain serde deserializes a wire `null` identically to an absent key (both collapse to the outer `None`), so the UI's "clear attribution" patch (`{"profile_id": null}`) was a silent no-op — the job kept its profile. Fix: a `deserialize_double_option` helper (used via `#[serde(default, deserialize_with = ...)]`) restores real double-option semantics — absent key -> None (no change), present null -> Some(None) (clear), present value -> Some(Some(v)) (set). serde only invokes a `deserialize_with` when the key is present, which is what makes the absent-vs-null distinction recoverable. Applied consistently to BOTH `profile_id` and `agent_id`: `agent_id` is the same `Option>` shape and shared the identical latent bug, and its struct doc + the existing `cron_job_patch_agent_id_supports_explicit_none_clearing` test already document the `Some(None)` = clear intent. Fixing it is behavior-neutral for real callers (the frontend never patches `agent_id` — it is a create-only param; the patch TS type omits it) and aligns the wire with the documented struct semantics, so it is the "trivially safe + clearly intended" case rather than a silent behavior change. No other `CronJobPatch` field is a clearable `Option>`; the remaining fields are plain `Option` where absent and null both correctly mean "no change". The codebase already had this exact helper as `deserialize_nullable_patch` in `app_state/schemas.rs` (private to that domain); this adds a local, slightly simpler equivalent to keep the cron domain self-contained. Tests: `patch_profile_id_wire_double_option_semantics` / `patch_agent_id_wire_double_option_semantics` pin absent/null/value through the real `serde_json::from_value` path (the same one `read_required::` uses); the json_rpc_e2e cron round-trip now updates a job with `profile_id: null` over `/rpc` and asserts `cron_list` shows the attribution gone. cargo check green; cron types+store lib tests 47 passed; e2e passed. --- src/openhuman/cron/types.rs | 65 +++++++++++++++++++++++++++++++++++++ tests/json_rpc_e2e.rs | 48 ++++++++++++++++++++++++--- 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/src/openhuman/cron/types.rs b/src/openhuman/cron/types.rs index 368f5652ce..27bc06e0dc 100644 --- a/src/openhuman/cron/types.rs +++ b/src/openhuman/cron/types.rs @@ -262,6 +262,30 @@ pub struct CronRun { pub duration_ms: Option, } +/// Deserialize a nullable patch field with true double-option semantics: +/// +/// | wire | result | meaning | +/// | --------------- | ------------- | ------------- | +/// | key absent | `None` | no change | +/// | key present `null` | `Some(None)` | clear the value | +/// | key present value | `Some(Some(v))` | set the value | +/// +/// A plain `#[derive(Deserialize)]` on `Option>` collapses the absent +/// and the `null` cases *both* to the outer `None`, so "clear over the wire" +/// (`{"profile_id": null}`) silently deserializes as "no change" — a no-op. Used +/// with `#[serde(default, deserialize_with = "deserialize_double_option")]`, +/// this helper restores the distinction: serde only invokes it when the key is +/// *present*, so a present `null` becomes `Some(None)` and a present value +/// becomes `Some(Some(v))`, while an absent key falls back to the `default` +/// (`None`). +fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Ok(Some(Option::::deserialize(deserializer)?)) +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CronJobPatch { pub schedule: Option, @@ -273,9 +297,15 @@ pub struct CronJobPatch { pub model: Option, pub session_target: Option, pub delete_after_run: Option, + /// `Option>` distinguishes "no change" (`None`) from + /// "clear the agent definition" (`Some(None)`). See + /// [`deserialize_double_option`] for why the custom deserializer is required + /// to honor a wire `null` as a clear rather than a silent no-op. + #[serde(default, deserialize_with = "deserialize_double_option")] pub agent_id: Option>, /// `Option>` distinguishes "no change" (`None`) from /// "clear the profile" (`Some(None)`) — same shape as `agent_id`. + #[serde(default, deserialize_with = "deserialize_double_option")] pub profile_id: Option>, } @@ -569,6 +599,41 @@ mod tests { assert_eq!(set.profile_id, Some(Some("alice".to_string()))); } + #[test] + fn patch_profile_id_wire_double_option_semantics() { + // The RPC path deserializes CronJobPatch from JSON params — pin the three + // wire cases so a `null` clears rather than silently no-ops. + // absent key → no change. + let absent: CronJobPatch = serde_json::from_value(json!({})).unwrap(); + assert_eq!(absent.profile_id, None, "absent key means no change"); + // present null → clear. + let cleared: CronJobPatch = serde_json::from_value(json!({ "profile_id": null })).unwrap(); + assert_eq!( + cleared.profile_id, + Some(None), + "wire null must clear the attribution" + ); + // present value → set. + let set: CronJobPatch = serde_json::from_value(json!({ "profile_id": "writer" })).unwrap(); + assert_eq!(set.profile_id, Some(Some("writer".to_string()))); + } + + #[test] + fn patch_agent_id_wire_double_option_semantics() { + // Same fix applied consistently to `agent_id` (its doc + the struct-level + // clearing test already document the Some(None)=clear intent). + let absent: CronJobPatch = serde_json::from_value(json!({})).unwrap(); + assert_eq!(absent.agent_id, None, "absent key means no change"); + let cleared: CronJobPatch = serde_json::from_value(json!({ "agent_id": null })).unwrap(); + assert_eq!( + cleared.agent_id, + Some(None), + "wire null must clear the agent definition" + ); + let set: CronJobPatch = serde_json::from_value(json!({ "agent_id": "welcome" })).unwrap(); + assert_eq!(set.agent_id, Some(Some("welcome".to_string()))); + } + #[test] fn cron_job_patch_agent_id_supports_explicit_none_clearing() { // Option> lets callers distinguish "no change" diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index e5b5b18e93..b630553d26 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -7899,7 +7899,9 @@ async fn json_rpc_profiles_dedicated_memory_lifecycle() { .find(|p| p.get("id").and_then(Value::as_str) == Some("writer")) .expect("writer present in list"); assert_eq!( - listed_writer.get("dedicatedMemory").and_then(Value::as_bool), + listed_writer + .get("dedicatedMemory") + .and_then(Value::as_bool), Some(true) ); // The home was materialized on upsert, so SOUL.md exists and its path is @@ -7969,13 +7971,47 @@ async fn json_rpc_profiles_dedicated_memory_lifecycle() { ) .await; let cron_update_result = assert_no_jsonrpc_error(&cron_update, "cron_update"); - let updated_job = cron_update_result.get("result").unwrap_or(cron_update_result); + let updated_job = cron_update_result + .get("result") + .unwrap_or(cron_update_result); assert_eq!( updated_job.get("profile_id").and_then(Value::as_str), Some("editor"), "cron_update must repoint profile_id: {cron_update_result}" ); + // cron_update with a wire `null` clears the attribution (double-option + // semantics — a plain `Option>` would silently no-op here). + let cron_clear = post_json_rpc( + &rpc_base, + 67, + "openhuman.cron_update", + json!({ "job_id": job_id, "patch": { "profile_id": null } }), + ) + .await; + let cron_clear_result = assert_no_jsonrpc_error(&cron_clear, "cron_update clear"); + let cleared_job = cron_clear_result.get("result").unwrap_or(cron_clear_result); + assert!( + cleared_job.get("profile_id").is_none_or(Value::is_null), + "cron_update patch profile_id=null must clear the attribution: {cron_clear_result}" + ); + + // ...and cron_list confirms the attribution is gone. + let cron_list_after = post_json_rpc(&rpc_base, 68, "openhuman.cron_list", json!({})).await; + let cron_list_after_result = assert_no_jsonrpc_error(&cron_list_after, "cron_list after clear"); + let jobs_after = cron_list_after_result + .get("result") + .and_then(Value::as_array) + .expect("cron_list jobs array"); + let listed_after = jobs_after + .iter() + .find(|j| j.get("id").and_then(Value::as_str) == Some(job_id.as_str())) + .expect("job present in cron_list after clear"); + assert!( + listed_after.get("profile_id").is_none_or(Value::is_null), + "cleared profile_id must not reappear in cron_list: {listed_after}" + ); + // --- select then delete round-trips the active id --- let select = post_json_rpc( &rpc_base, @@ -7987,7 +8023,9 @@ async fn json_rpc_profiles_dedicated_memory_lifecycle() { let select_result = assert_no_jsonrpc_error(&select, "profiles_select"); let select_payload = select_result.get("result").unwrap_or(select_result); assert_eq!( - select_payload.get("activeProfileId").and_then(Value::as_str), + select_payload + .get("activeProfileId") + .and_then(Value::as_str), Some("writer") ); @@ -8001,7 +8039,9 @@ async fn json_rpc_profiles_dedicated_memory_lifecycle() { let delete_result = assert_no_jsonrpc_error(&delete, "profiles_delete"); let delete_payload = delete_result.get("result").unwrap_or(delete_result); assert_eq!( - delete_payload.get("activeProfileId").and_then(Value::as_str), + delete_payload + .get("activeProfileId") + .and_then(Value::as_str), Some("default"), "deleting the active custom profile falls back to default" ); From 273a93a3d7ae2e252b2d6fa02a6b0cd64e0a5738 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 13:41:43 +0400 Subject: [PATCH 18/80] docs(profiles): add agent profile homes section to test coverage matrix --- docs/TEST-COVERAGE-MATRIX.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index e7c1a6f3ad..02f5d6ad84 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -611,15 +611,31 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an | 14.1.4 | Disabled-build stub (`--no-default-features`) | RU | `src/core/cli_tests.rs` (`tui`/`chat` `*_reports_disabled_build_when_gate_off`) | ✅ | Build-fact error, not `unknown namespace` | | 14.1.5 | Interactive terminal session (raw mode, streaming) | MS | manual smoke | 🚫 | Needs a real TTY; not driver-automatable | +## 15. Agent Profile Homes (multi-agent profiles) + +### 15.1 Profile Homes & Isolation + +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | -------------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------- | +| 15.1.1 | Profile home materialization + file-backed SOUL.md | RU | `src/openhuman/profiles/home.rs`, `src/openhuman/profiles/paths.rs` | ✅ | Idempotent atomic seeds, id validation matrix, soul resolution order incl. legacy-id skip | +| 15.1.2 | Dedicated memory suffix + dedicated workspace descriptor | RU | `src/openhuman/profiles/paths.rs`, `src/openhuman/agent/harness/session/builder/factory.rs` | ✅ | `effective_memory_suffix` matrix; real `derive_profile_workspace_descriptor` seam, None path unchanged | +| 15.1.3 | Cross-profile write guard (file tools + shell) | RU | `src/openhuman/profiles/guard.rs`, `src/openhuman/security/policy/policy_tests.rs` | ✅ | Block sibling / allow own / disarmed-allows; traversal + symlink cases through real `validate_parent_path` | +| 15.1.4 | Profile-scoped agent experience (capture stamp + partition) | RU | `src/openhuman/agent_experience/` (capture/store/ops tests) | ✅ | Profiled query sees own + legacy, excludes siblings; profile-less sees all | +| 15.1.5 | Per-profile skills dir (discover/describe/read/run precedence) | RU | `src/openhuman/skills/registry.rs`, `src/openhuman/skills/ops_discover.rs` | ✅ | Owner-only visibility; profile-local wins collisions; global-only resolves everywhere | +| 15.1.6 | Cron profile attribution (schema, patch clearing, fallback) | RU/RI | `src/openhuman/cron/types.rs`, `src/openhuman/cron/store.rs`, `tests/json_rpc_e2e.rs` | ✅ | Double-option wire semantics (absent/null/value); deleted-profile fallback; e2e round-trip incl. null clearing | +| 15.1.7 | Profiles RPC lifecycle (dedicated memory, enriched paths) | RI | `tests/json_rpc_e2e.rs` (`json_rpc_profiles_dedicated_memory_lifecycle`) | ✅ | Upsert → list shows `dedicatedMemory` + enriched `soulMdFile`/`workspaceDir`/`skillsDir` | +| 15.1.8 | Profile editor isolation UI (toggles, path rows) | VU | `app/src/components/settings/panels/ProfileEditorPage.test.tsx` | ✅ | Default-off render, dispatch payload, hydration, path rows shown/hidden | +| 15.1.9 | Cron profile picker UI (assign, clear, deleted fallback) | VU | `app/src/components/settings/panels/cron/` tests, `app/src/services/api/agentProfilesApi.test.ts` | ✅ | Create with/without id, edit prefill, clear→null, deleted-profile preserved, list label fallback | + ## Summary | Status | Count | | ---------------- | ------------------------------------------------ | -| ✅ Covered | 70 | +| ✅ Covered | 79 | | 🟡 Partial | 27 | | ❌ Missing | 26 | | 🚫 Manual smoke | 11 | -| **Total leaves** | **135 explicit + nested = 206 product features** | +| **Total leaves** | **144 explicit + nested = 215 product features** | PR-A delta: 13 leaves moved from ❌ → ✅ via 5 WDIO specs + 2 Vitest + 1 Rust integration test. Remaining gaps tracked under sub-issues #965 (process), #966 (docs), #967 (tools), #968 (auth/perm), #969 (settings), #970 (rewards), #971 (manual smoke). From 21e4683a41d16717f0672366914942eec405cef9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 13:44:15 +0400 Subject: [PATCH 19/80] style: cargo fmt on agent_experience + policy tests --- src/openhuman/agent_experience/schemas.rs | 6 ++++-- src/openhuman/agent_experience/store.rs | 10 ++++++++-- src/openhuman/security/policy/policy_tests.rs | 4 +--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/openhuman/agent_experience/schemas.rs b/src/openhuman/agent_experience/schemas.rs index 332540baf4..8d0812b3ba 100644 --- a/src/openhuman/agent_experience/schemas.rs +++ b/src/openhuman/agent_experience/schemas.rs @@ -96,7 +96,8 @@ pub fn schemas(function: &str) -> ControllerSchema { FieldSchema { name: "profile_id", ty: TypeSchema::Option(Box::new(TypeSchema::String)), - comment: "Optional profile partition: returns records stamped with this \ + comment: + "Optional profile partition: returns records stamped with this \ profile plus unstamped legacy records; omit to recall the whole pool.", required: false, }, @@ -121,7 +122,8 @@ pub fn schemas(function: &str) -> ControllerSchema { inputs: vec![FieldSchema { name: "profile_id", ty: TypeSchema::Option(Box::new(TypeSchema::String)), - comment: "Optional profile partition: lists records stamped with this profile plus \ + comment: + "Optional profile partition: lists records stamped with this profile plus \ unstamped legacy records; omit to list the whole pool.", required: false, }], diff --git a/src/openhuman/agent_experience/store.rs b/src/openhuman/agent_experience/store.rs index 219569c5e6..380ecf8867 100644 --- a/src/openhuman/agent_experience/store.rs +++ b/src/openhuman/agent_experience/store.rs @@ -33,7 +33,10 @@ pub struct ExperienceQuery { /// - A **profiled** query (`Some(P)`) sees records stamped `P` plus unstamped /// legacy/shared records (`record_profile == None`), and excludes records /// stamped with a different profile `Q` — the isolation the feature adds. -pub fn experience_matches_profile(record_profile: Option<&str>, query_profile: Option<&str>) -> bool { +pub fn experience_matches_profile( + record_profile: Option<&str>, + query_profile: Option<&str>, +) -> bool { match query_profile { None => true, Some(active) => match record_profile { @@ -485,7 +488,10 @@ mod tests { .unwrap(); let ids: BTreeSet<_> = hits.iter().map(|h| h.experience.id.clone()).collect(); assert!(ids.contains("exp_p"), "profile P must see its own record"); - assert!(ids.contains("exp_legacy"), "profile P must see legacy records"); + assert!( + ids.contains("exp_legacy"), + "profile P must see legacy records" + ); assert!(!ids.contains("exp_q"), "profile P must not see sibling Q"); // Profile-less: sees everything. diff --git a/src/openhuman/security/policy/policy_tests.rs b/src/openhuman/security/policy/policy_tests.rs index e81f9a0a70..c41cbba458 100644 --- a/src/openhuman/security/policy/policy_tests.rs +++ b/src/openhuman/security/policy/policy_tests.rs @@ -28,9 +28,7 @@ fn full_policy() -> SecurityPolicy { /// Build a `/projects/profiles/{alice,bob}` layout and a policy whose cwd /// is scoped to alice (as `security_for_tool_context` would), with the guard /// optionally armed for alice against the broad action root. -fn cross_profile_policy( - arm_for_alice: bool, -) -> (tempfile::TempDir, PathBuf, SecurityPolicy) { +fn cross_profile_policy(arm_for_alice: bool) -> (tempfile::TempDir, PathBuf, SecurityPolicy) { let root = tempfile::tempdir().expect("root tempdir"); let action_root = root.path().join("projects"); let profiles = action_root.join("profiles"); From d0e72dd390f907a0515a9aa2939457eaca200ee2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 15:12:41 +0400 Subject: [PATCH 20/80] fix(profiles): sync edited SOUL.md on upsert; dedicated_memory beats numeric suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review fixes to the profiles domain (PR #5118 review). 1. Preserve edited SOUL.md from Settings (Codex, home.rs). ensure_profile_home only seeds SOUL.md when absent, and resolve_personality_soul reads the file first, so a persona edited in Settings after the home already existed updated agent_profiles.json but never reached the prompt. Add sync_soul_md_on_upsert(): on an explicit (non-built-in) upsert, when profile.soul_md is Some(non-empty) and differs from the on-disk SOUL.md, atomically overwrite the file (same temp+rename as the seed path). Empty/None soul_md leaves the file untouched so a manual file edit stays authoritative; select never calls it, so it can't clobber a manual edit with a stale inline value. Wired into ops::upsert (non-fatal). 2. Let dedicated_memory override the auto-assigned numeric suffix (Codex, paths.rs). AgentProfileStore::upsert stamps every non-default profile with Some("-1"), Some("-2"), … so effective_memory_suffix's "legacy numeric wins" branch made the dedicated_memory toggle dead code (always memory-1, never memory-). Flip precedence: dedicated_memory (with a valid id) wins; the numeric suffix applies only when dedicated_memory is off (back-compat for existing non-dedicated profiles). Back-compat note: toggling dedicated_memory ON is an explicit user opt-in to a fresh dedicated subtree, so switching the subtree on toggle is intended. Doc comments (paths.rs, mod.rs) updated. Tests: sync overwrite/no-op/invalid-id cases; flipped-precedence matrix (dedicated-wins-over-numeric, numeric-retained-when-not-dedicated, invalid-id-dedicated-falls-back-to-numeric). --- src/openhuman/profiles/home.rs | 143 ++++++++++++++++++++++++++++++++ src/openhuman/profiles/mod.rs | 5 +- src/openhuman/profiles/ops.rs | 11 +++ src/openhuman/profiles/paths.rs | 72 +++++++++++----- 4 files changed, 208 insertions(+), 23 deletions(-) diff --git a/src/openhuman/profiles/home.rs b/src/openhuman/profiles/home.rs index b4007ab8ab..eea153537c 100644 --- a/src/openhuman/profiles/home.rs +++ b/src/openhuman/profiles/home.rs @@ -295,6 +295,83 @@ pub fn ensure_profile_home( Ok(()) } +/// Reconcile the on-disk `SOUL.md` with an edited inline `soul_md` on an +/// **explicit profile save** (upsert only — never on select). +/// +/// [`ensure_profile_home`] seeds `SOUL.md` only when the file is *absent*, so a +/// persona edited in Settings after the home already exists would update +/// `agent_profiles.json` but leave the file stale — and because +/// [`resolve_personality_soul`](super::paths::resolve_personality_soul) reads +/// the file first, the agent would keep using the old identity. This closes that +/// gap by overwriting the file (atomic temp+rename, same as the seed path) when: +/// - the id passes [`validate_profile_id`] (read paths would load it), +/// - `profile.soul_md` is `Some(non-empty)`, and +/// - the trimmed inline content differs from the current file content. +/// +/// When `soul_md` is empty/`None` the file is left untouched, so a user who +/// manually edits `SOUL.md` (and clears the inline value) keeps that file +/// authoritative. Only ever called from the upsert path; select must not clobber +/// a manually edited file with a stale inline value. Returns `Ok(true)` when the +/// file was rewritten. +pub fn sync_soul_md_on_upsert(workspace_dir: &Path, profile: &AgentProfile) -> io::Result { + if let Err(e) = validate_profile_id(&profile.id) { + tracing::debug!( + profile_id = %profile.id, + error = %e, + "[profiles][home] sync_soul_md_on_upsert skipped: id fails validation" + ); + return Ok(false); + } + // Empty / absent inline soul_md → leave any manual file edits authoritative. + let desired = match profile + .soul_md + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + Some(s) => s, + None => { + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] sync_soul_md_on_upsert: inline soul_md empty, file left as-is" + ); + return Ok(false); + } + }; + + let home = profile_home(workspace_dir, &profile.id); + let soul_path = home.join("SOUL.md"); + // No-op when the file already matches the edited value (compare trimmed so a + // trailing-newline difference doesn't churn the file). + if let Ok(current) = std::fs::read_to_string(&soul_path) { + if current.trim() == desired { + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] sync_soul_md_on_upsert: file already matches inline soul_md" + ); + return Ok(false); + } + } + + // Persist with a trailing newline for tidy files, matching the seed path. + let contents = format!("{desired}\n"); + std::fs::create_dir_all(&home)?; + seed_file_atomic(&home, &soul_path, contents.as_bytes()).map_err(|e| { + tracing::debug!( + profile_id = %profile.id, + soul_path = %soul_path.display(), + error = %e, + "[profiles][home] sync_soul_md_on_upsert: overwrite SOUL.md failed" + ); + e + })?; + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] sync_soul_md_on_upsert: SOUL.md overwritten from edited inline soul_md" + ); + Ok(true) +} + /// Resolve the agent-writable workspace directory for a profile *iff* it opts /// into a dedicated workspace and its id passes [`validate_profile_id`]. /// @@ -503,6 +580,72 @@ mod tests { assert!(!ws.path().join("personalities").join("Bad Id").exists()); } + #[test] + fn sync_soul_md_on_upsert_overwrites_edited_inline_soul() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + let mut profile = test_profile("grace"); + profile.soul_md = Some("Original identity.".to_string()); + // Seed the home once (writes SOUL.md from the original inline value). + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + let soul_path = profile_home(ws.path(), "grace").join("SOUL.md"); + assert_eq!( + std::fs::read_to_string(&soul_path).unwrap(), + "Original identity.\n" + ); + + // User edits the persona in Settings → the stored inline value changes. + profile.soul_md = Some("Rewritten identity from Settings.".to_string()); + let rewritten = sync_soul_md_on_upsert(ws.path(), &profile).expect("sync"); + assert!( + rewritten, + "differing inline soul_md must overwrite the file" + ); + assert_eq!( + std::fs::read_to_string(&soul_path).unwrap(), + "Rewritten identity from Settings.\n" + ); + + // Idempotent: a second sync with the same value is a no-op. + let again = sync_soul_md_on_upsert(ws.path(), &profile).expect("sync 2"); + assert!(!again, "matching inline soul_md must not rewrite the file"); + } + + #[test] + fn sync_soul_md_on_upsert_leaves_file_when_inline_empty() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + // Seed with a default template (no inline soul_md). + let mut profile = test_profile("heidi"); + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + let soul_path = profile_home(ws.path(), "heidi").join("SOUL.md"); + // User edits the file manually; inline soul_md stays empty/None. + std::fs::write(&soul_path, "MANUALLY EDITED SOUL").unwrap(); + + profile.soul_md = None; + let none_written = sync_soul_md_on_upsert(ws.path(), &profile).expect("sync none"); + assert!(!none_written); + profile.soul_md = Some(" ".to_string()); // whitespace-only → treated as empty + let blank_written = sync_soul_md_on_upsert(ws.path(), &profile).expect("sync blank"); + assert!(!blank_written); + + // The manual edit stays authoritative. + assert_eq!( + std::fs::read_to_string(&soul_path).unwrap(), + "MANUALLY EDITED SOUL" + ); + } + + #[test] + fn sync_soul_md_on_upsert_skips_invalid_id() { + let ws = TempDir::new().unwrap(); + let mut profile = test_profile("placeholder"); + profile.id = "Bad Id".to_string(); + profile.soul_md = Some("ignored".to_string()); + assert!(!sync_soul_md_on_upsert(ws.path(), &profile).expect("sync")); + assert!(!profile_home(ws.path(), "Bad Id").join("SOUL.md").exists()); + } + #[test] fn dedicated_workspace_dir_gates_on_flag_and_id() { let action = Path::new("/tmp/act"); diff --git a/src/openhuman/profiles/mod.rs b/src/openhuman/profiles/mod.rs index 9509dd7d00..4e31460e8f 100644 --- a/src/openhuman/profiles/mod.rs +++ b/src/openhuman/profiles/mod.rs @@ -29,8 +29,9 @@ //! - `SOUL.md` is re-read on every prompt build (see //! [`resolve_personality_soul`]) so identity edits take effect live. //! - `dedicated_memory` derives a `-` memory suffix (see -//! [`effective_memory_suffix`]); a legacy numeric `memory_dir_suffix` still -//! wins for back-compat. +//! [`effective_memory_suffix`]) and, as an explicit user opt-in, wins over the +//! store's auto-assigned numeric `memory_dir_suffix`; the numeric suffix is +//! retained only when `dedicated_memory` is off (back-compat). //! - `dedicated_workspace` roots a per-profile default cwd for acting tools (see //! [`dedicated_workspace_dir`] and the session builder's section-D wiring). //! - `skills/` (see [`profile_skills_dir`]) holds SKILL.md/WORKFLOW.md bundles diff --git a/src/openhuman/profiles/ops.rs b/src/openhuman/profiles/ops.rs index d0b53aa9c6..7918fe4b45 100644 --- a/src/openhuman/profiles/ops.rs +++ b/src/openhuman/profiles/ops.rs @@ -221,6 +221,17 @@ pub async fn upsert(profile: AgentProfile) -> Result { .filter(|p| !p.built_in) { materialize_home(&workspace_dir, &action_dir, persisted); + // Reconcile an edited persona into the on-disk SOUL.md: `ensure_profile_home` + // only seeds the file when absent, and `resolve_personality_soul` reads the + // file first, so a Settings edit after the home exists would otherwise never + // reach the prompt. Non-fatal — the profile is already persisted. + if let Err(e) = super::home::sync_soul_md_on_upsert(&workspace_dir, persisted) { + tracing::warn!( + profile_id = %persisted.id, + error = %e, + "[profiles][ops] sync_soul_md_on_upsert failed (non-fatal)" + ); + } } tracing::debug!( request_id = %request_id, diff --git a/src/openhuman/profiles/paths.rs b/src/openhuman/profiles/paths.rs index bf2571a739..80e9e918cb 100644 --- a/src/openhuman/profiles/paths.rs +++ b/src/openhuman/profiles/paths.rs @@ -208,30 +208,24 @@ pub fn resolve_personality_memory_md( /// Derive the effective memory directory suffix for a profile. /// /// Precedence: -/// 1. an explicit `memory_dir_suffix` (the legacy auto-assigned numeric suffix, -/// e.g. `"-1"`) always wins — pre-existing profiles keep their directories; -/// 2. else, when `dedicated_memory` is set, derive `"-"` from the profile id +/// 1. when `dedicated_memory` is set, derive `"-"` from the profile id /// (id must pass [`validate_profile_id`], else fall back to the shared `""` -/// and warn — a legacy id can't mint an unexpected directory name); +/// and warn — a legacy id can't mint an unexpected directory name). This is +/// an explicit user opt-in and **wins over** the auto-assigned numeric +/// suffix: the store stamps every non-default profile with `Some("-1")`, +/// `Some("-2")`, … on upsert, so if the numeric suffix took precedence the +/// isolation toggle could never take effect (it would be dead code). Toggling +/// `dedicated_memory` on therefore switches the profile to its own +/// `memory-` subtree — the intended behaviour of the toggle. +/// 2. else, an explicit `memory_dir_suffix` (the legacy auto-assigned numeric +/// suffix, e.g. `"-1"`) — pre-existing non-dedicated profiles keep their +/// directories; /// 3. else `""` (the shared/global memory tree). /// /// The returned suffix feeds the existing /// [`memory_subdir_for_suffix`] / [`memory_tree_subdir_for_suffix`] / /// [`session_raw_subdir_for_suffix`] helpers unchanged. pub fn effective_memory_suffix(profile: &AgentProfile) -> String { - if let Some(suffix) = profile - .memory_dir_suffix - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - { - tracing::debug!( - profile_id = %profile.id, - suffix = %suffix, - "[personality] effective_memory_suffix using legacy numeric suffix" - ); - return suffix.to_string(); - } if profile.dedicated_memory { match validate_profile_id(&profile.id) { Ok(()) => { @@ -248,11 +242,24 @@ pub fn effective_memory_suffix(profile: &AgentProfile) -> String { profile_id = %profile.id, error = %e, "[personality] dedicated_memory requested but id fails validation, \ - falling back to shared memory tree" + falling back to legacy/shared memory tree" ); } } } + if let Some(suffix) = profile + .memory_dir_suffix + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + tracing::debug!( + profile_id = %profile.id, + suffix = %suffix, + "[personality] effective_memory_suffix using legacy numeric suffix" + ); + return suffix.to_string(); + } tracing::debug!( profile_id = %profile.id, "[personality] effective_memory_suffix using shared memory tree" @@ -424,15 +431,38 @@ mod tests { } #[test] - fn effective_memory_suffix_legacy_numeric_wins() { + fn effective_memory_suffix_dedicated_wins_over_numeric() { let mut profile = test_profile("alice"); + // The store auto-assigns a numeric suffix to every non-default profile, + // so `dedicated_memory` must win over it — otherwise the toggle would be + // dead code and could never route to the `memory-` subtree. profile.memory_dir_suffix = Some("-3".to_string()); - // Even with dedicated_memory set, the persisted legacy suffix wins so the - // existing memory directory is never orphaned. profile.dedicated_memory = true; + assert_eq!(effective_memory_suffix(&profile), "-alice"); + } + + #[test] + fn effective_memory_suffix_numeric_retained_when_not_dedicated() { + let mut profile = test_profile("alice"); + // With dedicated_memory off, the persisted legacy numeric suffix is + // retained so an existing memory directory is never orphaned. + profile.memory_dir_suffix = Some("-3".to_string()); + profile.dedicated_memory = false; assert_eq!(effective_memory_suffix(&profile), "-3"); } + #[test] + fn effective_memory_suffix_invalid_id_dedicated_falls_back_to_numeric() { + let mut profile = test_profile("placeholder"); + profile.id = "Bad Id".to_string(); + // An invalid id can't mint a `-` directory even with dedicated on, so + // it falls back to the persisted numeric suffix rather than the shared + // tree. + profile.memory_dir_suffix = Some("-2".to_string()); + profile.dedicated_memory = true; + assert_eq!(effective_memory_suffix(&profile), "-2"); + } + #[test] fn effective_memory_suffix_dedicated_derives_from_id() { let mut profile = test_profile("alice"); From 9e411b283f16f2ddb13ee601f0d623272ed89986 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 15:12:50 +0400 Subject: [PATCH 21/80] fix(agent_experience): partition captured experience keys by profile The storage key (id) was derived in build_experience before profile_id was stamped in on_turn_complete, so identical task/tool/outcome triples from different profiles collapsed onto one experience/ key and the later store.put() overwrote the earlier profile's record (Codex, capture.rs). Add stable_experience_id_for_profile(): mixes the profile id into the digest only when Some(non-empty); None is byte-identical to the pre-1c derivation so existing stored (legacy/profile-less) records keep their identity. Re-derive the id in on_turn_complete after stamping the profile. Tests: None matches the legacy derivation (incl. blank id); A/B/None yield three distinct keys; hook-level test proves three records coexist instead of one being overwritten, and the None record's key equals the legacy derivation. --- src/openhuman/agent_experience/capture.rs | 61 +++++++++++++++- src/openhuman/agent_experience/types.rs | 89 +++++++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) diff --git a/src/openhuman/agent_experience/capture.rs b/src/openhuman/agent_experience/capture.rs index b97c72347f..facf395e20 100644 --- a/src/openhuman/agent_experience/capture.rs +++ b/src/openhuman/agent_experience/capture.rs @@ -5,7 +5,8 @@ use std::sync::Arc; use crate::openhuman::agent::hooks::{PostTurnHook, ToolCallRecord, TurnContext}; use crate::openhuman::agent_experience::store::AgentExperienceStore; use crate::openhuman::agent_experience::types::{ - redact_text, stable_experience_id, AgentExperience, ExperienceOutcome, ExperienceSource, + redact_text, stable_experience_id, stable_experience_id_for_profile, AgentExperience, + ExperienceOutcome, ExperienceSource, }; use crate::openhuman::memory::Memory; @@ -88,6 +89,18 @@ impl PostTurnHook for AgentExperienceCaptureHook { // `None` for the profile-less session — unstamped records read as // shared/legacy and surface under any profile. candidate.profile_id = self.profile_id.clone(); + // Re-derive the storage key to include the profile now that it's + // stamped: `extract_candidates` builds the id profile-agnostically, so + // two profiles hitting the same task/tool/outcome triple would + // otherwise share one `experience/` key and overwrite each other. + // `None` reproduces the legacy id byte-for-byte (see + // `stable_experience_id_for_profile`). + candidate.id = stable_experience_id_for_profile( + &candidate.task_summary, + &candidate.tool_sequence, + candidate.outcome, + candidate.profile_id.as_deref(), + ); if let Err(err) = self.store.put(candidate).await { log::warn!("[agent-experience] failed to capture turn experience: {err}"); } @@ -425,4 +438,50 @@ mod tests { "captured record must be stamped with the active profile id" ); } + + #[tokio::test] + async fn identical_candidates_under_different_profiles_do_not_collide() { + // Alice and Bob learn the same task/tool/outcome triple. Their records + // must land under distinct storage keys so neither overwrites the other, + // and the profile-less (None) key must match the legacy derivation. + let calls = || { + vec![ + call("grep", true, "grep: ok (20 chars)"), + call("file_read", true, "file_read: ok (100 chars)"), + ] + }; + + let memory: Arc = Arc::new(MockMemory::default()); + AgentExperienceCaptureHook::with_profile(memory.clone(), true, Some("alice".to_string())) + .on_turn_complete(&ctx_with(calls())) + .await + .unwrap(); + AgentExperienceCaptureHook::with_profile(memory.clone(), true, Some("bob".to_string())) + .on_turn_complete(&ctx_with(calls())) + .await + .unwrap(); + AgentExperienceCaptureHook::new(memory.clone(), true) + .on_turn_complete(&ctx_with(calls())) + .await + .unwrap(); + + let stored = AgentExperienceStore::new(memory).list().await.unwrap(); + // Three distinct records rather than one repeatedly-overwritten key. + assert_eq!(stored.len(), 3, "each profile keeps its own record"); + let ids: std::collections::HashSet<&str> = stored.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(ids.len(), 3, "the three storage keys must be distinct"); + + // The profile-less record's key matches the legacy (profile-agnostic) + // derivation for the same triple. + let none_record = stored + .iter() + .find(|e| e.profile_id.is_none()) + .expect("a profile-less record"); + let legacy_id = stable_experience_id( + &none_record.task_summary, + &none_record.tool_sequence, + none_record.outcome, + ); + assert_eq!(none_record.id, legacy_id); + } } diff --git a/src/openhuman/agent_experience/types.rs b/src/openhuman/agent_experience/types.rs index 5456240f5f..8de027de0f 100644 --- a/src/openhuman/agent_experience/types.rs +++ b/src/openhuman/agent_experience/types.rs @@ -74,6 +74,29 @@ pub fn stable_experience_id( task_summary: &str, tool_sequence: &[String], outcome: ExperienceOutcome, +) -> String { + stable_experience_id_for_profile(task_summary, tool_sequence, outcome, None) +} + +/// Derive a stable experience id, partitioning the storage key by the capturing +/// profile when one is set. +/// +/// The store keys records by this id, so two profiles that learn the *same* +/// task/tool/outcome triple must not collapse onto one key (the later +/// `store.put()` would otherwise overwrite the earlier profile's record — see +/// the 1c retrieval partition). Mixing the profile id into the digest keeps each +/// profile's procedural experience distinct. +/// +/// `profile_id == None` (the profile-less / legacy session) is **byte-identical** +/// to the pre-1c derivation, so every record written before profile scoping keeps +/// its exact id and stays retrievable. A `Some` profile appends a +/// domain-separated segment, so profile A, profile B, and `None` yield three +/// different keys for the same triple. +pub fn stable_experience_id_for_profile( + task_summary: &str, + tool_sequence: &[String], + outcome: ExperienceOutcome, + profile_id: Option<&str>, ) -> String { let mut hasher = Sha256::new(); hasher.update(task_summary.trim().to_lowercase().as_bytes()); @@ -83,6 +106,13 @@ pub fn stable_experience_id( hasher.update(b"\0"); } hasher.update(outcome_key(outcome).as_bytes()); + // Only stamp the profile segment when a non-empty id is present: an absent or + // blank profile must reproduce the legacy digest byte-for-byte so existing + // stored records keep their identity. + if let Some(profile_id) = profile_id.map(str::trim).filter(|id| !id.is_empty()) { + hasher.update(b"\0profile\0"); + hasher.update(profile_id.to_lowercase().as_bytes()); + } let digest = format!("{:x}", hasher.finalize()); format!("exp_{}", &digest[..24]) } @@ -155,4 +185,63 @@ mod tests { let failure = stable_experience_id("same task", &sequence, ExperienceOutcome::Failure); assert_ne!(success, failure); } + + #[test] + fn stable_experience_id_for_profile_none_matches_legacy_derivation() { + // `None` must be byte-identical to the pre-1c derivation so existing + // stored records keep their identity. + let sequence = vec!["grep".to_string(), "file_read".to_string()]; + let legacy = stable_experience_id("same task", &sequence, ExperienceOutcome::Success); + let none = stable_experience_id_for_profile( + "same task", + &sequence, + ExperienceOutcome::Success, + None, + ); + assert_eq!(legacy, none); + // An empty / whitespace-only profile id is treated as `None`. + let blank = stable_experience_id_for_profile( + "same task", + &sequence, + ExperienceOutcome::Success, + Some(" "), + ); + assert_eq!(legacy, blank); + } + + #[test] + fn stable_experience_id_for_profile_partitions_by_profile() { + // Same task/tool/outcome triple under profile A vs B vs None yields three + // distinct keys, so no profile can overwrite another's record. + let sequence = vec!["grep".to_string(), "file_read".to_string()]; + let none = stable_experience_id_for_profile( + "same task", + &sequence, + ExperienceOutcome::Success, + None, + ); + let alice = stable_experience_id_for_profile( + "same task", + &sequence, + ExperienceOutcome::Success, + Some("alice"), + ); + let bob = stable_experience_id_for_profile( + "same task", + &sequence, + ExperienceOutcome::Success, + Some("bob"), + ); + assert_ne!(none, alice); + assert_ne!(none, bob); + assert_ne!(alice, bob); + // Deterministic per profile. + let alice_again = stable_experience_id_for_profile( + "same task", + &sequence, + ExperienceOutcome::Success, + Some("alice"), + ); + assert_eq!(alice, alice_again); + } } From f5ca3dc170d601110dd039d7976212d771fd8e08 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 15:12:56 +0400 Subject: [PATCH 22/80] fix(profiles): fall back to shared cwd when dedicated workspace dir can't be created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit derive_profile_workspace_descriptor logged a create_dir_all failure but still returned Some(descriptor), binding every acting tool (shell/file/git) to a nonexistent cwd and silently breaking the whole profile session (CodeRabbit, factory.rs). Return None on create failure so callers fall back to the shared action_dir cwd, matching the docstring's "common case" fallback. Doc updated; regression test points action_dir at a regular file so profiles/… can't be created and asserts None. --- .../agent/harness/session/builder/factory.rs | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 2afe256ff0..878c2df88c 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -1527,10 +1527,11 @@ mod provider_role_tests { /// rooted at `/profiles/` when `profile` opts into /// `dedicated_workspace` and its id passes validation (via /// [`dedicated_workspace_dir`](crate::openhuman::profiles::dedicated_workspace_dir)), -/// creating the dir as a side effect; `None` for the shared-workspace common case -/// and for legacy ids that fail validation (both fall back to the shared -/// `action_dir` cwd). The returned descriptor propagates to subagents — see the -/// deliberate-isolation note at the call site. +/// creating the dir as a side effect; `None` for the shared-workspace common case, +/// for legacy ids that fail validation, and when the directory can't be created +/// (all three fall back to the shared `action_dir` cwd rather than binding tools +/// to a nonexistent dir). The returned descriptor propagates to subagents — see +/// the deliberate-isolation note at the call site. pub(crate) fn derive_profile_workspace_descriptor( action_dir: &std::path::Path, profile: Option<&crate::openhuman::profiles::AgentProfile>, @@ -1544,9 +1545,12 @@ pub(crate) fn derive_profile_workspace_descriptor( profile_id = %profile_id, dir = %dir.display(), error = %e, - "[profiles] failed to create dedicated workspace dir; \ - tools will still target it as cwd" + "[profiles] failed to create dedicated workspace dir — \ + falling back to the shared action_dir cwd for this session" ); + // Return None so callers fall back to the shared action_dir rather than + // binding every acting tool (shell/file/git) to a cwd that doesn't exist. + return None; } tracing::debug!( profile_id = %profile_id, @@ -1617,4 +1621,19 @@ mod profile_workspace_descriptor_tests { let p = profile("Bad Id", true); assert!(derive_profile_workspace_descriptor(action.path(), Some(&p)).is_none()); } + + #[test] + fn create_dir_failure_yields_no_descriptor() { + // Point `action_dir` at a regular file so `profiles/…` can't be created. + // The function must fall back to `None` (shared action_dir cwd) rather + // than hand tools a descriptor rooted at a nonexistent dir. + let tmp = tempfile::tempdir().expect("tempdir"); + let action_file = tmp.path().join("not-a-dir"); + std::fs::write(&action_file, b"x").expect("write file"); + let p = profile("alice", true); + assert!( + derive_profile_workspace_descriptor(&action_file, Some(&p)).is_none(), + "a create_dir_all failure must fall back to None" + ); + } } From 3dd8ef1b5e570e9d5d91cd9740607e0b28deb6b1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 15:13:09 +0400 Subject: [PATCH 23/80] fix(profiles): harden shell cross-profile scan + document its guarantee level The token scan in scan_command_for_cross_profile is a best-effort static backstop, not a hard boundary: paths embedded in interpreter code (python -c 'open("../bob/x","w")') or built from shell variables/command substitution ($HOME, $(cmd)) can reach a sibling profile without a standalone path token (Codex P1 shell.rs; CodeRabbit guard.rs). Full pre-execution enforcement is impossible; OS-level shell confinement (cwd_jail / Seatbelt / Landlock) is deliberate follow-up. This commit takes the cheap, clearly-correct robustness wins and states the guarantee honestly: - Split the command on shell punctuation (quotes, parens, backtick, comma, '=', braces, '&') in addition to whitespace/redirects, so paths embedded in simple quoted strings and flag=value forms are isolated and classified. Splitting only ever produces substrings of the original command, so it adds coverage without new false positives. - Prominent doc comment on scan_command_for_cross_profile stating: best-effort defense-in-depth for the model-facing shell path; the hard boundary for file mutations is SecurityPolicy::validate_path; airtight shell confinement is follow-up. Explicitly does NOT claim full coverage; a None result is not proof. Does NOT add the blanket fail-closed-on-$ mitigation CodeRabbit floated: it would over-block ordinary agent shell usage. The variable-expansion gap is documented and pinned by a test instead. Tests: python -c embedded path blocked; --flag=../bob/x blocked; variable- expansion gap ($TARGET_DIR) documented as a known limitation. --- src/openhuman/profiles/guard.rs | 106 ++++++++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 13 deletions(-) diff --git a/src/openhuman/profiles/guard.rs b/src/openhuman/profiles/guard.rs index bea3156a12..f1715a4857 100644 --- a/src/openhuman/profiles/guard.rs +++ b/src/openhuman/profiles/guard.rs @@ -121,26 +121,61 @@ pub fn classify_cross_profile_target( /// profile's workspace, given the command's working directory `cwd` (the active /// profile's own dir). /// -/// Shell commands do not funnel through the per-path `validate_path` gate that -/// file tools use, so this is the shell-side call site of the same guard. It -/// splits the command on whitespace and redirect operators, keeps only -/// path-shaped tokens (those containing a path separator or a leading `~`), -/// resolves each against `cwd`, and classifies it via -/// [`classify_cross_profile_target`]. Returns the first sibling profile id it -/// would write into, or `None` when the command stays clear of other profiles. +/// # Guarantee level (read before relying on this) +/// +/// This is **best-effort defense-in-depth for the model-facing shell path, not a +/// hard boundary.** It is a static, pre-execution token scan — it cannot see +/// what the shell will actually do at runtime. Known, deliberate gaps: +/// +/// - **Variable / command substitution.** `$HOME`, `${VAR}`, `$(cmd)`, and +/// backtick substitution resolve to paths only at runtime; a token like +/// `$SOME_VAR` is not path-shaped textually and is skipped. +/// - **Paths embedded inside interpreter code.** `python -c 'open("../bob/x","w")'` +/// or any inline script hides the path inside a program string. The tokenizer +/// below splits on quotes/parens/commas/`=` so the *simple* embedded cases are +/// still isolated and classified, but an arbitrary interpreter can construct a +/// path the scanner never sees. +/// +/// The **hard** cross-profile boundary for file mutations is +/// [`SecurityPolicy::validate_path`](crate::openhuman::security) at the file-tool +/// call site (every write funnels through it). Shell commands do **not** funnel +/// through that gate, so this scan is their only in-Rust backstop — and the +/// airtight shell confinement (an OS sandbox: cwd_jail / Seatbelt / Landlock +/// restricting the process to its own subtree) is deliberate follow-up work, not +/// provided here. Do not treat a `None` result as proof a command cannot reach a +/// sibling profile. /// -/// This is deliberately a *containment* backstop layered on top of the cwd — -/// which is already rooted at the profile's own workspace — rather than a full -/// shell parser. It reliably catches the realistic escape vectors (an absolute -/// path into `profiles/` or a `..//…` traversal); the airtight guarantee -/// for file mutations lives in the file-tool `validate_path` call site. +/// # What it does catch +/// +/// It splits the command on whitespace, redirect/pipe operators, and common +/// shell punctuation (quotes, parens, backtick, `,`, `=`, `{`, `}`, `&`), keeps +/// only path-shaped tokens (those containing a path separator or a leading `~`), +/// resolves each against `cwd`, and classifies it via +/// [`classify_cross_profile_target`]. This reliably catches the realistic +/// non-adversarial escape vectors: an absolute path into `profiles/`, a +/// `..//…` traversal, a `--flag=..//…` form, and simple quoted paths. +/// Returns the first sibling profile id it would write into, or `None` when no +/// scanned token lands in another profile. pub fn scan_command_for_cross_profile( command: &str, cwd: &Path, action_dir: &Path, active_profile: &str, ) -> Option { - for raw in command.split(|c: char| c.is_whitespace() || matches!(c, '>' | '<' | '|' | ';')) { + // Split on shell punctuation as well as whitespace/redirects so a path + // embedded in a quoted string, a `flag=value`, a comma list, or a brace + // expansion is isolated into its own token. Splitting only ever produces + // substrings of the original command, so it cannot invent a path that + // resolves into a sibling — it can only surface one that was genuinely + // referenced (no new false positives, strictly better coverage). + for raw in command.split(|c: char| { + c.is_whitespace() + || matches!( + c, + '>' | '<' | '|' | ';' | ',' | '=' | '"' | '\'' | '(' | ')' | '`' | '{' | '}' | '&' + ) + }) { + // Residual wrapper chars the split didn't consume at the edges. let token = raw.trim_matches(|c| matches!(c, '"' | '\'' | '(' | ')' | '`')); if token.is_empty() { continue; @@ -377,4 +412,49 @@ mod tests { Some("bob".to_string()) ); } + + #[test] + fn scan_command_blocks_path_embedded_in_quoted_interpreter_arg() { + // The path is buried inside a python -c program string. Splitting on + // quotes/parens/commas isolates `../bob/loot.txt` so the simple embedded + // case is still caught. + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + let command = r#"python -c 'open("../bob/loot.txt","w").write("x")'"#; + assert_eq!( + scan_command_for_cross_profile(command, &cwd, &action, "alice"), + Some("bob".to_string()) + ); + } + + #[test] + fn scan_command_blocks_flag_equals_sibling_path() { + // A `--flag=../bob/…` form: splitting on `=` isolates the path token. + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + assert_eq!( + scan_command_for_cross_profile( + "tar --directory=../bob/x -cf a.tar .", + &cwd, + &action, + "alice" + ), + Some("bob".to_string()) + ); + } + + #[test] + fn scan_command_documents_variable_expansion_gap() { + // Documented best-effort limitation: a shell variable that expands to a + // sibling path at runtime is not statically resolvable, so the scan does + // not catch it. The hard boundary for this is an OS sandbox (follow-up); + // this test pins the known gap so it's a conscious contract, not a + // surprise regression. + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + assert_eq!( + scan_command_for_cross_profile("cp x $TARGET_DIR/y", &cwd, &action, "alice"), + None + ); + } } From 1d3f7dae478953cc54744104e18b4e622e785c1b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 15:13:17 +0400 Subject: [PATCH 24/80] chore(profiles): privacy-safe diagnostics for skills refresh + cron attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two observability-only review fixes (CodeRabbit), no behaviour change, no PII. - turn/tools.rs refresh_workflows: log the profile-aware discovery branch — whether profile-local skills discovery is active (boolean) and the loaded workflow count. Never the profile id or resolved path (an invalid id falls back to shared discovery, represented by the boolean=false branch). - cron/schemas.rs: log has_profile_attribution on job create, and patches_/clears_profile_attribution on update (double-option patch: absent = no change, null = clear, value = set). Boolean state only, never the profile id. Frontend CronJobFormModal create/update submit metadata already covered in the profiles-ui commit. --- .../agent/harness/session/turn/tools.rs | 11 +++++++++++ src/openhuman/cron/schemas.rs | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/openhuman/agent/harness/session/turn/tools.rs b/src/openhuman/agent/harness/session/turn/tools.rs index 3e485619a4..a654111760 100644 --- a/src/openhuman/agent/harness/session/turn/tools.rs +++ b/src/openhuman/agent/harness/session/turn/tools.rs @@ -333,10 +333,21 @@ impl Agent { let profile_skills_root = self.active_profile_id.as_deref().and_then(|id| { crate::openhuman::profiles::profile_skills_root(&self.workspace_dir, id) }); + // An invalid/absent active profile id silently falls back to shared + // discovery. Log the branch id-free (boolean only, never the profile id or + // resolved path) per the observability convention for new/changed flows. + let profile_local_skills_active = profile_skills_root.is_some(); + log::debug!( + "[agent_loop] refreshing installed-skills metadata (trigger={trigger}, profile_local_skills_active={profile_local_skills_active})" + ); let latest = crate::openhuman::skills::load_workflow_metadata_for_profile( &self.workspace_dir, profile_skills_root.as_deref(), ); + log::debug!( + "[agent_loop] refreshed installed-skills metadata (trigger={trigger}, profile_local_skills_active={profile_local_skills_active}, workflow_count={})", + latest.len() + ); let current_ids: std::collections::HashSet = self.workflows.iter().map(&id_of).collect(); let latest_ids: std::collections::HashSet = latest.iter().map(&id_of).collect(); diff --git a/src/openhuman/cron/schemas.rs b/src/openhuman/cron/schemas.rs index 13d8c8f85d..2a75269020 100644 --- a/src/openhuman/cron/schemas.rs +++ b/src/openhuman/cron/schemas.rs @@ -324,6 +324,12 @@ fn handle_add(params: Map) -> ControllerFuture { .get("profile_id") .and_then(|v| v.as_str()) .map(|s| s.to_string()); + // Privacy-safe diagnostic: whether the created job carries profile + // attribution, never the profile id itself. + tracing::debug!( + has_profile_attribution = profile_id.is_some(), + "[cron][schemas] create: parsed agent-profile attribution" + ); let delivery: Option = match params.get("delivery") { None | Some(Value::Null) => None, @@ -394,6 +400,19 @@ fn handle_update(params: Map) -> ControllerFuture { let config = config_rpc::load_config_with_timeout().await?; let job_id = read_required::(¶ms, "job_id")?; let patch = read_required::(¶ms, "patch")?; + // Privacy-safe diagnostic for the profile-attribution patch. Double-option + // `profile_id`: `None` = no change, `Some(None)` = clear, `Some(Some)` = + // (re)attribute. Log only the state, never the profile id. + let (patches_profile_attribution, clears_profile_attribution) = match &patch.profile_id { + None => (false, false), + Some(None) => (true, true), + Some(Some(_)) => (true, false), + }; + tracing::debug!( + patches_profile_attribution, + clears_profile_attribution, + "[cron][schemas] update: parsed agent-profile attribution patch" + ); to_json(crate::openhuman::cron::rpc::cron_update(&config, job_id.trim(), patch).await?) }) } From 109e775094695594ca9caf3feb707460206f4406 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 15:13:30 +0400 Subject: [PATCH 25/80] fix(profiles-ui): associate cron profile label; correct SKILL.md/SOUL.md i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CronJobFormModal: add id="cron-form-profile" to the profile setProfileId(e.target.value)} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 9c50aaf324..df345f76dd 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -7127,11 +7127,11 @@ const messages: TranslationMap = { 'settings.profiles.editor.dedicatedWorkspace': 'مساحة عمل مخصصة', 'settings.profiles.editor.dedicatedWorkspaceHint': 'امنح هذا الملف مجلد عمل خاص به لعمليات الملفات والأدوات.', - 'settings.profiles.editor.soulMdFile': 'ملف الهوية', + 'settings.profiles.editor.soulMdFile': 'ملف SOUL.md', 'settings.profiles.editor.workspaceDir': 'مجلد مساحة العمل', 'settings.profiles.editor.skillsDir': 'دليل المهارات', 'settings.profiles.editor.skillsDirHint': - 'ملفات سير عمل SKILL.md الموضوعة هنا خاصة بهذا الملف وحده.', + 'ملفات SKILL.md الموضوعة هنا خاصة بهذا الملف الشخصي وحده.', 'settings.profiles.editor.all': 'الكل', 'settings.profiles.editor.selected': 'محدّد', 'settings.profiles.editor.addPlaceholder': 'اكتب معرّفًا ثم اضغط Enter', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 6aa8104c20..109220f195 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -7293,11 +7293,11 @@ const messages: TranslationMap = { 'settings.profiles.editor.dedicatedWorkspace': 'নিবেদিত ওয়ার্কস্পেস', 'settings.profiles.editor.dedicatedWorkspaceHint': 'ফাইল ও টুল কাজের জন্য এই প্রোফাইলকে নিজস্ব ওয়ার্কিং ডিরেক্টরি দিন।', - 'settings.profiles.editor.soulMdFile': 'পরিচয় ফাইল', + 'settings.profiles.editor.soulMdFile': 'SOUL.md ফাইল', 'settings.profiles.editor.workspaceDir': 'ওয়ার্কস্পেস ডিরেক্টরি', 'settings.profiles.editor.skillsDir': 'স্কিল ডিরেক্টরি', 'settings.profiles.editor.skillsDirHint': - 'এখানে রাখা SKILL.md ওয়ার্কফ্লোগুলি শুধু এই প্রোফাইলের জন্য ব্যক্তিগত।', + 'এখানে রাখা SKILL.md ফাইলগুলি শুধু এই প্রোফাইলের জন্য ব্যক্তিগত।', 'settings.profiles.editor.all': 'সব', 'settings.profiles.editor.selected': 'নির্বাচিত', 'settings.profiles.editor.addPlaceholder': 'একটি আইডি লিখে এন্টার চাপুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 51df33dc2a..fa9f6cb412 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7509,11 +7509,11 @@ const messages: TranslationMap = { 'settings.profiles.editor.dedicatedWorkspace': 'Dedizierter Arbeitsbereich', 'settings.profiles.editor.dedicatedWorkspaceHint': 'Gib diesem Profil ein eigenes Arbeitsverzeichnis für Datei- und Tool-Operationen.', - 'settings.profiles.editor.soulMdFile': 'Identitätsdatei', + 'settings.profiles.editor.soulMdFile': 'SOUL.md-Datei', 'settings.profiles.editor.workspaceDir': 'Arbeitsverzeichnis', 'settings.profiles.editor.skillsDir': 'Skill-Verzeichnis', 'settings.profiles.editor.skillsDirHint': - 'Hier abgelegte SKILL.md-Workflows sind privat für dieses Profil.', + 'Hier abgelegte SKILL.md-Dateien sind privat für dieses Profil.', 'settings.profiles.editor.all': 'Alle', 'settings.profiles.editor.selected': 'Ausgewählt', 'settings.profiles.editor.addPlaceholder': 'Kennung eingeben und Enter drücken', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 0c28924562..51503cb787 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7435,11 +7435,11 @@ const en: TranslationMap = { 'settings.profiles.editor.dedicatedWorkspace': 'Dedicated workspace', 'settings.profiles.editor.dedicatedWorkspaceHint': 'Give this profile its own working directory for file and tool operations.', - 'settings.profiles.editor.soulMdFile': 'Identity file', + 'settings.profiles.editor.soulMdFile': 'SOUL.md file', 'settings.profiles.editor.workspaceDir': 'Workspace directory', 'settings.profiles.editor.skillsDir': 'Skills directory', 'settings.profiles.editor.skillsDirHint': - 'SKILL.md workflows placed here are private to this profile.', + 'SKILL.md files placed here are private to this profile.', 'settings.profiles.editor.all': 'All', 'settings.profiles.editor.selected': 'Selected', 'settings.profiles.editor.addPlaceholder': 'Type an id, press Enter', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 46135c449f..8270ab7213 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7445,11 +7445,11 @@ const messages: TranslationMap = { 'settings.profiles.editor.dedicatedWorkspace': 'Espacio de trabajo dedicado', 'settings.profiles.editor.dedicatedWorkspaceHint': 'Dale a este perfil su propio directorio de trabajo para operaciones de archivos y herramientas.', - 'settings.profiles.editor.soulMdFile': 'Archivo de identidad', + 'settings.profiles.editor.soulMdFile': 'Archivo SOUL.md', 'settings.profiles.editor.workspaceDir': 'Directorio de trabajo', 'settings.profiles.editor.skillsDir': 'Directorio de habilidades', 'settings.profiles.editor.skillsDirHint': - 'Los flujos de SKILL.md colocados aquí son privados de este perfil.', + 'Las habilidades basadas en SKILL.md colocadas aquí son privadas de este perfil.', 'settings.profiles.editor.all': 'Todos', 'settings.profiles.editor.selected': 'Seleccionados', 'settings.profiles.editor.addPlaceholder': 'Escribe un identificador y pulsa Intro', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index ca19da1625..02e07ab0a0 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7478,11 +7478,11 @@ const messages: TranslationMap = { 'settings.profiles.editor.dedicatedWorkspace': 'Espace de travail dédié', 'settings.profiles.editor.dedicatedWorkspaceHint': 'Donner à ce profil son propre répertoire de travail pour les opérations de fichiers et d’outils.', - 'settings.profiles.editor.soulMdFile': 'Fichier d’identité', + 'settings.profiles.editor.soulMdFile': 'Fichier SOUL.md', 'settings.profiles.editor.workspaceDir': 'Répertoire de travail', 'settings.profiles.editor.skillsDir': 'Répertoire des compétences', 'settings.profiles.editor.skillsDirHint': - 'Les workflows SKILL.md placés ici sont privés à ce profil.', + 'Les fichiers SKILL.md placés ici sont privés à ce profil.', 'settings.profiles.editor.all': 'Tous', 'settings.profiles.editor.selected': 'Sélectionnés', 'settings.profiles.editor.addPlaceholder': 'Saisissez un identifiant, puis Entrée', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 9e167f5346..7d270ed7e2 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7288,11 +7288,11 @@ const messages: TranslationMap = { 'settings.profiles.editor.dedicatedWorkspace': 'समर्पित वर्कस्पेस', 'settings.profiles.editor.dedicatedWorkspaceHint': 'फ़ाइल और टूल कार्यों के लिए इस प्रोफ़ाइल को अपनी खुद की वर्किंग डायरेक्टरी दें।', - 'settings.profiles.editor.soulMdFile': 'पहचान फ़ाइल', + 'settings.profiles.editor.soulMdFile': 'SOUL.md फ़ाइल', 'settings.profiles.editor.workspaceDir': 'वर्कस्पेस डायरेक्टरी', 'settings.profiles.editor.skillsDir': 'स्किल निर्देशिका', 'settings.profiles.editor.skillsDirHint': - 'यहाँ रखे गए SKILL.md वर्कफ़्लो केवल इस प्रोफ़ाइल के लिए निजी हैं।', + 'यहाँ रखी गई SKILL.md फ़ाइलें केवल इस प्रोफ़ाइल के लिए निजी हैं।', 'settings.profiles.editor.all': 'सभी', 'settings.profiles.editor.selected': 'चयनित', 'settings.profiles.editor.addPlaceholder': 'आईडी टाइप करें, एंटर दबाएँ', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 903770499a..549631d1ff 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -7325,11 +7325,11 @@ const messages: TranslationMap = { 'settings.profiles.editor.dedicatedWorkspace': 'Ruang kerja khusus', 'settings.profiles.editor.dedicatedWorkspaceHint': 'Berikan profil ini direktori kerjanya sendiri untuk operasi file dan alat.', - 'settings.profiles.editor.soulMdFile': 'Berkas identitas', + 'settings.profiles.editor.soulMdFile': 'Berkas SOUL.md', 'settings.profiles.editor.workspaceDir': 'Direktori ruang kerja', 'settings.profiles.editor.skillsDir': 'Direktori keterampilan', 'settings.profiles.editor.skillsDirHint': - 'Alur kerja SKILL.md yang ditempatkan di sini bersifat pribadi untuk profil ini.', + 'File SKILL.md yang ditempatkan di sini bersifat pribadi untuk profil ini.', 'settings.profiles.editor.all': 'Semua', 'settings.profiles.editor.selected': 'Terpilih', 'settings.profiles.editor.addPlaceholder': 'Ketik id, tekan Enter', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 225073ad6e..86055a8f4d 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7430,11 +7430,11 @@ const messages: TranslationMap = { 'settings.profiles.editor.dedicatedWorkspace': 'Area di lavoro dedicata', 'settings.profiles.editor.dedicatedWorkspaceHint': 'Assegna a questo profilo una propria directory di lavoro per le operazioni su file e strumenti.', - 'settings.profiles.editor.soulMdFile': 'File di identità', + 'settings.profiles.editor.soulMdFile': 'File SOUL.md', 'settings.profiles.editor.workspaceDir': 'Directory di lavoro', 'settings.profiles.editor.skillsDir': 'Cartella delle competenze', 'settings.profiles.editor.skillsDirHint': - 'I flussi SKILL.md inseriti qui sono privati per questo profilo.', + 'I file SKILL.md inseriti qui sono privati per questo profilo.', 'settings.profiles.editor.all': 'Tutti', 'settings.profiles.editor.selected': 'Selezionati', 'settings.profiles.editor.addPlaceholder': 'Digita un identificativo e premi Invio', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 11e8c3f0bb..be420b2bfc 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -7205,11 +7205,11 @@ const messages: TranslationMap = { 'settings.profiles.editor.dedicatedWorkspace': '전용 작업 공간', 'settings.profiles.editor.dedicatedWorkspaceHint': '파일 및 도구 작업을 위해 이 프로필에 전용 작업 디렉터리를 부여합니다.', - 'settings.profiles.editor.soulMdFile': '아이덴티티 파일', + 'settings.profiles.editor.soulMdFile': 'SOUL.md 파일', 'settings.profiles.editor.workspaceDir': '작업 공간 디렉터리', 'settings.profiles.editor.skillsDir': '기술 디렉터리', 'settings.profiles.editor.skillsDirHint': - '여기에 넣은 SKILL.md 워크플로는 이 프로필에만 적용됩니다.', + '여기에 넣은 SKILL.md 파일은 이 프로필에만 적용됩니다.', 'settings.profiles.editor.all': '전체', 'settings.profiles.editor.selected': '선택됨', 'settings.profiles.editor.addPlaceholder': '식별자를 입력하고 Enter를 누르세요', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 2598b575c5..da1a81ff19 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7403,11 +7403,11 @@ const messages: TranslationMap = { 'settings.profiles.editor.dedicatedWorkspace': 'Dedykowany obszar roboczy', 'settings.profiles.editor.dedicatedWorkspaceHint': 'Nadaj temu profilowi własny katalog roboczy do operacji na plikach i narzędziach.', - 'settings.profiles.editor.soulMdFile': 'Plik tożsamości', + 'settings.profiles.editor.soulMdFile': 'Plik SOUL.md', 'settings.profiles.editor.workspaceDir': 'Katalog roboczy', 'settings.profiles.editor.skillsDir': 'Katalog umiejętności', 'settings.profiles.editor.skillsDirHint': - 'Przepływy SKILL.md umieszczone tutaj są prywatne dla tego profilu.', + 'Pliki SKILL.md umieszczone tutaj są prywatne dla tego profilu.', 'settings.profiles.editor.all': 'Wszystkie', 'settings.profiles.editor.selected': 'Wybrane', 'settings.profiles.editor.addPlaceholder': 'Wpisz identyfikator i naciśnij Enter', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 44ee2721be..755a009e3f 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7412,11 +7412,11 @@ const messages: TranslationMap = { 'settings.profiles.editor.dedicatedWorkspace': 'Espaço de trabalho dedicado', 'settings.profiles.editor.dedicatedWorkspaceHint': 'Dê a este perfil seu próprio diretório de trabalho para operações de arquivos e ferramentas.', - 'settings.profiles.editor.soulMdFile': 'Arquivo de identidade', + 'settings.profiles.editor.soulMdFile': 'Arquivo SOUL.md', 'settings.profiles.editor.workspaceDir': 'Diretório de trabalho', 'settings.profiles.editor.skillsDir': 'Diretório de habilidades', 'settings.profiles.editor.skillsDirHint': - 'Os fluxos SKILL.md colocados aqui são privados deste perfil.', + 'Os arquivos SKILL.md colocados aqui são privados deste perfil.', 'settings.profiles.editor.all': 'Todos', 'settings.profiles.editor.selected': 'Selecionados', 'settings.profiles.editor.addPlaceholder': 'Digite um identificador e pressione Enter', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 3340726453..a2f9d0c5cf 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -7376,11 +7376,11 @@ const messages: TranslationMap = { 'settings.profiles.editor.dedicatedWorkspace': 'Выделенное рабочее пространство', 'settings.profiles.editor.dedicatedWorkspaceHint': 'Выделить этому профилю собственный рабочий каталог для операций с файлами и инструментами.', - 'settings.profiles.editor.soulMdFile': 'Файл идентичности', + 'settings.profiles.editor.soulMdFile': 'Файл SOUL.md', 'settings.profiles.editor.workspaceDir': 'Рабочий каталог', 'settings.profiles.editor.skillsDir': 'Каталог навыков', 'settings.profiles.editor.skillsDirHint': - 'Рабочие процессы SKILL.md, размещённые здесь, доступны только этому профилю.', + 'Файлы SKILL.md, размещённые здесь, доступны только этому профилю.', 'settings.profiles.editor.all': 'Все', 'settings.profiles.editor.selected': 'Выбранные', 'settings.profiles.editor.addPlaceholder': 'Введите идентификатор и нажмите Enter', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 451bae832b..c52735771f 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6893,10 +6893,10 @@ const messages: TranslationMap = { 'settings.profiles.editor.dedicatedWorkspace': '专属工作区', 'settings.profiles.editor.dedicatedWorkspaceHint': '为此配置分配专属工作目录,用于文件和工具操作。', - 'settings.profiles.editor.soulMdFile': '身份文件', + 'settings.profiles.editor.soulMdFile': 'SOUL.md 文件', 'settings.profiles.editor.workspaceDir': '工作目录', 'settings.profiles.editor.skillsDir': '技能目录', - 'settings.profiles.editor.skillsDirHint': '放在此处的 SKILL.md 工作流仅对该配置私有。', + 'settings.profiles.editor.skillsDirHint': '放在此处的 SKILL.md 文件仅对该配置私有。', 'settings.profiles.editor.all': '全部', 'settings.profiles.editor.selected': '指定', 'settings.profiles.editor.addPlaceholder': '输入标识后按回车', From 7b9d07e07b12c44209c312ac4d70529387ef638e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 15:13:37 +0400 Subject: [PATCH 26/80] docs: reconcile coverage-matrix totals with the real status counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The summary status counts sum to 143 (79 ✅ + 27 🟡 + 26 ❌ + 11 🚫) but Total leaves reported 144 explicit (CodeRabbit). Correct the explicit total to 143 and the combined total to 214 (143 explicit + 71 nested). Note: the off-by-one predates this PR — before its 9 ✅ rows the table summed to 134 while claiming 135 explicit. This aligns the summary with the actual counts. --- docs/TEST-COVERAGE-MATRIX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 02f5d6ad84..78ffd1bd24 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -635,7 +635,7 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an | 🟡 Partial | 27 | | ❌ Missing | 26 | | 🚫 Manual smoke | 11 | -| **Total leaves** | **144 explicit + nested = 215 product features** | +| **Total leaves** | **143 explicit + nested = 214 product features** | PR-A delta: 13 leaves moved from ❌ → ✅ via 5 WDIO specs + 2 Vitest + 1 Rust integration test. Remaining gaps tracked under sub-issues #965 (process), #966 (docs), #967 (tools), #968 (auth/perm), #969 (settings), #970 (rewards), #971 (manual smoke). From 20bc437408dfa667abba35b8b4584bcc48fdeff9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 15:26:39 +0400 Subject: [PATCH 27/80] style(i18n): prettier formatting on ko locale --- app/src/lib/i18n/ko.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index be420b2bfc..84e5ef4614 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -7208,8 +7208,7 @@ const messages: TranslationMap = { 'settings.profiles.editor.soulMdFile': 'SOUL.md 파일', 'settings.profiles.editor.workspaceDir': '작업 공간 디렉터리', 'settings.profiles.editor.skillsDir': '기술 디렉터리', - 'settings.profiles.editor.skillsDirHint': - '여기에 넣은 SKILL.md 파일은 이 프로필에만 적용됩니다.', + 'settings.profiles.editor.skillsDirHint': '여기에 넣은 SKILL.md 파일은 이 프로필에만 적용됩니다.', 'settings.profiles.editor.all': '전체', 'settings.profiles.editor.selected': '선택됨', 'settings.profiles.editor.addPlaceholder': '식별자를 입력하고 Enter를 누르세요', From 4a71e549136c247258e66cc6dae0c5ddb6f4741b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 15:46:42 +0400 Subject: [PATCH 28/80] fix(profiles): propagate dedicated-workspace descriptor to subagents on root turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_parent_execution_context derived the parent snapshot's workspace_descriptor only from harness::current_parent(), which is None on a ROOT chat turn. The session Agent's own descriptor (self.workspace_descriptor — rooted at /profiles/ for a dedicated_workspace profile) was therefore dropped, so subagents spawned via spawn_subagent / spawn_async_subagent from a top-level turn fell back to the shared action_dir and the profile isolation silently did not apply to delegated shell/file/git writes (Codex, factory.rs thread). Scope (traced all three delegation paths): - spawn_subagent / spawn_async_subagent: BUGGY — go through build_parent_execution_context → parent.workspace_descriptor (None on root). Fixed by falling back to self.workspace_descriptor when current_parent() has none; a nested subagent still prefers the ambient descriptor threaded down. - delegate_to_personality: already correct — reads ctx.workspace (the RunContext workspace, which carries the descriptor on the top-level turn) into options.workspace_descriptor, which wins in workspace_descriptor_for_subagent. - task_dispatcher: no independent workspace plumbing; rides the same seams. The earlier reviewer who traced "spawn tools see the descriptor via ctx.workspace" was right about the top-level agent's own tools and delegate_to_personality; Codex was right about the spawn_subagent parent-snapshot path. Both are now covered. Regression tests (fail before, pass after): root turn with an own descriptor and no ambient parent propagates it; profile-less root turn keeps it None. --- .../agent/harness/session/turn/tools.rs | 16 +++++-- .../agent/harness/session/turn_tests.rs | 42 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/openhuman/agent/harness/session/turn/tools.rs b/src/openhuman/agent/harness/session/turn/tools.rs index a654111760..d33e7dab7f 100644 --- a/src/openhuman/agent/harness/session/turn/tools.rs +++ b/src/openhuman/agent/harness/session/turn/tools.rs @@ -15,13 +15,23 @@ impl Agent { /// Snapshot the parent's runtime so spawned sub-agents can read /// it via the [`harness::PARENT_CONTEXT`] task-local. pub(super) fn build_parent_execution_context(&self) -> harness::ParentExecutionContext { - let workspace_descriptor = - harness::current_parent().and_then(|parent| parent.workspace_descriptor); + // Prefer an ambient `current_parent()` descriptor (a nested subagent + // inherits its enclosing worktree/profile workspace threaded down the + // spawn chain). Fall back to THIS session agent's own descriptor: on a + // ROOT chat turn `current_parent()` is `None`, so without the fallback a + // dedicated-workspace profile's descriptor (`/profiles/`, + // set on the Agent at build time) would never reach delegated subagents + // spawned via `spawn_subagent` / `spawn_async_subagent`, and they'd drop + // to the shared `action_dir` — the profile isolation would silently not + // apply to common delegated writes. + let workspace_descriptor = harness::current_parent() + .and_then(|parent| parent.workspace_descriptor) + .or_else(|| self.workspace_descriptor.clone()); if let Some(descriptor) = workspace_descriptor.as_ref() { tracing::debug!( root = %descriptor.root.display(), policy_id = %descriptor.policy_id, - "[agent_loop] inheriting ambient workspace descriptor for parent context snapshot" + "[agent_loop] snapshotting workspace descriptor for parent context (ambient or own)" ); } let allowed_subagent_ids = crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::global() diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index 5569f14476..4e694edd3b 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -492,6 +492,48 @@ fn build_parent_context_and_sanitize_helpers_cover_snapshot_paths() { assert!(collect_tree_root_summaries(agent.workspace_dir(), 8_000, 32_000).is_empty()); } +#[test] +fn build_parent_context_propagates_own_descriptor_on_root_turn() { + // Regression (PR #5118 review, Codex): on a ROOT chat turn `current_parent()` + // is `None`, so the parent snapshot must fall back to the agent's OWN + // descriptor. Without it, a dedicated-workspace profile's descriptor never + // reaches subagents spawned via spawn_subagent/spawn_async_subagent, and they + // silently fall back to the shared action_dir instead of + // `/profiles/`. + let descriptor = tinyagents::harness::workspace::WorkspaceDescriptor::new( + std::path::PathBuf::from("/tmp/act/profiles/alice"), + ) + .with_policy_id("openhuman.profile:alice"); + + let mut agent = make_agent(None); + // No ambient parent context is installed in this test, so current_parent() + // is None — exactly the root-turn scenario. + agent.workspace_descriptor = Some(descriptor); + + let parent = agent.build_parent_execution_context(); + assert_eq!( + parent.workspace_descriptor.as_ref().map(|d| d.root.clone()), + Some(std::path::PathBuf::from("/tmp/act/profiles/alice")), + "root turn must propagate the agent's own profile descriptor to spawned subagents" + ); + assert_eq!( + parent + .workspace_descriptor + .as_ref() + .map(|d| d.policy_id.clone()), + Some("openhuman.profile:alice".to_string()), + ); +} + +#[test] +fn build_parent_context_has_no_descriptor_without_profile_or_parent() { + // A profile-less root turn (no ambient parent, no own descriptor) keeps the + // snapshot's descriptor `None` so shared-action_dir behaviour is unchanged. + let agent = make_agent(None); + let parent = agent.build_parent_execution_context(); + assert!(parent.workspace_descriptor.is_none()); +} + #[test] fn collect_tree_root_summaries_maps_namespace_body_and_timestamp() { // #2944: the wrapper must carry the root node's `updated_at` from the From 5b6473bb96948b4a758a402e2155811944054fc2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 15:46:53 +0400 Subject: [PATCH 29/80] fix(profiles): sync SOUL.md on upsert for built-in profiles too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SOUL.md reconciliation added on upsert was gated behind `!p.built_in`, but select() seeds built-in homes on first activation. A user who selected Default/Research once and later edited its Soul in Settings kept a stale personalities//SOUL.md, which resolve_personality_soul reads before the inline value — so the saved edit never reached the prompt (Codex, ops.rs thread). Verified built-ins are upsertable through profile_upsert (the store persists their soul_md), so the editor path is real. Fix: run sync_soul_md_on_upsert for EVERY persisted profile, built-in included (sync_soul_md_on_upsert already create_dir_all's the home if needed). Full home materialization (MEMORY.md, skills/, dedicated workspace) stays custom-only — built-ins are still seeded on select, per spec. Empty/None soul_md is still a no-op (manual file edits stay authoritative); select never rewrites. Regression tests (schemas): editing a built-in's soul overwrites its seeded SOUL.md (fails before the fix); empty soulMd leaves a manually-edited built-in SOUL.md untouched. Tests resolve the file via the enriched soulMdFile path so they don't hardcode the workspace layout. --- src/openhuman/profiles/ops.rs | 21 +++++-- src/openhuman/profiles/schemas.rs | 93 +++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/openhuman/profiles/ops.rs b/src/openhuman/profiles/ops.rs index 7918fe4b45..dc2e063c17 100644 --- a/src/openhuman/profiles/ops.rs +++ b/src/openhuman/profiles/ops.rs @@ -218,13 +218,22 @@ pub async fn upsert(profile: AgentProfile) -> Result { .profiles .iter() .find(|p| p.id == super::store::normalise_profile_id(&upserted_id)) - .filter(|p| !p.built_in) { - materialize_home(&workspace_dir, &action_dir, persisted); - // Reconcile an edited persona into the on-disk SOUL.md: `ensure_profile_home` - // only seeds the file when absent, and `resolve_personality_soul` reads the - // file first, so a Settings edit after the home exists would otherwise never - // reach the prompt. Non-fatal — the profile is already persisted. + // Full home materialization (SOUL.md seed + MEMORY.md + skills/ + + // dedicated workspace) is for CUSTOM profiles here; built-ins are seeded + // on `select` (first activation), matching the spec. + if !persisted.built_in { + materialize_home(&workspace_dir, &action_dir, persisted); + } + // Reconcile an edited persona into the on-disk SOUL.md for EVERY profile, + // built-in included. `ensure_profile_home` only seeds SOUL.md when absent, + // and `select` seeds built-in homes on first activation — so a user who + // selects Default/Research once and later edits its Soul in Settings would + // otherwise keep a stale `personalities//SOUL.md` that + // `resolve_personality_soul` reads before the inline value. The sync is a + // no-op when `soul_md` is empty/None (manual file edits stay + // authoritative) and creates the home dir if needed. Non-fatal — the + // profile is already persisted. if let Err(e) = super::home::sync_soul_md_on_upsert(&workspace_dir, persisted) { tracing::warn!( profile_id = %persisted.id, diff --git a/src/openhuman/profiles/schemas.rs b/src/openhuman/profiles/schemas.rs index e562712d94..da07c0ee9c 100644 --- a/src/openhuman/profiles/schemas.rs +++ b/src/openhuman/profiles/schemas.rs @@ -277,6 +277,99 @@ mod tests { assert_eq!(deleted["activeProfileId"], DEFAULT_PROFILE_ID); } + /// Resolve the enriched `soulMdFile` absolute path for `profile_id` from a + /// profiles-state payload (present once the home's SOUL.md exists on disk). + fn soul_md_file(payload: &Value, profile_id: &str) -> Option { + payload["profiles"] + .as_array()? + .iter() + .find(|p| p["id"] == profile_id)? + .get("soulMdFile")? + .as_str() + .map(str::to_string) + } + + #[tokio::test] + async fn built_in_profile_soul_edit_syncs_to_disk() { + // Regression (PR #5118 review, Codex): select() seeds a built-in's home on + // first activation, so a later Soul edit through the editor must reconcile + // the on-disk SOUL.md — the sync path must NOT be gated on `!built_in`. + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + let _env = WorkspaceEnvGuard::set(temp.path()); + + // Seed the built-in default home the way first activation does; the + // enriched payload advertises the resolved SOUL.md path once it exists. + let selected = handle_profile_select(Map::from_iter([( + "profile_id".into(), + Value::String(DEFAULT_PROFILE_ID.into()), + )])) + .await + .expect("select default"); + let soul_path = + soul_md_file(&selected, DEFAULT_PROFILE_ID).expect("select seeds the built-in SOUL.md"); + + // User later edits the built-in's Soul in Settings. + handle_profile_upsert(Map::from_iter([( + "profile".into(), + json!({ + "id": DEFAULT_PROFILE_ID, + "name": "Default", + "description": "", + "agentId": "orchestrator", + "soulMd": "Edited built-in persona.", + "builtIn": true, + }), + )])) + .await + .expect("upsert default with edited soul"); + + assert_eq!( + std::fs::read_to_string(&soul_path).unwrap(), + "Edited built-in persona.\n", + "editing a built-in's soul must overwrite its seeded SOUL.md" + ); + } + + #[tokio::test] + async fn built_in_profile_empty_soul_leaves_file_untouched() { + // The sync is a no-op when soulMd is empty/None, so a user's manual edit + // to a built-in's SOUL.md stays authoritative. + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + let _env = WorkspaceEnvGuard::set(temp.path()); + + let selected = handle_profile_select(Map::from_iter([( + "profile_id".into(), + Value::String(DEFAULT_PROFILE_ID.into()), + )])) + .await + .expect("select default"); + let soul_path = + soul_md_file(&selected, DEFAULT_PROFILE_ID).expect("select seeds the built-in SOUL.md"); + std::fs::write(&soul_path, "MANUAL EDIT").unwrap(); + + // Upsert with no soulMd — must not touch the manually edited file. + handle_profile_upsert(Map::from_iter([( + "profile".into(), + json!({ + "id": DEFAULT_PROFILE_ID, + "name": "Default", + "description": "", + "agentId": "orchestrator", + "builtIn": true, + }), + )])) + .await + .expect("upsert default without soul"); + + assert_eq!( + std::fs::read_to_string(&soul_path).unwrap(), + "MANUAL EDIT", + "empty soulMd must leave a manually edited built-in SOUL.md untouched" + ); + } + #[tokio::test] async fn profile_upsert_rejects_unknown_registered_agent_id() { let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); From 9d385fe19d005f63c59f5de182b79bb84e9665bc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 16:28:32 +0400 Subject: [PATCH 30/80] fix(profiles): apply dedicated memory + profile SOUL.md on the ordinary session path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Codex P2s: the profile-homes feature's core promises took effect only on the task/delegation paths, never on ordinary web-chat / cron sessions. 1. Dedicated memory subtree (paths.rs / factory.rs thread). effective_memory_suffix and memory_subdir_for_suffix had NO runtime consumer — build_session_agent_inner built session_memory from config.workspace_dir with the hardcoded "memory" subdir, so captures + recall under a dedicatedMemory profile still used the shared memory/ database. (The delegation path never applied it either — see the TODO(phase-2) in delegate_to_personality.rs.) Fix at the narrowest seam: thread a memory_subdir through create_session_memory_with_local_ai → create_unified_memory_full → UnifiedMemory::new_with_memory_dir, and have the session builder derive it from the active profile's effective suffix. Profile-less / default / shared → "memory" (byte-identical); dedicatedMemory → "memory-"; legacy numeric-suffix profile → "memory-". Non-session callers (migration, standalone) pass "memory". Back-compat: a non-default profile's session memory now lands in its own subtree instead of the shared one — this is the advertised isolation finally taking effect (it was never wired, so no prior data used the per-profile subtrees). 2. Profile SOUL.md in the live prompt (factory.rs:735 thread). Ordinary profile sessions only injected systemPromptSuffix via AgentProfilePromptSection; the seeded/synced personalities//SOUL.md never reached the prompt (the live PromptContext left personality_soul_md unset, and the main orchestrator omits the identity section anyway). Fix at the section the builder already adds: AgentProfilePromptSection.with_profile_soul(profile) hot-reads the profile's SOUL.md each build via resolve_personality_soul (same order as PersonalityContext: personalities//SOUL.md → soul_md_path → inline), rendered ahead of the persona body, capped at 20k chars. Gated to NON-default profiles: the default/master profile keeps the workspace root SOUL.md, so the common default session stays byte-identical (avoids the seeded default template overriding the user's root SOUL.md). Profile-less → no section (unchanged); nothing-resolves → root fallback intact. Regression tests (fail before, pass after — verified by neutralizing each fix): build_session_agent_routes_dedicated_memory_to_profile_subtree, ..._injects_profile_soul_into_prompt; plus profile-less byte-identical guards (..._uses_shared_memory_subtree, ..._prompt_has_no_personality_soul) which pass in both directions. Out of scope (pre-existing, unchanged): the delegate_to_personality memory-isolation TODO; memory_tree/session_raw suffix helpers (still unwired); the channels-runtime free-function prompt path (Discord/Slack/etc.). --- .../harness/session/builder/builder_tests.rs | 133 ++++++++++++++++++ .../agent/harness/session/builder/factory.rs | 38 ++++- src/openhuman/memory_store/factories.rs | 22 ++- src/openhuman/profiles/prompt_section.rs | 65 ++++++++- 4 files changed, 247 insertions(+), 11 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/builder_tests.rs b/src/openhuman/agent/harness/session/builder/builder_tests.rs index 44be23c398..ae453dcfab 100644 --- a/src/openhuman/agent/harness/session/builder/builder_tests.rs +++ b/src/openhuman/agent/harness/session/builder/builder_tests.rs @@ -292,6 +292,139 @@ async fn build_session_agent_leaves_active_profile_id_none_without_profile() { ); } +// ── Finding #1 (Codex): dedicated memory subtree on the ordinary session path ─ + +/// Build a non-default profile with the given id + dedicated-memory flag. +fn custom_profile(id: &str, dedicated_memory: bool) -> crate::openhuman::profiles::AgentProfile { + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.id = id.to_string(); + profile.name = id.to_string(); + profile.built_in = false; + profile.is_master = false; + profile.memory_dir_suffix = None; + profile.dedicated_memory = dedicated_memory; + profile +} + +#[tokio::test] +async fn build_session_agent_routes_dedicated_memory_to_profile_subtree() { + use crate::openhuman::agent::harness::session::types::Agent; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + let profile = custom_profile("alice", true); + + let _agent = Agent::build_session_agent_inner( + &config, + "orchestrator", + None, + None, + None, + false, + Some(&profile), + ) + .expect("build_session_agent_inner with a dedicated-memory profile should succeed"); + + // The session's capture/recall store (UnifiedMemory) is rooted at + // `/memory-alice`, not the shared `memory/` tree. + assert!( + config + .workspace_dir + .join("memory-alice") + .join("memory.db") + .exists(), + "a dedicatedMemory profile must route session memory to memory-" + ); +} + +#[tokio::test] +async fn build_session_agent_profile_less_uses_shared_memory_subtree() { + use crate::openhuman::agent::harness::session::types::Agent; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + + // Profile-less path stays byte-identical: session memory uses the shared + // `memory/` subtree, and no per-profile subtree is created. + let _agent = + Agent::build_session_agent_inner(&config, "orchestrator", None, None, None, false, None) + .expect("build_session_agent_inner without a profile should succeed"); + + assert!( + config + .workspace_dir + .join("memory") + .join("memory.db") + .exists(), + "the profile-less session must use the shared memory subtree" + ); + assert!( + !config.workspace_dir.join("memory-alice").exists(), + "no per-profile memory subtree should exist for a profile-less session" + ); +} + +// ── Finding #2 (Codex): profile SOUL.md injected into the live session prompt ─ + +#[tokio::test] +async fn build_session_agent_injects_profile_soul_into_prompt() { + use crate::openhuman::agent::harness::session::types::Agent; + use crate::openhuman::context::prompt::LearnedContextData; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + // Seed the non-default profile's home SOUL.md (as ensure_profile_home would). + let home = config.workspace_dir.join("personalities").join("alice"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("SOUL.md"), "I am Alice, a meticulous archivist.").unwrap(); + + let profile = custom_profile("alice", false); + let agent = Agent::build_session_agent_inner( + &config, + "orchestrator", + None, + None, + None, + false, + Some(&profile), + ) + .expect("build_session_agent_inner with a profile should succeed"); + + let prompt = agent + .build_system_prompt(LearnedContextData::default()) + .expect("build_system_prompt"); + assert!( + prompt.contains("I am Alice, a meticulous archivist."), + "the live profile session prompt must include the profile SOUL.md content" + ); +} + +#[tokio::test] +async fn build_session_agent_profile_less_prompt_has_no_personality_soul() { + use crate::openhuman::agent::harness::session::types::Agent; + use crate::openhuman::context::prompt::LearnedContextData; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + // A personalities/alice/SOUL.md exists on disk, but a profile-less session + // must never pull it — the prompt stays byte-identical to today. + let home = config.workspace_dir.join("personalities").join("alice"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("SOUL.md"), "I am Alice, a meticulous archivist.").unwrap(); + + let agent = + Agent::build_session_agent_inner(&config, "orchestrator", None, None, None, false, None) + .expect("build_session_agent_inner without a profile should succeed"); + + let prompt = agent + .build_system_prompt(LearnedContextData::default()) + .expect("build_system_prompt"); + assert!( + !prompt.contains("I am Alice, a meticulous archivist."), + "a profile-less session must not inject any profile SOUL.md" + ); +} + // ── #5050 Fix 1: shared `Arc` for the per-build tool config ────────── #[test] diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 878c2df88c..e14d06a7cb 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -385,6 +385,25 @@ impl Agent { config, &config.memory.embedding_provider, ); + // Route this session's captures + recall into the active profile's memory + // subtree so `dedicatedMemory` isolation takes effect on the ordinary + // session path (web chat, cron), not just delegation preambles. The + // profile-less / default / shared cases resolve to `"memory"` + // (byte-identical): `effective_memory_suffix` returns `""` for them and + // `memory_subdir_for_suffix("")` == `"memory"`. A dedicated-memory profile + // yields `"memory-"`; a legacy numeric-suffix profile `"memory-"`. + let memory_subdir = profile + .map(|p| { + crate::openhuman::profiles::memory_subdir_for_suffix( + &crate::openhuman::profiles::effective_memory_suffix(p), + ) + }) + .unwrap_or_else(|| "memory".to_string()); + tracing::debug!( + memory_subdir = %memory_subdir, + has_profile = profile.is_some(), + "[profiles] session memory subtree selected" + ); let session_memory = memory_store::factories::create_session_memory_with_local_ai( &config.memory, local_embedding.as_deref(), @@ -392,6 +411,7 @@ impl Agent { &config.embedding_routes, Some(&config.storage.provider.config), &config.workspace_dir, + &memory_subdir, )?; let archivist_connection = session_memory.sqlite_connection; let memory: Arc = Arc::from(session_memory.memory); @@ -743,11 +763,20 @@ impl Agent { ) }) }); - if profile_suffix.is_some() || workspace_notice.is_some() { + // A non-default profile drives its live persona from its own SOUL.md + // (hot-read in the section), so ordinary web-chat / cron turns get the + // seeded/synced identity — not just delegation. The default/master + // profile keeps the workspace root SOUL.md (its identity), so the common + // default session stays byte-identical. + let soul_profile: Option = profile + .filter(|p| p.id != crate::openhuman::profiles::DEFAULT_PROFILE_ID) + .cloned(); + if profile_suffix.is_some() || workspace_notice.is_some() || soul_profile.is_some() { log::debug!( - "[agent:builder] profile prompt section injected suffix_chars={} workspace_notice={}", + "[agent:builder] profile prompt section injected suffix_chars={} workspace_notice={} profile_soul={}", profile_suffix.as_deref().map(|s| s.chars().count()).unwrap_or(0), - workspace_notice.is_some() + workspace_notice.is_some(), + soul_profile.is_some(), ); let mut section = crate::openhuman::profiles::AgentProfilePromptSection::new( profile_suffix.unwrap_or_default(), @@ -755,6 +784,9 @@ impl Agent { if let Some(notice) = workspace_notice { section = section.with_workspace_notice(notice); } + if let Some(soul_profile) = soul_profile { + section = section.with_profile_soul(soul_profile); + } prompt_builder = prompt_builder.add_section(Box::new(section)); } diff --git a/src/openhuman/memory_store/factories.rs b/src/openhuman/memory_store/factories.rs index 594157733a..278f240df1 100644 --- a/src/openhuman/memory_store/factories.rs +++ b/src/openhuman/memory_store/factories.rs @@ -356,6 +356,12 @@ pub(crate) fn create_session_memory_with_local_ai( embedding_routes: &[EmbeddingRouteConfig], storage_provider: Option<&StorageProviderConfig>, workspace_dir: &Path, + // Memory subdirectory under `workspace_dir` — `"memory"` for the shared + // tree, `"memory-"` for a profile that opted into dedicated memory + // (derived by the session builder from `effective_memory_suffix`). Routes + // the session's captures + recall (the `UnifiedMemory` SQLite store) into the + // profile's own subtree so `dedicatedMemory` isolation actually takes effect. + memory_subdir: &str, ) -> anyhow::Result { let memory = create_unified_memory_full( memory, @@ -364,6 +370,7 @@ pub(crate) fn create_session_memory_with_local_ai( local_embedding_model, embedding_api_key, workspace_dir, + memory_subdir, )?; let sqlite_connection = Arc::clone(&memory.conn); Ok(SessionMemory { @@ -427,6 +434,9 @@ fn create_memory_full( local_embedding_model, embedding_api_key, workspace_dir, + // Non-session callers (migration, standalone memory) always use the + // shared default subtree. + "memory", )?)) } @@ -437,6 +447,7 @@ fn create_unified_memory_full( local_embedding_model: Option<&str>, embedding_api_key: &str, workspace_dir: &Path, + memory_subdir: &str, ) -> anyhow::Result { // 1. Resolve the intended provider from config. let intended = effective_embedding_settings(config, local_embedding_model); @@ -506,8 +517,15 @@ fn create_unified_memory_full( })?, ); - // 4. Instantiate UnifiedMemory which handles SQLite and vector storage. - UnifiedMemory::new(workspace_dir, embedder, config.sqlite_open_timeout_secs) + // 4. Instantiate UnifiedMemory which handles SQLite and vector storage, + // rooted at the caller-selected subtree (`memory` shared, `memory-` + // for a dedicated-memory profile). + UnifiedMemory::new_with_memory_dir( + workspace_dir, + memory_subdir, + embedder, + config.sqlite_open_timeout_secs, + ) } /// Create a memory instance specifically for migration purposes. diff --git a/src/openhuman/profiles/prompt_section.rs b/src/openhuman/profiles/prompt_section.rs index a752628a77..f1b0a3d316 100644 --- a/src/openhuman/profiles/prompt_section.rs +++ b/src/openhuman/profiles/prompt_section.rs @@ -4,9 +4,15 @@ use std::path::Path; +use super::types::AgentProfile; use crate::openhuman::context::prompt::{PromptContext, PromptSection}; use anyhow::Result; +/// Upper bound on the injected persona SOUL.md, mirroring the identity-file cap +/// (`BOOTSTRAP_MAX_CHARS`) so a large per-profile SOUL.md can't bloat the frozen +/// prompt prefix. +const PROFILE_SOUL_MAX_CHARS: usize = 20_000; + /// One-sentence system-prompt notice, mirroring hermes's cross-profile /// disclosure: names the profile's dedicated workspace and states that other /// profiles' directories are off-limits. Rendered only when a dedicated @@ -26,6 +32,12 @@ pub fn cross_profile_workspace_notice(profile_id: &str, workspace_path: &Path) - pub struct AgentProfilePromptSection { body: String, workspace_notice: Option, + /// When set, the active profile whose `SOUL.md` identity is hot-read on each + /// prompt build and rendered ahead of the persona body. Carried (rather than + /// a pre-resolved string) so the file is re-read at `build` time, matching + /// the identity section's live-file semantics — a Settings/file edit takes + /// effect on the next prompt build. + soul_profile: Option, } impl AgentProfilePromptSection { @@ -33,6 +45,7 @@ impl AgentProfilePromptSection { Self { body, workspace_notice: None, + soul_profile: None, } } @@ -46,6 +59,35 @@ impl AgentProfilePromptSection { self.workspace_notice = (!notice.is_empty()).then_some(notice); self } + + /// Bind the active profile so its resolved `SOUL.md` identity is injected + /// into the live session prompt (ordinary web-chat / cron turns, not just + /// delegation). The soul is hot-read via + /// [`resolve_personality_soul`](crate::openhuman::profiles::resolve_personality_soul) + /// on each `build`, following the same resolution order as + /// `PersonalityContext` (`personalities//SOUL.md` → `soul_md_path` → + /// inline → `None`). Callers pass this only for non-default profiles; the + /// default/master profile keeps the workspace root `SOUL.md`. + #[must_use] + pub fn with_profile_soul(mut self, profile: AgentProfile) -> Self { + self.soul_profile = Some(profile); + self + } + + /// Hot-read the bound profile's SOUL.md, trimmed and capped. `None` when no + /// profile is bound or nothing resolves (caller's root fallback stays intact). + fn resolve_soul(&self, workspace_dir: &Path) -> Option { + self.soul_profile + .as_ref() + .and_then(|profile| super::resolve_personality_soul(workspace_dir, profile)) + .map(|soul| soul.trim().to_string()) + .filter(|soul| !soul.is_empty()) + .map(|soul| { + soul.chars() + .take(PROFILE_SOUL_MAX_CHARS) + .collect::() + }) + } } impl PromptSection for AgentProfilePromptSection { @@ -53,15 +95,26 @@ impl PromptSection for AgentProfilePromptSection { "agent_profile" } - fn build(&self, _ctx: &PromptContext<'_>) -> Result { + fn build(&self, ctx: &PromptContext<'_>) -> Result { + // Hot-read the profile identity each build; `None` (no bound profile or + // nothing resolves) preserves the prior body/notice-only output exactly. + let soul = self.resolve_soul(ctx.workspace_dir); let body = self.body.trim(); let notice = self.workspace_notice.as_deref().unwrap_or_default(); - match (body.is_empty(), notice.is_empty()) { - (true, true) => Ok(String::new()), - (false, true) => Ok(format!("## Agent profile\n\n{body}")), - (true, false) => Ok(format!("## Agent profile\n\n{notice}")), - (false, false) => Ok(format!("## Agent profile\n\n{body}\n\n{notice}")), + let mut parts: Vec<&str> = Vec::new(); + if let Some(ref soul) = soul { + parts.push(soul.as_str()); + } + if !body.is_empty() { + parts.push(body); + } + if !notice.is_empty() { + parts.push(notice); + } + if parts.is_empty() { + return Ok(String::new()); } + Ok(format!("## Agent profile\n\n{}", parts.join("\n\n"))) } } From 814bd4bd0571d3a060c12f0b2b393301f0ade665 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 14:19:34 +0000 Subject: [PATCH 31/80] fix(security): protect profile homes as internal state --- src/openhuman/security/policy/policy_tests.rs | 10 ++++++++++ src/openhuman/security/policy/types.rs | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/src/openhuman/security/policy/policy_tests.rs b/src/openhuman/security/policy/policy_tests.rs index c41cbba458..ea11e08ee7 100644 --- a/src/openhuman/security/policy/policy_tests.rs +++ b/src/openhuman/security/policy/policy_tests.rs @@ -2639,6 +2639,16 @@ fn is_workspace_internal_path_blocks_state_dirs() { assert!(policy.is_workspace_internal_path(&ws.join("state"))); assert!(policy.is_workspace_internal_path(&ws.join("cron"))); assert!(policy.is_workspace_internal_path(&ws.join("memory_tree"))); + assert!( + policy.is_workspace_internal_path(&ws.join("personalities").join("alice").join("SOUL.md")) + ); + assert!(policy.is_workspace_internal_path( + &ws.join("personalities") + .join("alice") + .join("skills") + .join("private-skill") + .join("SKILL.md") + )); assert!(policy.is_workspace_internal_path(&ws.join("approval"))); assert!(policy.is_workspace_internal_path(&ws.join("mcp_clients"))); } diff --git a/src/openhuman/security/policy/types.rs b/src/openhuman/security/policy/types.rs index f916332578..8bd8ee7ec5 100644 --- a/src/openhuman/security/policy/types.rs +++ b/src/openhuman/security/policy/types.rs @@ -177,6 +177,10 @@ pub(super) const WORKSPACE_INTERNAL_DIRS: &[&str] = &[ "approval", "sessions", "session_raw", + // Per-profile homes contain prompt-controlling SOUL.md files and private + // skills. They are core-managed state, never part of the agent action + // surface, even when a trusted root otherwise reaches workspace_dir. + "personalities", "cron", "devices", "mcp_clients", From b8751db80861656c6b290e8e9106165232d6dbf1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 14:19:39 +0000 Subject: [PATCH 32/80] fix(skills): deduplicate profile workflows by runnable id --- src/openhuman/skills/ops_discover.rs | 108 ++++++++++++++++++++------- 1 file changed, 83 insertions(+), 25 deletions(-) diff --git a/src/openhuman/skills/ops_discover.rs b/src/openhuman/skills/ops_discover.rs index f489ac2680..8a0aa94051 100644 --- a/src/openhuman/skills/ops_discover.rs +++ b/src/openhuman/skills/ops_discover.rs @@ -323,35 +323,63 @@ fn project_roots(workspace: &Path) -> Vec<(PathBuf, RootKind)> { fn absorb(by_name: &mut HashMap, incoming: Vec) { for mut skill in incoming { let key = skill.name.clone(); - if let Some(existing) = by_name.remove(&key) { - // Higher-precedence scope wins; lower loses and is dropped. - let (winner, loser) = if precedence(skill.scope) >= precedence(existing.scope) { - (&mut skill, existing) - } else { - // Put existing back; discard incoming. - let mut kept = existing; - kept.warnings.push(format!( - "name '{}' also declared in {:?} scope at {} (ignored)", - kept.name, - skill.scope, - skill + // A workflow's runnable identity is `dir_name`, while `name` is only + // display metadata. Collapse on either so a profile-local `foo/` also + // shadows a global `foo/` whose frontmatter happens to use a different + // display name. Otherwise registry lookup by slug could nondeterministically + // select the global copy. + let collision_keys: Vec = by_name + .iter() + .filter(|(existing_name, existing)| { + existing_name.as_str() == key || existing.dir_name == skill.dir_name + }) + .map(|(existing_name, _)| existing_name.clone()) + .collect(); + + if let Some((_, highest_name, highest_scope)) = collision_keys + .iter() + .filter_map(|collision_key| by_name.get(collision_key)) + .map(|existing| { + ( + precedence(existing.scope), + existing.name.clone(), + existing.scope, + ) + }) + .max_by_key(|(rank, _, _)| *rank) + { + if precedence(skill.scope) < precedence(highest_scope) { + if let Some(kept) = by_name.get_mut(&highest_name) { + kept.warnings.push(format!( + "workflow id '{}' or name '{}' also declared in {:?} scope at {} (ignored)", + skill.dir_name, + skill.name, + skill.scope, + skill + .location + .as_deref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "".to_string()) + )); + } + continue; + } + } + + for collision_key in collision_keys { + if let Some(loser) = by_name.remove(&collision_key) { + skill.warnings.push(format!( + "shadowed {:?}-scope skill '{}' (workflow id '{}') at {}", + loser.scope, + loser.name, + loser.dir_name, + loser .location .as_deref() .map(|p| p.display().to_string()) .unwrap_or_else(|| "".to_string()) )); - by_name.insert(key, kept); - continue; - }; - winner.warnings.push(format!( - "shadowed {:?}-scope skill at {} with same name", - loser.scope, - loser - .location - .as_deref() - .map(|p| p.display().to_string()) - .unwrap_or_else(|| "".to_string()) - )); + } } by_name.insert(key, skill); } @@ -743,11 +771,15 @@ mod profile_scope_tests { /// Write a minimal `WORKFLOW.md` bundle under `root/slug/`. fn seed_bundle(root: &Path, slug: &str) { + seed_bundle_with_name(root, slug, slug); + } + + fn seed_bundle_with_name(root: &Path, slug: &str, name: &str) { let dir = root.join(slug); std::fs::create_dir_all(&dir).unwrap(); std::fs::write( dir.join("WORKFLOW.md"), - format!("---\nname: {slug}\ndescription: {slug} desc\n---\n\n{slug} body\n"), + format!("---\nname: {name}\ndescription: {name} desc\n---\n\n{name} body\n"), ) .unwrap(); } @@ -848,6 +880,32 @@ mod profile_scope_tests { ); } + #[test] + fn profile_local_wins_same_runnable_id_with_different_display_name() { + let home = tempfile::TempDir::new().unwrap(); + seed_bundle_with_name( + &home.path().join(".openhuman").join("skills"), + "shared-slug", + "Global display name", + ); + let profile_root = tempfile::TempDir::new().unwrap(); + seed_bundle_with_name(profile_root.path(), "shared-slug", "Profile display name"); + + let workflows = discover_workflows_with_profile( + Some(home.path()), + None, + Some(profile_root.path()), + false, + ); + let by_slug: Vec<_> = workflows + .iter() + .filter(|workflow| workflow.dir_name == "shared-slug") + .collect(); + assert_eq!(by_slug.len(), 1, "runnable ids must be unique"); + assert_eq!(by_slug[0].scope, WorkflowScope::Profile); + assert_eq!(by_slug[0].name, "Profile display name"); + } + /// `WorkflowScope::Profile` outranks every global scope in the precedence /// ladder (the mechanism the collision test relies on). #[test] From 9e0f69361e6720fb4bcba2b4b2167b2254f27fc2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 14:40:33 +0000 Subject: [PATCH 33/80] fix(profiles): preserve isolation across stores and workflow runs --- .../harness/session/builder/builder_tests.rs | 29 ++++++ .../agent/harness/session/builder/factory.rs | 11 +++ .../agent/harness/session/builder/setters.rs | 18 ++++ .../agent/harness/session/runtime.rs | 7 +- .../agent/harness/session/transcript.rs | 11 ++- .../agent/harness/session/turn/context.rs | 1 + .../agent/harness/session/turn/mod.rs | 89 ++++++++++++++++--- .../agent/harness/session/turn/session_io.rs | 2 +- .../agent/harness/session/turn_tests.rs | 41 ++++++++- src/openhuman/agent/harness/session/types.rs | 10 +++ src/openhuman/agent/tools/run_workflow.rs | 13 +++ src/openhuman/channels/runtime/startup.rs | 1 + src/openhuman/runtime_node/ops.rs | 1 + src/openhuman/skill_runtime/run_machinery.rs | 14 ++- src/openhuman/tools/ops.rs | 3 + 15 files changed, 227 insertions(+), 24 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/builder_tests.rs b/src/openhuman/agent/harness/session/builder/builder_tests.rs index ae453dcfab..6ae333f30a 100644 --- a/src/openhuman/agent/harness/session/builder/builder_tests.rs +++ b/src/openhuman/agent/harness/session/builder/builder_tests.rs @@ -273,6 +273,35 @@ async fn build_session_agent_carries_active_profile_id_when_profile_present() { ); } +#[tokio::test] +async fn dedicated_memory_profile_scopes_tree_and_transcript_storage() { + use crate::openhuman::agent::harness::session::types::Agent; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.id = "alice".to_string(); + profile.built_in = false; + profile.dedicated_memory = true; + + let agent = Agent::build_session_agent_inner( + &config, + "orchestrator", + None, + None, + None, + false, + Some(&profile), + ) + .expect("build dedicated-memory session"); + + assert_eq!(agent.memory_subdir, "memory-alice"); + assert_eq!( + agent.session_raw_dir, + config.workspace_dir.join("session_raw-alice") + ); +} + #[tokio::test] async fn build_session_agent_leaves_active_profile_id_none_without_profile() { use crate::openhuman::agent::harness::session::types::Agent; diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index e14d06a7cb..7c787b9376 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -399,6 +399,15 @@ impl Agent { ) }) .unwrap_or_else(|| "memory".to_string()); + let memory_suffix = profile + .map(crate::openhuman::profiles::effective_memory_suffix) + .unwrap_or_default(); + let session_raw_dir = + config + .workspace_dir + .join(crate::openhuman::profiles::session_raw_subdir_for_suffix( + &memory_suffix, + )); tracing::debug!( memory_subdir = %memory_subdir, has_profile = profile.is_some(), @@ -486,6 +495,7 @@ impl Agent { &tool_config.action_dir, &tool_config.agents, &tool_config, + profile, profile_skill_allowlist.as_ref(), profile_mcp_allowlist.as_deref(), profile_skills_root.as_deref(), @@ -1303,6 +1313,7 @@ impl Agent { // see which profile the turn ran under. `None` for the profile-less // session keeps every consumer byte-identical. .active_profile_id(profile.map(|p| p.id.clone())) + .profile_memory_storage(memory_subdir, session_raw_dir) .workflows( crate::openhuman::skills::load_workflow_metadata_for_profile( &config.workspace_dir, diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs index 30285e08be..561a18711c 100644 --- a/src/openhuman/agent/harness/session/builder/setters.rs +++ b/src/openhuman/agent/harness/session/builder/setters.rs @@ -43,6 +43,8 @@ impl AgentBuilder { event_channel: None, agent_definition_name: None, active_profile_id: None, + memory_subdir: None, + session_raw_dir: None, session_parent_prefix: None, omit_profile: None, omit_memory_md: None, @@ -213,6 +215,16 @@ impl AgentBuilder { self } + pub fn profile_memory_storage( + mut self, + memory_subdir: String, + session_raw_dir: std::path::PathBuf, + ) -> Self { + self.memory_subdir = Some(memory_subdir); + self.session_raw_dir = Some(session_raw_dir); + self + } + /// Sets the skills available to the agent. pub fn workflows(mut self, skills: Vec) -> Self { self.workflows = Some(skills); @@ -492,6 +504,10 @@ impl AgentBuilder { .workspace_dir .unwrap_or_else(|| std::path::PathBuf::from(".")); let action_dir = self.action_dir.unwrap_or_else(|| workspace_dir.clone()); + let memory_subdir = self.memory_subdir.unwrap_or_else(|| "memory".to_string()); + let session_raw_dir = self + .session_raw_dir + .unwrap_or_else(|| workspace_dir.join("session_raw")); Ok(Agent { turn_model_source, @@ -537,6 +553,8 @@ impl AgentBuilder { // `subagents` declaration against the global registry. agent_definition_id: agent_definition_name.clone(), active_profile_id: self.active_profile_id, + memory_subdir, + session_raw_dir, session_transcript_path: None, persisted_transcript_messages: Vec::new(), session_key: { diff --git a/src/openhuman/agent/harness/session/runtime.rs b/src/openhuman/agent/harness/session/runtime.rs index 074c756612..f327fadd8b 100644 --- a/src/openhuman/agent/harness/session/runtime.rs +++ b/src/openhuman/agent/harness/session/runtime.rs @@ -426,9 +426,10 @@ impl Agent { return false; } - let Some(path) = - super::transcript::find_root_transcript_for_thread(&self.workspace_dir, thread_id) - else { + let Some(path) = super::transcript::find_root_transcript_for_thread_in_dir( + &self.session_raw_dir, + thread_id, + ) else { log::debug!( "[web-channel] no root session_raw transcript for thread={thread_id} — \ falling back to conversation-log prose seeding" diff --git a/src/openhuman/agent/harness/session/transcript.rs b/src/openhuman/agent/harness/session/transcript.rs index fd73cd211f..51b5f38604 100644 --- a/src/openhuman/agent/harness/session/transcript.rs +++ b/src/openhuman/agent/harness/session/transcript.rs @@ -1186,13 +1186,16 @@ pub fn read_transcript_display(path: &Path) -> Result /// transcript without accidentally folding delegated worker transcripts /// into the main chat timeline. pub fn find_root_transcript_for_thread(workspace_dir: &Path, thread_id: &str) -> Option { + find_root_transcript_for_thread_in_dir(&raw_session_dir(workspace_dir), thread_id) +} + +pub fn find_root_transcript_for_thread_in_dir(raw_dir: &Path, thread_id: &str) -> Option { let thread_id = thread_id.trim(); if thread_id.is_empty() { return None; } - let raw_dir = raw_session_dir(workspace_dir); - let entries = fs::read_dir(&raw_dir).ok()?; + let entries = fs::read_dir(raw_dir).ok()?; let mut matches: Vec = entries .flatten() .map(|entry| entry.path()) @@ -1439,6 +1442,10 @@ pub fn read_thread_usage_summary( /// own key so collisions are effectively impossible. pub fn resolve_keyed_transcript_path(workspace_dir: &Path, stem: &str) -> Result { let raw_dir = raw_session_dir(workspace_dir); + resolve_keyed_transcript_path_in_dir(&raw_dir, stem) +} + +pub fn resolve_keyed_transcript_path_in_dir(raw_dir: &Path, stem: &str) -> Result { fs::create_dir_all(&raw_dir) .with_context(|| format!("create session_raw dir {}", raw_dir.display()))?; let sanitized = sanitize_stem(stem); diff --git a/src/openhuman/agent/harness/session/turn/context.rs b/src/openhuman/agent/harness/session/turn/context.rs index dfbcf90b4c..185b85c162 100644 --- a/src/openhuman/agent/harness/session/turn/context.rs +++ b/src/openhuman/agent/harness/session/turn/context.rs @@ -244,6 +244,7 @@ impl Agent { let limits = self.config.resolved_memory_limits(); let tree_root_summaries = collect_tree_root_summaries( &self.workspace_dir, + &self.memory_subdir, limits.per_namespace_max_chars, limits.total_tree_max_chars, ); diff --git a/src/openhuman/agent/harness/session/turn/mod.rs b/src/openhuman/agent/harness/session/turn/mod.rs index ddd03491f5..8f1d5e9f00 100644 --- a/src/openhuman/agent/harness/session/turn/mod.rs +++ b/src/openhuman/agent/harness/session/turn/mod.rs @@ -159,23 +159,84 @@ Do not attempt to run them with `run_skill` — they have been removed. Tell the /// preset by [`crate::openhuman::config::schema::agent::AgentConfig::resolved_memory_limits`]. pub(super) fn collect_tree_root_summaries( workspace_dir: &std::path::Path, + memory_subdir: &str, per_namespace_cap: usize, total_cap: usize, ) -> Vec { - crate::openhuman::memory_tree::tree_runtime::store::collect_root_summaries_with_caps( - workspace_dir, - per_namespace_cap, - total_cap, - ) - .into_iter() - .map( - |(namespace, body, updated_at)| crate::openhuman::context::prompt::NamespaceSummary { - namespace, - body, - updated_at, - }, - ) - .collect() + let rows = if memory_subdir == "memory" { + crate::openhuman::memory_tree::tree_runtime::store::collect_root_summaries_with_caps( + workspace_dir, + per_namespace_cap, + total_cap, + ) + } else { + collect_profile_tree_root_summaries( + &workspace_dir.join(memory_subdir), + per_namespace_cap, + total_cap, + ) + }; + rows.into_iter() + .map( + |(namespace, body, updated_at)| crate::openhuman::context::prompt::NamespaceSummary { + namespace, + body, + updated_at, + }, + ) + .collect() +} + +/// Read TinyCortex root summaries from an already-resolved `memory-*` subtree. +/// TinyCortex's compatibility helper hardcodes `/memory`; dedicated +/// profiles instead supply `/memory-`, so scan that equivalent +/// namespace layout directly rather than falling back to shared memory. +fn collect_profile_tree_root_summaries( + memory_dir: &std::path::Path, + per_namespace_cap: usize, + total_cap: usize, +) -> Vec<(String, String, chrono::DateTime)> { + let Ok(entries) = std::fs::read_dir(memory_dir.join("namespaces")) else { + return Vec::new(); + }; + let mut roots: Vec<_> = entries + .flatten() + .filter_map(|entry| { + let namespace = entry.file_name().to_string_lossy().into_owned(); + let raw = std::fs::read_to_string(entry.path().join("tree").join("root.md")).ok()?; + let node = tinycortex::memory::tree::runtime::store::parse_node_markdown_pub( + &raw, &namespace, "root", + ) + .ok()?; + Some((namespace, node)) + }) + .collect(); + roots.sort_by(|(left, _), (right, _)| left.cmp(right)); + + let mut total_chars = 0usize; + let mut out = Vec::new(); + for (namespace, node) in roots { + if total_chars >= total_cap { + break; + } + let body = node.summary.trim(); + if body.is_empty() { + continue; + } + let remaining = total_cap.saturating_sub(total_chars); + let cap = per_namespace_cap.min(remaining); + let body_chars = body.chars().count(); + let rendered = if body_chars > cap { + let mut clipped: String = body.chars().take(cap).collect(); + clipped.push_str("\n\n[... truncated]"); + clipped + } else { + body.to_string() + }; + total_chars += rendered.chars().count(); + out.push((namespace, rendered, node.updated_at)); + } + out } /// Sanitize a learned memory entry before injecting into the system prompt. diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs index b058469c29..9a3118935a 100644 --- a/src/openhuman/agent/harness/session/turn/session_io.rs +++ b/src/openhuman/agent/harness/session/turn/session_io.rs @@ -502,7 +502,7 @@ impl Agent { Some(prefix) => format!("{}__{}", prefix, self.session_key), None => self.session_key.clone(), }; - match transcript::resolve_keyed_transcript_path(&self.workspace_dir, &stem) { + match transcript::resolve_keyed_transcript_path_in_dir(&self.session_raw_dir, &stem) { Ok(path) => { log::info!( "[transcript] new session transcript path={}", diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index 4e694edd3b..af09565798 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -489,7 +489,7 @@ fn build_parent_context_and_sanitize_helpers_cover_snapshot_paths() { ); let long = "x".repeat(500); assert_eq!(sanitize_learned_entry(&long).chars().count(), 200); - assert!(collect_tree_root_summaries(agent.workspace_dir(), 8_000, 32_000).is_empty()); + assert!(collect_tree_root_summaries(agent.workspace_dir(), "memory", 8_000, 32_000).is_empty()); } #[test] @@ -570,13 +570,50 @@ fn collect_tree_root_summaries_maps_namespace_body_and_timestamp() { }; write_node(&config, &node).unwrap(); - let summaries = collect_tree_root_summaries(&workspace, 8_000, 32_000); + let summaries = collect_tree_root_summaries(&workspace, "memory", 8_000, 32_000); assert_eq!(summaries.len(), 1); assert_eq!(summaries[0].namespace, "activities"); assert_eq!(summaries[0].body, summary); assert_eq!(summaries[0].updated_at, updated_at); } +#[test] +fn collect_tree_root_summaries_reads_only_profile_memory_subtree() { + use crate::openhuman::config::Config; + use crate::openhuman::memory_tree::tree_runtime::store::write_node; + use crate::openhuman::memory_tree::tree_runtime::types::{ + derive_parent_id, estimate_tokens, level_from_node_id, TreeNode, + }; + + let tmp = tempfile::TempDir::new().unwrap(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + let config = Config { + workspace_dir: workspace.clone(), + ..Config::default() + }; + let now = chrono::Utc::now(); + let node = TreeNode { + node_id: "root".into(), + namespace: "private".into(), + level: level_from_node_id("root"), + parent_id: derive_parent_id("root"), + summary: "Alice-only context".into(), + token_count: estimate_tokens("Alice-only context"), + child_count: 0, + created_at: now, + updated_at: now, + metadata: None, + }; + write_node(&config, &node).unwrap(); + std::fs::rename(workspace.join("memory"), workspace.join("memory-alice")).unwrap(); + + assert!(collect_tree_root_summaries(&workspace, "memory", 8_000, 32_000).is_empty()); + let summaries = collect_tree_root_summaries(&workspace, "memory-alice", 8_000, 32_000); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].body, "Alice-only context"); +} + #[tokio::test] async fn transcript_roundtrip_work() { let mut agent = make_agent(None); diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index d2c164b7a6..79ea34c1f8 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -139,6 +139,14 @@ pub struct Agent { /// [`WorkspaceDescriptor::policy_id`](tinyagents::harness::workspace::WorkspaceDescriptor) /// and only exists for *dedicated-workspace* profiles. pub(super) active_profile_id: Option, + /// Profile-selected memory subtree name (`memory`, `memory-`, or a + /// legacy numeric suffix). Used for memory-tree reads and paired with the + /// profile-specific transcript directory below. + pub(super) memory_subdir: String, + /// Exact directory for JSONL transcripts. Keeping this separate from + /// `workspace_dir` prevents dedicated-memory profiles from reading or + /// writing the shared `session_raw/` store. + pub(super) session_raw_dir: PathBuf, /// Resolved filesystem path for this session's transcript file. /// Set on first write, reused for subsequent **appends** within the /// same session. @@ -385,6 +393,8 @@ pub struct AgentBuilder { /// (default) means the profile-less session; the profile-launching callers /// (web chat, task dispatcher, cron) set the active profile id here. pub(super) active_profile_id: Option, + pub(super) memory_subdir: Option, + pub(super) session_raw_dir: Option, /// Directory chain of parent session keys for a sub-agent. `None` /// (default) means this is a root session — its transcript lands /// flat in `session_raw/DDMMYYYY/{session_key}.jsonl`. Populated diff --git a/src/openhuman/agent/tools/run_workflow.rs b/src/openhuman/agent/tools/run_workflow.rs index 56592b79ed..873dbca75c 100644 --- a/src/openhuman/agent/tools/run_workflow.rs +++ b/src/openhuman/agent/tools/run_workflow.rs @@ -210,6 +210,8 @@ fn outcome_to_result( /// `run_workflow` — orchestrator-callable spawn + inline await of another /// workflow. pub struct RunWorkflowTool { + /// Full active profile context inherited by the autonomous workflow agent. + active_profile: Option, /// Per-profile allowlist of runnable workflow `dir_name` slugs. `None` /// (the default) means every installed workflow may be run. skill_allowlist: Option>, @@ -228,11 +230,20 @@ impl Default for RunWorkflowTool { impl RunWorkflowTool { pub fn new() -> Self { Self { + active_profile: None, skill_allowlist: None, profile_skills_root: None, } } + pub fn with_active_profile( + mut self, + profile: Option, + ) -> Self { + self.active_profile = profile; + self + } + /// Restrict which workflows this tool may run to a per-profile allowlist. pub fn with_skill_allowlist( mut self, @@ -344,6 +355,7 @@ impl Tool for RunWorkflowTool { workflow_id.clone(), inputs, self.profile_skills_root.clone(), + self.active_profile.clone(), ) .await { @@ -384,6 +396,7 @@ impl Tool for RunWorkflowTool { workflow_id.clone(), inputs, self.profile_skills_root.clone(), + self.active_profile.clone(), ) .await { diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index 79232906aa..5721176d22 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -346,6 +346,7 @@ pub async fn start_channels(mut config: Config) -> Result<()> { None, None, None, + None, )); let skills = crate::openhuman::skills::load_workflow_metadata(&workspace); diff --git a/src/openhuman/runtime_node/ops.rs b/src/openhuman/runtime_node/ops.rs index c5b935797a..c78340bbd9 100644 --- a/src/openhuman/runtime_node/ops.rs +++ b/src/openhuman/runtime_node/ops.rs @@ -128,6 +128,7 @@ fn build_runtime_tools(config: &Config) -> Result>, String> { None, None, None, + None, ); debug!( tool_count = built.len(), diff --git a/src/openhuman/skill_runtime/run_machinery.rs b/src/openhuman/skill_runtime/run_machinery.rs index 7cacfe21b3..3e0c014815 100644 --- a/src/openhuman/skill_runtime/run_machinery.rs +++ b/src/openhuman/skill_runtime/run_machinery.rs @@ -42,7 +42,7 @@ pub async fn spawn_workflow_run_background( skill_id_param: String, inputs_param: Option, ) -> Result { - spawn_workflow_run_background_with_profile(skill_id_param, inputs_param, None).await + spawn_workflow_run_background_with_profile(skill_id_param, inputs_param, None, None).await } /// Like [`spawn_workflow_run_background`], but resolves the target skill against @@ -56,6 +56,7 @@ pub async fn spawn_workflow_run_background_with_profile( skill_id_param: String, inputs_param: Option, profile_skills_root: Option, + active_profile: Option, ) -> Result { let workspace = resolve_workspace_dir().await; let skill = registry::get_workflow_with_profile( @@ -176,6 +177,7 @@ pub async fn spawn_workflow_run_background_with_profile( let inputs = inputs.clone(); let log_path = log_path.clone(); let inherited_origin = inherited_origin.clone(); + let active_profile = active_profile.clone(); tokio::spawn(async move { if let Err(e) = run_log::write_header(&log_path, &workflow_id, &run_id, &inputs, &task_prompt).await @@ -201,7 +203,15 @@ pub async fn spawn_workflow_run_background_with_profile( if config.http_request.allowed_domains.is_empty() { config.http_request.allowed_domains = vec!["*".to_string()]; } - let mut agent = match Agent::from_config_for_agent(&config, "orchestrator") { + let mut agent = match Agent::from_config_for_agent_with_profile( + &config, + "orchestrator", + None, + active_profile + .as_ref() + .and_then(|profile| profile.system_prompt_suffix.clone()), + active_profile.as_ref(), + ) { Ok(a) => a, Err(e) => { let _ = run_log::write_footer( diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 0b6bf5a266..c1a44f71fc 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -79,6 +79,7 @@ pub fn all_tools( None, None, None, + None, ) } @@ -99,6 +100,7 @@ pub fn all_tools_with_runtime( action_dir: &std::path::Path, agents: &HashMap, root_config: &crate::openhuman::config::Config, + active_profile: Option<&crate::openhuman::profiles::AgentProfile>, skill_allowlist: Option<&std::collections::HashSet>, mcp_allowlist: Option<&[String]>, profile_skills_root: Option<&std::path::Path>, @@ -231,6 +233,7 @@ pub fn all_tools_with_runtime( #[cfg(feature = "skills")] Box::new( RunWorkflowTool::new() + .with_active_profile(active_profile.cloned()) .with_skill_allowlist(skill_allowlist.cloned()) .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), ), From bdc4097976fa8fbed6dfd70a2174ac72cf9c862d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 14:53:46 +0000 Subject: [PATCH 34/80] fix(skills): keep disabled build warning-free --- src/openhuman/tools/ops.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index c1a44f71fc..9cc8a4b758 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -109,7 +109,7 @@ pub fn all_tools_with_runtime( // tool registrations below, so they are genuinely unread when that feature // is compiled out. #[cfg(not(feature = "skills"))] - let _ = (skill_allowlist, profile_skills_root); + let _ = (active_profile, skill_allowlist, profile_skills_root); // Build a session-scoped managed Node.js bootstrap once, so ShellTool, // NodeExecTool, and NpmExecTool all share the same memoised resolution From cc5ddfebdd9c298e8aed698789da373184dbb39f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 15:09:19 +0000 Subject: [PATCH 35/80] fix(profiles): satisfy transcript path clippy --- src/openhuman/agent/harness/session/transcript.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openhuman/agent/harness/session/transcript.rs b/src/openhuman/agent/harness/session/transcript.rs index 51b5f38604..edcf8b638b 100644 --- a/src/openhuman/agent/harness/session/transcript.rs +++ b/src/openhuman/agent/harness/session/transcript.rs @@ -1446,7 +1446,7 @@ pub fn resolve_keyed_transcript_path(workspace_dir: &Path, stem: &str) -> Result } pub fn resolve_keyed_transcript_path_in_dir(raw_dir: &Path, stem: &str) -> Result { - fs::create_dir_all(&raw_dir) + fs::create_dir_all(raw_dir) .with_context(|| format!("create session_raw dir {}", raw_dir.display()))?; let sanitized = sanitize_stem(stem); Ok(raw_dir.join(format!("{sanitized}.jsonl"))) From 09d0b9bd0d67a05f94e1214f6163c7ca396a26fe Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 16:38:30 +0000 Subject: [PATCH 36/80] fix(profiles): resolve transcripts from current workspace --- .../agent/harness/session/builder/builder_tests.rs | 5 +---- .../agent/harness/session/builder/factory.rs | 10 +++------- .../agent/harness/session/builder/setters.rs | 14 +++++++------- src/openhuman/agent/harness/session/runtime.rs | 8 ++++---- .../agent/harness/session/turn/session_io.rs | 3 ++- src/openhuman/agent/harness/session/types.rs | 11 ++++++----- 6 files changed, 23 insertions(+), 28 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/builder_tests.rs b/src/openhuman/agent/harness/session/builder/builder_tests.rs index 6ae333f30a..ab0ff98240 100644 --- a/src/openhuman/agent/harness/session/builder/builder_tests.rs +++ b/src/openhuman/agent/harness/session/builder/builder_tests.rs @@ -296,10 +296,7 @@ async fn dedicated_memory_profile_scopes_tree_and_transcript_storage() { .expect("build dedicated-memory session"); assert_eq!(agent.memory_subdir, "memory-alice"); - assert_eq!( - agent.session_raw_dir, - config.workspace_dir.join("session_raw-alice") - ); + assert_eq!(agent.session_raw_subdir, "session_raw-alice"); } #[tokio::test] diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 7c787b9376..87fb92c766 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -402,12 +402,8 @@ impl Agent { let memory_suffix = profile .map(crate::openhuman::profiles::effective_memory_suffix) .unwrap_or_default(); - let session_raw_dir = - config - .workspace_dir - .join(crate::openhuman::profiles::session_raw_subdir_for_suffix( - &memory_suffix, - )); + let session_raw_subdir = + crate::openhuman::profiles::session_raw_subdir_for_suffix(&memory_suffix); tracing::debug!( memory_subdir = %memory_subdir, has_profile = profile.is_some(), @@ -1313,7 +1309,7 @@ impl Agent { // see which profile the turn ran under. `None` for the profile-less // session keeps every consumer byte-identical. .active_profile_id(profile.map(|p| p.id.clone())) - .profile_memory_storage(memory_subdir, session_raw_dir) + .profile_memory_storage(memory_subdir, session_raw_subdir) .workflows( crate::openhuman::skills::load_workflow_metadata_for_profile( &config.workspace_dir, diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs index 561a18711c..610bfe641f 100644 --- a/src/openhuman/agent/harness/session/builder/setters.rs +++ b/src/openhuman/agent/harness/session/builder/setters.rs @@ -44,7 +44,7 @@ impl AgentBuilder { agent_definition_name: None, active_profile_id: None, memory_subdir: None, - session_raw_dir: None, + session_raw_subdir: None, session_parent_prefix: None, omit_profile: None, omit_memory_md: None, @@ -218,10 +218,10 @@ impl AgentBuilder { pub fn profile_memory_storage( mut self, memory_subdir: String, - session_raw_dir: std::path::PathBuf, + session_raw_subdir: String, ) -> Self { self.memory_subdir = Some(memory_subdir); - self.session_raw_dir = Some(session_raw_dir); + self.session_raw_subdir = Some(session_raw_subdir); self } @@ -505,9 +505,9 @@ impl AgentBuilder { .unwrap_or_else(|| std::path::PathBuf::from(".")); let action_dir = self.action_dir.unwrap_or_else(|| workspace_dir.clone()); let memory_subdir = self.memory_subdir.unwrap_or_else(|| "memory".to_string()); - let session_raw_dir = self - .session_raw_dir - .unwrap_or_else(|| workspace_dir.join("session_raw")); + let session_raw_subdir = self + .session_raw_subdir + .unwrap_or_else(|| "session_raw".to_string()); Ok(Agent { turn_model_source, @@ -554,7 +554,7 @@ impl AgentBuilder { agent_definition_id: agent_definition_name.clone(), active_profile_id: self.active_profile_id, memory_subdir, - session_raw_dir, + session_raw_subdir, session_transcript_path: None, persisted_transcript_messages: Vec::new(), session_key: { diff --git a/src/openhuman/agent/harness/session/runtime.rs b/src/openhuman/agent/harness/session/runtime.rs index f327fadd8b..0ac6368dfd 100644 --- a/src/openhuman/agent/harness/session/runtime.rs +++ b/src/openhuman/agent/harness/session/runtime.rs @@ -426,10 +426,10 @@ impl Agent { return false; } - let Some(path) = super::transcript::find_root_transcript_for_thread_in_dir( - &self.session_raw_dir, - thread_id, - ) else { + let session_raw_dir = self.workspace_dir.join(&self.session_raw_subdir); + let Some(path) = + super::transcript::find_root_transcript_for_thread_in_dir(&session_raw_dir, thread_id) + else { log::debug!( "[web-channel] no root session_raw transcript for thread={thread_id} — \ falling back to conversation-log prose seeding" diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs index 9a3118935a..ad219247b5 100644 --- a/src/openhuman/agent/harness/session/turn/session_io.rs +++ b/src/openhuman/agent/harness/session/turn/session_io.rs @@ -502,7 +502,8 @@ impl Agent { Some(prefix) => format!("{}__{}", prefix, self.session_key), None => self.session_key.clone(), }; - match transcript::resolve_keyed_transcript_path_in_dir(&self.session_raw_dir, &stem) { + let session_raw_dir = self.workspace_dir.join(&self.session_raw_subdir); + match transcript::resolve_keyed_transcript_path_in_dir(&session_raw_dir, &stem) { Ok(path) => { log::info!( "[transcript] new session transcript path={}", diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index 79ea34c1f8..3e248d5076 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -143,10 +143,11 @@ pub struct Agent { /// legacy numeric suffix). Used for memory-tree reads and paired with the /// profile-specific transcript directory below. pub(super) memory_subdir: String, - /// Exact directory for JSONL transcripts. Keeping this separate from - /// `workspace_dir` prevents dedicated-memory profiles from reading or - /// writing the shared `session_raw/` store. - pub(super) session_raw_dir: PathBuf, + /// Profile-selected JSONL transcript subdirectory (`session_raw` or + /// `session_raw-`). It is resolved against the current `workspace_dir` + /// at I/O time so relocating an agent does not leave transcripts pinned to + /// its original workspace while dedicated-memory profiles remain isolated. + pub(super) session_raw_subdir: String, /// Resolved filesystem path for this session's transcript file. /// Set on first write, reused for subsequent **appends** within the /// same session. @@ -394,7 +395,7 @@ pub struct AgentBuilder { /// (web chat, task dispatcher, cron) set the active profile id here. pub(super) active_profile_id: Option, pub(super) memory_subdir: Option, - pub(super) session_raw_dir: Option, + pub(super) session_raw_subdir: Option, /// Directory chain of parent session keys for a sub-agent. `None` /// (default) means this is a root session — its transcript lands /// flat in `session_raw/DDMMYYYY/{session_key}.jsonl`. Populated From 1085c6b67b52b1e6821e9cf4e0483d8e2b1b1feb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 16:56:40 +0000 Subject: [PATCH 37/80] fix(profiles): persist default isolation settings --- src/openhuman/profiles/store.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/openhuman/profiles/store.rs b/src/openhuman/profiles/store.rs index 5a242e0ad3..0ca7833f31 100644 --- a/src/openhuman/profiles/store.rs +++ b/src/openhuman/profiles/store.rs @@ -171,6 +171,8 @@ impl AgentProfileStore { default.include_agent_conversations = profile.include_agent_conversations; default.allowed_skills = profile.allowed_skills; default.allowed_mcp_servers = profile.allowed_mcp_servers; + default.dedicated_memory = profile.dedicated_memory; + default.dedicated_workspace = profile.dedicated_workspace; // memory_dir_suffix stays as built-in default (don't let user override the default's suffix) default.sort_order = profile.sort_order; default @@ -749,6 +751,8 @@ mod tests { profile.system_prompt_suffix = Some(" suffix ".into()); profile.allowed_tools = Some(vec![" todo ".into()]); profile.memory_sources = Some(vec!["slack-eng".into()]); + profile.dedicated_memory = true; + profile.dedicated_workspace = true; let state = store.upsert(profile).expect("upsert default"); let default = state .profiles @@ -764,6 +768,8 @@ mod tests { default.memory_sources.as_deref(), Some(vec!["slack-eng".to_string()].as_slice()) ); + assert!(default.dedicated_memory); + assert!(default.dedicated_workspace); } #[test] From f3260479a7507f201acf25282ef66055bc29b73d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 16:56:40 +0000 Subject: [PATCH 38/80] fix(profiles): preserve manually edited soul files --- src/openhuman/profiles/home.rs | 57 +++++++++++++++++++++++++++++----- src/openhuman/profiles/ops.rs | 33 ++++++++++++++------ 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/src/openhuman/profiles/home.rs b/src/openhuman/profiles/home.rs index eea153537c..4cfe96c6fd 100644 --- a/src/openhuman/profiles/home.rs +++ b/src/openhuman/profiles/home.rs @@ -305,7 +305,8 @@ pub fn ensure_profile_home( /// the file first, the agent would keep using the old identity. This closes that /// gap by overwriting the file (atomic temp+rename, same as the seed path) when: /// - the id passes [`validate_profile_id`] (read paths would load it), -/// - `profile.soul_md` is `Some(non-empty)`, and +/// - `profile.soul_md` is `Some(non-empty)` and differs from the previously +/// persisted inline value, and /// - the trimmed inline content differs from the current file content. /// /// When `soul_md` is empty/`None` the file is left untouched, so a user who @@ -313,7 +314,11 @@ pub fn ensure_profile_home( /// authoritative. Only ever called from the upsert path; select must not clobber /// a manually edited file with a stale inline value. Returns `Ok(true)` when the /// file was rewritten. -pub fn sync_soul_md_on_upsert(workspace_dir: &Path, profile: &AgentProfile) -> io::Result { +pub fn sync_soul_md_on_upsert( + workspace_dir: &Path, + profile: &AgentProfile, + previous_soul_md: Option<&str>, +) -> io::Result { if let Err(e) = validate_profile_id(&profile.id) { tracing::debug!( profile_id = %profile.id, @@ -339,6 +344,18 @@ pub fn sync_soul_md_on_upsert(workspace_dir: &Path, profile: &AgentProfile) -> i } }; + // The editor submits the complete profile on every save. If the inline + // soul did not change, this is an unrelated settings update and the + // hot-read file remains authoritative (it may have been edited manually + // since the profile was loaded). + if previous_soul_md.map(str::trim) == Some(desired) { + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] sync_soul_md_on_upsert: inline soul_md unchanged, file left as-is" + ); + return Ok(false); + } + let home = profile_home(workspace_dir, &profile.id); let soul_path = home.join("SOUL.md"); // No-op when the file already matches the edited value (compare trimmed so a @@ -596,7 +613,8 @@ mod tests { // User edits the persona in Settings → the stored inline value changes. profile.soul_md = Some("Rewritten identity from Settings.".to_string()); - let rewritten = sync_soul_md_on_upsert(ws.path(), &profile).expect("sync"); + let rewritten = + sync_soul_md_on_upsert(ws.path(), &profile, Some("Original identity.")).expect("sync"); assert!( rewritten, "differing inline soul_md must overwrite the file" @@ -607,7 +625,12 @@ mod tests { ); // Idempotent: a second sync with the same value is a no-op. - let again = sync_soul_md_on_upsert(ws.path(), &profile).expect("sync 2"); + let again = sync_soul_md_on_upsert( + ws.path(), + &profile, + Some("Rewritten identity from Settings."), + ) + .expect("sync 2"); assert!(!again, "matching inline soul_md must not rewrite the file"); } @@ -623,10 +646,10 @@ mod tests { std::fs::write(&soul_path, "MANUALLY EDITED SOUL").unwrap(); profile.soul_md = None; - let none_written = sync_soul_md_on_upsert(ws.path(), &profile).expect("sync none"); + let none_written = sync_soul_md_on_upsert(ws.path(), &profile, None).expect("sync none"); assert!(!none_written); profile.soul_md = Some(" ".to_string()); // whitespace-only → treated as empty - let blank_written = sync_soul_md_on_upsert(ws.path(), &profile).expect("sync blank"); + let blank_written = sync_soul_md_on_upsert(ws.path(), &profile, None).expect("sync blank"); assert!(!blank_written); // The manual edit stays authoritative. @@ -642,10 +665,30 @@ mod tests { let mut profile = test_profile("placeholder"); profile.id = "Bad Id".to_string(); profile.soul_md = Some("ignored".to_string()); - assert!(!sync_soul_md_on_upsert(ws.path(), &profile).expect("sync")); + assert!(!sync_soul_md_on_upsert(ws.path(), &profile, None).expect("sync")); assert!(!profile_home(ws.path(), "Bad Id").join("SOUL.md").exists()); } + #[test] + fn sync_soul_md_on_upsert_preserves_manual_file_when_inline_unchanged() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + let mut profile = test_profile("ivy"); + profile.soul_md = Some("Stored identity.".to_string()); + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + let soul_path = profile_home(ws.path(), "ivy").join("SOUL.md"); + std::fs::write(&soul_path, "MANUALLY EDITED IDENTITY\n").unwrap(); + + let rewritten = sync_soul_md_on_upsert(ws.path(), &profile, Some("Stored identity.")) + .expect("sync unchanged"); + + assert!(!rewritten); + assert_eq!( + std::fs::read_to_string(soul_path).unwrap(), + "MANUALLY EDITED IDENTITY\n" + ); + } + #[test] fn dedicated_workspace_dir_gates_on_flag_and_id() { let action = Path::new("/tmp/act"); diff --git a/src/openhuman/profiles/ops.rs b/src/openhuman/profiles/ops.rs index dc2e063c17..fbfe512822 100644 --- a/src/openhuman/profiles/ops.rs +++ b/src/openhuman/profiles/ops.rs @@ -206,6 +206,21 @@ pub async fn upsert(profile: AgentProfile) -> Result { } let upserted_id = profile.id.clone(); let (store, workspace_dir, action_dir) = store_and_roots().await?; + // Keep the previous inline value so SOUL.md is rewritten only when the + // persona field itself changed. The editor submits the full profile for + // unrelated settings saves; comparing only against the file would clobber + // a newer manual edit with the unchanged, stale inline value. + let normalised_id = super::store::normalise_profile_id(&upserted_id); + let previous_soul_md = store + .load() + .map_err(|e| { + tracing::debug!(request_id = %request_id, error = %e, "[profiles][ops] upsert preload error"); + e + })? + .profiles + .into_iter() + .find(|p| p.id == normalised_id) + .and_then(|p| p.soul_md); let state = store.upsert(profile).map_err(|e| { tracing::debug!(request_id = %request_id, error = %e, "[profiles][ops] upsert error"); e @@ -214,11 +229,7 @@ pub async fn upsert(profile: AgentProfile) -> Result { // the id (slugify), so resolve the persisted profile from the returned state // rather than trusting the raw input id. Built-ins are seeded on select, not // here, matching the spec. - if let Some(persisted) = state - .profiles - .iter() - .find(|p| p.id == super::store::normalise_profile_id(&upserted_id)) - { + if let Some(persisted) = state.profiles.iter().find(|p| p.id == normalised_id) { // Full home materialization (SOUL.md seed + MEMORY.md + skills/ + // dedicated workspace) is for CUSTOM profiles here; built-ins are seeded // on `select` (first activation), matching the spec. @@ -231,10 +242,14 @@ pub async fn upsert(profile: AgentProfile) -> Result { // selects Default/Research once and later edits its Soul in Settings would // otherwise keep a stale `personalities//SOUL.md` that // `resolve_personality_soul` reads before the inline value. The sync is a - // no-op when `soul_md` is empty/None (manual file edits stay - // authoritative) and creates the home dir if needed. Non-fatal — the - // profile is already persisted. - if let Err(e) = super::home::sync_soul_md_on_upsert(&workspace_dir, persisted) { + // no-op when `soul_md` is empty/None or unchanged (manual file edits + // stay authoritative) and creates the home dir if needed. Non-fatal — + // the profile is already persisted. + if let Err(e) = super::home::sync_soul_md_on_upsert( + &workspace_dir, + persisted, + previous_soul_md.as_deref(), + ) { tracing::warn!( profile_id = %persisted.id, error = %e, From 19070991d55b57f0fac66ad667e70c0b851c018a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 17:13:36 +0000 Subject: [PATCH 39/80] fix(profiles): apply default soul to live prompts --- .../harness/session/builder/builder_tests.rs | 36 +++++++++++++++++++ .../agent/harness/session/builder/factory.rs | 15 ++++---- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/builder_tests.rs b/src/openhuman/agent/harness/session/builder/builder_tests.rs index ab0ff98240..f78ed3cb2b 100644 --- a/src/openhuman/agent/harness/session/builder/builder_tests.rs +++ b/src/openhuman/agent/harness/session/builder/builder_tests.rs @@ -425,6 +425,42 @@ async fn build_session_agent_injects_profile_soul_into_prompt() { ); } +#[tokio::test] +async fn build_session_agent_injects_default_profile_soul_into_prompt() { + use crate::openhuman::agent::harness::session::types::Agent; + use crate::openhuman::context::prompt::LearnedContextData; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + let home = config.workspace_dir.join("personalities").join("default"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write( + home.join("SOUL.md"), + "I am the user-edited Default profile identity.", + ) + .unwrap(); + + let profile = crate::openhuman::profiles::store::built_in_default_profile(); + let agent = Agent::build_session_agent_inner( + &config, + "orchestrator", + None, + None, + None, + false, + Some(&profile), + ) + .expect("build default-profile session"); + + let prompt = agent + .build_system_prompt(LearnedContextData::default()) + .expect("build_system_prompt"); + assert!( + prompt.contains("I am the user-edited Default profile identity."), + "the live Default profile prompt must include personalities/default/SOUL.md" + ); +} + #[tokio::test] async fn build_session_agent_profile_less_prompt_has_no_personality_soul() { use crate::openhuman::agent::harness::session::types::Agent; diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 87fb92c766..698c80f5b3 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -769,14 +769,13 @@ impl Agent { ) }) }); - // A non-default profile drives its live persona from its own SOUL.md - // (hot-read in the section), so ordinary web-chat / cron turns get the - // seeded/synced identity — not just delegation. The default/master - // profile keeps the workspace root SOUL.md (its identity), so the common - // default session stays byte-identical. - let soul_profile: Option = profile - .filter(|p| p.id != crate::openhuman::profiles::DEFAULT_PROFILE_ID) - .cloned(); + // Every explicitly selected profile drives its live persona from its + // own SOUL.md (hot-read in the section), so ordinary web-chat / cron + // turns get the seeded/synced identity — not just delegation. This + // includes the Default profile because Settings persists its edits at + // `personalities/default/SOUL.md`. The profile-less path remains + // byte-identical and continues using only the workspace-root identity. + let soul_profile: Option = profile.cloned(); if profile_suffix.is_some() || workspace_notice.is_some() || soul_profile.is_some() { log::debug!( "[agent:builder] profile prompt section injected suffix_chars={} workspace_notice={} profile_soul={}", From 42c67f7d0c1dfcb615059c00e557771b00dc7ad4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 17:37:08 +0000 Subject: [PATCH 40/80] fix(security): protect scoped profile state --- src/openhuman/security/policy/path_checks.rs | 51 ++++++++++++------- src/openhuman/security/policy/policy_tests.rs | 37 ++++++++++++++ src/openhuman/security/policy/types.rs | 4 +- 3 files changed, 72 insertions(+), 20 deletions(-) diff --git a/src/openhuman/security/policy/path_checks.rs b/src/openhuman/security/policy/path_checks.rs index 40edb0e8fc..84d8638126 100644 --- a/src/openhuman/security/policy/path_checks.rs +++ b/src/openhuman/security/policy/path_checks.rs @@ -48,22 +48,21 @@ impl SecurityPolicy { // operation-specific validators (validate_path / validate_parent_path). let in_trusted_root = self.is_within_trusted_root(expanded_path, false); - // Block agent access to internal state paths under workspace_dir - // (unless the path falls under an explicitly granted trusted root). - if !in_trusted_root { - let check = if expanded_path.is_absolute() { - expanded_path.to_path_buf() - } else { - self.workspace_dir.join(expanded_path) - }; - if self.is_workspace_internal_path(&check) { - log::trace!( - "[security:policy] path blocked: agent access to workspace-internal state (requested={}, resolved={})", - path, - check.display() - ); - return false; - } + // Workspace-internal application state is never agent-accessible. A + // trusted-root grant cannot weaken this invariant, even when it points + // at workspace_dir or one of its parents. + let check = if expanded_path.is_absolute() { + expanded_path.to_path_buf() + } else { + self.workspace_dir.join(expanded_path) + }; + if self.is_workspace_internal_path(&check) { + log::trace!( + "[security:policy] path blocked: agent access to workspace-internal state (requested={}, resolved={})", + path, + check.display() + ); + return false; } // Block absolute paths when workspace_only is set (unless trusted-rooted). @@ -364,9 +363,15 @@ impl SecurityPolicy { Some(std::path::Component::Normal(s)) => s.to_string_lossy(), _ => return false, }; - if WORKSPACE_INTERNAL_DIRS - .iter() - .any(|d| *d == first_component.as_ref()) + let component = first_component.as_ref(); + if WORKSPACE_INTERNAL_DIRS.iter().any(|d| *d == component) + || ["memory-", "memory_tree-", "session_raw-"] + .iter() + .any(|prefix| { + component + .strip_prefix(prefix) + .is_some_and(|s| !s.is_empty()) + }) { return true; } @@ -492,6 +497,14 @@ impl SecurityPolicy { resolved.display() )); } + // Trusted roots may override user-configured forbidden paths, but never + // the core-managed workspace-state boundary. + if self.is_workspace_internal_path(resolved) { + return Err(format!( + "{POLICY_BLOCKED_MARKER} Resolved path is workspace-internal application state: {}", + resolved.display() + )); + } // A trusted-root grant takes precedence over forbidden_paths for its subtree. if self.is_within_trusted_root(resolved, false) { return Ok(()); diff --git a/src/openhuman/security/policy/policy_tests.rs b/src/openhuman/security/policy/policy_tests.rs index ea11e08ee7..bc991513c5 100644 --- a/src/openhuman/security/policy/policy_tests.rs +++ b/src/openhuman/security/policy/policy_tests.rs @@ -2635,6 +2635,12 @@ fn is_workspace_internal_path_blocks_state_dirs() { }; assert!(policy.is_workspace_internal_path(&ws.join("memory"))); assert!(policy.is_workspace_internal_path(&ws.join("memory").join("namespaces"))); + assert!(policy.is_workspace_internal_path(&ws.join("memory-alice").join("memory.db"))); + assert!(policy.is_workspace_internal_path(&ws.join("memory_tree-alice").join("tree"))); + assert!(policy.is_workspace_internal_path( + &ws.join("session_raw-alice") + .join("1700000000_orchestrator.jsonl") + )); assert!(policy.is_workspace_internal_path(&ws.join("sessions"))); assert!(policy.is_workspace_internal_path(&ws.join("state"))); assert!(policy.is_workspace_internal_path(&ws.join("cron"))); @@ -2702,6 +2708,37 @@ fn is_path_string_allowed_blocks_workspace_internal() { ); } +#[tokio::test] +async fn trusted_root_cannot_expose_workspace_internal_state() { + let tmp = tempfile::tempdir().expect("tempdir"); + let ws = tmp.path().join("workspace"); + let personality = ws.join("personalities").join("alice"); + std::fs::create_dir_all(&personality).expect("create profile home"); + let soul = personality.join("SOUL.md"); + std::fs::write(&soul, "private identity").expect("write soul"); + let policy = SecurityPolicy { + workspace_dir: ws.clone(), + action_dir: ws.clone(), + workspace_only: false, + trusted_roots: vec![TrustedRoot { + path: ws.to_string_lossy().into_owned(), + access: TrustedAccess::ReadWrite, + }], + ..SecurityPolicy::default() + }; + + assert!(!policy.is_path_string_allowed(&soul.to_string_lossy())); + assert!(policy.validate_path(&soul.to_string_lossy()).await.is_err()); + assert!(policy + .validate_parent_path( + &ws.join("session_raw-alice") + .join("new.jsonl") + .to_string_lossy() + ) + .await + .is_err()); +} + #[test] fn action_dir_in_default_policy() { let policy = SecurityPolicy::default(); diff --git a/src/openhuman/security/policy/types.rs b/src/openhuman/security/policy/types.rs index 8bd8ee7ec5..36babadc8b 100644 --- a/src/openhuman/security/policy/types.rs +++ b/src/openhuman/security/policy/types.rs @@ -51,7 +51,9 @@ pub enum TrustedAccess { /// A directory outside the workspace the agent is explicitly granted access to. /// Takes precedence over `workspace_only` and `forbidden_paths` for its subtree, -/// except for credential stores (see `SecurityPolicy::is_always_forbidden`). +/// except for credential stores and workspace-internal application state (see +/// `SecurityPolicy::is_always_forbidden` and +/// `SecurityPolicy::is_workspace_internal_path`). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct TrustedRoot { /// Absolute path (a leading `~` is expanded to the user's home). From 6982909f02cce4c0072ca942feba8dfa07a32889 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 17:37:14 +0000 Subject: [PATCH 41/80] fix(threads): read profile-scoped transcripts --- .../agent/harness/session/transcript.rs | 24 ++++++++++-- .../agent/harness/session/transcript_tests.rs | 17 +++++++++ .../threads/transcript_view/project.rs | 13 +++---- .../threads/transcript_view/tests.rs | 37 ++++++++++++++++++- 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/src/openhuman/agent/harness/session/transcript.rs b/src/openhuman/agent/harness/session/transcript.rs index edcf8b638b..dd913437e8 100644 --- a/src/openhuman/agent/harness/session/transcript.rs +++ b/src/openhuman/agent/harness/session/transcript.rs @@ -1177,8 +1177,9 @@ pub fn read_transcript_display(path: &Path) -> Result Ok(DisplaySessionTranscript { meta, records }) } -/// Find the newest root `session_raw/*.jsonl` transcript whose metadata -/// declares `thread_id`. +/// Find the newest root transcript whose metadata declares `thread_id`, across +/// the shared `session_raw/` store and every profile-scoped +/// `session_raw-/` store. /// /// Root transcripts live directly under `session_raw/` and do not carry /// the `__` separator used for sub-agent siblings. This helper is the @@ -1186,7 +1187,24 @@ pub fn read_transcript_display(path: &Path) -> Result /// transcript without accidentally folding delegated worker transcripts /// into the main chat timeline. pub fn find_root_transcript_for_thread(workspace_dir: &Path, thread_id: &str) -> Option { - find_root_transcript_for_thread_in_dir(&raw_session_dir(workspace_dir), thread_id) + let mut raw_dirs = vec![raw_session_dir(workspace_dir)]; + if let Ok(entries) = fs::read_dir(workspace_dir) { + raw_dirs.extend(entries.flatten().map(|entry| entry.path()).filter(|path| { + path.is_dir() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.strip_prefix("session_raw-") + .is_some_and(|suffix| !suffix.is_empty()) + }) + })); + } + + raw_dirs + .into_iter() + .filter_map(|raw_dir| find_root_transcript_for_thread_in_dir(&raw_dir, thread_id)) + .max_by(|left, right| left.file_name().cmp(&right.file_name())) } pub fn find_root_transcript_for_thread_in_dir(raw_dir: &Path, thread_id: &str) -> Option { diff --git a/src/openhuman/agent/harness/session/transcript_tests.rs b/src/openhuman/agent/harness/session/transcript_tests.rs index ed649b63c4..6f2d6fcbd5 100644 --- a/src/openhuman/agent/harness/session/transcript_tests.rs +++ b/src/openhuman/agent/harness/session/transcript_tests.rs @@ -329,6 +329,23 @@ fn find_root_transcript_for_thread_skips_subagent_siblings() { .ends_with("1714000000_orchestrator_thread-abc.jsonl")); } +#[test] +fn find_root_transcript_for_thread_scans_profile_scoped_raw_dirs() { + let dir = TempDir::new().unwrap(); + let scoped_raw = dir.path().join("session_raw-alice"); + fs::create_dir_all(&scoped_raw).unwrap(); + + let mut meta = sample_meta(); + meta.thread_id = Some("thread-scoped".into()); + let expected = scoped_raw.join("1714000000_orchestrator_thread-scoped.jsonl"); + write_transcript(&expected, &sample_messages(), &meta, None).unwrap(); + + assert_eq!( + find_root_transcript_for_thread(dir.path(), "thread-scoped"), + Some(expected) + ); +} + #[test] fn find_latest_falls_back_to_legacy_ddmmyyyy_raw_dir() { // Pre-migration transcript at session_raw/DDMMYYYY/main_*.jsonl diff --git a/src/openhuman/threads/transcript_view/project.rs b/src/openhuman/threads/transcript_view/project.rs index 328e56102c..b372aff25f 100644 --- a/src/openhuman/threads/transcript_view/project.rs +++ b/src/openhuman/threads/transcript_view/project.rs @@ -46,7 +46,7 @@ pub fn project_thread(workspace_dir: &Path, thread_id: &str) -> Option Option<(PathBuf, Vec)> { let root_path = transcript::find_root_transcript_for_thread(workspace_dir, thread_id)?; let root_stem = root_path.file_stem()?.to_str()?.to_string(); - let sub_paths = discover_subagent_files(workspace_dir, &root_stem); + let sub_paths = discover_subagent_files(root_path.parent()?, &root_stem); Some((root_path, sub_paths)) } @@ -96,13 +96,12 @@ pub fn project_from_files( } } -/// Discover every sub-agent transcript file for `root_stem` under -/// `session_raw/`. Sub-agent stems are `{root_stem}__…`; results are sorted so -/// the timestamp-prefixed suffixes order by creation time. -fn discover_subagent_files(workspace_dir: &Path, root_stem: &str) -> Vec { - let raw_dir = workspace_dir.join("session_raw"); +/// Discover every sub-agent transcript file beside the resolved root. +/// Sub-agent stems are `{root_stem}__…`; results are sorted so the +/// timestamp-prefixed suffixes order by creation time. +fn discover_subagent_files(raw_dir: &Path, root_stem: &str) -> Vec { let prefix = format!("{root_stem}__"); - let Ok(entries) = fs::read_dir(&raw_dir) else { + let Ok(entries) = fs::read_dir(raw_dir) else { return Vec::new(); }; let mut paths: Vec = entries diff --git a/src/openhuman/threads/transcript_view/tests.rs b/src/openhuman/threads/transcript_view/tests.rs index 4eed3a126d..5290cefd7c 100644 --- a/src/openhuman/threads/transcript_view/tests.rs +++ b/src/openhuman/threads/transcript_view/tests.rs @@ -18,6 +18,11 @@ fn meta_line(thread_id: &str) -> String { /// `session_raw/{stem}.jsonl` and return the path. fn write_raw(workspace: &Path, stem: &str, thread_id: &str, body: &[&str]) -> PathBuf { let path = transcript::resolve_keyed_transcript_path(workspace, stem).expect("resolve"); + write_raw_at(&path, thread_id, body); + path +} + +fn write_raw_at(path: &Path, thread_id: &str, body: &[&str]) { let mut buf = meta_line(thread_id); buf.push('\n'); for line in body { @@ -25,7 +30,6 @@ fn write_raw(workspace: &Path, stem: &str, thread_id: &str, body: &[&str]) -> Pa buf.push('\n'); } std::fs::write(&path, buf).expect("write raw transcript"); - path } /// A full turn: system scaffolding, a user prompt with the injected datetime @@ -230,6 +234,37 @@ fn subagent_file_projects_as_nested_item() { )); } +#[test] +fn profile_scoped_root_and_subagent_project_together() { + let dir = TempDir::new().unwrap(); + let raw_dir = dir.path().join("session_raw-alice"); + std::fs::create_dir_all(&raw_dir).unwrap(); + let root_stem = "450_orchestrator"; + let thread_id = "thr_profile"; + + write_raw_at( + &raw_dir.join(format!("{root_stem}.jsonl")), + thread_id, + &[r#"{"role":"user","content":"delegate","request_id":"req-1"}"#], + ); + write_raw_at( + &raw_dir.join(format!("{root_stem}__451_coder.jsonl")), + thread_id, + &[r#"{"role":"assistant","content":"scoped sub work"}"#], + ); + + let projected = project_thread(dir.path(), thread_id).expect("project scoped thread"); + assert!(projected.items.iter().any(|item| matches!( + item, + DisplayItem::Subagent { items, .. } + if items.iter().any(|inner| matches!( + inner, + DisplayItem::AssistantMessage { content, .. } + if content == "scoped sub work" + )) + ))); +} + #[test] fn get_page_paginates_newest_first_with_cursor() { let dir = TempDir::new().unwrap(); From 0d419783f266d2a52cf0587142f039b819041ad1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 17:40:26 +0000 Subject: [PATCH 42/80] fix(security): satisfy strict path lint --- src/openhuman/security/policy/path_checks.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openhuman/security/policy/path_checks.rs b/src/openhuman/security/policy/path_checks.rs index 84d8638126..f64d718b2e 100644 --- a/src/openhuman/security/policy/path_checks.rs +++ b/src/openhuman/security/policy/path_checks.rs @@ -364,7 +364,7 @@ impl SecurityPolicy { _ => return false, }; let component = first_component.as_ref(); - if WORKSPACE_INTERNAL_DIRS.iter().any(|d| *d == component) + if WORKSPACE_INTERNAL_DIRS.contains(&component) || ["memory-", "memory_tree-", "session_raw-"] .iter() .any(|prefix| { From 6e37e634d29eceebff9dde5263cdccbd2e937f0f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 18:48:38 +0000 Subject: [PATCH 43/80] fix(tools): guard node processes across profiles --- src/openhuman/profiles/guard.rs | 18 +++--- src/openhuman/tools/impl/system/mod.rs | 38 ++++++++++++ src/openhuman/tools/impl/system/node_exec.rs | 58 +++++++++++++++++- src/openhuman/tools/impl/system/npm_exec.rs | 63 +++++++++++++++++--- src/openhuman/tools/impl/system/shell.rs | 30 ++-------- 5 files changed, 164 insertions(+), 43 deletions(-) diff --git a/src/openhuman/profiles/guard.rs b/src/openhuman/profiles/guard.rs index f1715a4857..6bd77f29d2 100644 --- a/src/openhuman/profiles/guard.rs +++ b/src/openhuman/profiles/guard.rs @@ -14,9 +14,9 @@ //! write/command whose resolved target lands in a *sibling* profile's //! workspace `/profiles//` (Q != P) is blocked. See //! [`classify_cross_profile_target`] (file tools) and -//! [`scan_command_for_cross_profile`] (shell). The guard only ever -//! **tightens**: with no active profile the classifier is never consulted -//! and behaviour is byte-identical to today. +//! [`scan_command_for_cross_profile`] (shell / `node_exec` / `npm_exec`). The +//! guard only ever **tightens**: with no active profile the classifier is +//! never consulted and behaviour is byte-identical to today. use std::path::{Component, Path, PathBuf}; @@ -117,15 +117,15 @@ pub fn classify_cross_profile_target( } } -/// Best-effort scan of a shell `command` for a token that targets a sibling +/// Best-effort scan of a process `command` for a token that targets a sibling /// profile's workspace, given the command's working directory `cwd` (the active /// profile's own dir). /// /// # Guarantee level (read before relying on this) /// -/// This is **best-effort defense-in-depth for the model-facing shell path, not a +/// This is **best-effort defense-in-depth for model-facing process tools, not a /// hard boundary.** It is a static, pre-execution token scan — it cannot see -/// what the shell will actually do at runtime. Known, deliberate gaps: +/// what the process will actually do at runtime. Known, deliberate gaps: /// /// - **Variable / command substitution.** `$HOME`, `${VAR}`, `$(cmd)`, and /// backtick substitution resolve to paths only at runtime; a token like @@ -138,9 +138,9 @@ pub fn classify_cross_profile_target( /// /// The **hard** cross-profile boundary for file mutations is /// [`SecurityPolicy::validate_path`](crate::openhuman::security) at the file-tool -/// call site (every write funnels through it). Shell commands do **not** funnel -/// through that gate, so this scan is their only in-Rust backstop — and the -/// airtight shell confinement (an OS sandbox: cwd_jail / Seatbelt / Landlock +/// call site (every write funnels through it). Process commands do **not** funnel +/// through that gate, so this scan is their only in-Rust backstop — and +/// airtight process confinement (an OS sandbox: cwd_jail / Seatbelt / Landlock /// restricting the process to its own subtree) is deliberate follow-up work, not /// provided here. Do not treat a `None` result as proof a command cannot reach a /// sibling profile. diff --git a/src/openhuman/tools/impl/system/mod.rs b/src/openhuman/tools/impl/system/mod.rs index 5435dc010e..29d6149dca 100644 --- a/src/openhuman/tools/impl/system/mod.rs +++ b/src/openhuman/tools/impl/system/mod.rs @@ -19,6 +19,7 @@ mod update_check; mod workspace_state; use crate::openhuman::security::SecurityPolicy; +use std::path::Path; use tinyagents::harness::tool::ToolExecutionContext; pub use current_time::CurrentTimeTool; @@ -62,3 +63,40 @@ pub(super) fn security_for_tool_context( } scoped } + +/// Apply the dedicated-workspace profile boundary to an arbitrary process +/// command before it is spawned. Process tools do not funnel their runtime file +/// writes through `SecurityPolicy::validate_path`, so shell, Node, and npm must +/// all share this defense-in-depth scan. +pub(super) fn check_cross_profile_command( + security: &SecurityPolicy, + command: &str, + cwd: &Path, + tool: &str, +) -> Result<(), String> { + let Some(guard) = security.active_profile.as_ref() else { + return Ok(()); + }; + let Some(other_id) = crate::openhuman::profiles::scan_command_for_cross_profile( + command, + cwd, + &guard.action_dir, + &guard.profile_id, + ) else { + return Ok(()); + }; + + tracing::warn!( + tool, + active_profile = %guard.profile_id, + other_profile = %other_id, + "[profiles] cross-profile process command blocked" + ); + Err(format!( + "{} Cross-profile access blocked: profile '{}' may not touch profile '{}'s workspace. \ + Stay within your own profile directory; do not retry this command.", + crate::openhuman::security::POLICY_BLOCKED_MARKER, + guard.profile_id, + other_id + )) +} diff --git a/src/openhuman/tools/impl/system/node_exec.rs b/src/openhuman/tools/impl/system/node_exec.rs index 789e8f6eec..d2c579bb91 100644 --- a/src/openhuman/tools/impl/system/node_exec.rs +++ b/src/openhuman/tools/impl/system/node_exec.rs @@ -204,6 +204,21 @@ impl NodeExecTool { "[policy-blocked] Action blocked: the agent is in read-only mode and cannot execute code.", )); } + let path_policy = super::security_for_tool_context(&self.security, context, "node_exec"); + let guard_command = inline_code.clone().unwrap_or_else(|| { + std::iter::once(script_path.as_deref().unwrap_or_default()) + .chain(extra_args.iter().map(String::as_str)) + .collect::>() + .join(" ") + }); + if let Err(reason) = super::check_cross_profile_command( + &path_policy, + &guard_command, + &path_policy.action_dir, + "node_exec", + ) { + return Ok(ToolResult::error(reason)); + } if self.security.is_rate_limited() { return Ok(ToolResult::error( "Rate limit exceeded: too many actions in the last hour", @@ -232,8 +247,6 @@ impl NodeExecTool { "[node_exec] starting invocation" ); - let path_policy = super::security_for_tool_context(&self.security, context, "node_exec"); - let command = if let Some(code) = inline_code.as_deref() { format!( "{} -e {}", @@ -656,4 +669,45 @@ mod tests { resolved.display() ); } + + #[tokio::test] + async fn inline_code_cannot_write_to_sibling_profile() { + use crate::openhuman::agent::host_runtime::NativeRuntime; + use crate::openhuman::config::schema::NodeConfig; + use crate::openhuman::security::policy::ActiveProfileGuard; + use crate::openhuman::security::AutonomyLevel; + + let temp = tempfile::tempdir().unwrap(); + let action_root = temp.path().join("actions"); + let alice = action_root.join("profiles/alice"); + std::fs::create_dir_all(action_root.join("profiles/bob")).unwrap(); + std::fs::create_dir_all(&alice).unwrap(); + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Full, + workspace_dir: temp.path().join("state"), + action_dir: alice, + workspace_only: false, + active_profile: Some(ActiveProfileGuard { + profile_id: "alice".into(), + action_dir: action_root, + }), + ..SecurityPolicy::default() + }); + let bootstrap = Arc::new(NodeBootstrap::new( + NodeConfig::default(), + temp.path().to_path_buf(), + reqwest::Client::new(), + )); + let tool = NodeExecTool::new(security, Arc::new(NativeRuntime::new()), bootstrap); + + let result = tool + .execute(json!({ + "inline_code": "require('fs').writeFileSync('../bob/loot.txt', 'x')" + })) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.text().contains("Cross-profile access blocked")); + } } diff --git a/src/openhuman/tools/impl/system/npm_exec.rs b/src/openhuman/tools/impl/system/npm_exec.rs index 8d0ede92cb..083e9d1da6 100644 --- a/src/openhuman/tools/impl/system/npm_exec.rs +++ b/src/openhuman/tools/impl/system/npm_exec.rs @@ -229,6 +229,20 @@ impl NpmExecTool { "[policy-blocked] Action blocked: the agent is in read-only mode and cannot run npm.", )); } + let path_policy = super::security_for_tool_context(&self.security, context, "npm_exec"); + let cwd = match resolve_cwd(&path_policy.action_dir, cwd_override.as_deref()) { + Ok(p) => p, + Err(msg) => return Ok(ToolResult::error(msg)), + }; + let guard_command = std::iter::once(subcommand.as_str()) + .chain(extra_args.iter().map(String::as_str)) + .collect::>() + .join(" "); + if let Err(reason) = + super::check_cross_profile_command(&path_policy, &guard_command, &cwd, "npm_exec") + { + return Ok(ToolResult::error(reason)); + } if self.security.is_rate_limited() { return Ok(ToolResult::error( "Rate limit exceeded: too many actions in the last hour", @@ -240,13 +254,6 @@ impl NpmExecTool { )); } - let path_policy = super::security_for_tool_context(&self.security, context, "npm_exec"); - - let cwd = match resolve_cwd(&path_policy.action_dir, cwd_override.as_deref()) { - Ok(p) => p, - Err(msg) => return Ok(ToolResult::error(msg)), - }; - let resolved = match self.bootstrap.resolve().await { Ok(r) => r, Err(e) => { @@ -625,4 +632,46 @@ mod tests { ); } } + + #[tokio::test] + async fn args_cannot_target_sibling_profile() { + use crate::openhuman::agent::host_runtime::NativeRuntime; + use crate::openhuman::config::schema::NodeConfig; + use crate::openhuman::security::policy::ActiveProfileGuard; + use crate::openhuman::security::AutonomyLevel; + + let temp = tempfile::tempdir().unwrap(); + let action_root = temp.path().join("actions"); + let alice = action_root.join("profiles/alice"); + std::fs::create_dir_all(action_root.join("profiles/bob")).unwrap(); + std::fs::create_dir_all(&alice).unwrap(); + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Full, + workspace_dir: temp.path().join("state"), + action_dir: alice, + workspace_only: false, + active_profile: Some(ActiveProfileGuard { + profile_id: "alice".into(), + action_dir: action_root, + }), + ..SecurityPolicy::default() + }); + let bootstrap = Arc::new(NodeBootstrap::new( + NodeConfig::default(), + temp.path().to_path_buf(), + reqwest::Client::new(), + )); + let tool = NpmExecTool::new(security, Arc::new(NativeRuntime::new()), bootstrap); + + let result = tool + .execute(json!({ + "subcommand": "install", + "args": ["--prefix", "../bob"] + })) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.text().contains("Cross-profile access blocked")); + } } diff --git a/src/openhuman/tools/impl/system/shell.rs b/src/openhuman/tools/impl/system/shell.rs index 2b1879e3e2..0d0b2a6901 100644 --- a/src/openhuman/tools/impl/system/shell.rs +++ b/src/openhuman/tools/impl/system/shell.rs @@ -356,31 +356,11 @@ impl ShellTool { // session runs under a dedicated-workspace profile. See // `profiles::guard::scan_command_for_cross_profile` for the containment // rationale (the cwd is already rooted at the profile's own dir). - if let Some(guard) = self.security.active_profile.as_ref() { - let cwd = self.effective_action_dir_for_context(context); - if let Some(other_id) = crate::openhuman::profiles::scan_command_for_cross_profile( - command, - &cwd, - &guard.action_dir, - &guard.profile_id, - ) { - tracing::warn!( - active_profile = %guard.profile_id, - other_profile = %other_id, - "[profiles] cross-profile shell command blocked" - ); - return ( - false, - ToolResult::error(format!( - "{} Cross-profile access blocked: profile '{}' may not touch profile \ - '{}'s workspace. Stay within your own profile directory; do not retry \ - this command.", - crate::openhuman::security::POLICY_BLOCKED_MARKER, - guard.profile_id, - other_id - )), - ); - } + let cwd = self.effective_action_dir_for_context(context); + if let Err(reason) = + super::check_cross_profile_command(self.security.as_ref(), command, &cwd, "shell") + { + return (false, ToolResult::error(reason)); } if self.security.is_rate_limited() { From be5c4e29e849a78fb83d9859179f4cb455d199fc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 19:20:39 +0000 Subject: [PATCH 44/80] test(composio): isolate contract cache coverage --- ...osio_credentials_state_raw_coverage_e2e.rs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs b/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs index 5cade5a55b..b4713fd652 100644 --- a/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs +++ b/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs @@ -298,17 +298,20 @@ async fn round15_composio_agent_tools_backend_cache_and_trigger_history_edges() let action_tool = ComposioActionTool::new( arc_config, - "GMAIL_FETCH_EMAILS".to_string(), + // Use a round-local toolkit prefix so the process-global live catalog + // cache cannot inherit a GMAIL contract seeded by another raw-coverage + // module in this shared integration-test binary. + "ROUND15MAIL_FETCH_EMAILS".to_string(), "Fetch inbox".to_string(), Some(json!({ "type": "object", "properties": { "query": { "type": "string" } } })), ); - assert_eq!(action_tool.name(), "GMAIL_FETCH_EMAILS"); + assert_eq!(action_tool.name(), "ROUND15MAIL_FETCH_EMAILS"); assert_eq!(action_tool.category().to_string(), "skill"); let contract_result = action_tool - .execute(json!({ "query": "from:me" })) + .execute(json!({})) .await .expect("per-action contract gate"); assert!(contract_result.is_error); @@ -805,6 +808,20 @@ async fn composio_backend_handler(State(state): State, request: Reque } } }, + { + "type": "function", + "function": { + "name": "ROUND15MAIL_FETCH_EMAILS", + "description": "Fetch Round15 test messages", + "parameters": { + "type": "object", + "required": ["query"], + "properties": { + "query": { "type": "string" } + } + } + } + }, { "type": "function", "function": { @@ -833,7 +850,7 @@ async fn composio_backend_handler(State(state): State, request: Reque })), (Method::POST, "/agent-integrations/composio/execute") => { match body.get("tool").and_then(Value::as_str) { - Some("GMAIL_FETCH_EMAILS") => ok(json!({ + Some("GMAIL_FETCH_EMAILS" | "ROUND15MAIL_FETCH_EMAILS") => ok(json!({ "data": { "messages": [{ "id": "msg-round15" }] }, "successful": true, "error": null, From 91f3b9a7333ab245ffdc5553d230912087883866 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 19:39:06 +0000 Subject: [PATCH 45/80] fix(security): guard every active profile --- .../agent/harness/session/builder/factory.rs | 66 ++++++++++++------- src/openhuman/agent/harness/session/types.rs | 7 +- src/openhuman/security/policy/enforcement.rs | 8 +-- src/openhuman/security/policy/path_checks.rs | 4 +- 4 files changed, 53 insertions(+), 32 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 674dac421b..d61e624730 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -294,27 +294,12 @@ impl Agent { let runtime: Arc = Arc::from( host_runtime::create_runtime(&config.runtime, config.shell.hide_window)?, ); - // 1b — arm the cross-profile write guard for the session iff the active - // profile owns a dedicated workspace (i.e. a descriptor was derived - // above). The guard blocks tool writes/commands that target a *sibling* - // profile's `/profiles/` dir. A profile-less session, or - // a shared-workspace profile, leaves it disarmed (`active_profile = - // None`) so path validation is byte-identical. The broad `action_dir` - // is captured so the guard survives the per-tool-call `action_dir` - // override `security_for_tool_context` applies. - let base_security = SecurityPolicy::from_config( - &config.autonomy, - &config.workspace_dir, - &config.action_dir, - ); - let security = Arc::new( - match profile.filter(|_| profile_workspace_descriptor.is_some()) { - Some(p) => { - base_security.with_active_profile(p.id.clone(), config.action_dir.clone()) - } - None => base_security, - }, - ); + // 1b — arm the cross-profile write guard for every active profile, + // independently of whether that profile uses (or successfully created) + // a dedicated workspace. A shared/default profile still must not reach + // another profile's `/profiles/` subtree from the broad + // action root. Profile-less sessions remain byte-identical. + let security = Arc::new(build_profile_security(config, profile)); // Phase 1 of #1401: see comment in channels/runtime/startup.rs. let audit = crate::openhuman::security::get_or_create_workspace_audit_logger( crate::openhuman::config::AuditConfig::default(), @@ -1645,6 +1630,18 @@ pub(crate) fn derive_profile_workspace_descriptor( ) } +fn build_profile_security( + config: &crate::openhuman::config::Config, + profile: Option<&crate::openhuman::profiles::AgentProfile>, +) -> SecurityPolicy { + let base = + SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir, &config.action_dir); + match profile { + Some(profile) => base.with_active_profile(profile.id.clone(), config.action_dir.clone()), + None => base, + } +} + /// Section D — per-profile dedicated-workspace descriptor seam. /// /// These tests exercise the **production** [`derive_profile_workspace_descriptor`] @@ -1654,7 +1651,7 @@ pub(crate) fn derive_profile_workspace_descriptor( /// profiles produce no descriptor (so the shared `action_dir` cwd is preserved). #[cfg(test)] mod profile_workspace_descriptor_tests { - use super::derive_profile_workspace_descriptor; + use super::{build_profile_security, derive_profile_workspace_descriptor}; use crate::openhuman::profiles::store::built_in_default_profile; fn profile(id: &str, dedicated_workspace: bool) -> crate::openhuman::profiles::AgentProfile { @@ -1718,4 +1715,29 @@ mod profile_workspace_descriptor_tests { "a create_dir_all failure must fall back to None" ); } + + #[test] + fn shared_profile_still_arms_cross_profile_guard() { + let temp = tempfile::tempdir().expect("tempdir"); + let mut config = crate::openhuman::config::Config::default(); + config.action_dir = temp.path().join("actions"); + config.workspace_dir = temp.path().join("state"); + let profile = profile("default", false); + + let security = build_profile_security(&config, Some(&profile)); + + let guard = security + .active_profile + .expect("every active profile must arm the guard"); + assert_eq!(guard.profile_id, "default"); + assert_eq!(guard.action_dir, config.action_dir); + } + + #[test] + fn profile_less_session_leaves_cross_profile_guard_disarmed() { + let config = crate::openhuman::config::Config::default(); + assert!(build_profile_security(&config, None) + .active_profile + .is_none()); + } } diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index 3e248d5076..5558a1960c 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -134,10 +134,9 @@ pub struct Agent { /// Set once at build time from the resolved [`AgentProfile`] and never /// rewritten. Consumed by the profile-scoped agent-experience capture + /// retrieval (1c): records are stamped with this id and only records - /// matching it (plus unstamped legacy records) are recalled. Distinct from - /// the tool-layer guard identity, which rides the - /// [`WorkspaceDescriptor::policy_id`](tinyagents::harness::workspace::WorkspaceDescriptor) - /// and only exists for *dedicated-workspace* profiles. + /// matching it (plus unstamped legacy records) are recalled. The same + /// active id also arms the tool-layer sibling-workspace guard, regardless + /// of whether this profile uses a dedicated cwd. pub(super) active_profile_id: Option, /// Profile-selected memory subtree name (`memory`, `memory-`, or a /// legacy numeric suffix). Used for memory-tree reads and paired with the diff --git a/src/openhuman/security/policy/enforcement.rs b/src/openhuman/security/policy/enforcement.rs index 41b36caa44..cf1f5fe907 100644 --- a/src/openhuman/security/policy/enforcement.rs +++ b/src/openhuman/security/policy/enforcement.rs @@ -169,8 +169,8 @@ impl SecurityPolicy { tracker: ActionTracker::new(), canonical_workspace: Arc::new(OnceCell::new()), // No active profile by default — armed explicitly by the session - // builder via [`with_active_profile`] when a dedicated-workspace - // profile is in play. Keeps every existing `from_config` caller on + // builder via [`with_active_profile`] when any profile is in play. + // Keeps every existing profile-less `from_config` caller on // the byte-identical, guard-off path. active_profile: None, } @@ -182,8 +182,8 @@ impl SecurityPolicy { /// /// Builder-style so the session builder reads as /// `SecurityPolicy::from_config(..).with_active_profile(id, action_dir)`. - /// Only the dedicated-workspace path calls this; a profile-less session - /// never does, so the guard stays dormant and path validation is unchanged. + /// Every profiled session calls this; a profile-less session never does, so + /// the guard stays dormant and path validation is unchanged. #[must_use] pub fn with_active_profile( mut self, diff --git a/src/openhuman/security/policy/path_checks.rs b/src/openhuman/security/policy/path_checks.rs index f64d718b2e..ab73376785 100644 --- a/src/openhuman/security/policy/path_checks.rs +++ b/src/openhuman/security/policy/path_checks.rs @@ -302,8 +302,8 @@ impl SecurityPolicy { Ok(result) } - /// Cross-profile write guard (1b). A no-op unless the session runs under a - /// dedicated-workspace profile (`active_profile` armed via + /// Cross-profile write guard (1b). A no-op unless the session runs under an + /// active profile (`active_profile` armed via /// [`SecurityPolicy::with_active_profile`]). When armed, a resolved target /// that lands inside a *sibling* profile's workspace /// (`/profiles//`, `Q != active`) is refused with the From e0c1a56921aa6f65244c0c958e60b2c9e8edbc78 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 19:39:06 +0000 Subject: [PATCH 46/80] fix(threads): include scoped usage transcripts --- .../agent/harness/session/transcript.rs | 54 ++++++++++--------- .../agent/harness/session/transcript_tests.rs | 25 +++++++++ .../threads/transcript_view/project.rs | 20 +++++-- 3 files changed, 72 insertions(+), 27 deletions(-) diff --git a/src/openhuman/agent/harness/session/transcript.rs b/src/openhuman/agent/harness/session/transcript.rs index dd913437e8..4e33a36199 100644 --- a/src/openhuman/agent/harness/session/transcript.rs +++ b/src/openhuman/agent/harness/session/transcript.rs @@ -1187,6 +1187,13 @@ pub fn read_transcript_display(path: &Path) -> Result /// transcript without accidentally folding delegated worker transcripts /// into the main chat timeline. pub fn find_root_transcript_for_thread(workspace_dir: &Path, thread_id: &str) -> Option { + raw_session_dirs(workspace_dir) + .into_iter() + .filter_map(|raw_dir| find_root_transcript_for_thread_in_dir(&raw_dir, thread_id)) + .max_by(|left, right| left.file_name().cmp(&right.file_name())) +} + +fn raw_session_dirs(workspace_dir: &Path) -> Vec { let mut raw_dirs = vec![raw_session_dir(workspace_dir)]; if let Ok(entries) = fs::read_dir(workspace_dir) { raw_dirs.extend(entries.flatten().map(|entry| entry.path()).filter(|path| { @@ -1200,11 +1207,8 @@ pub fn find_root_transcript_for_thread(workspace_dir: &Path, thread_id: &str) -> }) })); } - + raw_dirs.sort(); raw_dirs - .into_iter() - .filter_map(|raw_dir| find_root_transcript_for_thread_in_dir(&raw_dir, thread_id)) - .max_by(|left, right| left.file_name().cmp(&right.file_name())) } pub fn find_root_transcript_for_thread_in_dir(raw_dir: &Path, thread_id: &str) -> Option { @@ -1356,39 +1360,41 @@ pub fn read_thread_usage_summary( return None; } - let raw_dir = raw_session_dir(workspace_dir); - let entries = fs::read_dir(&raw_dir).ok()?; - // Single scan: split the thread's transcripts into root (orchestrator) and // `__` sub-agent files. Root totals stay the parent's; sub-agent files are // grouped by archetype for the per-agent breakdown. let mut root_matches: Vec = Vec::new(); let mut sub_matches: Vec = Vec::new(); - for path in entries.flatten().map(|entry| entry.path()) { - if path.extension().and_then(|s| s.to_str()) != Some("jsonl") { - continue; - } - let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + for raw_dir in raw_session_dirs(workspace_dir) { + let Ok(entries) = fs::read_dir(&raw_dir) else { continue; }; - let is_subagent = stem.contains("__"); - let matches_thread = read_transcript_meta_only(&path) - .map(|m| m.thread_id.as_deref() == Some(thread_id)) - .unwrap_or(false); - if !matches_thread { - continue; - } - if is_subagent { - sub_matches.push(path); - } else { - root_matches.push(path); + for path in entries.flatten().map(|entry| entry.path()) { + if path.extension().and_then(|s| s.to_str()) != Some("jsonl") { + continue; + } + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let is_subagent = stem.contains("__"); + let matches_thread = read_transcript_meta_only(&path) + .map(|m| m.thread_id.as_deref() == Some(thread_id)) + .unwrap_or(false); + if !matches_thread { + continue; + } + if is_subagent { + sub_matches.push(path); + } else { + root_matches.push(path); + } } } if root_matches.is_empty() && sub_matches.is_empty() { return None; } - root_matches.sort(); + root_matches.sort_by(|left, right| left.file_name().cmp(&right.file_name())); let mut summary = ThreadUsageSummary::default(); for path in &root_matches { diff --git a/src/openhuman/agent/harness/session/transcript_tests.rs b/src/openhuman/agent/harness/session/transcript_tests.rs index 6f2d6fcbd5..260ff125de 100644 --- a/src/openhuman/agent/harness/session/transcript_tests.rs +++ b/src/openhuman/agent/harness/session/transcript_tests.rs @@ -913,6 +913,31 @@ fn read_thread_usage_summary_sums_multiple_transcripts() { assert_eq!(s.turn_count, 2); } +#[test] +fn read_thread_usage_summary_scans_profile_scoped_raw_dirs() { + let ws = TempDir::new().unwrap(); + let raw = ws.path().join("session_raw-alice"); + std::fs::create_dir_all(&raw).unwrap(); + let mut meta = sample_meta(); + meta.thread_id = Some("thr-scoped-usage".into()); + meta.input_tokens = 321; + meta.output_tokens = 45; + meta.turn_count = 2; + write_transcript( + &raw.join("1700000000_main.jsonl"), + &sample_messages(), + &meta, + None, + ) + .unwrap(); + + let summary = read_thread_usage_summary(ws.path(), "thr-scoped-usage") + .expect("scoped usage summary present"); + assert_eq!(summary.input_tokens, 321); + assert_eq!(summary.output_tokens, 45); + assert_eq!(summary.turn_count, 2); +} + #[test] fn read_thread_usage_summary_none_for_unknown_thread() { let ws = TempDir::new().unwrap(); diff --git a/src/openhuman/threads/transcript_view/project.rs b/src/openhuman/threads/transcript_view/project.rs index b372aff25f..ea9aa125d1 100644 --- a/src/openhuman/threads/transcript_view/project.rs +++ b/src/openhuman/threads/transcript_view/project.rs @@ -46,7 +46,14 @@ pub fn project_thread(workspace_dir: &Path, thread_id: &str) -> Option Option<(PathBuf, Vec)> { let root_path = transcript::find_root_transcript_for_thread(workspace_dir, thread_id)?; let root_stem = root_path.file_stem()?.to_str()?.to_string(); - let sub_paths = discover_subagent_files(root_path.parent()?, &root_stem); + let Some(raw_dir) = root_path.parent() else { + log::warn!( + "{LOG_PREFIX} resolved root has no parent thread={thread_id} root={}", + root_path.display() + ); + return None; + }; + let sub_paths = discover_subagent_files(raw_dir, &root_stem); Some((root_path, sub_paths)) } @@ -101,8 +108,15 @@ pub fn project_from_files( /// timestamp-prefixed suffixes order by creation time. fn discover_subagent_files(raw_dir: &Path, root_stem: &str) -> Vec { let prefix = format!("{root_stem}__"); - let Ok(entries) = fs::read_dir(raw_dir) else { - return Vec::new(); + let entries = match fs::read_dir(raw_dir) { + Ok(entries) => entries, + Err(error) => { + log::debug!( + "{LOG_PREFIX} subagent discovery read_dir failed dir={} error={error}", + raw_dir.display() + ); + return Vec::new(); + } }; let mut paths: Vec = entries .flatten() From 05e9f8f22345379904a3a7082691d7cadea3bd98 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 19:58:20 +0000 Subject: [PATCH 47/80] fix(experience): scope generated ids by profile --- src/openhuman/agent_experience/store.rs | 27 +++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/openhuman/agent_experience/store.rs b/src/openhuman/agent_experience/store.rs index 380ecf8867..ece811410d 100644 --- a/src/openhuman/agent_experience/store.rs +++ b/src/openhuman/agent_experience/store.rs @@ -1,5 +1,5 @@ use crate::openhuman::agent_experience::types::{ - redact_text, stable_experience_id, AgentExperience, ExperienceHit, + redact_text, stable_experience_id_for_profile, AgentExperience, ExperienceHit, }; use crate::openhuman::memory::{Memory, MemoryCategory}; use serde::{Deserialize, Serialize}; @@ -58,10 +58,11 @@ impl AgentExperienceStore { pub async fn put(&self, mut experience: AgentExperience) -> Result { if experience.id.trim().is_empty() { - experience.id = stable_experience_id( + experience.id = stable_experience_id_for_profile( &experience.task_summary, &experience.tool_sequence, experience.outcome, + experience.profile_id.as_deref(), ); } if experience.task_summary.trim().is_empty() { @@ -392,6 +393,28 @@ mod tests { assert!(listed[0].dismissed); } + #[tokio::test] + async fn generated_ids_partition_identical_experiences_by_profile() { + let (store, _) = fresh_store(); + let mut alice = sample_experience("", "same task", vec!["grep"], vec!["docs"], 0.8); + alice.profile_id = Some("alice".into()); + let mut bob = alice.clone(); + bob.profile_id = Some("bob".into()); + + let alice = store.put(alice).await.unwrap(); + let bob = store.put(bob).await.unwrap(); + + assert_ne!(alice.id, bob.id); + let listed = store.list().await.unwrap(); + assert_eq!(listed.len(), 2); + assert!(listed + .iter() + .any(|item| item.profile_id.as_deref() == Some("alice"))); + assert!(listed + .iter() + .any(|item| item.profile_id.as_deref() == Some("bob"))); + } + #[tokio::test] async fn retrieve_ranks_tool_and_query_matches() { let (store, _) = fresh_store(); From f6f17907a0455746065ead822a3988b6dcde3b4d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 19:58:20 +0000 Subject: [PATCH 48/80] fix(skills): allow private workflow names and slugs --- src/openhuman/skills/ops_discover.rs | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/openhuman/skills/ops_discover.rs b/src/openhuman/skills/ops_discover.rs index 8a0aa94051..8bc4b6940b 100644 --- a/src/openhuman/skills/ops_discover.rs +++ b/src/openhuman/skills/ops_discover.rs @@ -525,12 +525,12 @@ pub fn profile_local_skill_ids( }; scan_root(root, WorkflowScope::Profile) .into_iter() - .map(|w| { - if w.dir_name.is_empty() { - w.name - } else { - w.dir_name + .flat_map(|w| { + let mut ids = vec![w.name]; + if !w.dir_name.is_empty() { + ids.push(w.dir_name); } + ids }) .collect() } @@ -974,9 +974,9 @@ mod profile_scope_tests { assert_eq!(read("rescollide7788", None).unwrap(), "GLOBAL_RES"); } - /// `profile_local_skill_ids` returns exactly the dir_names under the profile - /// root (the implicit-allow set the describe/read/run tools consult), and is - /// empty for the profile-less session. + /// `profile_local_skill_ids` returns both runnable names and directory slugs + /// under the profile root (the implicit-allow set the describe/read/run tools + /// consult), and is empty for the profile-less session. #[test] fn profile_local_skill_ids_lists_only_the_profile_root() { let profile_root = tempfile::TempDir::new().unwrap(); @@ -994,6 +994,17 @@ mod profile_scope_tests { ); } + #[test] + fn profile_local_skill_ids_include_distinct_name_and_slug() { + let profile_root = tempfile::TempDir::new().unwrap(); + seed_bundle_with_name(profile_root.path(), "mail-helper", "Inbox Assistant"); + + let ids = profile_local_skill_ids(Some(profile_root.path())); + assert_eq!(ids.len(), 2); + assert!(ids.contains("mail-helper")); + assert!(ids.contains("Inbox Assistant")); + } + /// A `None` profile root reproduces `load_workflow_metadata` byte-for-byte — /// the back-compat guarantee for the profile-less session. #[test] From 871ced582f735c9197f29c3c03630a4ff9c15b9c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 20:17:29 +0000 Subject: [PATCH 49/80] fix(cron): fail closed for attributed profile builds --- src/openhuman/cron/scheduler.rs | 68 +++++++++++++-------------- src/openhuman/cron/scheduler_tests.rs | 36 ++++++++++++-- 2 files changed, 65 insertions(+), 39 deletions(-) diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index 7f964f8687..0ece0fc07a 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -1017,13 +1017,17 @@ const EMPTY_AGENT_OUTPUT: &str = "agent job executed"; /// Resolve the agent profile a cron job is attributed to, if any. /// /// Returns `Some(profile)` only when `job.profile_id` is set AND that profile -/// still exists in the store. A deleted profile (or a load error) yields `None` -/// so the caller runs the job without a profile rather than failing it (2b). +/// still exists in the store. A deleted profile yields `Ok(None)` so the caller +/// runs the job without a profile rather than failing it (2b). Profile-store +/// failures are returned: attribution must not fail open when the scheduler +/// cannot determine whether the referenced profile still exists. fn resolve_cron_profile( config: &Config, job: &CronJob, -) -> Option { - let profile_id = job.profile_id.as_deref()?; +) -> anyhow::Result> { + let Some(profile_id) = job.profile_id.as_deref() else { + return Ok(None); + }; match crate::openhuman::profiles::load_profiles(&config.workspace_dir) { Ok(state) => { let found = state.profiles.into_iter().find(|p| p.id == profile_id); @@ -1034,17 +1038,12 @@ fn resolve_cron_profile( "[cron] attributed profile no longer exists — running job without a profile" ); } - found - } - Err(e) => { - tracing::warn!( - job_id = %job.id, - profile_id = %profile_id, - error = %e, - "[cron] failed to load profiles for cron attribution — running without a profile" - ); - None + Ok(found) } + Err(e) => Err(anyhow::anyhow!( + "failed to load attributed profile {profile_id:?} for cron job {}: {e}", + job.id + )), } } @@ -1055,39 +1054,36 @@ fn build_agent_for_cron_job(config: &Config, job: &CronJob) -> anyhow::Result { - tracing::debug!( - job_id = %job.id, - profile_id = %profile.id, - agent_id = %agent_id, - "[cron] built scheduled job agent under attributed profile" - ); - return Ok(agent); - } - Err(e) => { - tracing::warn!( - job_id = %job.id, - profile_id = %profile.id, - agent_id = %agent_id, - error = %e, - "[cron] profile-aware agent build failed; falling back to profile-less build" - ); - } - } + ) + .inspect(|_| { + tracing::debug!( + job_id = %job.id, + profile_id = %profile.id, + agent_id = %agent_id, + "[cron] built scheduled job agent under attributed profile" + ); + }) + .map_err(|e| { + anyhow::anyhow!( + "failed to build cron job {} under attributed profile {:?} with agent {:?}: {e:#}", + job.id, + profile.id, + agent_id + ) + }); } if let Some(agent_id) = job.agent_id.as_deref() { diff --git a/src/openhuman/cron/scheduler_tests.rs b/src/openhuman/cron/scheduler_tests.rs index 8ca0fb0618..1c9fc40e18 100644 --- a/src/openhuman/cron/scheduler_tests.rs +++ b/src/openhuman/cron/scheduler_tests.rs @@ -64,7 +64,7 @@ async fn resolve_cron_profile_present_and_deleted_fallback() { // Profile does not exist yet → None (the deleted-profile fallback path; // the scheduler runs the job without a profile rather than failing it). assert!( - resolve_cron_profile(&config, &job).is_none(), + resolve_cron_profile(&config, &job).unwrap().is_none(), "missing profile must resolve to None" ); @@ -77,12 +77,42 @@ async fn resolve_cron_profile_present_and_deleted_fallback() { crate::openhuman::profiles::store::AgentProfileStore::new(config.workspace_dir.clone()) .upsert(profile) .expect("seed profile"); - let resolved = resolve_cron_profile(&config, &job).expect("profile resolves"); + let resolved = resolve_cron_profile(&config, &job) + .expect("profile store loads") + .expect("profile resolves"); assert_eq!(resolved.id, "alice"); // A job with no attribution is always None. let plain = test_job(""); - assert!(resolve_cron_profile(&config, &plain).is_none()); + assert!(resolve_cron_profile(&config, &plain).unwrap().is_none()); +} + +#[tokio::test] +async fn existing_profile_agent_build_failure_does_not_fall_back_profile_less() { + crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::init_global_builtins() + .expect("init built-in agent definitions"); + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp).await; + + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.id = "alice".into(); + profile.agent_id = "removed-agent-definition".into(); + profile.built_in = false; + profile.is_master = false; + crate::openhuman::profiles::store::AgentProfileStore::new(config.workspace_dir.clone()) + .upsert(profile) + .expect("seed profile"); + + let mut job = test_job(""); + job.job_type = JobType::Agent; + job.profile_id = Some("alice".into()); + + let error = match build_agent_for_cron_job(&config, &job) { + Ok(_) => panic!("existing profile build failure must not fall back profile-less"), + Err(error) => error, + }; + assert!(error.to_string().contains("under attributed profile")); + assert!(error.to_string().contains("removed-agent-definition")); } #[test] From 1aecb08d8c9219dffceb420d2ab74e9d8849dd3e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 20:49:39 +0000 Subject: [PATCH 50/80] fix(agent): load profile memory in live sessions --- .../harness/session/builder/builder_tests.rs | 36 +++++++++++++++++++ .../agent/harness/session/builder/factory.rs | 6 ++++ .../agent/harness/session/builder/setters.rs | 9 +++++ .../agent/harness/session/turn/context.rs | 9 +++-- src/openhuman/agent/harness/session/types.rs | 5 +++ 5 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/builder_tests.rs b/src/openhuman/agent/harness/session/builder/builder_tests.rs index 5d3ba30188..1524f6008f 100644 --- a/src/openhuman/agent/harness/session/builder/builder_tests.rs +++ b/src/openhuman/agent/harness/session/builder/builder_tests.rs @@ -425,6 +425,42 @@ async fn build_session_agent_injects_profile_soul_into_prompt() { ); } +#[tokio::test] +async fn build_session_agent_uses_profile_memory_instead_of_root_memory() { + use crate::openhuman::agent::harness::session::types::Agent; + use crate::openhuman::context::prompt::LearnedContextData; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + std::fs::write( + config.workspace_dir.join("MEMORY.md"), + "shared root memory marker", + ) + .unwrap(); + let home = config.workspace_dir.join("personalities").join("alice"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("MEMORY.md"), "alice private memory marker").unwrap(); + + let profile = custom_profile("alice", false); + let orchestrator = builtin_def("orchestrator"); + let agent = Agent::build_session_agent_inner( + &config, + "orchestrator", + Some(&orchestrator), + None, + None, + false, + Some(&profile), + ) + .expect("build profile session"); + + let prompt = agent + .build_system_prompt(LearnedContextData::default()) + .expect("build_system_prompt"); + assert!(prompt.contains("alice private memory marker")); + assert!(!prompt.contains("shared root memory marker")); +} + // ───────────────────────────────────────────────────────────────────────────── // B38 (Gap 2) — a custom (non-shipped) `AgentRegistryEntry` must synthesize a // real `AgentDefinition` and run with its own `ToolScope::Named` filter, diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index d61e624730..0d50b6b0a9 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -1262,6 +1262,12 @@ impl Agent { // see which profile the turn ran under. `None` for the profile-less // session keeps every consumer byte-identical. .active_profile_id(profile.map(|p| p.id.clone())) + .personality_memory_md(profile.and_then(|profile| { + crate::openhuman::profiles::resolve_personality_memory_md( + &config.workspace_dir, + profile, + ) + })) .profile_memory_storage(memory_subdir, session_raw_subdir) .workflows( crate::openhuman::skills::load_workflow_metadata_for_profile( diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs index 610bfe641f..14f5c487c0 100644 --- a/src/openhuman/agent/harness/session/builder/setters.rs +++ b/src/openhuman/agent/harness/session/builder/setters.rs @@ -43,6 +43,7 @@ impl AgentBuilder { event_channel: None, agent_definition_name: None, active_profile_id: None, + personality_memory_md: None, memory_subdir: None, session_raw_subdir: None, session_parent_prefix: None, @@ -215,6 +216,13 @@ impl AgentBuilder { self } + /// Binds the active profile's curated MEMORY.md to the frozen session + /// prompt. `None` keeps the legacy workspace-root fallback. + pub fn personality_memory_md(mut self, memory_md: Option) -> Self { + self.personality_memory_md = memory_md; + self + } + pub fn profile_memory_storage( mut self, memory_subdir: String, @@ -553,6 +561,7 @@ impl AgentBuilder { // `subagents` declaration against the global registry. agent_definition_id: agent_definition_name.clone(), active_profile_id: self.active_profile_id, + personality_memory_md: self.personality_memory_md, memory_subdir, session_raw_subdir, session_transcript_path: None, diff --git a/src/openhuman/agent/harness/session/turn/context.rs b/src/openhuman/agent/harness/session/turn/context.rs index 185b85c162..37e4de1dfa 100644 --- a/src/openhuman/agent/harness/session/turn/context.rs +++ b/src/openhuman/agent/harness/session/turn/context.rs @@ -322,12 +322,11 @@ impl Agent { include_memory_md: !self.omit_memory_md, curated_snapshot: None, user_identity: crate::openhuman::app_state::peek_cached_current_user_identity(), - // TODO(phase-2): Wire personality context into the live agent turn. - // Currently personalities only take effect during delegate_to_personality sub-agent runs. - // To activate: load the active profile via AgentProfileStore::resolve(), build - // PersonalityContext::from_profile(), and populate these fields. + // Profile SOUL.md is rendered by AgentProfilePromptSection; curated + // MEMORY.md is bound at session construction so UserFilesSection + // uses it instead of the workspace-root fallback. personality_soul_md: None, // TODO: personality_ctx.soul_md_override - personality_memory_md: None, // TODO: personality_ctx.memory_md_override + personality_memory_md: self.personality_memory_md.clone(), personality_roster: vec![], // TODO: build_personality_roster(&workspace_dir) agents_md_global: agents_md.global, agents_md_local: agents_md.local, diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index 5558a1960c..cf71e79fc7 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -138,6 +138,9 @@ pub struct Agent { /// active id also arms the tool-layer sibling-workspace guard, regardless /// of whether this profile uses a dedicated cwd. pub(super) active_profile_id: Option, + /// Profile-local curated MEMORY.md resolved when the session is built. + /// `None` preserves the workspace-root MEMORY.md fallback. + pub(super) personality_memory_md: Option, /// Profile-selected memory subtree name (`memory`, `memory-`, or a /// legacy numeric suffix). Used for memory-tree reads and paired with the /// profile-specific transcript directory below. @@ -393,6 +396,8 @@ pub struct AgentBuilder { /// (default) means the profile-less session; the profile-launching callers /// (web chat, task dispatcher, cron) set the active profile id here. pub(super) active_profile_id: Option, + /// Forwarded to [`Agent::personality_memory_md`] at build time. + pub(super) personality_memory_md: Option, pub(super) memory_subdir: Option, pub(super) session_raw_subdir: Option, /// Directory chain of parent session keys for a sub-agent. `None` From d49a66ce88edfe63822c16b0f8f2d550a1f0ec5e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 20:49:39 +0000 Subject: [PATCH 51/80] fix(profiles): reject invalid normalized ids --- src/openhuman/profiles/store.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/openhuman/profiles/store.rs b/src/openhuman/profiles/store.rs index 0ca7833f31..3ed640a48d 100644 --- a/src/openhuman/profiles/store.rs +++ b/src/openhuman/profiles/store.rs @@ -148,6 +148,7 @@ impl AgentProfileStore { pub fn upsert(&self, profile: AgentProfile) -> Result { let mut state = self.load()?; let profile = normalise_profile(profile); + super::home::validate_profile_id(&profile.id)?; tracing::debug!( profile_id = %profile.id, agent_id = %profile.agent_id, @@ -686,6 +687,24 @@ mod tests { assert_eq!(resolved.id, "custom-profile"); } + #[test] + fn upsert_rejects_profile_ids_longer_than_home_path_limit() { + let dir = tempdir().expect("tempdir"); + let store = AgentProfileStore::new(dir.path().to_path_buf()); + let profile = custom(&"a".repeat(65), "Overlong", "orchestrator"); + + let error = store + .upsert(profile) + .expect_err("overlong id must be rejected"); + assert!(error.contains("too long")); + assert!(store + .load() + .expect("load") + .profiles + .iter() + .all(|profile| profile.name != "Overlong")); + } + #[test] fn built_in_profiles_are_merged_when_file_is_missing() { let dir = tempdir().expect("tempdir"); From 90211eae4839d5656222517cfcdd9d1d6ae80a05 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 21:09:21 +0000 Subject: [PATCH 52/80] fix(skills): resolve workflow display names --- src/openhuman/skills/registry.rs | 57 ++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/src/openhuman/skills/registry.rs b/src/openhuman/skills/registry.rs index 36fc6cf789..6e096d94de 100644 --- a/src/openhuman/skills/registry.rs +++ b/src/openhuman/skills/registry.rs @@ -286,9 +286,37 @@ pub fn get_workflow_with_profile( id: &str, profile_skills_root: Option<&Path>, ) -> Option { - load_workflows_with_profile(workspace_dir, profile_skills_root) + let workflows = load_workflows_with_profile(workspace_dir, profile_skills_root); + if let Some(exact) = workflows.iter().find(|s| s.definition.id == id) { + return Some(exact.clone()); + } + + // Lists advertise the frontmatter display name as well as the directory + // slug. Resolve that name back to the canonical runnable slug so a private + // workflow admitted by the profile-local allow set can actually be + // described and run. Discovery has already applied scope precedence and + // collision deduplication, matching the list surface. + let home = dirs::home_dir(); + let trusted = super::ops_discover::is_workspace_trusted(workspace_dir); + let slug = super::ops_discover::discover_workflows_with_profile( + home.as_deref(), + Some(workspace_dir), + profile_skills_root, + trusted, + ) + .into_iter() + .find(|workflow| workflow.name == id) + .map(|workflow| { + if workflow.dir_name.is_empty() { + workflow.name + } else { + workflow.dir_name + } + })?; + + workflows .into_iter() - .find(|s| s.definition.id == id) + .find(|workflow| workflow.definition.id == slug) } #[cfg(test)] @@ -358,11 +386,15 @@ mod tests { /// Seed a runnable WORKFLOW.md bundle under `root/slug/` with a distinct /// body marker so the resolved definition can be traced back to its source. fn seed_runnable(root: &std::path::Path, slug: &str, body_marker: &str) { + seed_runnable_with_name(root, slug, slug, body_marker); + } + + fn seed_runnable_with_name(root: &std::path::Path, slug: &str, name: &str, body_marker: &str) { let dir = root.join(slug); std::fs::create_dir_all(&dir).unwrap(); std::fs::write( dir.join("WORKFLOW.md"), - format!("---\nname: {slug}\ndescription: {slug} desc\n---\n\n{body_marker}\n"), + format!("---\nname: {name}\ndescription: {name} desc\n---\n\n{body_marker}\n"), ) .unwrap(); } @@ -432,6 +464,25 @@ mod tests { ); } + #[test] + fn get_profile_workflow_resolves_distinct_display_name() { + let ws = tempfile::TempDir::new().unwrap(); + let profile_root = tempfile::TempDir::new().unwrap(); + seed_runnable_with_name( + profile_root.path(), + "mail-helper", + "Inbox Assistant", + "PROFILE_NAME_BODY", + ); + + let resolved = + get_workflow_with_profile(ws.path(), "Inbox Assistant", Some(profile_root.path())) + .expect("display name must resolve for the owning profile"); + assert_eq!(resolved.definition.id, "mail-helper"); + assert!(resolved_body(&resolved).contains("PROFILE_NAME_BODY")); + assert!(get_workflow_with_profile(ws.path(), "Inbox Assistant", None).is_none()); + } + #[test] fn load_skills_reads_runtime_skill_prompt_and_inputs() { let tmp = tempfile::TempDir::new().unwrap(); From 574491257dc95d94a13b13e295e8583426c2aefb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 21:09:21 +0000 Subject: [PATCH 53/80] fix(experience): route RPCs to profile memory --- src/openhuman/agent_experience/ops.rs | 102 +++++++++++++++++++--- src/openhuman/agent_experience/schemas.rs | 20 +++-- 2 files changed, 105 insertions(+), 17 deletions(-) diff --git a/src/openhuman/agent_experience/ops.rs b/src/openhuman/agent_experience/ops.rs index 9a445256d5..7f59bec3c0 100644 --- a/src/openhuman/agent_experience/ops.rs +++ b/src/openhuman/agent_experience/ops.rs @@ -4,6 +4,7 @@ use crate::openhuman::agent_experience::store::{AgentExperienceStore, Experience use crate::openhuman::agent_experience::types::{AgentExperience, ExperienceHit}; use crate::openhuman::config::Config; use crate::rpc::RpcOutcome; +use std::sync::Arc; #[derive(Debug, Deserialize)] pub struct CaptureParams { @@ -39,6 +40,8 @@ pub struct ListParams { #[derive(Debug, Deserialize)] pub struct DismissParams { pub id: String, + #[serde(default)] + pub profile_id: Option, } #[derive(Debug, Serialize)] @@ -47,27 +50,75 @@ pub struct DismissResult { pub dismissed: bool, } -async fn open_store() -> Result { +fn profile_memory_subdir( + workspace_dir: &std::path::Path, + profile_id: Option<&str>, +) -> Result { + let Some(profile_id) = profile_id.map(str::trim).filter(|id| !id.is_empty()) else { + return Ok("memory".to_string()); + }; + let state = crate::openhuman::profiles::load_profiles(workspace_dir)?; + let profile = state + .profiles + .iter() + .find(|profile| profile.id == profile_id) + .ok_or_else(|| format!("agent profile '{profile_id}' not found"))?; + let suffix = crate::openhuman::profiles::effective_memory_suffix(profile); + Ok(crate::openhuman::profiles::memory_subdir_for_suffix( + &suffix, + )) +} + +async fn open_store(profile_id: Option<&str>) -> Result { + let profile_id = profile_id.map(str::trim).filter(|id| !id.is_empty()); + if profile_id.is_none() { + let client = match crate::openhuman::memory::global::client_if_ready() { + Some(client) => client, + None => { + let config = Config::load_or_init() + .await + .map_err(|e| format!("load config: {e}"))?; + crate::openhuman::memory::global::init(config.workspace_dir)? + } + }; + return Ok(AgentExperienceStore::new(client.memory_handle())); + } + + let config = Config::load_or_init() + .await + .map_err(|e| format!("load config: {e}"))?; + let memory_subdir = profile_memory_subdir(&config.workspace_dir, profile_id)?; + + // Keep the shared-memory path on the process-global client. Profile memory + // uses the same concrete store layout as the session builder, but opens the + // profile-derived subtree so RPC capture/retrieve/list/dismiss see the + // records learned by that profile's live agent sessions. + if memory_subdir != "memory" { + let memory = crate::openhuman::memory_store::UnifiedMemory::new_with_memory_dir( + &config.workspace_dir, + &memory_subdir, + crate::openhuman::embeddings::default_embedding_provider(), + config.memory.sqlite_open_timeout_secs, + ) + .map_err(|e| format!("open agent experience store '{memory_subdir}': {e:#}"))?; + return Ok(AgentExperienceStore::new(Arc::new(memory))); + } + let client = match crate::openhuman::memory::global::client_if_ready() { Some(client) => client, - None => { - let config = Config::load_or_init() - .await - .map_err(|e| format!("load config: {e}"))?; - crate::openhuman::memory::global::init(config.workspace_dir)? - } + None => crate::openhuman::memory::global::init(config.workspace_dir)?, }; Ok(AgentExperienceStore::new(client.memory_handle())) } pub async fn capture(params: CaptureParams) -> Result, String> { - let store = open_store().await?; + let store = open_store(params.experience.profile_id.as_deref()).await?; let stored = store.put(params.experience).await?; Ok(RpcOutcome::single_log(stored, "agent experience captured")) } pub async fn retrieve(params: RetrieveParams) -> Result>, String> { - let store = open_store().await?; + let store = open_store(params.profile_id.as_deref()).await?; let hits = store .retrieve(ExperienceQuery { query: params.query, @@ -83,7 +134,7 @@ pub async fn retrieve(params: RetrieveParams) -> Result Result>, String> { - let store = open_store().await?; + let store = open_store(params.profile_id.as_deref()).await?; let experiences = store.list_for_profile(params.profile_id.as_deref()).await?; Ok(RpcOutcome::single_log( experiences, @@ -92,7 +143,7 @@ pub async fn list(params: ListParams) -> Result> } pub async fn dismiss(params: DismissParams) -> Result, String> { - let store = open_store().await?; + let store = open_store(params.profile_id.as_deref()).await?; let dismissed = store.dismiss(¶ms.id).await?; Ok(RpcOutcome::single_log( DismissResult { @@ -102,3 +153,32 @@ pub async fn dismiss(params: DismissParams) -> Result, "agent experience dismissed", )) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_memory_subdir_matches_live_session_derivation() { + let workspace = tempfile::TempDir::new().unwrap(); + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.id = "alice".into(); + profile.name = "Alice".into(); + profile.built_in = false; + profile.is_master = false; + profile.dedicated_memory = true; + crate::openhuman::profiles::store::AgentProfileStore::new(workspace.path().to_path_buf()) + .upsert(profile) + .expect("seed profile"); + + assert_eq!( + profile_memory_subdir(workspace.path(), Some("alice")).unwrap(), + "memory-alice" + ); + assert_eq!( + profile_memory_subdir(workspace.path(), None).unwrap(), + "memory" + ); + assert!(profile_memory_subdir(workspace.path(), Some("missing")).is_err()); + } +} diff --git a/src/openhuman/agent_experience/schemas.rs b/src/openhuman/agent_experience/schemas.rs index 8d0812b3ba..fa95107147 100644 --- a/src/openhuman/agent_experience/schemas.rs +++ b/src/openhuman/agent_experience/schemas.rs @@ -138,12 +138,20 @@ pub fn schemas(function: &str) -> ControllerSchema { namespace: "agent_experience", function: "dismiss", description: "Mark an operating experience as dismissed so retrieval ignores it.", - inputs: vec![FieldSchema { - name: "id", - ty: TypeSchema::String, - comment: "Experience id to dismiss.", - required: true, - }], + inputs: vec![ + FieldSchema { + name: "id", + ty: TypeSchema::String, + comment: "Experience id to dismiss.", + required: true, + }, + FieldSchema { + name: "profile_id", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Optional owning profile whose memory store contains the experience.", + required: false, + }, + ], outputs: vec![FieldSchema { name: "result", ty: TypeSchema::Object { From 9e3548a8687c4891bcdfb2b18dafc287a9e0dc39 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 21:32:00 +0000 Subject: [PATCH 54/80] fix(agent): make profile soul override root identity --- .../harness/session/builder/builder_tests.rs | 9 ++++ .../agent/harness/session/builder/factory.rs | 18 ++----- .../agent/harness/session/builder/setters.rs | 8 +++ .../agent/harness/session/turn/context.rs | 8 +-- src/openhuman/agent/harness/session/types.rs | 5 ++ src/openhuman/profiles/prompt_section.rs | 50 +------------------ 6 files changed, 32 insertions(+), 66 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/builder_tests.rs b/src/openhuman/agent/harness/session/builder/builder_tests.rs index 1524f6008f..61662d978d 100644 --- a/src/openhuman/agent/harness/session/builder/builder_tests.rs +++ b/src/openhuman/agent/harness/session/builder/builder_tests.rs @@ -399,6 +399,11 @@ async fn build_session_agent_injects_profile_soul_into_prompt() { let tmp = tempfile::TempDir::new().unwrap(); let config = test_config(&tmp); + std::fs::write( + config.workspace_dir.join("SOUL.md"), + "I am the conflicting workspace-root identity.", + ) + .unwrap(); // Seed the non-default profile's home SOUL.md (as ensure_profile_home would). let home = config.workspace_dir.join("personalities").join("alice"); std::fs::create_dir_all(&home).unwrap(); @@ -423,6 +428,10 @@ async fn build_session_agent_injects_profile_soul_into_prompt() { prompt.contains("I am Alice, a meticulous archivist."), "the live profile session prompt must include the profile SOUL.md content" ); + assert!( + !prompt.contains("I am the conflicting workspace-root identity."), + "profile SOUL.md must replace, not accompany, workspace-root SOUL.md" + ); } #[tokio::test] diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 0d50b6b0a9..a96be81dff 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -695,19 +695,11 @@ impl Agent { ) }) }); - // Every explicitly selected profile drives its live persona from its - // own SOUL.md (hot-read in the section), so ordinary web-chat / cron - // turns get the seeded/synced identity — not just delegation. This - // includes the Default profile because Settings persists its edits at - // `personalities/default/SOUL.md`. The profile-less path remains - // byte-identical and continues using only the workspace-root identity. - let soul_profile: Option = profile.cloned(); - if profile_suffix.is_some() || workspace_notice.is_some() || soul_profile.is_some() { + if profile_suffix.is_some() || workspace_notice.is_some() { log::debug!( - "[agent:builder] profile prompt section injected suffix_chars={} workspace_notice={} profile_soul={}", + "[agent:builder] profile prompt section injected suffix_chars={} workspace_notice={}", profile_suffix.as_deref().map(|s| s.chars().count()).unwrap_or(0), workspace_notice.is_some(), - soul_profile.is_some(), ); let mut section = crate::openhuman::profiles::AgentProfilePromptSection::new( profile_suffix.unwrap_or_default(), @@ -715,9 +707,6 @@ impl Agent { if let Some(notice) = workspace_notice { section = section.with_workspace_notice(notice); } - if let Some(soul_profile) = soul_profile { - section = section.with_profile_soul(soul_profile); - } prompt_builder = prompt_builder.add_section(Box::new(section)); } @@ -1262,6 +1251,9 @@ impl Agent { // see which profile the turn ran under. `None` for the profile-less // session keeps every consumer byte-identical. .active_profile_id(profile.map(|p| p.id.clone())) + .personality_soul_md(profile.and_then(|profile| { + crate::openhuman::profiles::resolve_personality_soul(&config.workspace_dir, profile) + })) .personality_memory_md(profile.and_then(|profile| { crate::openhuman::profiles::resolve_personality_memory_md( &config.workspace_dir, diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs index 14f5c487c0..21a4041990 100644 --- a/src/openhuman/agent/harness/session/builder/setters.rs +++ b/src/openhuman/agent/harness/session/builder/setters.rs @@ -43,6 +43,7 @@ impl AgentBuilder { event_channel: None, agent_definition_name: None, active_profile_id: None, + personality_soul_md: None, personality_memory_md: None, memory_subdir: None, session_raw_subdir: None, @@ -216,6 +217,12 @@ impl AgentBuilder { self } + /// Binds the active profile's SOUL.md as the session identity override. + pub fn personality_soul_md(mut self, soul_md: Option) -> Self { + self.personality_soul_md = soul_md; + self + } + /// Binds the active profile's curated MEMORY.md to the frozen session /// prompt. `None` keeps the legacy workspace-root fallback. pub fn personality_memory_md(mut self, memory_md: Option) -> Self { @@ -561,6 +568,7 @@ impl AgentBuilder { // `subagents` declaration against the global registry. agent_definition_id: agent_definition_name.clone(), active_profile_id: self.active_profile_id, + personality_soul_md: self.personality_soul_md, personality_memory_md: self.personality_memory_md, memory_subdir, session_raw_subdir, diff --git a/src/openhuman/agent/harness/session/turn/context.rs b/src/openhuman/agent/harness/session/turn/context.rs index 37e4de1dfa..d820ebe610 100644 --- a/src/openhuman/agent/harness/session/turn/context.rs +++ b/src/openhuman/agent/harness/session/turn/context.rs @@ -322,10 +322,10 @@ impl Agent { include_memory_md: !self.omit_memory_md, curated_snapshot: None, user_identity: crate::openhuman::app_state::peek_cached_current_user_identity(), - // Profile SOUL.md is rendered by AgentProfilePromptSection; curated - // MEMORY.md is bound at session construction so UserFilesSection - // uses it instead of the workspace-root fallback. - personality_soul_md: None, // TODO: personality_ctx.soul_md_override + // Profile SOUL.md and curated MEMORY.md are bound at session + // construction so the normal identity/user-files sections use + // them instead of their workspace-root fallbacks. + personality_soul_md: self.personality_soul_md.clone(), personality_memory_md: self.personality_memory_md.clone(), personality_roster: vec![], // TODO: build_personality_roster(&workspace_dir) agents_md_global: agents_md.global, diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index cf71e79fc7..ba3df7d586 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -138,6 +138,9 @@ pub struct Agent { /// active id also arms the tool-layer sibling-workspace guard, regardless /// of whether this profile uses a dedicated cwd. pub(super) active_profile_id: Option, + /// Profile-local SOUL.md resolved when the session is built. When set, + /// IdentitySection uses it instead of the workspace-root identity. + pub(super) personality_soul_md: Option, /// Profile-local curated MEMORY.md resolved when the session is built. /// `None` preserves the workspace-root MEMORY.md fallback. pub(super) personality_memory_md: Option, @@ -396,6 +399,8 @@ pub struct AgentBuilder { /// (default) means the profile-less session; the profile-launching callers /// (web chat, task dispatcher, cron) set the active profile id here. pub(super) active_profile_id: Option, + /// Forwarded to [`Agent::personality_soul_md`] at build time. + pub(super) personality_soul_md: Option, /// Forwarded to [`Agent::personality_memory_md`] at build time. pub(super) personality_memory_md: Option, pub(super) memory_subdir: Option, diff --git a/src/openhuman/profiles/prompt_section.rs b/src/openhuman/profiles/prompt_section.rs index f1b0a3d316..6c7fc93b50 100644 --- a/src/openhuman/profiles/prompt_section.rs +++ b/src/openhuman/profiles/prompt_section.rs @@ -4,15 +4,9 @@ use std::path::Path; -use super::types::AgentProfile; use crate::openhuman::context::prompt::{PromptContext, PromptSection}; use anyhow::Result; -/// Upper bound on the injected persona SOUL.md, mirroring the identity-file cap -/// (`BOOTSTRAP_MAX_CHARS`) so a large per-profile SOUL.md can't bloat the frozen -/// prompt prefix. -const PROFILE_SOUL_MAX_CHARS: usize = 20_000; - /// One-sentence system-prompt notice, mirroring hermes's cross-profile /// disclosure: names the profile's dedicated workspace and states that other /// profiles' directories are off-limits. Rendered only when a dedicated @@ -32,12 +26,6 @@ pub fn cross_profile_workspace_notice(profile_id: &str, workspace_path: &Path) - pub struct AgentProfilePromptSection { body: String, workspace_notice: Option, - /// When set, the active profile whose `SOUL.md` identity is hot-read on each - /// prompt build and rendered ahead of the persona body. Carried (rather than - /// a pre-resolved string) so the file is re-read at `build` time, matching - /// the identity section's live-file semantics — a Settings/file edit takes - /// effect on the next prompt build. - soul_profile: Option, } impl AgentProfilePromptSection { @@ -45,7 +33,6 @@ impl AgentProfilePromptSection { Self { body, workspace_notice: None, - soul_profile: None, } } @@ -59,35 +46,6 @@ impl AgentProfilePromptSection { self.workspace_notice = (!notice.is_empty()).then_some(notice); self } - - /// Bind the active profile so its resolved `SOUL.md` identity is injected - /// into the live session prompt (ordinary web-chat / cron turns, not just - /// delegation). The soul is hot-read via - /// [`resolve_personality_soul`](crate::openhuman::profiles::resolve_personality_soul) - /// on each `build`, following the same resolution order as - /// `PersonalityContext` (`personalities//SOUL.md` → `soul_md_path` → - /// inline → `None`). Callers pass this only for non-default profiles; the - /// default/master profile keeps the workspace root `SOUL.md`. - #[must_use] - pub fn with_profile_soul(mut self, profile: AgentProfile) -> Self { - self.soul_profile = Some(profile); - self - } - - /// Hot-read the bound profile's SOUL.md, trimmed and capped. `None` when no - /// profile is bound or nothing resolves (caller's root fallback stays intact). - fn resolve_soul(&self, workspace_dir: &Path) -> Option { - self.soul_profile - .as_ref() - .and_then(|profile| super::resolve_personality_soul(workspace_dir, profile)) - .map(|soul| soul.trim().to_string()) - .filter(|soul| !soul.is_empty()) - .map(|soul| { - soul.chars() - .take(PROFILE_SOUL_MAX_CHARS) - .collect::() - }) - } } impl PromptSection for AgentProfilePromptSection { @@ -95,16 +53,10 @@ impl PromptSection for AgentProfilePromptSection { "agent_profile" } - fn build(&self, ctx: &PromptContext<'_>) -> Result { - // Hot-read the profile identity each build; `None` (no bound profile or - // nothing resolves) preserves the prior body/notice-only output exactly. - let soul = self.resolve_soul(ctx.workspace_dir); + fn build(&self, _ctx: &PromptContext<'_>) -> Result { let body = self.body.trim(); let notice = self.workspace_notice.as_deref().unwrap_or_default(); let mut parts: Vec<&str> = Vec::new(); - if let Some(ref soul) = soul { - parts.push(soul.as_str()); - } if !body.is_empty() { parts.push(body); } From 2d8eeba72471df4822dd5637ec9d00151029ceff Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 21:53:35 +0000 Subject: [PATCH 55/80] fix(skills): prefer private workflows over builtins --- src/openhuman/skills/registry.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/openhuman/skills/registry.rs b/src/openhuman/skills/registry.rs index 6e096d94de..9cec31b2a3 100644 --- a/src/openhuman/skills/registry.rs +++ b/src/openhuman/skills/registry.rs @@ -287,7 +287,10 @@ pub fn get_workflow_with_profile( profile_skills_root: Option<&Path>, ) -> Option { let workflows = load_workflows_with_profile(workspace_dir, profile_skills_root); - if let Some(exact) = workflows.iter().find(|s| s.definition.id == id) { + // Built-ins are prepended and discovered workflows follow them. Search in + // reverse so the scope-resolved discovered entry (profile wins over global) + // also wins over a built-in with the same runnable id. + if let Some(exact) = workflows.iter().rev().find(|s| s.definition.id == id) { return Some(exact.clone()); } @@ -316,6 +319,7 @@ pub fn get_workflow_with_profile( workflows .into_iter() + .rev() .find(|workflow| workflow.definition.id == slug) } @@ -483,6 +487,17 @@ mod tests { assert!(get_workflow_with_profile(ws.path(), "Inbox Assistant", None).is_none()); } + #[test] + fn profile_workflow_exact_id_overrides_builtin() { + let ws = tempfile::TempDir::new().unwrap(); + let profile_root = tempfile::TempDir::new().unwrap(); + seed_runnable(profile_root.path(), "critic", "PROFILE_CRITIC_BODY"); + + let resolved = get_workflow_with_profile(ws.path(), "critic", Some(profile_root.path())) + .expect("profile critic resolves"); + assert!(resolved_body(&resolved).contains("PROFILE_CRITIC_BODY")); + } + #[test] fn load_skills_reads_runtime_skill_prompt_and_inputs() { let tmp = tempfile::TempDir::new().unwrap(); From ef0ddc211f73d0c6578ea38b2a3b71c478944e1b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 21:53:35 +0000 Subject: [PATCH 56/80] fix(experience): merge shared and profile stores --- src/openhuman/agent_experience/ops.rs | 138 ++++++++++++++++++++++---- 1 file changed, 116 insertions(+), 22 deletions(-) diff --git a/src/openhuman/agent_experience/ops.rs b/src/openhuman/agent_experience/ops.rs index 7f59bec3c0..1fa339d3b2 100644 --- a/src/openhuman/agent_experience/ops.rs +++ b/src/openhuman/agent_experience/ops.rs @@ -4,6 +4,8 @@ use crate::openhuman::agent_experience::store::{AgentExperienceStore, Experience use crate::openhuman::agent_experience::types::{AgentExperience, ExperienceHit}; use crate::openhuman::config::Config; use crate::rpc::RpcOutcome; +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; #[derive(Debug, Deserialize)] @@ -89,14 +91,17 @@ async fn open_store(profile_id: Option<&str>) -> Result Result { if memory_subdir != "memory" { let memory = crate::openhuman::memory_store::UnifiedMemory::new_with_memory_dir( &config.workspace_dir, - &memory_subdir, + memory_subdir, crate::openhuman::embeddings::default_embedding_provider(), config.memory.sqlite_open_timeout_secs, ) @@ -106,11 +111,51 @@ async fn open_store(profile_id: Option<&str>) -> Result client, - None => crate::openhuman::memory::global::init(config.workspace_dir)?, + None => crate::openhuman::memory::global::init(config.workspace_dir.clone())?, }; Ok(AgentExperienceStore::new(client.memory_handle())) } +fn query_memory_subdirs( + workspace_dir: &std::path::Path, + profile_id: Option<&str>, +) -> Result, String> { + let state = crate::openhuman::profiles::load_profiles(workspace_dir)?; + let mut subdirs = BTreeSet::from(["memory".to_string()]); + let profile_id = profile_id.map(str::trim).filter(|id| !id.is_empty()); + + for profile in &state.profiles { + if profile_id.is_none_or(|id| profile.id == id) { + let suffix = crate::openhuman::profiles::effective_memory_suffix(profile); + subdirs.insert(crate::openhuman::profiles::memory_subdir_for_suffix( + &suffix, + )); + } + } + if let Some(profile_id) = profile_id { + if !state + .profiles + .iter() + .any(|profile| profile.id == profile_id) + { + return Err(format!("agent profile '{profile_id}' not found")); + } + } + Ok(subdirs.into_iter().collect()) +} + +async fn open_query_stores(profile_id: Option<&str>) -> Result, String> { + let config = Config::load_or_init() + .await + .map_err(|e| format!("load config: {e}"))?; + let subdirs = query_memory_subdirs(&config.workspace_dir, profile_id)?; + let mut stores = Vec::with_capacity(subdirs.len()); + for subdir in subdirs { + stores.push(open_store_in_subdir(&config, &subdir).await?); + } + Ok(stores) +} + pub async fn capture(params: CaptureParams) -> Result, String> { let store = open_store(params.experience.profile_id.as_deref()).await?; let stored = store.put(params.experience).await?; @@ -118,24 +163,61 @@ pub async fn capture(params: CaptureParams) -> Result Result>, String> { - let store = open_store(params.profile_id.as_deref()).await?; - let hits = store - .retrieve(ExperienceQuery { - query: params.query, - tools: params.tools, - tags: params.tags, - agent_id: params.agent_id, - entrypoint: params.entrypoint, - profile_id: params.profile_id, - max_hits: params.max_hits.unwrap_or(5), - }) - .await?; + let stores = open_query_stores(params.profile_id.as_deref()).await?; + let max_hits = params.max_hits.unwrap_or(5); + let query = ExperienceQuery { + query: params.query, + tools: params.tools, + tags: params.tags, + agent_id: params.agent_id, + entrypoint: params.entrypoint, + profile_id: params.profile_id, + max_hits, + }; + let mut by_id: BTreeMap = BTreeMap::new(); + for store in stores { + for hit in store.retrieve(query.clone()).await? { + let id = hit.experience.id.clone(); + match by_id.get(&id) { + Some(existing) if existing.score >= hit.score => {} + _ => { + by_id.insert(id, hit); + } + } + } + } + let mut hits: Vec<_> = by_id.into_values().collect(); + hits.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(Ordering::Equal) + .then_with(|| b.experience.updated_at_ms.cmp(&a.experience.updated_at_ms)) + .then_with(|| a.experience.id.cmp(&b.experience.id)) + }); + hits.truncate(max_hits); Ok(RpcOutcome::single_log(hits, "agent experiences retrieved")) } pub async fn list(params: ListParams) -> Result>, String> { - let store = open_store(params.profile_id.as_deref()).await?; - let experiences = store.list_for_profile(params.profile_id.as_deref()).await?; + let stores = open_query_stores(params.profile_id.as_deref()).await?; + let mut by_id: BTreeMap = BTreeMap::new(); + for store in stores { + for experience in store.list_for_profile(params.profile_id.as_deref()).await? { + let id = experience.id.clone(); + match by_id.get(&id) { + Some(existing) if existing.updated_at_ms >= experience.updated_at_ms => {} + _ => { + by_id.insert(id, experience); + } + } + } + } + let mut experiences: Vec<_> = by_id.into_values().collect(); + experiences.sort_by(|a, b| { + b.updated_at_ms + .cmp(&a.updated_at_ms) + .then_with(|| a.id.cmp(&b.id)) + }); Ok(RpcOutcome::single_log( experiences, "agent experiences listed", @@ -143,8 +225,11 @@ pub async fn list(params: ListParams) -> Result> } pub async fn dismiss(params: DismissParams) -> Result, String> { - let store = open_store(params.profile_id.as_deref()).await?; - let dismissed = store.dismiss(¶ms.id).await?; + let stores = open_query_stores(params.profile_id.as_deref()).await?; + let mut dismissed = false; + for store in stores { + dismissed |= store.dismiss(¶ms.id).await?; + } Ok(RpcOutcome::single_log( DismissResult { id: params.id, @@ -180,5 +265,14 @@ mod tests { "memory" ); assert!(profile_memory_subdir(workspace.path(), Some("missing")).is_err()); + + assert_eq!( + query_memory_subdirs(workspace.path(), Some("alice")).unwrap(), + vec!["memory".to_string(), "memory-alice".to_string()] + ); + assert_eq!( + query_memory_subdirs(workspace.path(), None).unwrap(), + vec!["memory".to_string(), "memory-alice".to_string()] + ); } } From 686202b52c7e42e5648b93aa0701daeb5ba131a6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 22:17:17 +0000 Subject: [PATCH 57/80] fix(agent): resume from profile transcript scope --- .../agent/harness/session/transcript.rs | 17 ++++++- .../agent/harness/session/turn/session_io.rs | 6 ++- .../agent/harness/session/turn_tests.rs | 48 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/openhuman/agent/harness/session/transcript.rs b/src/openhuman/agent/harness/session/transcript.rs index 4e33a36199..31c76a5bb6 100644 --- a/src/openhuman/agent/harness/session/transcript.rs +++ b/src/openhuman/agent/harness/session/transcript.rs @@ -1525,8 +1525,19 @@ pub fn resolve_new_transcript_path(workspace_dir: &Path, agent_name: &str) -> Re /// The fallback is one-release transitional and can be removed once /// existing transcripts have rolled forward. pub fn find_latest_transcript(workspace_dir: &Path, agent_name: &str) -> Option { + find_latest_transcript_in_subdir(workspace_dir, "session_raw", agent_name) +} + +/// Find the most recent transcript inside a session's configured raw subtree. +/// Scoped profile sessions must never fall back to shared transcripts; the +/// legacy date-grouped/markdown fallback applies only to `session_raw`. +pub fn find_latest_transcript_in_subdir( + workspace_dir: &Path, + session_raw_subdir: &str, + agent_name: &str, +) -> Option { let sanitized = sanitize_agent_name(agent_name); - let raw_root = workspace_dir.join("session_raw"); + let raw_root = workspace_dir.join(session_raw_subdir); let sessions_root = workspace_dir.join("sessions"); // Primary path: flat session_raw/ directory. The stem-suffix scan @@ -1538,6 +1549,10 @@ pub fn find_latest_transcript(workspace_dir: &Path, agent_name: &str) -> Option< } } + if session_raw_subdir != "session_raw" { + return None; + } + // Fallback: legacy date-grouped layout (one-release migration // window). Today first, then yesterday — matches the previous // behaviour so we don't regress while users still have files in diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs index ad219247b5..3479e62ea5 100644 --- a/src/openhuman/agent/harness/session/turn/session_io.rs +++ b/src/openhuman/agent/harness/session/turn/session_io.rs @@ -20,7 +20,11 @@ impl Agent { /// /// Best-effort: failures are logged and silently ignored. pub(in super::super) fn try_load_session_transcript(&mut self) { - match transcript::find_latest_transcript(&self.workspace_dir, &self.agent_definition_name) { + match transcript::find_latest_transcript_in_subdir( + &self.workspace_dir, + &self.session_raw_subdir, + &self.agent_definition_name, + ) { Some(path) => { log::info!( "[transcript] found previous transcript path={}", diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index af09565798..b00521b9d5 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -669,6 +669,54 @@ async fn transcript_resume_is_bounded_by_max_history_messages() { assert_eq!(cached[4].content, "a7"); } +#[tokio::test] +async fn transcript_resume_uses_profile_scoped_raw_directory() { + let mut shared = make_agent(None); + shared.persist_session_transcript( + &[ + ChatMessage::system("shared-system"), + ChatMessage::user("shared-user"), + ], + 0, + 0, + 0, + 0.0, + None, + ); + + let mut profile = make_agent(None); + profile.workspace_dir = shared.workspace_dir.clone(); + profile.agent_definition_name = shared.agent_definition_name.clone(); + profile.session_raw_subdir = "session_raw-alice".to_string(); + profile.persist_session_transcript( + &[ + ChatMessage::system("profile-system"), + ChatMessage::user("profile-user"), + ], + 0, + 0, + 0, + 0.0, + None, + ); + + let mut resumed = make_agent(None); + resumed.workspace_dir = shared.workspace_dir.clone(); + resumed.agent_definition_name = shared.agent_definition_name.clone(); + resumed.session_raw_subdir = "session_raw-alice".to_string(); + resumed.try_load_session_transcript(); + + let cached = resumed + .cached_transcript_messages + .expect("profile transcript"); + assert!(cached + .iter() + .any(|message| message.content == "profile-user")); + assert!(cached + .iter() + .all(|message| message.content != "shared-user")); +} + // NOTE: The `execute_tool_call_*` tests that exercised the legacy per-call // direct tool executor (`Agent::execute_tool_call`) were removed during the // tinyagents migration. The direct executor and its test-only parity shim From 9a3cf7779cd20dc0721b5fd636615739c7288fd1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 22:17:17 +0000 Subject: [PATCH 58/80] fix(profiles): clear synced soul files --- src/openhuman/profiles/home.rs | 54 +++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/src/openhuman/profiles/home.rs b/src/openhuman/profiles/home.rs index 4cfe96c6fd..63a8e3271f 100644 --- a/src/openhuman/profiles/home.rs +++ b/src/openhuman/profiles/home.rs @@ -309,11 +309,12 @@ pub fn ensure_profile_home( /// persisted inline value, and /// - the trimmed inline content differs from the current file content. /// -/// When `soul_md` is empty/`None` the file is left untouched, so a user who -/// manually edits `SOUL.md` (and clears the inline value) keeps that file -/// authoritative. Only ever called from the upsert path; select must not clobber -/// a manually edited file with a stale inline value. Returns `Ok(true)` when the -/// file was rewritten. +/// When `soul_md` was already empty/`None`, the file is left untouched so a +/// manual `SOUL.md` remains authoritative. A transition from a previously +/// non-empty inline value to empty removes the file that Settings had synced, +/// allowing the normal root fallback to take effect. Only ever called from the +/// upsert path; select must not clobber a manually edited file with a stale +/// inline value. Returns `Ok(true)` when the file was rewritten or removed. pub fn sync_soul_md_on_upsert( workspace_dir: &Path, profile: &AgentProfile, @@ -327,7 +328,12 @@ pub fn sync_soul_md_on_upsert( ); return Ok(false); } - // Empty / absent inline soul_md → leave any manual file edits authoritative. + let home = profile_home(workspace_dir, &profile.id); + let soul_path = home.join("SOUL.md"); + + // Clearing a previously persisted inline soul is an explicit Settings edit: + // remove the file that prior inline value created. If inline was already + // empty, preserve any manual file exactly as before. let desired = match profile .soul_md .as_ref() @@ -336,6 +342,22 @@ pub fn sync_soul_md_on_upsert( { Some(s) => s, None => { + let previously_inline = previous_soul_md + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + if previously_inline { + match std::fs::remove_file(&soul_path) { + Ok(()) => { + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] sync_soul_md_on_upsert: cleared synced SOUL.md" + ); + return Ok(true); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + } + } tracing::debug!( profile_id = %profile.id, "[profiles][home] sync_soul_md_on_upsert: inline soul_md empty, file left as-is" @@ -356,8 +378,6 @@ pub fn sync_soul_md_on_upsert( return Ok(false); } - let home = profile_home(workspace_dir, &profile.id); - let soul_path = home.join("SOUL.md"); // No-op when the file already matches the edited value (compare trimmed so a // trailing-newline difference doesn't churn the file). if let Ok(current) = std::fs::read_to_string(&soul_path) { @@ -659,6 +679,24 @@ mod tests { ); } + #[test] + fn sync_soul_md_on_upsert_removes_file_when_inline_is_cleared() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + let mut profile = test_profile("judy"); + profile.soul_md = Some("Settings identity".to_string()); + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + let soul_path = profile_home(ws.path(), "judy").join("SOUL.md"); + assert!(soul_path.exists()); + + profile.soul_md = None; + assert!( + sync_soul_md_on_upsert(ws.path(), &profile, Some("Settings identity")) + .expect("clear synced soul") + ); + assert!(!soul_path.exists()); + } + #[test] fn sync_soul_md_on_upsert_skips_invalid_id() { let ws = TempDir::new().unwrap(); From c34d5e7bb72f0d358aa15166b9965565576d9d74 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 22:36:15 +0000 Subject: [PATCH 59/80] fix(profiles): align file write approval workspace --- .../agent/harness/session/builder/factory.rs | 3 ++ src/openhuman/channels/runtime/startup.rs | 1 + src/openhuman/runtime_node/ops.rs | 1 + .../tools/impl/filesystem/file_write.rs | 41 ++++++++++++++++++- src/openhuman/tools/ops.rs | 12 +++++- 5 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index a96be81dff..38fbdeb7bc 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -421,6 +421,9 @@ impl Agent { profile_skill_allowlist.as_ref(), profile_mcp_allowlist.as_deref(), profile_skills_root.as_deref(), + profile_workspace_descriptor + .as_ref() + .map(|descriptor| descriptor.root.as_path()), ); // Filter tools by the user preference loaded above. diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index 5721176d22..c803676dc4 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -347,6 +347,7 @@ pub async fn start_channels(mut config: Config) -> Result<()> { None, None, None, + None, )); let skills = crate::openhuman::skills::load_workflow_metadata(&workspace); diff --git a/src/openhuman/runtime_node/ops.rs b/src/openhuman/runtime_node/ops.rs index c30048674a..d819dc9426 100644 --- a/src/openhuman/runtime_node/ops.rs +++ b/src/openhuman/runtime_node/ops.rs @@ -129,6 +129,7 @@ pub fn build_runtime_tools(config: &Config) -> Result>, String None, None, None, + None, ); debug!( tool_count = built.len(), diff --git a/src/openhuman/tools/impl/filesystem/file_write.rs b/src/openhuman/tools/impl/filesystem/file_write.rs index af818c83a2..399d65eccd 100644 --- a/src/openhuman/tools/impl/filesystem/file_write.rs +++ b/src/openhuman/tools/impl/filesystem/file_write.rs @@ -9,11 +9,27 @@ use tinyagents::harness::tool::ToolExecutionContext; /// Write file contents with path sandboxing pub struct FileWriteTool { security: Arc, + approval_workspace_root: Option, } impl FileWriteTool { pub fn new(security: Arc) -> Self { - Self { security } + Self { + security, + approval_workspace_root: None, + } + } + + /// Use the same effective workspace root for approval routing that tool + /// execution receives through its [`ToolExecutionContext`]. + pub fn with_approval_workspace_root( + security: Arc, + approval_workspace_root: std::path::PathBuf, + ) -> Self { + Self { + security, + approval_workspace_root: Some(approval_workspace_root), + } } } @@ -73,7 +89,10 @@ impl Tool for FileWriteTool { let target = if std::path::Path::new(path).is_absolute() { std::path::PathBuf::from(path) } else { - self.security.action_dir.join(path) + self.approval_workspace_root + .as_deref() + .unwrap_or(&self.security.action_dir) + .join(path) }; // Sync `stat` — intentionally blocking, since the `Tool` trait makes // this method sync. Fast for local paths; would only need @@ -233,6 +252,24 @@ mod tests { assert!(required.contains(&json!("content"))); } + #[test] + fn approval_probe_uses_effective_workspace_root() { + let root = tempfile::tempdir().expect("root"); + let profile_workspace = root.path().join("profiles/alice"); + std::fs::create_dir_all(&profile_workspace).expect("profile workspace"); + std::fs::write(profile_workspace.join("notes.md"), "existing").expect("seed file"); + + let tool = FileWriteTool::with_approval_workspace_root( + test_security(root.path().to_path_buf()), + profile_workspace, + ); + + assert!(tool.external_effect_with_args(&json!({ + "path": "notes.md", + "content": "updated" + }))); + } + #[tokio::test] async fn file_write_creates_file() { let dir = std::env::temp_dir().join("openhuman_test_file_write"); diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 9cc8a4b758..7d56aa351f 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -80,6 +80,7 @@ pub fn all_tools( None, None, None, + None, ) } @@ -104,6 +105,7 @@ pub fn all_tools_with_runtime( skill_allowlist: Option<&std::collections::HashSet>, mcp_allowlist: Option<&[String]>, profile_skills_root: Option<&std::path::Path>, + approval_workspace_root: Option<&std::path::Path>, ) -> Vec> { // `skill_allowlist` / `profile_skills_root` scope only the `skills`-gated // tool registrations below, so they are genuinely unread when that feature @@ -156,10 +158,18 @@ pub fn all_tools_with_runtime( python_bootstrap.as_ref().map(Arc::clone), )); + let file_write: Box = match approval_workspace_root { + Some(root) => Box::new(FileWriteTool::with_approval_workspace_root( + security.clone(), + root.to_path_buf(), + )), + None => Box::new(FileWriteTool::new(security.clone())), + }; + let mut tools: Vec> = vec![ shell, Box::new(FileReadTool::new(security.clone())), - Box::new(FileWriteTool::new(security.clone())), + file_write, // Coding-harness baseline tools (issue #1205): file navigation // + atomic editing primitives. Use these instead of falling // through to `shell` for grep/find/sed work. From 25636b3036ee850977ceb68f6cd76a97c109eda7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 22:43:58 +0000 Subject: [PATCH 60/80] fix(profiles): invalidate sessions on home edits --- src/openhuman/profiles/mod.rs | 4 ++-- src/openhuman/profiles/paths.rs | 34 +++++++++++++++++++++++++++++++ src/openhuman/web_chat/session.rs | 9 +++++--- src/openhuman/web_chat/types.rs | 6 ++++-- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/openhuman/profiles/mod.rs b/src/openhuman/profiles/mod.rs index 4e31460e8f..678c591d10 100644 --- a/src/openhuman/profiles/mod.rs +++ b/src/openhuman/profiles/mod.rs @@ -70,8 +70,8 @@ pub use home::{ }; pub use paths::{ effective_memory_suffix, filter_integrations, memory_subdir_for_suffix, - memory_tree_subdir_for_suffix, resolve_personality_memory_md, resolve_personality_soul, - session_raw_subdir_for_suffix, HasToolkit, PersonalityContext, + memory_tree_subdir_for_suffix, profile_session_signature, resolve_personality_memory_md, + resolve_personality_soul, session_raw_subdir_for_suffix, HasToolkit, PersonalityContext, }; pub use prompt_section::{cross_profile_workspace_notice, AgentProfilePromptSection}; pub use store::{built_in_profiles, load_profiles, AgentProfileStore}; diff --git a/src/openhuman/profiles/paths.rs b/src/openhuman/profiles/paths.rs index 80e9e918cb..a6c0ac674f 100644 --- a/src/openhuman/profiles/paths.rs +++ b/src/openhuman/profiles/paths.rs @@ -1,5 +1,6 @@ //! Personality-scoped path resolution and context for multi-agent sessions. +use std::hash::{Hash, Hasher}; use std::path::{Component, Path}; use super::home::validate_profile_id; @@ -205,6 +206,20 @@ pub fn resolve_personality_memory_md( } } +/// Fingerprint every profile input baked into a cached session agent. +/// +/// The profile record alone is insufficient because users may edit the +/// canonical SOUL.md or MEMORY.md files directly. Hashing their resolved +/// contents makes the next web-chat turn rebuild its cached agent without +/// retaining the files themselves in cache metadata or logs. +pub fn profile_session_signature(workspace_dir: &Path, profile: &AgentProfile) -> String { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + super::types::profile_signature(profile).hash(&mut hasher); + resolve_personality_soul(workspace_dir, profile).hash(&mut hasher); + resolve_personality_memory_md(workspace_dir, profile).hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + /// Derive the effective memory directory suffix for a profile. /// /// Precedence: @@ -554,6 +569,25 @@ mod tests { assert!(result.is_none()); } + #[test] + fn profile_session_signature_tracks_profile_file_edits() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("personalities/alice"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write(home.join("SOUL.md"), "first soul").unwrap(); + std::fs::write(home.join("MEMORY.md"), "first memory").unwrap(); + let profile = test_profile("alice"); + + let original = profile_session_signature(tmp.path(), &profile); + std::fs::write(home.join("SOUL.md"), "second soul").unwrap(); + let after_soul_edit = profile_session_signature(tmp.path(), &profile); + assert_ne!(original, after_soul_edit); + + std::fs::write(home.join("MEMORY.md"), "second memory").unwrap(); + let after_memory_edit = profile_session_signature(tmp.path(), &profile); + assert_ne!(after_soul_edit, after_memory_edit); + } + #[test] fn personality_context_from_profile() { let tmp = TempDir::new().unwrap(); diff --git a/src/openhuman/web_chat/session.rs b/src/openhuman/web_chat/session.rs index 43aec745a0..8bfb5091bc 100644 --- a/src/openhuman/web_chat/session.rs +++ b/src/openhuman/web_chat/session.rs @@ -197,8 +197,11 @@ pub(super) fn build_session_fingerprint( target_agent_id, autonomy_signature: autonomy_signature(config), model_registry_signature: model_registry_signature(config), - // Any change to the resolved profile (id, allowlists, soul, …) changes - // this string and forces a session-agent rebuild — see the field doc. - profile_signature: crate::openhuman::profiles::profile_signature(profile), + // Any change to the resolved profile record or its canonical on-disk + // SOUL/MEMORY files forces a session-agent rebuild — see the field doc. + profile_signature: crate::openhuman::profiles::profile_session_signature( + &config.workspace_dir, + profile, + ), } } diff --git a/src/openhuman/web_chat/types.rs b/src/openhuman/web_chat/types.rs index 9fe1f9100f..db7d006ed3 100644 --- a/src/openhuman/web_chat/types.rs +++ b/src/openhuman/web_chat/types.rs @@ -25,12 +25,14 @@ pub(crate) struct SessionCacheFingerprint { /// change) — without this the stale session would be reused. Mirrors /// [`Self::autonomy_signature`]. pub(super) model_registry_signature: String, - /// Serialized signature of the active agent profile. The cached `Agent` + /// Hashed signature of the active agent profile record and its resolved + /// SOUL/MEMORY file contents. The cached `Agent` /// bakes in the profile's tool/skill/MCP/connector visibility and SOUL/MEMORY /// overrides at build time; switching profiles on the same thread keeps the /// same model/agent/provider, so without this the previous profile's /// capability surface would leak into the new profile's turns. Any change to - /// the resolved profile forces a rebuild. + /// the resolved profile or a direct edit to either profile file forces a + /// rebuild on the next turn. pub(super) profile_signature: String, } From e3dc87c9c0633ab3ffb87e8faf9384e39217d2d8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 23:07:50 +0000 Subject: [PATCH 61/80] fix(profiles): scope workflow run logs --- src/openhuman/skill_runtime/run_machinery.rs | 15 +- src/openhuman/skills/run_log.rs | 23 ++++ src/openhuman/skills/tools.rs | 137 ++++++++++++++++++- src/openhuman/tools/ops.rs | 14 +- 4 files changed, 181 insertions(+), 8 deletions(-) diff --git a/src/openhuman/skill_runtime/run_machinery.rs b/src/openhuman/skill_runtime/run_machinery.rs index 3e0c014815..51d890f3f6 100644 --- a/src/openhuman/skill_runtime/run_machinery.rs +++ b/src/openhuman/skill_runtime/run_machinery.rs @@ -107,12 +107,13 @@ pub async fn spawn_workflow_run_background_with_profile( gate decision: FAILED ({tag})\n\ detail: {body}" ); - if let Err(e) = run_log::write_header( + if let Err(e) = run_log::write_header_with_profile( &gate_log_path, &skill.definition.id, &gate_run_id, &inputs, &header_prompt, + active_profile.as_ref().map(|profile| profile.id.as_str()), ) .await { @@ -178,9 +179,17 @@ pub async fn spawn_workflow_run_background_with_profile( let log_path = log_path.clone(); let inherited_origin = inherited_origin.clone(); let active_profile = active_profile.clone(); + let run_profile_id = active_profile.as_ref().map(|profile| profile.id.clone()); tokio::spawn(async move { - if let Err(e) = - run_log::write_header(&log_path, &workflow_id, &run_id, &inputs, &task_prompt).await + if let Err(e) = run_log::write_header_with_profile( + &log_path, + &workflow_id, + &run_id, + &inputs, + &task_prompt, + run_profile_id.as_deref(), + ) + .await { tracing::warn!(run_id = %run_id, error = %e, "[skills] workflow_run: header write failed"); } diff --git a/src/openhuman/skills/run_log.rs b/src/openhuman/skills/run_log.rs index bbdcddcb0d..f8e1a4db8c 100644 --- a/src/openhuman/skills/run_log.rs +++ b/src/openhuman/skills/run_log.rs @@ -138,9 +138,26 @@ pub async fn write_header( inputs: &Value, task_prompt: &str, ) -> std::io::Result<()> { + write_header_with_profile(path, workflow_id, run_id, inputs, task_prompt, None).await +} + +/// Write a run header attributed to the active profile. Profile-less callers +/// retain the legacy header format through [`write_header`]. +pub async fn write_header_with_profile( + path: &Path, + workflow_id: &str, + run_id: &str, + inputs: &Value, + task_prompt: &str, + profile_id: Option<&str>, +) -> std::io::Result<()> { + let profile_line = profile_id + .map(|id| format!("profile_id: {id}\n")) + .unwrap_or_default(); let header = format!( "==== workflow_run: {skill} ====\n\ run_id : {run}\n\ + {profile_line}\ started: {start} UTC\n\ inputs : {inputs}\n\n\ --- task prompt ---\n{prompt}\n\n\ @@ -291,6 +308,9 @@ pub fn detect_repeated_line( pub struct ScannedRun { pub run_id: String, pub workflow_id: String, + /// Profile that launched the run. `None` denotes a legacy/profile-less run. + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_id: Option, /// Header `started:` timestamp (RFC3339); empty if header was malformed. pub started: String, /// `"DONE"` / `"DEGENERATE"` / `"FAILED"` / `"RUNNING"` (running ⇔ no footer yet). @@ -330,6 +350,7 @@ pub fn scan_runs(workspace: &Path, workflow_id: Option<&str>, limit: usize) -> V let mut sid = String::new(); let mut rid = String::new(); let mut started = String::new(); + let mut profile_id: Option = None; let mut status = String::from("RUNNING"); let mut duration_ms: Option = None; let mut finished: Option = None; @@ -363,6 +384,7 @@ pub fn scan_runs(workspace: &Path, workflow_id: Option<&str>, limit: usize) -> V match (label, seen_result) { // Header fields (before --- result ---) ("run_id", false) => rid = value.to_string(), + ("profile_id", false) => profile_id = Some(value.to_string()), ("started", false) => started = value.to_string(), // Footer fields (after --- result ---) ("status", true) => status = value.to_string(), @@ -391,6 +413,7 @@ pub fn scan_runs(workspace: &Path, workflow_id: Option<&str>, limit: usize) -> V runs.push(ScannedRun { run_id: rid, workflow_id: sid, + profile_id, started, status, duration_ms, diff --git a/src/openhuman/skills/tools.rs b/src/openhuman/skills/tools.rs index 670e8ec60c..7e5b590e9e 100644 --- a/src/openhuman/skills/tools.rs +++ b/src/openhuman/skills/tools.rs @@ -34,7 +34,7 @@ use super::ops_install::{ }; use super::ops_types::WorkflowScope; use super::registry::get_workflow_with_profile; -use super::run_log::{find_run_log_path, read_run_log_slice, scan_runs}; +use super::run_log::{read_run_log_slice, scan_runs}; fn read_required_str(args: &serde_json::Value, key: &str) -> anyhow::Result { args.get(key) @@ -338,14 +338,38 @@ impl Tool for WorkflowReadResourceTool { /// List recent skill runs. pub struct WorkflowRecentRunsTool { workspace_dir: PathBuf, + active_profile_id: Option, + skill_allowlist: SkillAllowlist, + profile_skills_root: Option, } impl WorkflowRecentRunsTool { pub fn new(config: Arc) -> Self { Self { workspace_dir: config.workspace_dir.clone(), + active_profile_id: None, + skill_allowlist: None, + profile_skills_root: None, } } + + pub fn with_active_profile( + mut self, + profile: Option, + ) -> Self { + self.active_profile_id = profile.map(|profile| profile.id); + self + } + + pub fn with_skill_allowlist(mut self, allowlist: SkillAllowlist) -> Self { + self.skill_allowlist = allowlist; + self + } + + pub fn with_profile_skills_root(mut self, root: Option) -> Self { + self.profile_skills_root = root; + self + } } #[async_trait] @@ -384,7 +408,19 @@ impl Tool for WorkflowRecentRunsTool { .and_then(serde_json::Value::as_u64) .map(|v| v as usize) .unwrap_or(20); - let runs = scan_runs(&self.workspace_dir, skill_id, limit); + let profile_local = profile_local_skill_ids(self.profile_skills_root.as_deref()); + let runs = scan_runs(&self.workspace_dir, skill_id, usize::MAX) + .into_iter() + .filter(|run| { + run.profile_id.as_deref() == self.active_profile_id.as_deref() + && skill_allowed_including_profile( + &self.skill_allowlist, + &profile_local, + &run.workflow_id, + ) + }) + .take(limit) + .collect::>(); Ok(ToolResult::success(serde_json::to_string(&json!({ "count": runs.len(), "runs": runs, @@ -399,14 +435,38 @@ impl Tool for WorkflowRecentRunsTool { /// Read a slice of a run log. pub struct WorkflowReadRunLogTool { workspace_dir: PathBuf, + active_profile_id: Option, + skill_allowlist: SkillAllowlist, + profile_skills_root: Option, } impl WorkflowReadRunLogTool { pub fn new(config: Arc) -> Self { Self { workspace_dir: config.workspace_dir.clone(), + active_profile_id: None, + skill_allowlist: None, + profile_skills_root: None, } } + + pub fn with_active_profile( + mut self, + profile: Option, + ) -> Self { + self.active_profile_id = profile.map(|profile| profile.id); + self + } + + pub fn with_skill_allowlist(mut self, allowlist: SkillAllowlist) -> Self { + self.skill_allowlist = allowlist; + self + } + + pub fn with_profile_skills_root(mut self, root: Option) -> Self { + self.profile_skills_root = root; + self + } } #[async_trait] @@ -446,8 +506,20 @@ impl Tool for WorkflowReadRunLogTool { .and_then(serde_json::Value::as_u64) .map(|v| v as usize) .unwrap_or(65536); - let path = find_run_log_path(&self.workspace_dir, &run_id) + let profile_local = profile_local_skill_ids(self.profile_skills_root.as_deref()); + let run = scan_runs(&self.workspace_dir, None, usize::MAX) + .into_iter() + .find(|run| { + run.run_id == run_id + && run.profile_id.as_deref() == self.active_profile_id.as_deref() + && skill_allowed_including_profile( + &self.skill_allowlist, + &profile_local, + &run.workflow_id, + ) + }) .ok_or_else(|| anyhow::anyhow!("read_workflow_run_log: run `{run_id}` not found"))?; + let path = PathBuf::from(run.log_path); let slice = read_run_log_slice(&path, offset, max_bytes) .map_err(|e| anyhow::anyhow!("read_workflow_run_log: {e}"))?; Ok(ToolResult::success(serde_json::to_string(&slice)?)) @@ -654,6 +726,65 @@ mod tests { ); } + #[tokio::test] + async fn run_history_is_scoped_to_profile_and_allowlist() { + let tmp = tempfile::TempDir::new().expect("tempdir"); + let mut config = Config::default(); + config.workspace_dir = tmp.path().to_path_buf(); + let config = Arc::new(config); + let run_id = "aaaaaaaa-1111-2222-3333-444444444444"; + let path = + crate::openhuman::skills::run_log::run_log_path(tmp.path(), "private-flow", run_id); + crate::openhuman::skills::run_log::write_header_with_profile( + &path, + "private-flow", + run_id, + &json!({"secret": true}), + "private prompt", + Some("alice"), + ) + .await + .expect("write header"); + + let mut alice = crate::openhuman::profiles::built_in_profiles() + .into_iter() + .next() + .expect("built-in profile"); + alice.id = "alice".to_string(); + let mut bob = alice.clone(); + bob.id = "bob".to_string(); + + let alice_list = WorkflowRecentRunsTool::new(config.clone()) + .with_active_profile(Some(alice.clone())) + .execute(json!({})) + .await + .expect("alice list"); + assert!(alice_list.output_for_llm(false).contains(run_id)); + + let bob_list = WorkflowRecentRunsTool::new(config.clone()) + .with_active_profile(Some(bob.clone())) + .execute(json!({})) + .await + .expect("bob list"); + assert!(!bob_list.output_for_llm(false).contains(run_id)); + + let bob_read = WorkflowReadRunLogTool::new(config.clone()) + .with_active_profile(Some(bob)) + .execute(json!({"run_id": run_id})) + .await; + assert!(bob_read.is_err(), "another profile must not read the log"); + + let alice_disallowed = WorkflowReadRunLogTool::new(config) + .with_active_profile(Some(alice)) + .with_skill_allowlist(Some(std::collections::HashSet::new())) + .execute(json!({"run_id": run_id})) + .await; + assert!( + alice_disallowed.is_err(), + "the profile allowlist must also gate run logs" + ); + } + #[test] fn names_and_levels() { let c = cfg(); diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 7d56aa351f..0f7a74bcc1 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -570,9 +570,19 @@ pub fn all_tools_with_runtime( .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), ), #[cfg(feature = "skills")] - Box::new(WorkflowRecentRunsTool::new(config.clone())), + Box::new( + WorkflowRecentRunsTool::new(config.clone()) + .with_active_profile(active_profile.cloned()) + .with_skill_allowlist(skill_allowlist.cloned()) + .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), + ), #[cfg(feature = "skills")] - Box::new(WorkflowReadRunLogTool::new(config.clone())), + Box::new( + WorkflowReadRunLogTool::new(config.clone()) + .with_active_profile(active_profile.cloned()) + .with_skill_allowlist(skill_allowlist.cloned()) + .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), + ), #[cfg(feature = "skills")] Box::new(WorkflowCreateTool::new(config.clone())), #[cfg(feature = "skills")] From 118a9d824c7320544a5b1db8db08d95af5131c6e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 23:42:45 +0000 Subject: [PATCH 62/80] fix(profiles): persist cleared soul tombstone --- src/openhuman/profiles/home.rs | 42 +++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/src/openhuman/profiles/home.rs b/src/openhuman/profiles/home.rs index 63a8e3271f..57b6f2cca6 100644 --- a/src/openhuman/profiles/home.rs +++ b/src/openhuman/profiles/home.rs @@ -311,10 +311,11 @@ pub fn ensure_profile_home( /// /// When `soul_md` was already empty/`None`, the file is left untouched so a /// manual `SOUL.md` remains authoritative. A transition from a previously -/// non-empty inline value to empty removes the file that Settings had synced, -/// allowing the normal root fallback to take effect. Only ever called from the -/// upsert path; select must not clobber a manually edited file with a stale -/// inline value. Returns `Ok(true)` when the file was rewritten or removed. +/// non-empty inline value to empty replaces the synced file with an empty +/// tombstone, allowing the normal root fallback while preventing a later +/// `ensure_profile_home` call from re-seeding the default template. Only ever +/// called from the upsert path; select must not clobber a manually edited file +/// with a stale inline value. Returns `Ok(true)` when the file was rewritten. pub fn sync_soul_md_on_upsert( workspace_dir: &Path, profile: &AgentProfile, @@ -332,8 +333,10 @@ pub fn sync_soul_md_on_upsert( let soul_path = home.join("SOUL.md"); // Clearing a previously persisted inline soul is an explicit Settings edit: - // remove the file that prior inline value created. If inline was already - // empty, preserve any manual file exactly as before. + // replace the file that prior inline value created with an empty tombstone. + // `ensure_profile_home` treats that existing file as materialized, while + // `resolve_personality_soul` treats it as empty and falls back to root. + // If inline was already empty, preserve any manual file exactly as before. let desired = match profile .soul_md .as_ref() @@ -346,17 +349,13 @@ pub fn sync_soul_md_on_upsert( .map(str::trim) .is_some_and(|value| !value.is_empty()); if previously_inline { - match std::fs::remove_file(&soul_path) { - Ok(()) => { - tracing::debug!( - profile_id = %profile.id, - "[profiles][home] sync_soul_md_on_upsert: cleared synced SOUL.md" - ); - return Ok(true); - } - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), - Err(error) => return Err(error), - } + std::fs::create_dir_all(&home)?; + seed_file_atomic(&home, &soul_path, b"")?; + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] sync_soul_md_on_upsert: cleared synced SOUL.md with tombstone" + ); + return Ok(true); } tracing::debug!( profile_id = %profile.id, @@ -680,7 +679,7 @@ mod tests { } #[test] - fn sync_soul_md_on_upsert_removes_file_when_inline_is_cleared() { + fn sync_soul_md_on_upsert_persists_empty_tombstone_when_inline_is_cleared() { let ws = TempDir::new().unwrap(); let action = TempDir::new().unwrap(); let mut profile = test_profile("judy"); @@ -694,7 +693,12 @@ mod tests { sync_soul_md_on_upsert(ws.path(), &profile, Some("Settings identity")) .expect("clear synced soul") ); - assert!(!soul_path.exists()); + assert_eq!(std::fs::read_to_string(&soul_path).unwrap(), ""); + + // Selecting/materializing the profile again must not resurrect the + // default profile template over the explicit clear. + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure after clear"); + assert_eq!(std::fs::read_to_string(&soul_path).unwrap(), ""); } #[test] From 74712459fbd1a6de5cb79f86879ec9079f0d6983 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 23:42:45 +0000 Subject: [PATCH 63/80] fix(profiles): enforce gates for cron runs --- .../harness/session/builder/builder_tests.rs | 29 ++++++++++++++ .../agent/harness/session/builder/factory.rs | 29 ++++++++++++++ src/openhuman/cron/scheduler.rs | 39 ++++++++++++++----- src/openhuman/cron/scheduler_tests.rs | 36 ++++++++++++++++- src/openhuman/memory/source_scope.rs | 4 +- src/openhuman/web_chat/session.rs | 17 +------- 6 files changed, 125 insertions(+), 29 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/builder_tests.rs b/src/openhuman/agent/harness/session/builder/builder_tests.rs index 61662d978d..ad51e2fe15 100644 --- a/src/openhuman/agent/harness/session/builder/builder_tests.rs +++ b/src/openhuman/agent/harness/session/builder/builder_tests.rs @@ -273,6 +273,35 @@ async fn build_session_agent_carries_active_profile_id_when_profile_present() { ); } +#[tokio::test] +async fn profile_allowed_tools_restrict_shared_session_builder() { + use crate::openhuman::agent::harness::session::types::Agent; + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.id = "alice".to_string(); + profile.built_in = false; + profile.allowed_tools = Some(vec!["file_read".to_string()]); + + let agent = Agent::build_session_agent_inner( + &config, + "orchestrator", + None, + None, + None, + false, + Some(&profile), + ) + .expect("build profile-scoped session"); + + assert_eq!( + agent.visible_tool_names_for_test(), + &["file_read".to_string()].into_iter().collect(), + "every profile-aware caller must inherit the same tool restriction" + ); +} + #[tokio::test] async fn dedicated_memory_profile_scopes_tree_and_transcript_storage() { use crate::openhuman::agent::harness::session::types::Agent; diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 38fbdeb7bc..a38e80092e 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -1001,6 +1001,35 @@ impl Agent { } } + // Profile tool selection is a restriction on the resolved agent + // definition, never a replacement for it. Apply it here at the shared + // session-builder seam so web chat, cron, tasks, and delegated profile + // runs all enforce the same callable surface. The web wrapper used to + // replace this set after construction, which both missed background + // runs and could broaden a named agent definition. + if let Some(allowed_tools) = profile + .and_then(|profile| profile.allowed_tools.as_ref()) + .filter(|tools| !tools.is_empty()) + { + let profile_visible: std::collections::HashSet<&str> = allowed_tools + .iter() + .map(|tool| tool.trim()) + .filter(|tool| !tool.is_empty()) + .collect(); + if visible.is_empty() { + visible = profile_visible.into_iter().map(str::to_string).collect(); + } else { + visible.retain(|tool| profile_visible.contains(tool.as_str())); + // Empty is the Agent's historical "all tools" sentinel. A + // disjoint profile/definition intersection must instead stay + // non-empty with an unregistered name so it advertises and + // permits zero tools rather than accidentally broadening. + if visible.is_empty() { + visible.insert("__profile_no_tools__".to_string()); + } + } + } + // Phase 4 (#566): add the MemoryAccessSection bias instruction only // when at least one retrieval tool is actually loaded AND survives // filtering. We require both because: diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index 0ece0fc07a..e0e51e21b8 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -911,7 +911,7 @@ async fn run_agent_job(config: &Config, job: &CronJob) -> (bool, String, Option< "[cron] building isolated agent for scheduled job" ); match build_agent_for_cron_job(&effective, job) { - Ok(mut agent) => { + Ok(BuiltCronAgent { mut agent, profile }) => { // Tag events so downstream subscribers can correlate // cron-triggered turns. `cron` is the channel so the // event bus can filter from other flows (`cli`, `web`…). @@ -927,9 +927,12 @@ async fn run_agent_job(config: &Config, job: &CronJob) -> (bool, String, Option< source: crate::openhuman::agent::turn_origin::TrustedAutomationSource::Cron, }; - let turn = crate::openhuman::agent::turn_origin::with_origin( - origin, - agent.run_single(&prefixed_prompt), + let turn = crate::openhuman::memory::source_scope::with_source_scope( + profile.and_then(|profile| profile.memory_sources), + crate::openhuman::agent::turn_origin::with_origin( + origin, + agent.run_single(&prefixed_prompt), + ), ); // Morning briefing only: install a 24h task-recency window // so Composio task-fetch tools (Linear/ClickUp/Notion/Asana) @@ -1047,7 +1050,12 @@ fn resolve_cron_profile( } } -fn build_agent_for_cron_job(config: &Config, job: &CronJob) -> anyhow::Result { +struct BuiltCronAgent { + agent: Agent, + profile: Option, +} + +fn build_agent_for_cron_job(config: &Config, job: &CronJob) -> anyhow::Result { // 2b — profile attribution. When the job names a profile that still exists, // build the run under it via the SAME profile-aware session path the task // dispatcher uses (`from_config_for_agent_with_profile`), so the run inherits @@ -1061,7 +1069,7 @@ fn build_agent_for_cron_job(config: &Config, job: &CronJob) -> anyhow::Result anyhow::Result anyhow::Result { tracing::warn!( @@ -1103,11 +1118,17 @@ fn build_agent_for_cron_job(config: &Config, job: &CronJob) -> anyhow::Result>(), - ); - } agent.set_event_context( json!({"client_id": client_id, "thread_id": thread_id}).to_string(), "web_channel", From 6b2cb27a6b0e692ff3c5c3e895a996daa6a95c8a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 00:01:01 +0000 Subject: [PATCH 64/80] fix(profiles): scope detached workflow awaits --- src/openhuman/agent/tools/run_workflow.rs | 119 +++++++++++++++++++--- src/openhuman/tools/ops.rs | 7 +- 2 files changed, 110 insertions(+), 16 deletions(-) diff --git a/src/openhuman/agent/tools/run_workflow.rs b/src/openhuman/agent/tools/run_workflow.rs index 873dbca75c..8536f01fd4 100644 --- a/src/openhuman/agent/tools/run_workflow.rs +++ b/src/openhuman/agent/tools/run_workflow.rs @@ -429,7 +429,11 @@ impl Tool for RunWorkflowTool { } /// `await_workflow` — re-attach to a detached run by `run_id` and wait. -pub struct AwaitWorkflowTool; +pub struct AwaitWorkflowTool { + active_profile_id: Option, + skill_allowlist: Option>, + profile_skills_root: Option, +} impl Default for AwaitWorkflowTool { fn default() -> Self { @@ -439,10 +443,46 @@ impl Default for AwaitWorkflowTool { impl AwaitWorkflowTool { pub fn new() -> Self { - Self + Self { + active_profile_id: None, + skill_allowlist: None, + profile_skills_root: None, + } + } + + pub fn with_active_profile( + mut self, + profile: Option, + ) -> Self { + self.active_profile_id = profile.map(|profile| profile.id); + self + } + + pub fn with_skill_allowlist( + mut self, + allowlist: Option>, + ) -> Self { + self.skill_allowlist = allowlist; + self + } + + pub fn with_profile_skills_root(mut self, root: Option) -> Self { + self.profile_skills_root = root; + self } } +fn run_visible_to_profile( + run: &crate::openhuman::skills::run_log::ScannedRun, + active_profile_id: Option<&str>, + skill_allowlist: Option<&std::collections::HashSet>, + profile_local_ids: &std::collections::HashSet, +) -> bool { + run.profile_id.as_deref() == active_profile_id + && (profile_local_ids.contains(&run.workflow_id) + || skill_allowlist.is_none_or(|allowlist| allowlist.contains(&run.workflow_id))) +} + #[async_trait] impl Tool for AwaitWorkflowTool { fn name(&self) -> &str { @@ -491,16 +531,27 @@ impl Tool for AwaitWorkflowTool { let wait_seconds = parse_wait_seconds(&args); let workspace = resolve_workspace_dir().await; - let log_path = - match crate::openhuman::skills::run_log::find_run_log_path(&workspace, &run_id) { - Some(p) => p, - None => { - return Ok(ToolResult::error(format!( - "await_workflow: no run found for run_id `{run_id}` (it may not exist or \ - hasn't started writing its log yet)" - ))); - } - }; + let profile_local_ids = + crate::openhuman::skills::profile_local_skill_ids(self.profile_skills_root.as_deref()); + let visible_run = + crate::openhuman::skills::run_log::scan_runs(&workspace, None, usize::MAX) + .into_iter() + .find(|run| { + run.run_id == run_id + && run_visible_to_profile( + run, + self.active_profile_id.as_deref(), + self.skill_allowlist.as_ref(), + &profile_local_ids, + ) + }); + let Some(visible_run) = visible_run else { + return Ok(ToolResult::error(format!( + "await_workflow: no run found for run_id `{run_id}` (it may not exist, is not \ + available to the active profile, or hasn't started writing its log yet)" + ))); + }; + let log_path = std::path::PathBuf::from(&visible_run.log_path); // Take an await slot so the LLM can't stack unbounded waits or // double-await the same run; keyed by run_id. @@ -511,9 +562,12 @@ impl Tool for AwaitWorkflowTool { let outcome = await_run_outcome(&log_path, std::time::Duration::from_secs(wait_seconds)).await; - // workflow_id isn't carried on the handle here; the run_id is the - // stable key the caller holds, so echo that and leave workflow_id blank. - Ok(outcome_to_result(&run_id, "", &log_path, outcome)) + Ok(outcome_to_result( + &run_id, + &visible_run.workflow_id, + &log_path, + outcome, + )) } } @@ -567,6 +621,41 @@ mod tests { assert!(res.output().contains("run_id")); } + #[test] + fn detached_run_visibility_requires_profile_and_allowlist() { + let run = crate::openhuman::skills::run_log::ScannedRun { + run_id: "run-1".to_string(), + workflow_id: "private-flow".to_string(), + profile_id: Some("alice".to_string()), + started: String::new(), + status: "DONE".to_string(), + duration_ms: None, + finished: None, + log_path: "/tmp/run.log".to_string(), + }; + let allowed = ["private-flow".to_string()].into_iter().collect(); + let empty = std::collections::HashSet::new(); + + assert!(run_visible_to_profile( + &run, + Some("alice"), + Some(&allowed), + &empty + )); + assert!(!run_visible_to_profile( + &run, + Some("bob"), + Some(&allowed), + &empty + )); + assert!(!run_visible_to_profile( + &run, + Some("alice"), + Some(&empty), + &empty + )); + } + #[test] fn wait_seconds_defaults_and_clamps() { assert_eq!(parse_wait_seconds(&json!({})), DEFAULT_WAIT_SECONDS); diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 0f7a74bcc1..1d12a94910 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -248,7 +248,12 @@ pub fn all_tools_with_runtime( .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), ), #[cfg(feature = "skills")] - Box::new(AwaitWorkflowTool::new()), + Box::new( + AwaitWorkflowTool::new() + .with_active_profile(active_profile.cloned()) + .with_skill_allowlist(skill_allowlist.cloned()) + .with_profile_skills_root(profile_skills_root.map(|p| p.to_path_buf())), + ), Box::new(CurrentTimeTool::new()), // Reversibility for native tool-output compaction (Stage 1a): when a // large result is compacted with a `retrieve_tool_output("")` From 810d5ac96883e2ffd7569aefbc18b62599a9238e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 00:16:44 +0000 Subject: [PATCH 65/80] fix(profiles): preserve default root soul --- src/openhuman/profiles/home.rs | 46 +++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/src/openhuman/profiles/home.rs b/src/openhuman/profiles/home.rs index 57b6f2cca6..930a796fbf 100644 --- a/src/openhuman/profiles/home.rs +++ b/src/openhuman/profiles/home.rs @@ -19,7 +19,7 @@ use std::io; use std::path::{Path, PathBuf}; -use super::types::AgentProfile; +use super::types::{AgentProfile, DEFAULT_PROFILE_ID}; /// Directory holding a profile's core-managed identity + memory files: /// `/personalities//`. @@ -192,11 +192,25 @@ pub fn ensure_profile_home( })?; let soul_path = home.join("SOUL.md"); + let has_inline_soul = profile + .soul_md + .as_ref() + .is_some_and(|soul| !soul.trim().is_empty()); if soul_path.exists() { tracing::debug!( profile_id = %profile.id, "[profiles][home] SOUL.md already present, not overwriting" ); + } else if profile.id == DEFAULT_PROFILE_ID && !has_inline_soul { + // Backward compatibility: the built-in Default profile represents the + // legacy workspace identity. Selecting it must not create a generic + // profile-local template that shadows the user's root SOUL.md. Once a + // user authors a Default soul it is seeded normally; an explicit clear + // writes an existing empty tombstone in `sync_soul_md_on_upsert`. + tracing::debug!( + profile_id = %profile.id, + "[profiles][home] Default has no authored soul; preserving root SOUL.md fallback" + ); } else { let contents = profile .soul_md @@ -212,11 +226,7 @@ pub fn ensure_profile_home( } }) .unwrap_or_else(|| default_soul_template(profile)); - let seeded_from_inline = profile - .soul_md - .as_ref() - .map(|s| !s.trim().is_empty()) - .unwrap_or(false); + let seeded_from_inline = has_inline_soul; seed_file_atomic(&home, &soul_path, contents.as_bytes()).map_err(|e| { tracing::debug!( profile_id = %profile.id, @@ -528,6 +538,30 @@ mod tests { assert_eq!(soul, "I am Bob, terse and exact.\n"); } + #[test] + fn ensure_default_profile_without_authored_soul_preserves_root_fallback() { + let ws = TempDir::new().unwrap(); + let action = TempDir::new().unwrap(); + std::fs::write(ws.path().join("SOUL.md"), "Established root identity").unwrap(); + let mut profile = test_profile(DEFAULT_PROFILE_ID); + profile.built_in = true; + profile.soul_md = None; + + ensure_profile_home(ws.path(), action.path(), &profile).expect("ensure"); + + assert!(profile_home(ws.path(), DEFAULT_PROFILE_ID) + .join("MEMORY.md") + .exists()); + assert!(!profile_home(ws.path(), DEFAULT_PROFILE_ID) + .join("SOUL.md") + .exists()); + assert_eq!( + super::super::paths::resolve_personality_soul(ws.path(), &profile), + None, + "no profile override leaves prompt construction on the root SOUL.md" + ); + } + #[test] fn ensure_profile_home_is_idempotent_and_preserves_edits() { let ws = TempDir::new().unwrap(); From 0d7fc3329ba9dd43dacf94b498386db53225aec1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 00:29:42 +0000 Subject: [PATCH 66/80] fix(profiles): replace soul files on windows --- src/openhuman/profiles/home.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/openhuman/profiles/home.rs b/src/openhuman/profiles/home.rs index 930a796fbf..1d256cad5f 100644 --- a/src/openhuman/profiles/home.rs +++ b/src/openhuman/profiles/home.rs @@ -98,12 +98,12 @@ pub fn validate_profile_id(id: &str) -> Result<(), String> { Ok(()) } -/// Seed `target` with `contents` atomically: write a temp file in the same -/// directory, then `rename` it over `target`. Callers invoke this only when -/// `target` is absent, so the rename never clobbers a user's edited file; the -/// write-then-rename keeps any concurrent reader from ever observing a -/// half-written seed (a bare `write` can be seen mid-flush). Idempotency -/// semantics are identical to the previous check-then-`write`. +/// Write `contents` through a temp file in the same directory, then move it to +/// `target`. Unix `rename` atomically replaces an existing target. Windows +/// `rename` cannot overwrite, so rewrites remove the old target immediately +/// before the move; this is the platform-safe remove/replace fallback used by +/// Settings SOUL edits and clear tombstones. First-time seeds stay atomic on +/// every platform, and no reader can observe a partially-written file. fn seed_file_atomic(dir: &Path, target: &Path, contents: &[u8]) -> io::Result<()> { let base = target .file_name() @@ -111,6 +111,17 @@ fn seed_file_atomic(dir: &Path, target: &Path, contents: &[u8]) -> io::Result<() .unwrap_or("seed"); let tmp = dir.join(format!(".{base}.tmp-{}", std::process::id())); std::fs::write(&tmp, contents)?; + + #[cfg(windows)] + match std::fs::remove_file(target) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + let _ = std::fs::remove_file(&tmp); + return Err(error); + } + } + match std::fs::rename(&tmp, target) { Ok(()) => Ok(()), Err(e) => { From 6287656636add413f30e5bfc662f0f5f2fc6d3e6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 00:46:14 +0000 Subject: [PATCH 67/80] fix(profiles): track shell cwd in write guard --- src/openhuman/profiles/guard.rs | 127 +++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 1 deletion(-) diff --git a/src/openhuman/profiles/guard.rs b/src/openhuman/profiles/guard.rs index 6bd77f29d2..45d24e0670 100644 --- a/src/openhuman/profiles/guard.rs +++ b/src/openhuman/profiles/guard.rs @@ -147,7 +147,9 @@ pub fn classify_cross_profile_target( /// /// # What it does catch /// -/// It splits the command on whitespace, redirect/pipe operators, and common +/// It splits the command into simple `;` / `&&` / `||` segments, tracks a +/// leading literal `cd ` across those segments, then splits each segment +/// on whitespace, redirect/pipe operators, and common /// shell punctuation (quotes, parens, backtick, `,`, `=`, `{`, `}`, `&`), keeps /// only path-shaped tokens (those containing a path separator or a leading `~`), /// resolves each against `cwd`, and classifies it via @@ -161,6 +163,70 @@ pub fn scan_command_for_cross_profile( cwd: &Path, action_dir: &Path, active_profile: &str, +) -> Option { + let mut effective_cwd = cwd.to_path_buf(); + for segment in split_command_segments(command) { + if let Some(other_id) = + scan_command_segment(segment, &effective_cwd, action_dir, active_profile) + { + return Some(other_id); + } + + let Some(next_cwd) = simple_cd_target(segment, &effective_cwd) else { + continue; + }; + if let CrossProfileDecision::Block { other_id } = + classify_cross_profile_target(action_dir, active_profile, &next_cwd) + { + return Some(other_id); + } + effective_cwd = next_cwd; + } + None +} + +/// Split at top-level shell sequencing operators while leaving separators +/// inside simple quotes alone. This is intentionally not a complete shell +/// parser; it only supplies enough ordering for literal `cd` tracking. +fn split_command_segments(command: &str) -> Vec<&str> { + let bytes = command.as_bytes(); + let mut segments = Vec::new(); + let mut start = 0; + let mut quote: Option = None; + let mut i = 0; + while i < bytes.len() { + let byte = bytes[i]; + if matches!(byte, b'\'' | b'"' | b'`') { + if quote == Some(byte) { + quote = None; + } else if quote.is_none() { + quote = Some(byte); + } + i += 1; + continue; + } + if quote.is_none() + && (byte == b';' + || (i + 1 < bytes.len() + && ((byte == b'&' && bytes[i + 1] == b'&') + || (byte == b'|' && bytes[i + 1] == b'|')))) + { + segments.push(&command[start..i]); + i += if byte == b';' { 1 } else { 2 }; + start = i; + continue; + } + i += 1; + } + segments.push(&command[start..]); + segments +} + +fn scan_command_segment( + command: &str, + cwd: &Path, + action_dir: &Path, + active_profile: &str, ) -> Option { // Split on shell punctuation as well as whitespace/redirects so a path // embedded in a quoted string, a `flag=value`, a comma list, or a brace @@ -203,6 +269,35 @@ pub fn scan_command_for_cross_profile( None } +/// Resolve a literal leading `cd` for the next sequenced command. Dynamic +/// targets (`$VAR`, command substitution) remain in the documented best-effort +/// gap. Nonexistent/non-directory targets are ignored because the shell's `cd` +/// would fail and leave cwd unchanged. +fn simple_cd_target(segment: &str, cwd: &Path) -> Option { + let mut words = segment.split_whitespace(); + if words.next()? != "cd" { + return None; + } + let mut target = words.next()?; + if target == "--" { + target = words.next()?; + } + let target = target.trim_matches(|c| matches!(c, '"' | '\'')); + if target.is_empty() || target.contains('$') || target.contains('`') || target.contains("$(") { + return None; + } + let expanded = crate::openhuman::config::expand_tilde(target); + let path = Path::new(&expanded); + let candidate = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + candidate + .is_dir() + .then(|| canonicalize_best_effort(&candidate)) +} + /// Canonicalize `path`, falling back to the raw path when it does not exist. fn canonicalize_best_effort(path: &Path) -> PathBuf { path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) @@ -413,6 +508,36 @@ mod tests { ); } + #[test] + fn scan_command_tracks_cd_before_sibling_write() { + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + assert_eq!( + scan_command_for_cross_profile( + "cd .. && printf x > bob/loot.txt", + &cwd, + &action, + "alice" + ), + Some("bob".to_string()) + ); + } + + #[test] + fn scan_command_tracks_chained_bare_cd_into_sibling() { + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + assert_eq!( + scan_command_for_cross_profile( + "cd ..; cd bob; printf x > loot.txt", + &cwd, + &action, + "alice" + ), + Some("bob".to_string()) + ); + } + #[test] fn scan_command_blocks_path_embedded_in_quoted_interpreter_arg() { // The path is buried inside a python -c program string. Splitting on From c985535c30aac3c04bb72727fc90bd19ce32a071 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 01:09:56 +0000 Subject: [PATCH 68/80] fix(skills): limit display aliases to profiles --- src/openhuman/skills/registry.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/openhuman/skills/registry.rs b/src/openhuman/skills/registry.rs index 9cec31b2a3..d887a716d5 100644 --- a/src/openhuman/skills/registry.rs +++ b/src/openhuman/skills/registry.rs @@ -13,6 +13,7 @@ use std::path::Path; use serde::{Deserialize, Serialize}; use crate::openhuman::agent::harness::definition::{AgentDefinition, PromptSource}; +use crate::openhuman::skills::WorkflowScope; /// One declared input — a parameter the skill needs, with a human description. /// `required` inputs must be supplied at run time; `kind` is an optional type @@ -294,11 +295,11 @@ pub fn get_workflow_with_profile( return Some(exact.clone()); } - // Lists advertise the frontmatter display name as well as the directory - // slug. Resolve that name back to the canonical runnable slug so a private - // workflow admitted by the profile-local allow set can actually be - // described and run. Discovery has already applied scope precedence and - // collision deduplication, matching the list surface. + // Profile lists advertise the frontmatter display name as well as the + // directory slug. Resolve that name back to the canonical runnable slug so + // a private workflow admitted by the profile-local allow set can actually + // be described and run. Keep the legacy profile-less lookup id-only: global + // display names have never been runnable ids and may collide with builtins. let home = dirs::home_dir(); let trusted = super::ops_discover::is_workspace_trusted(workspace_dir); let slug = super::ops_discover::discover_workflows_with_profile( @@ -308,7 +309,7 @@ pub fn get_workflow_with_profile( trusted, ) .into_iter() - .find(|workflow| workflow.name == id) + .find(|workflow| workflow.scope == WorkflowScope::Profile && workflow.name == id) .map(|workflow| { if workflow.dir_name.is_empty() { workflow.name From 502a7ef795570d176ca9c64d68db59be510ab8be Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 01:26:56 +0000 Subject: [PATCH 69/80] fix(profiles): guard bare sibling operands --- src/openhuman/profiles/guard.rs | 44 ++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/openhuman/profiles/guard.rs b/src/openhuman/profiles/guard.rs index 45d24e0670..31e0ea9ac5 100644 --- a/src/openhuman/profiles/guard.rs +++ b/src/openhuman/profiles/guard.rs @@ -151,8 +151,9 @@ pub fn classify_cross_profile_target( /// leading literal `cd ` across those segments, then splits each segment /// on whitespace, redirect/pipe operators, and common /// shell punctuation (quotes, parens, backtick, `,`, `=`, `{`, `}`, `&`), keeps -/// only path-shaped tokens (those containing a path separator or a leading `~`), -/// resolves each against `cwd`, and classifies it via +/// path-shaped tokens (those containing a path separator or a leading `~`), +/// plus bare operands naming an existing profile when the tracked cwd is the +/// profiles root, resolves each against `cwd`, and classifies it via /// [`classify_cross_profile_target`]. This reliably catches the realistic /// non-adversarial escape vectors: an absolute path into `profiles/`, a /// `..//…` traversal, a `--flag=..//…` form, and simple quoted paths. @@ -228,6 +229,9 @@ fn scan_command_segment( action_dir: &Path, active_profile: &str, ) -> Option { + let profiles_root = canonicalize_best_effort(&action_dir.join("profiles")); + let cwd_is_profiles_root = canonicalize_best_effort(cwd) == profiles_root; + let mut token_index = 0usize; // Split on shell punctuation as well as whitespace/redirects so a path // embedded in a quoted string, a `flag=value`, a comma list, or a brace // expansion is isolated into its own token. Splitting only ever produces @@ -246,11 +250,19 @@ fn scan_command_segment( if token.is_empty() { continue; } - // Only path-shaped tokens can reach another directory: they contain a - // separator or start with `~`. A bare word (`ls`, `bob`) resolves under - // cwd (the profile's own dir) and is never a sibling. + let is_command_word = token_index == 0; + token_index += 1; + // Ordinarily only path-shaped tokens can reach another directory. Once + // a preceding `cd` has moved cwd to `/profiles`, however, a + // bare operand can name a sibling directly (`rm -rf bob`). Scan such an + // operand only when it resolves to an existing profile directory; this + // avoids treating ordinary arguments (`echo hi`) as profile ids. let path_shaped = token.contains('/') || token.contains('\\') || token.starts_with('~'); - if !path_shaped { + let bare_profile_operand = cwd_is_profiles_root + && !is_command_word + && !token.starts_with('-') + && cwd.join(token).is_dir(); + if !path_shaped && !bare_profile_operand { continue; } let expanded = crate::openhuman::config::expand_tilde(token); @@ -523,6 +535,26 @@ mod tests { ); } + #[test] + fn scan_command_tracks_cd_before_bare_sibling_operand() { + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + assert_eq!( + scan_command_for_cross_profile("cd ..; rm -rf bob", &cwd, &action, "alice"), + Some("bob".to_string()) + ); + } + + #[test] + fn scan_command_allows_non_profile_bare_operand_at_profiles_root() { + let (_g, action) = profiles_layout(); + let cwd = action.join("profiles").join("alice"); + assert_eq!( + scan_command_for_cross_profile("cd ..; echo hi", &cwd, &action, "alice"), + None + ); + } + #[test] fn scan_command_tracks_chained_bare_cd_into_sibling() { let (_g, action) = profiles_layout(); From f86a8f3cfed7858b722c20a984f233f4d6a127a9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 01:47:38 +0000 Subject: [PATCH 70/80] fix(cron): apply profile runtime defaults --- .../agent/harness/session/builder/factory.rs | 14 +++--- src/openhuman/cron/scheduler.rs | 29 ++++++++++- src/openhuman/cron/scheduler_tests.rs | 48 +++++++++++++++++++ 3 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index a38e80092e..b808811f0e 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -1117,13 +1117,13 @@ impl Agent { pformat_registry.len() ); - // Temperature override: when we have a target definition, use - // its declared temperature from the TOML (welcome is 0.7, - // orchestrator is 0.4, etc). Fall back to - // `config.default_temperature` for the legacy "no definition" - // path so existing callers keep getting their configured value. - let effective_temperature = target_def - .map(|def| def.temperature) + // Temperature override: an active profile is the user-selected runtime + // default; otherwise use the target definition's TOML value (welcome is + // 0.7, orchestrator is 0.4, etc). Fall back to config for the legacy + // no-definition path. + let effective_temperature = profile + .and_then(|profile| profile.temperature) + .or_else(|| target_def.map(|def| def.temperature)) .unwrap_or(config.default_temperature); // Thread PROFILE.md + MEMORY.md inclusion from the resolved diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index e0e51e21b8..a6ab512241 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -1055,6 +1055,25 @@ struct BuiltCronAgent { profile: Option, } +fn apply_cron_profile_runtime_defaults( + config: &Config, + job: &CronJob, + profile: &crate::openhuman::profiles::AgentProfile, +) -> Config { + let mut effective = config.clone(); + if let Some(model) = profile.model_override.clone() { + effective.default_model = Some(model); + } + if let Some(temperature) = profile.temperature { + effective.default_temperature = temperature; + } + // A job-level pin is the most specific model choice. + if let Some(model) = job.model.clone() { + effective.default_model = Some(model); + } + effective +} + fn build_agent_for_cron_job(config: &Config, job: &CronJob) -> anyhow::Result { // 2b — profile attribution. When the job names a profile that still exists, // build the run under it via the SAME profile-aware session path the task @@ -1063,6 +1082,12 @@ fn build_agent_for_cron_job(config: &Config, job: &CronJob) -> anyhow::Result anyhow::Result Date: Thu, 23 Jul 2026 01:47:39 +0000 Subject: [PATCH 71/80] fix(security): resolve relative policy paths from action root --- src/openhuman/security/policy/path_checks.rs | 6 ++++- src/openhuman/security/policy/policy_tests.rs | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/openhuman/security/policy/path_checks.rs b/src/openhuman/security/policy/path_checks.rs index ab73376785..4f8f212a4f 100644 --- a/src/openhuman/security/policy/path_checks.rs +++ b/src/openhuman/security/policy/path_checks.rs @@ -54,7 +54,11 @@ impl SecurityPolicy { let check = if expanded_path.is_absolute() { expanded_path.to_path_buf() } else { - self.workspace_dir.join(expanded_path) + // File tools resolve relative paths from action_dir, not the + // core-state workspace. Joining workspace_dir here accidentally + // reserves internal directory names (for example + // `personalities/alice.md`) in an otherwise legitimate project. + self.action_dir.join(expanded_path) }; if self.is_workspace_internal_path(&check) { log::trace!( diff --git a/src/openhuman/security/policy/policy_tests.rs b/src/openhuman/security/policy/policy_tests.rs index bc991513c5..ce0992d564 100644 --- a/src/openhuman/security/policy/policy_tests.rs +++ b/src/openhuman/security/policy/policy_tests.rs @@ -903,6 +903,30 @@ fn relative_paths_allowed() { assert!(p.is_path_string_allowed("deep/nested/dir/file.txt")); } +#[test] +fn relative_personalities_path_resolves_under_action_dir() { + let tmp = tempfile::tempdir().expect("tempdir"); + let workspace = tmp.path().join("state"); + let action = tmp.path().join("projects"); + std::fs::create_dir_all(workspace.join("personalities").join("alice")).unwrap(); + std::fs::create_dir_all(action.join("personalities")).unwrap(); + let policy = SecurityPolicy { + workspace_dir: workspace.clone(), + action_dir: action, + ..SecurityPolicy::default() + }; + + assert!(policy.is_path_string_allowed("personalities/alice.md")); + assert!(!policy.is_path_string_allowed( + workspace + .join("personalities") + .join("alice") + .join("SOUL.md") + .to_string_lossy() + .as_ref() + )); +} + #[test] fn path_traversal_blocked() { let p = default_policy(); From 995f09a63cb2f54353c42286b43e19aa0e11c520 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 02:15:26 +0000 Subject: [PATCH 72/80] fix(profiles): block symlinked process cwd --- src/openhuman/tools/impl/system/mod.rs | 22 ++++++++-- src/openhuman/tools/impl/system/npm_exec.rs | 47 +++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/openhuman/tools/impl/system/mod.rs b/src/openhuman/tools/impl/system/mod.rs index 29d6149dca..2a6f6dfb68 100644 --- a/src/openhuman/tools/impl/system/mod.rs +++ b/src/openhuman/tools/impl/system/mod.rs @@ -77,12 +77,26 @@ pub(super) fn check_cross_profile_command( let Some(guard) = security.active_profile.as_ref() else { return Ok(()); }; - let Some(other_id) = crate::openhuman::profiles::scan_command_for_cross_profile( - command, - cwd, + // Classify cwd itself before scanning command tokens. A process tool may + // accept a syntactically in-profile directory that is actually a symlink + // into a sibling; once spawned there, npm lifecycle hooks or a shell can + // mutate that sibling without mentioning its path in the command. + let other_id = match crate::openhuman::profiles::classify_cross_profile_target( &guard.action_dir, &guard.profile_id, - ) else { + cwd, + ) { + crate::openhuman::profiles::CrossProfileDecision::Block { other_id } => Some(other_id), + crate::openhuman::profiles::CrossProfileDecision::Allow => { + crate::openhuman::profiles::scan_command_for_cross_profile( + command, + cwd, + &guard.action_dir, + &guard.profile_id, + ) + } + }; + let Some(other_id) = other_id else { return Ok(()); }; diff --git a/src/openhuman/tools/impl/system/npm_exec.rs b/src/openhuman/tools/impl/system/npm_exec.rs index 083e9d1da6..0b6dc749cc 100644 --- a/src/openhuman/tools/impl/system/npm_exec.rs +++ b/src/openhuman/tools/impl/system/npm_exec.rs @@ -674,4 +674,51 @@ mod tests { assert!(result.is_error); assert!(result.text().contains("Cross-profile access blocked")); } + + #[cfg(unix)] + #[tokio::test] + async fn symlinked_cwd_cannot_target_sibling_profile() { + use crate::openhuman::agent::host_runtime::NativeRuntime; + use crate::openhuman::config::schema::NodeConfig; + use crate::openhuman::security::policy::ActiveProfileGuard; + use crate::openhuman::security::AutonomyLevel; + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let action_root = temp.path().join("actions"); + let alice = action_root.join("profiles/alice"); + let bob = action_root.join("profiles/bob"); + std::fs::create_dir_all(&bob).unwrap(); + std::fs::create_dir_all(&alice).unwrap(); + symlink(&bob, alice.join("link")).unwrap(); + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Full, + workspace_dir: temp.path().join("state"), + action_dir: alice, + workspace_only: false, + active_profile: Some(ActiveProfileGuard { + profile_id: "alice".into(), + action_dir: action_root, + }), + ..SecurityPolicy::default() + }); + let bootstrap = Arc::new(NodeBootstrap::new( + NodeConfig::default(), + temp.path().to_path_buf(), + reqwest::Client::new(), + )); + let tool = NpmExecTool::new(security, Arc::new(NativeRuntime::new()), bootstrap); + + let result = tool + .execute(json!({ + "subcommand": "run", + "args": ["build"], + "cwd": "link" + })) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.text().contains("Cross-profile access blocked")); + } } From 59ccc8c429a701c9a5a8acbe4bceb691bfd2ef5f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 02:48:20 +0000 Subject: [PATCH 73/80] fix(profiles): protect shared profiles root --- src/openhuman/profiles/guard.rs | 37 +++++++++++++------- src/openhuman/profiles/mod.rs | 2 +- src/openhuman/security/policy/path_checks.rs | 8 +++++ src/openhuman/tools/impl/system/mod.rs | 23 ++++++++---- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/openhuman/profiles/guard.rs b/src/openhuman/profiles/guard.rs index 31e0ea9ac5..b568d92ddd 100644 --- a/src/openhuman/profiles/guard.rs +++ b/src/openhuman/profiles/guard.rs @@ -24,6 +24,9 @@ use std::path::{Component, Path, PathBuf}; /// is the profile id. Kept private-behind-helpers so the encode/decode pair is /// the only way this string is produced or parsed. const PROFILE_POLICY_ID_PREFIX: &str = "openhuman.profile:"; +/// Marker returned when the protected target is the shared `profiles/` root +/// rather than one named sibling profile. +pub const PROFILES_ROOT_SENTINEL: &str = ""; /// Encode a profile id as the `WorkspaceDescriptor::policy_id` the session /// builder stamps onto a dedicated-workspace descriptor (`openhuman.profile:`). @@ -56,7 +59,8 @@ pub enum CrossProfileDecision { /// `/profiles/` entirely). Allow, /// The target lands inside `/profiles//` with - /// `other_id != active_profile` — blocked. + /// `other_id != active_profile`, or is the shared profiles root itself — + /// blocked. Root targets carry [`PROFILES_ROOT_SENTINEL`]. Block { /// The sibling profile whose workspace the target tried to reach. other_id: String, @@ -73,7 +77,8 @@ pub enum CrossProfileDecision { /// joined onto `action_dir` first so the classifier is robust to either. /// /// Returns [`CrossProfileDecision::Block`] iff the canonicalized `target` is -/// inside `/profiles//` for some `Q != active_profile`, otherwise +/// the shared `/profiles/` root or is inside +/// `/profiles//` for some `Q != active_profile`, otherwise /// [`CrossProfileDecision::Allow`]. /// /// **Symlink safety.** The comparison is done on canonicalized paths: the @@ -103,6 +108,11 @@ pub fn classify_cross_profile_target( let Ok(relative) = canon_target.strip_prefix(&canon_profiles_root) else { return CrossProfileDecision::Allow; }; + if relative.as_os_str().is_empty() { + return CrossProfileDecision::Block { + other_id: PROFILES_ROOT_SENTINEL.to_string(), + }; + } // First component under `profiles/` is the owning profile id. let Some(Component::Normal(owner)) = relative.components().next() else { return CrossProfileDecision::Allow; @@ -257,7 +267,8 @@ fn scan_command_segment( // bare operand can name a sibling directly (`rm -rf bob`). Scan such an // operand only when it resolves to an existing profile directory; this // avoids treating ordinary arguments (`echo hi`) as profile ids. - let path_shaped = token.contains('/') || token.contains('\\') || token.starts_with('~'); + let path_shaped = + token == ".." || token.contains('/') || token.contains('\\') || token.starts_with('~'); let bare_profile_operand = cwd_is_profiles_root && !is_command_word && !token.starts_with('-') @@ -471,13 +482,15 @@ mod tests { } #[test] - fn profiles_root_itself_is_allowed() { - // `profiles/` (no owner component) is not a sibling write. + fn profiles_root_itself_is_blocked() { + // Mutating the shared root can affect every sibling at once. let (_g, action) = profiles_layout(); let target = action.join("profiles"); assert_eq!( classify_cross_profile_target(&action, "alice", &target), - CrossProfileDecision::Allow + CrossProfileDecision::Block { + other_id: PROFILES_ROOT_SENTINEL.into() + } ); } @@ -531,7 +544,7 @@ mod tests { &action, "alice" ), - Some("bob".to_string()) + Some(PROFILES_ROOT_SENTINEL.to_string()) ); } @@ -541,17 +554,17 @@ mod tests { let cwd = action.join("profiles").join("alice"); assert_eq!( scan_command_for_cross_profile("cd ..; rm -rf bob", &cwd, &action, "alice"), - Some("bob".to_string()) + Some(PROFILES_ROOT_SENTINEL.to_string()) ); } #[test] - fn scan_command_allows_non_profile_bare_operand_at_profiles_root() { + fn scan_command_blocks_parent_profiles_root_operand() { let (_g, action) = profiles_layout(); let cwd = action.join("profiles").join("alice"); assert_eq!( - scan_command_for_cross_profile("cd ..; echo hi", &cwd, &action, "alice"), - None + scan_command_for_cross_profile("rm -rf ..", &cwd, &action, "alice"), + Some(PROFILES_ROOT_SENTINEL.to_string()) ); } @@ -566,7 +579,7 @@ mod tests { &action, "alice" ), - Some("bob".to_string()) + Some(PROFILES_ROOT_SENTINEL.to_string()) ); } diff --git a/src/openhuman/profiles/mod.rs b/src/openhuman/profiles/mod.rs index 678c591d10..8196fc6dec 100644 --- a/src/openhuman/profiles/mod.rs +++ b/src/openhuman/profiles/mod.rs @@ -62,7 +62,7 @@ pub mod types; pub use guard::{ classify_cross_profile_target, profile_id_from_policy_id, scan_command_for_cross_profile, - workspace_policy_id, CrossProfileDecision, + workspace_policy_id, CrossProfileDecision, PROFILES_ROOT_SENTINEL, }; pub use home::{ dedicated_workspace_dir, ensure_profile_home, profile_action_workspace, profile_home, diff --git a/src/openhuman/security/policy/path_checks.rs b/src/openhuman/security/policy/path_checks.rs index 4f8f212a4f..60d38f4d90 100644 --- a/src/openhuman/security/policy/path_checks.rs +++ b/src/openhuman/security/policy/path_checks.rs @@ -332,6 +332,14 @@ impl SecurityPolicy { target = %resolved.display(), "[profiles] cross-profile write blocked" ); + if other_id == crate::openhuman::profiles::PROFILES_ROOT_SENTINEL { + return Err(format!( + "{POLICY_BLOCKED_MARKER} Cross-profile access blocked: profile '{}' may not \ + write to the shared profiles root. Stay within your own profile directory; \ + do not retry this path.", + guard.profile_id + )); + } return Err(format!( "{POLICY_BLOCKED_MARKER} Cross-profile access blocked: profile '{}' may not write \ into profile '{}'s workspace. Stay within your own profile directory; do not \ diff --git a/src/openhuman/tools/impl/system/mod.rs b/src/openhuman/tools/impl/system/mod.rs index 2a6f6dfb68..1425525aec 100644 --- a/src/openhuman/tools/impl/system/mod.rs +++ b/src/openhuman/tools/impl/system/mod.rs @@ -106,11 +106,20 @@ pub(super) fn check_cross_profile_command( other_profile = %other_id, "[profiles] cross-profile process command blocked" ); - Err(format!( - "{} Cross-profile access blocked: profile '{}' may not touch profile '{}'s workspace. \ - Stay within your own profile directory; do not retry this command.", - crate::openhuman::security::POLICY_BLOCKED_MARKER, - guard.profile_id, - other_id - )) + if other_id == crate::openhuman::profiles::PROFILES_ROOT_SENTINEL { + Err(format!( + "{} Cross-profile access blocked: profile '{}' may not modify the shared profiles \ + root. Stay within your own profile directory; do not retry this command.", + crate::openhuman::security::POLICY_BLOCKED_MARKER, + guard.profile_id, + )) + } else { + Err(format!( + "{} Cross-profile access blocked: profile '{}' may not touch profile '{}'s workspace. \ + Stay within your own profile directory; do not retry this command.", + crate::openhuman::security::POLICY_BLOCKED_MARKER, + guard.profile_id, + other_id + )) + } } From c4ba7c5458602ac188d73c459d07aec7247a90db Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 02:48:20 +0000 Subject: [PATCH 74/80] fix(profiles): preserve tool limits in subagents --- src/openhuman/agent/harness/fork_context.rs | 6 +++-- src/openhuman/agent/harness/session/types.rs | 4 +-- .../harness/subagent_runner/ops/runner.rs | 17 +++++++++++++ .../harness/subagent_runner/tool_prep.rs | 25 +++++++++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/openhuman/agent/harness/fork_context.rs b/src/openhuman/agent/harness/fork_context.rs index 2957a36117..b2d2ddf9fd 100644 --- a/src/openhuman/agent/harness/fork_context.rs +++ b/src/openhuman/agent/harness/fork_context.rs @@ -60,8 +60,10 @@ pub struct ParentExecutionContext { /// to reason about what the parent can *actually* invoke — e.g. /// `agent_prepare_context` recommending next tool calls — must consult /// this, not `all_tool_specs` (which is the full registry, including - /// hidden direct-exec/spawn tools the parent never advertises). Empty - /// means "unknown" — callers should treat that as "no restriction". + /// hidden direct-exec/spawn tools the parent never advertises). The + /// sub-agent runner intersects child scopes with this set so profile and + /// channel allowlists cannot be widened through delegation. Empty means + /// "unknown" — callers should treat that as "no restriction". pub visible_tool_names: std::collections::HashSet, /// Model name the parent is currently using (after classification). diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index ba3df7d586..ec02379c43 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -44,8 +44,8 @@ pub struct Agent { /// provider in the main agent's chat requests. pub(super) visible_tool_specs: Arc>, /// When non-empty, only these tool names are visible in the main - /// agent's prompt and callable by the main agent. Sub-agents ignore - /// this filter — they apply per-definition whitelists in the runner. + /// agent's prompt and callable by the main agent. Sub-agents intersect + /// their per-definition scopes with the effective parent-visible set. /// Empty = no filter (all tools visible, backward compat). pub(super) visible_tool_names: std::collections::HashSet, pub(super) tool_policy_session: ToolPolicySession, diff --git a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs index 2c3659c0e0..991c0b72d1 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs @@ -643,6 +643,15 @@ async fn run_typed_mode( if definition.id == "tools_agent" { allowed_indices.retain(|&i| parent.all_tools[i].category() != ToolCategory::Workflow); } + // A child may only narrow the parent's effective tool surface, never widen + // it back to `all_tools`. This preserves profile allowlists and channel + // policy across delegation (including wildcard child definitions and + // force-included `extra_tools`). Empty retains legacy "unknown" semantics. + super::super::tool_prep::retain_parent_visible_tool_indices( + &mut allowed_indices, + &parent.all_tools, + &parent.visible_tool_names, + ); if is_integrations_agent_with_toolkit { if let Some(tk) = toolkit_filter { @@ -791,6 +800,14 @@ async fn run_typed_mode( } } + // Dynamic Composio action tools are effectful too. Do not let delegation + // synthesize one that the parent profile/policy did not expose. Internal + // runner-only tools (such as extract_from_result below) are added after + // this intersection and cannot access the filesystem/process surface. + if !parent.visible_tool_names.is_empty() { + dynamic_tools.retain(|tool| parent.visible_tool_names.contains(tool.name())); + } + // ── Progressive-disclosure handoff cache ─────────────────────────── let handoff_cache: Option> = if is_integrations_agent_with_toolkit { let cache = Arc::new(ResultHandoffCache::new()); diff --git a/src/openhuman/agent/harness/subagent_runner/tool_prep.rs b/src/openhuman/agent/harness/subagent_runner/tool_prep.rs index e0f5309558..892cb14b8f 100644 --- a/src/openhuman/agent/harness/subagent_runner/tool_prep.rs +++ b/src/openhuman/agent/harness/subagent_runner/tool_prep.rs @@ -205,6 +205,20 @@ pub(super) fn filter_tool_indices( .collect() } +/// Intersect a child definition's tool indices with the tools the parent turn +/// actually exposes. An empty parent set is the legacy "unknown/unrestricted" +/// sentinel used by internal callers and older tests. +pub(super) fn retain_parent_visible_tool_indices( + indices: &mut Vec, + parent_tools: &[Box], + parent_visible: &std::collections::HashSet, +) { + if parent_visible.is_empty() { + return; + } + indices.retain(|&index| parent_visible.contains(parent_tools[index].name())); +} + pub(super) fn disallowed_tool_matches(disallowed: &[String], name: &str) -> bool { disallowed.iter().any(|entry| { if let Some(prefix) = entry.strip_suffix('*') { @@ -340,6 +354,17 @@ mod recovery_visibility_tests { ); assert!(!names(&idx, &t).contains(&RECOVERY_TOOL_NAME.to_string())); } + + #[test] + fn parent_visibility_caps_wildcard_child_scope() { + let t = tools(); + let mut idx = filter_tool_indices(&t, &ToolScope::Wildcard, &[], None); + let parent_visible = ["current_time".to_string()].into_iter().collect(); + + retain_parent_visible_tool_indices(&mut idx, &t, &parent_visible); + + assert_eq!(names(&idx, &t), vec!["current_time".to_string()]); + } } // ── Prompt loading ────────────────────────────────────────────────────── From 155ad52f460afd787497150afd6467c575b2be09 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 03:07:10 +0000 Subject: [PATCH 75/80] test(profiles): seed non-default built-in soul --- src/openhuman/profiles/schemas.rs | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/openhuman/profiles/schemas.rs b/src/openhuman/profiles/schemas.rs index da07c0ee9c..09e1ae3161 100644 --- a/src/openhuman/profiles/schemas.rs +++ b/src/openhuman/profiles/schemas.rs @@ -298,23 +298,24 @@ mod tests { let temp = tempfile::tempdir().expect("tempdir"); let _env = WorkspaceEnvGuard::set(temp.path()); - // Seed the built-in default home the way first activation does; the + const PROFILE_ID: &str = "reasoning"; + // Seed a non-default built-in home the way first activation does; the + // unedited Default intentionally keeps using the legacy root SOUL.md. // enriched payload advertises the resolved SOUL.md path once it exists. let selected = handle_profile_select(Map::from_iter([( "profile_id".into(), - Value::String(DEFAULT_PROFILE_ID.into()), + Value::String(PROFILE_ID.into()), )])) .await - .expect("select default"); - let soul_path = - soul_md_file(&selected, DEFAULT_PROFILE_ID).expect("select seeds the built-in SOUL.md"); + .expect("select built-in"); + let soul_path = soul_md_file(&selected, PROFILE_ID).expect("select seeds built-in SOUL.md"); // User later edits the built-in's Soul in Settings. handle_profile_upsert(Map::from_iter([( "profile".into(), json!({ - "id": DEFAULT_PROFILE_ID, - "name": "Default", + "id": PROFILE_ID, + "name": "Reasoning", "description": "", "agentId": "orchestrator", "soulMd": "Edited built-in persona.", @@ -339,22 +340,22 @@ mod tests { let temp = tempfile::tempdir().expect("tempdir"); let _env = WorkspaceEnvGuard::set(temp.path()); + const PROFILE_ID: &str = "reasoning"; let selected = handle_profile_select(Map::from_iter([( "profile_id".into(), - Value::String(DEFAULT_PROFILE_ID.into()), + Value::String(PROFILE_ID.into()), )])) .await - .expect("select default"); - let soul_path = - soul_md_file(&selected, DEFAULT_PROFILE_ID).expect("select seeds the built-in SOUL.md"); + .expect("select built-in"); + let soul_path = soul_md_file(&selected, PROFILE_ID).expect("select seeds built-in SOUL.md"); std::fs::write(&soul_path, "MANUAL EDIT").unwrap(); // Upsert with no soulMd — must not touch the manually edited file. handle_profile_upsert(Map::from_iter([( "profile".into(), json!({ - "id": DEFAULT_PROFILE_ID, - "name": "Default", + "id": PROFILE_ID, + "name": "Reasoning", "description": "", "agentId": "orchestrator", "builtIn": true, From 4054b94499c6b465f55c2974b428dc90ed2543ac Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 03:07:10 +0000 Subject: [PATCH 76/80] fix(security): retain sensitive filename denies --- src/openhuman/security/policy/path_checks.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/openhuman/security/policy/path_checks.rs b/src/openhuman/security/policy/path_checks.rs index 60d38f4d90..32a56b5b58 100644 --- a/src/openhuman/security/policy/path_checks.rs +++ b/src/openhuman/security/policy/path_checks.rs @@ -38,6 +38,22 @@ impl SecurityPolicy { let expanded = self.expand_tilde(path); let expanded_path = Path::new(&expanded); + // Core-sensitive single-file names remain reserved even when a + // relative request resolves under the separate action root. Directory + // names are root-sensitive (a project may legitimately have + // `personalities/`), but `.env`/SOUL.md/etc. retain the existing + // top-level deny contract. + if !expanded_path.is_absolute() + && expanded_path.components().count() == 1 + && WORKSPACE_INTERNAL_FILES.iter().any(|name| { + expanded_path + .file_name() + .is_some_and(|requested| requested == std::ffi::OsStr::new(name)) + }) + { + return false; + } + // Credential stores are never reachable, even via a trusted-root grant. if Self::is_always_forbidden(expanded_path) { return false; From ad4df38adedfc9a7c92fcc3dc0367f4b54523d3a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 03:53:44 +0000 Subject: [PATCH 77/80] test(composio): preserve upstream contract case --- ...composio_credentials_state_raw_coverage_e2e.rs | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs b/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs index 85228a6704..663215119f 100644 --- a/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs +++ b/tests/raw_coverage/composio_credentials_state_raw_coverage_e2e.rs @@ -310,19 +310,12 @@ async fn round15_composio_agent_tools_backend_cache_and_trigger_history_edges() ); assert_eq!(action_tool.name(), "ROUND15MAIL_FETCH_EMAILS"); assert_eq!(action_tool.category().to_string(), "skill"); - let missing_required_result = action_tool - .execute(json!({})) - .await - .expect("per-action contract gate"); - assert!(missing_required_result.is_error); - assert!(missing_required_result.text().contains("Input JSON schema")); - - let unknown_property_result = action_tool + let contract_result = action_tool .execute(json!({ "invented_filter": "from:me" })) .await - .expect("per-action unknown property gate"); - assert!(unknown_property_result.is_error); - assert!(unknown_property_result.text().contains("Input JSON schema")); + .expect("per-action contract gate"); + assert!(contract_result.is_error); + assert!(contract_result.text().contains("Input JSON schema")); let action_result = action_tool .execute(json!({ "query": "from:me" })) From 7cc90a4c12255446cbc2d1d97e4bc7dbff40cb7e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 04:43:32 +0000 Subject: [PATCH 78/80] fix(profiles): merge shared experience recall --- .../agent/harness/session/builder/factory.rs | 14 +++++ .../agent/harness/session/builder/setters.rs | 9 +++ .../agent/harness/session/turn/core.rs | 10 +++- .../agent/harness/session/turn_tests.rs | 59 +++++++++++++++++++ src/openhuman/agent/harness/session/types.rs | 5 ++ src/openhuman/agent_experience/mod.rs | 4 +- src/openhuman/agent_experience/ops.rs | 27 ++------- src/openhuman/agent_experience/store.rs | 37 +++++++++++- 8 files changed, 137 insertions(+), 28 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index b808811f0e..4cc90df74c 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -346,6 +346,19 @@ impl Agent { )?; let archivist_connection = session_memory.sqlite_connection; let memory: Arc = Arc::from(session_memory.memory); + // Dedicated profiles still recall unstamped experiences written by + // pre-profile versions from the shared memory DB. Retain the global + // shared handle explicitly on the session rather than making the hot + // turn path reload config or reach into process-global state. + let shared_experience_memory = if memory_subdir == "memory" { + None + } else { + Some( + crate::openhuman::memory::global::init(config.workspace_dir.clone()) + .map_err(anyhow::Error::msg)? + .memory_handle(), + ) + }; // Per-profile skill (workflow) + MCP-server allowlists. `None` = all. let profile_skill_allowlist: Option> = profile @@ -1252,6 +1265,7 @@ impl Agent { .tools(tools) .visible_tool_names(visible) .memory(memory) + .shared_experience_memory(shared_experience_memory) .tool_dispatcher(tool_dispatcher) .memory_loader(Box::new( DefaultMemoryLoader::new(5, config.memory.min_relevance_score) diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs index 21a4041990..237f0a40dc 100644 --- a/src/openhuman/agent/harness/session/builder/setters.rs +++ b/src/openhuman/agent/harness/session/builder/setters.rs @@ -23,6 +23,7 @@ impl AgentBuilder { tools: None, visible_tool_names: None, memory: None, + shared_experience_memory: None, prompt_builder: None, tool_dispatcher: None, memory_loader: None, @@ -121,6 +122,13 @@ impl AgentBuilder { self } + /// Retains the shared store for experience recall when `memory` is a + /// dedicated profile subtree. + pub fn shared_experience_memory(mut self, memory: Option>) -> Self { + self.shared_experience_memory = memory; + self + } + /// Sets the system prompt builder for the agent. pub fn prompt_builder( mut self, @@ -534,6 +542,7 @@ impl AgentBuilder { memory: self .memory .ok_or_else(|| anyhow::anyhow!("memory is required"))?, + shared_experience_memory: self.shared_experience_memory, tool_dispatcher: std::sync::Arc::from( self.tool_dispatcher .ok_or_else(|| anyhow::anyhow!("tool_dispatcher is required"))?, diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index 7015ceb337..91f3a70057 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -11,7 +11,8 @@ use crate::openhuman::agent::harness::fork_context::ParentExecutionContext; use crate::openhuman::agent::hooks::{self, TurnContext}; use crate::openhuman::agent::progress::AgentProgress; use crate::openhuman::agent_experience::{ - prepend_experience_block, render_experience_hits, AgentExperienceStore, ExperienceQuery, + prepend_experience_block, render_experience_hits, retrieve_across_stores, AgentExperienceStore, + ExperienceQuery, }; use crate::openhuman::agent_memory::memory_loader::collect_recall_citations; use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage}; @@ -1668,7 +1669,10 @@ impl Agent { .iter() .map(|spec| spec.name.clone()) .collect(); - let store = AgentExperienceStore::new(self.memory.clone()); + let mut stores = vec![AgentExperienceStore::new(self.memory.clone())]; + if let Some(shared_memory) = &self.shared_experience_memory { + stores.push(AgentExperienceStore::new(shared_memory.clone())); + } let query = ExperienceQuery { query: user_message.to_string(), tools, @@ -1683,7 +1687,7 @@ impl Agent { max_hits: MAX_EXPERIENCE_HITS, }; - match store.retrieve(query).await { + match retrieve_across_stores(&stores, query).await { Ok(hits) => { let matched_hits: Vec<_> = hits .into_iter() diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index b00521b9d5..175dfe97c0 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -7,6 +7,9 @@ use crate::openhuman::agent::tool_policy::{ GeneratedToolRuntimeContext, GeneratedToolRuntimeRisk, ToolPolicy, ToolPolicyDecision, ToolPolicyRequest, }; +use crate::openhuman::agent_experience::{ + AgentExperience, AgentExperienceStore, ExperienceOutcome, ExperienceSource, +}; use crate::openhuman::agent_memory::memory_loader::MemoryLoader; use crate::openhuman::inference::provider::{ ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, UsageInfo, @@ -2073,6 +2076,62 @@ fn make_real_memory(workspace: &std::path::Path) -> Arc { Arc::new(UnifiedMemory::new(workspace, Arc::new(NoopEmbedding), None).unwrap()) } +#[tokio::test] +async fn dedicated_profile_experience_recall_merges_shared_legacy_store() { + let tmp = tempfile::TempDir::new().unwrap(); + let dedicated = make_real_memory(&tmp.path().join("dedicated")); + let shared = make_real_memory(&tmp.path().join("shared")); + AgentExperienceStore::new(shared.clone()) + .put(AgentExperience { + id: "legacy-shared-deploy".into(), + created_at_ms: 0, + updated_at_ms: 0, + source: ExperienceSource::ToolLoop, + agent_id: None, + entrypoint: None, + profile_id: None, + task_fingerprint: "deploy-rust-service".into(), + task_summary: "Deploy the Rust service safely".into(), + tools_used: vec![], + tool_sequence: vec![], + outcome: ExperienceOutcome::Success, + error_class: None, + lesson: "Legacy shared deployment guidance".into(), + reuse_hint: "Check the release health endpoint".into(), + avoid_hint: None, + confidence: 0.9, + tags: vec![], + payload_hash: None, + dismissed: false, + }) + .await + .unwrap(); + + let agent = Agent::builder() + .provider(Box::new(DummyProvider)) + .tools(vec![]) + .memory(dedicated) + .shared_experience_memory(Some(shared)) + .tool_dispatcher(Box::new(XmlToolDispatcher)) + .workspace_dir(tmp.path().to_path_buf()) + .event_context("profile-experience-test", "web_chat") + .active_profile_id(Some("alice".into())) + .profile_memory_storage("memory-alice".into(), "session_raw-alice".into()) + .learning_enabled(true) + .build() + .unwrap(); + + let enriched = agent + .inject_agent_experience_context( + "How should I deploy the Rust service?", + "original prompt".into(), + ) + .await; + + assert!(enriched.contains("Legacy shared deployment guidance")); + assert!(enriched.contains("original prompt")); +} + #[tokio::test] async fn fetch_learned_context_returns_empty_when_both_flags_off() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index ec02379c43..115388386f 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -50,6 +50,10 @@ pub struct Agent { pub(super) visible_tool_names: std::collections::HashSet, pub(super) tool_policy_session: ToolPolicySession, pub(super) memory: Arc, + /// Shared memory store retained alongside a dedicated profile store so live + /// experience recall can merge unstamped legacy guidance. `None` for the + /// shared/default memory path. + pub(super) shared_experience_memory: Option>, // `Arc` (not `Box`) so the tinyagents turn path can hold a cheap clone of // the dispatcher without borrowing the `Agent` while session state mutates. pub(super) tool_dispatcher: Arc, @@ -368,6 +372,7 @@ pub struct AgentBuilder { /// When set, restricts which tools the main agent sees/calls. pub(super) visible_tool_names: Option>, pub(super) memory: Option>, + pub(super) shared_experience_memory: Option>, pub(super) prompt_builder: Option, pub(super) tool_dispatcher: Option>, pub(super) memory_loader: Option>, diff --git a/src/openhuman/agent_experience/mod.rs b/src/openhuman/agent_experience/mod.rs index 032e74c38a..e73be02bfb 100644 --- a/src/openhuman/agent_experience/mod.rs +++ b/src/openhuman/agent_experience/mod.rs @@ -13,7 +13,9 @@ pub use schemas::{ all_controller_schemas as all_agent_experience_controller_schemas, all_registered_controllers as all_agent_experience_registered_controllers, }; -pub use store::{AgentExperienceStore, ExperienceQuery, AGENT_EXPERIENCE_NAMESPACE}; +pub use store::{ + retrieve_across_stores, AgentExperienceStore, ExperienceQuery, AGENT_EXPERIENCE_NAMESPACE, +}; pub use types::{ redact_text, stable_experience_id, AgentExperience, ExperienceHit, ExperienceOutcome, ExperienceSource, diff --git a/src/openhuman/agent_experience/ops.rs b/src/openhuman/agent_experience/ops.rs index 1fa339d3b2..caa2e0e535 100644 --- a/src/openhuman/agent_experience/ops.rs +++ b/src/openhuman/agent_experience/ops.rs @@ -1,10 +1,11 @@ use serde::{Deserialize, Serialize}; -use crate::openhuman::agent_experience::store::{AgentExperienceStore, ExperienceQuery}; +use crate::openhuman::agent_experience::store::{ + retrieve_across_stores, AgentExperienceStore, ExperienceQuery, +}; use crate::openhuman::agent_experience::types::{AgentExperience, ExperienceHit}; use crate::openhuman::config::Config; use crate::rpc::RpcOutcome; -use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; @@ -174,27 +175,7 @@ pub async fn retrieve(params: RetrieveParams) -> Result = BTreeMap::new(); - for store in stores { - for hit in store.retrieve(query.clone()).await? { - let id = hit.experience.id.clone(); - match by_id.get(&id) { - Some(existing) if existing.score >= hit.score => {} - _ => { - by_id.insert(id, hit); - } - } - } - } - let mut hits: Vec<_> = by_id.into_values().collect(); - hits.sort_by(|a, b| { - b.score - .partial_cmp(&a.score) - .unwrap_or(Ordering::Equal) - .then_with(|| b.experience.updated_at_ms.cmp(&a.experience.updated_at_ms)) - .then_with(|| a.experience.id.cmp(&b.experience.id)) - }); - hits.truncate(max_hits); + let hits = retrieve_across_stores(&stores, query).await?; Ok(RpcOutcome::single_log(hits, "agent experiences retrieved")) } diff --git a/src/openhuman/agent_experience/store.rs b/src/openhuman/agent_experience/store.rs index ece811410d..4a8aac8ae6 100644 --- a/src/openhuman/agent_experience/store.rs +++ b/src/openhuman/agent_experience/store.rs @@ -4,7 +4,7 @@ use crate::openhuman::agent_experience::types::{ use crate::openhuman::memory::{Memory, MemoryCategory}; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; pub const AGENT_EXPERIENCE_NAMESPACE: &str = "agent_experience"; @@ -220,6 +220,41 @@ impl AgentExperienceStore { } } +/// Retrieve one logical experience pool across multiple physical memory stores. +/// +/// Dedicated profiles write new experiences into their own memory subtree, but +/// still need to recall unstamped legacy experiences from the shared store. +/// Keep the merge, de-duplication, ordering, and final limit in one place so the +/// RPC and live-turn paths cannot drift. +pub async fn retrieve_across_stores( + stores: &[AgentExperienceStore], + query: ExperienceQuery, +) -> Result, String> { + let max_hits = query.max_hits; + let mut by_id: BTreeMap = BTreeMap::new(); + for store in stores { + for hit in store.retrieve(query.clone()).await? { + let id = hit.experience.id.clone(); + match by_id.get(&id) { + Some(existing) if existing.score >= hit.score => {} + _ => { + by_id.insert(id, hit); + } + } + } + } + let mut hits: Vec<_> = by_id.into_values().collect(); + hits.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(Ordering::Equal) + .then_with(|| b.experience.updated_at_ms.cmp(&a.experience.updated_at_ms)) + .then_with(|| a.experience.id.cmp(&b.experience.id)) + }); + hits.truncate(max_hits); + Ok(hits) +} + fn storage_key(id: &str) -> String { format!("experience/{}", id.trim()) } From 29b5b46c261859b4fa62276239bdad72fb5102f4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 05:07:47 +0000 Subject: [PATCH 79/80] fix(profiles): enforce workflow memory scope --- src/openhuman/agent_experience/ops.rs | 4 +- src/openhuman/agent_experience/store.rs | 36 +++++++++++++ src/openhuman/skill_runtime/run_machinery.rs | 56 ++++++++++++++++++-- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/openhuman/agent_experience/ops.rs b/src/openhuman/agent_experience/ops.rs index caa2e0e535..250a441831 100644 --- a/src/openhuman/agent_experience/ops.rs +++ b/src/openhuman/agent_experience/ops.rs @@ -209,7 +209,9 @@ pub async fn dismiss(params: DismissParams) -> Result, let stores = open_query_stores(params.profile_id.as_deref()).await?; let mut dismissed = false; for store in stores { - dismissed |= store.dismiss(¶ms.id).await?; + dismissed |= store + .dismiss_for_profile(¶ms.id, params.profile_id.as_deref()) + .await?; } Ok(RpcOutcome::single_log( DismissResult { diff --git a/src/openhuman/agent_experience/store.rs b/src/openhuman/agent_experience/store.rs index 4a8aac8ae6..45b443b4a4 100644 --- a/src/openhuman/agent_experience/store.rs +++ b/src/openhuman/agent_experience/store.rs @@ -147,10 +147,25 @@ impl AgentExperienceStore { } pub async fn dismiss(&self, id: &str) -> Result { + self.dismiss_for_profile(id, None).await + } + + /// Dismiss an experience only when it belongs to the caller's visible + /// profile partition. A profiled caller may dismiss its own or unstamped + /// legacy records, but never a sibling profile's record even if it knows + /// the storage id. + pub async fn dismiss_for_profile( + &self, + id: &str, + profile_id: Option<&str>, + ) -> Result { let key = storage_key(id); let Some(mut experience) = self.fetch(&key).await? else { return Ok(false); }; + if !experience_matches_profile(experience.profile_id.as_deref(), profile_id) { + return Ok(false); + } experience.dismissed = true; experience.updated_at_ms = now_ms(); self.put(experience).await?; @@ -570,6 +585,27 @@ mod tests { assert!(all_ids.contains("exp_legacy")); } + #[tokio::test] + async fn dismiss_for_profile_rejects_sibling_record() { + let (store, _) = fresh_store(); + seed_partitioned(&store).await; + + assert!(!store.dismiss_for_profile("exp_q", Some("p")).await.unwrap()); + assert!(store + .list() + .await + .unwrap() + .iter() + .find(|experience| experience.id == "exp_q") + .is_some_and(|experience| !experience.dismissed)); + + assert!(store + .dismiss_for_profile("exp_legacy", Some("p")) + .await + .unwrap()); + assert!(store.dismiss_for_profile("exp_p", Some("p")).await.unwrap()); + } + #[tokio::test] async fn list_for_profile_partitions() { let (store, _) = fresh_store(); diff --git a/src/openhuman/skill_runtime/run_machinery.rs b/src/openhuman/skill_runtime/run_machinery.rs index 51d890f3f6..6f238f3ff8 100644 --- a/src/openhuman/skill_runtime/run_machinery.rs +++ b/src/openhuman/skill_runtime/run_machinery.rs @@ -14,6 +14,20 @@ use crate::openhuman::skills::{preflight, registry, run_log}; use crate::openhuman::skills::schemas::resolve_workspace_dir; +async fn with_profile_memory_source_scope( + active_profile: Option<&crate::openhuman::profiles::AgentProfile>, + fut: F, +) -> T +where + F: std::future::Future, +{ + crate::openhuman::memory::source_scope::with_source_scope( + active_profile.and_then(|profile| profile.memory_sources.clone()), + fut, + ) + .await +} + /// Iteration cap for an autonomous skill run (orchestrator + sub-agents). High /// enough to "run until done", while the repeated-failure circuit breaker still /// stops dead-end grinding — deliberately bounded (not infinite) to cap spend. @@ -273,11 +287,14 @@ pub async fn spawn_workflow_run_background_with_profile( let result = tokio::select! { biased; _ = cancel_token.cancelled() => None, - r = crate::openhuman::agent::turn_origin::with_origin( - inherited_origin, - with_autonomous_iter_cap( - WORKFLOW_RUN_MAX_ITERATIONS, - agent.run_single(&task_prompt), + r = with_profile_memory_source_scope( + active_profile.as_ref(), + crate::openhuman::agent::turn_origin::with_origin( + inherited_origin, + with_autonomous_iter_cap( + WORKFLOW_RUN_MAX_ITERATIONS, + agent.run_single(&task_prompt), + ), ), ) => Some(r), }; @@ -358,3 +375,32 @@ pub async fn await_run_outcome( tokio::time::sleep(POLL_INTERVAL.min(remaining)).await; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn workflow_profile_installs_memory_source_scope() { + let mut profile = crate::openhuman::profiles::store::built_in_default_profile(); + profile.memory_sources = Some(vec!["slack:#eng".into(), "github:openhuman".into()]); + + let visible = with_profile_memory_source_scope(Some(&profile), async { + crate::openhuman::memory::source_scope::current_source_scope() + }) + .await; + + assert_eq!( + visible, + Some(std::collections::HashSet::from([ + "slack:#eng".into(), + "github:openhuman".into(), + ])) + ); + assert_eq!( + crate::openhuman::memory::source_scope::current_source_scope(), + None, + "workflow scope must not leak after the run future finishes" + ); + } +} From 7b25d03439e3db6f6cc3eec5d3196445735b0d14 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 23 Jul 2026 06:01:16 +0000 Subject: [PATCH 80/80] fix(profiles): guard bare profiles root --- src/openhuman/profiles/guard.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/openhuman/profiles/guard.rs b/src/openhuman/profiles/guard.rs index b568d92ddd..40244ae44b 100644 --- a/src/openhuman/profiles/guard.rs +++ b/src/openhuman/profiles/guard.rs @@ -240,6 +240,7 @@ fn scan_command_segment( active_profile: &str, ) -> Option { let profiles_root = canonicalize_best_effort(&action_dir.join("profiles")); + let cwd_is_action_root = canonicalize_best_effort(cwd) == canonicalize_best_effort(action_dir); let cwd_is_profiles_root = canonicalize_best_effort(cwd) == profiles_root; let mut token_index = 0usize; // Split on shell punctuation as well as whitespace/redirects so a path @@ -273,7 +274,13 @@ fn scan_command_segment( && !is_command_word && !token.starts_with('-') && cwd.join(token).is_dir(); - if !path_shaped && !bare_profile_operand { + // From the shared action root, the literal bare operand `profiles` + // names the protected collection root itself (`rm -rf profiles`, + // `mv profiles backup`). It has no slash, so classify it explicitly + // before spawning just as we do bare sibling ids from inside that root. + let bare_profiles_root_operand = + cwd_is_action_root && !is_command_word && token == "profiles"; + if !path_shaped && !bare_profile_operand && !bare_profiles_root_operand { continue; } let expanded = crate::openhuman::config::expand_tilde(token); @@ -568,6 +575,15 @@ mod tests { ); } + #[test] + fn scan_command_blocks_bare_profiles_root_from_action_dir() { + let (_g, action) = profiles_layout(); + assert_eq!( + scan_command_for_cross_profile("rm -rf profiles", &action, &action, "alice"), + Some(PROFILES_ROOT_SENTINEL.to_string()) + ); + } + #[test] fn scan_command_tracks_chained_bare_cd_into_sibling() { let (_g, action) = profiles_layout();