diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 43d01909..6ae29160 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -3703,7 +3703,14 @@ pub async fn install_skill( let config = openfang_skills::marketplace::MarketplaceConfig::default(); let client = openfang_skills::marketplace::MarketplaceClient::new(config); - match client.install(&req.name, &skills_dir).await { + let opts = openfang_skills::installer::InstallOptions { + require_signed: req.require_signed, + allowed_signer_keys: req.allowed_signer_keys.clone(), + }; + match client + .install_with_options(&req.name, &skills_dir, &opts) + .await + { Ok(version) => { // Hot-reload so agents see the new skill immediately state.kernel.reload_skills(); @@ -9973,8 +9980,10 @@ pub async fn clone_agent( // new_name always wins over any name field in overrides. cloned_manifest.name = new_name.clone(); - // Let the kernel assign a fresh workspace path — never share with the template. + // Let the kernel assign fresh workspace and state directory paths. + // Never share private state with the template (see issue #868, #1097). cloned_manifest.workspace = None; + cloned_manifest.state_dir = None; // Spawn the cloned agent. let new_id = match state.kernel.spawn_agent(cloned_manifest) { @@ -9987,25 +9996,42 @@ pub async fn clone_agent( } }; - // Copy non-memory workspace files from source to destination. - // MEMORY.md and HEARTBEAT.md are intentionally skipped — the cloned agent - // must start with independent memory (issue #868). + // Copy non-memory identity files from source state_dir to destination + // state_dir. MEMORY.md and HEARTBEAT.md are intentionally skipped — the + // cloned agent must start with independent memory (issue #868). Identity + // files live in state_dir per #1097; fall back to legacy workspace for + // older agents. let new_entry = state.kernel.registry.get(new_id); + let src_state = source + .manifest + .state_dir + .as_ref() + .or(source.manifest.workspace.as_ref()); + let dst_state = new_entry.as_ref().and_then(|e| { + e.manifest + .state_dir + .as_ref() + .or(e.manifest.workspace.as_ref()) + }); + if let (Some(src_ws), Some(dst_ws)) = (src_state, dst_state) { + if let (Ok(src_can), Ok(dst_can)) = (src_ws.canonicalize(), dst_ws.canonicalize()) { + for &fname in KNOWN_IDENTITY_FILES { + if MEMORY_FILES.contains(&fname) { + continue; + } + let src_file = src_can.join(fname); + let dst_file = dst_can.join(fname); + if src_file.exists() { + let _ = std::fs::copy(&src_file, &dst_file); + } + } + } + } + // Copy the user-facing `skills/` subdirectory so curated skills travel + // with the template. These live in the workspace, not the state dir. if let (Some(ref src_ws), Some(ref new_entry)) = (&source.manifest.workspace, &new_entry) { if let Some(ref dst_ws) = new_entry.manifest.workspace { if let (Ok(src_can), Ok(dst_can)) = (src_ws.canonicalize(), dst_ws.canonicalize()) { - for &fname in KNOWN_IDENTITY_FILES { - if MEMORY_FILES.contains(&fname) { - continue; - } - let src_file = src_can.join(fname); - let dst_file = dst_can.join(fname); - if src_file.exists() { - let _ = std::fs::copy(&src_file, &dst_file); - } - } - // Copy the `skills/` subdirectory if present so curated skills - // travel with the template. let src_skills = src_can.join("skills"); let dst_skills = dst_can.join("skills"); if src_skills.is_dir() { @@ -10108,8 +10134,11 @@ pub async fn list_agent_files( } }; - let workspace = match entry.manifest.workspace { - Some(ref ws) => ws.clone(), + // Identity files live in the agent's private state directory (see #1097). + // Fall back to the legacy workspace location for agents created before the + // split so existing on-disk files remain reachable. + let workspace = match entry.manifest.state_dir.as_ref().or(entry.manifest.workspace.as_ref()) { + Some(ws) => ws.clone(), None => { return ( StatusCode::NOT_FOUND, @@ -10170,8 +10199,15 @@ pub async fn get_agent_file( } }; - let workspace = match entry.manifest.workspace { - Some(ref ws) => ws.clone(), + // Identity files live in the agent's private state directory (see #1097). + // Fall back to legacy workspace for agents created before the split. + let workspace = match entry + .manifest + .state_dir + .as_ref() + .or(entry.manifest.workspace.as_ref()) + { + Some(ws) => ws.clone(), None => { return ( StatusCode::NOT_FOUND, @@ -10277,8 +10313,15 @@ pub async fn set_agent_file( } }; - let workspace = match entry.manifest.workspace { - Some(ref ws) => ws.clone(), + // Identity files live in the agent's private state directory (see #1097). + // Fall back to legacy workspace for agents created before the split. + let workspace = match entry + .manifest + .state_dir + .as_ref() + .or(entry.manifest.workspace.as_ref()) + { + Some(ws) => ws.clone(), None => { return ( StatusCode::NOT_FOUND, diff --git a/crates/openfang-kernel/src/heartbeat.rs b/crates/openfang-kernel/src/heartbeat.rs index da0eb803..75554b1d 100644 --- a/crates/openfang-kernel/src/heartbeat.rs +++ b/crates/openfang-kernel/src/heartbeat.rs @@ -339,6 +339,7 @@ mod tests { autonomous: None, pinned_model: None, workspace: None, + state_dir: None, generate_identity_files: true, exec_policy: None, tool_allowlist: vec![], diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 980690d2..131593a4 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -288,9 +288,38 @@ impl DeliveryTracker { } } -/// Create workspace directory structure for an agent. +/// Create the agent's private state directory layout. Holds identity files, +/// AGENT.json, sessions/, memory/, and logs/. Lives under +/// `~/.openfang/workspaces/{name}/` regardless of where the user pointed the +/// user-facing workspace. See issue #1097. +fn ensure_state_dir(state_dir: &Path, workspace: &Path) -> KernelResult<()> { + for subdir in &["sessions", "logs", "memory"] { + std::fs::create_dir_all(state_dir.join(subdir)).map_err(|e| { + KernelError::OpenFang(OpenFangError::Internal(format!( + "Failed to create state dir {}/{subdir}: {e}", + state_dir.display() + ))) + })?; + } + // Write agent metadata file (best-effort). + let meta = serde_json::json!({ + "created_at": chrono::Utc::now().to_rfc3339(), + "state_dir": state_dir.display().to_string(), + "workspace": workspace.display().to_string(), + }); + let _ = std::fs::write( + state_dir.join("AGENT.json"), + serde_json::to_string_pretty(&meta).unwrap_or_default(), + ); + Ok(()) +} + +/// Create the user-facing workspace layout. Only `data/`, `output/`, and +/// `skills/` are scaffolded here. When the user points the workspace at a +/// pre-existing path like `~/Documents`, these subdirs are created lazily +/// inside it without dumping identity files or sessions. See issue #1097. fn ensure_workspace(workspace: &Path) -> KernelResult<()> { - for subdir in &["data", "output", "sessions", "skills", "logs", "memory"] { + for subdir in &["data", "output", "skills"] { std::fs::create_dir_all(workspace.join(subdir)).map_err(|e| { KernelError::OpenFang(OpenFangError::Internal(format!( "Failed to create workspace dir {}/{subdir}: {e}", @@ -298,15 +327,6 @@ fn ensure_workspace(workspace: &Path) -> KernelResult<()> { ))) })?; } - // Write agent metadata file (best-effort) - let meta = serde_json::json!({ - "created_at": chrono::Utc::now().to_rfc3339(), - "workspace": workspace.display().to_string(), - }); - let _ = std::fs::write( - workspace.join("AGENT.json"), - serde_json::to_string_pretty(&meta).unwrap_or_default(), - ); Ok(()) } @@ -447,15 +467,18 @@ fn generate_identity_files(workspace: &Path, manifest: &AgentManifest) { } /// Append an assistant response summary to the daily memory log (best-effort, append-only). -/// Caps daily log at 1MB to prevent unbounded growth. -fn append_daily_memory_log(workspace: &Path, response: &str) { +/// Caps daily log at 1MB to prevent unbounded growth. Writes to the agent's +/// private state directory (`state_dir/memory/`), never to the user-facing +/// workspace, so pointing `workspace = "/home/me/Documents"` does not litter +/// the user's folder with per-day markdown files. See issue #1097. +fn append_daily_memory_log(state_dir: &Path, response: &str) { use std::io::Write; let trimmed = response.trim(); if trimmed.is_empty() { return; } let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); - let log_path = workspace.join("memory").join(format!("{today}.md")); + let log_path = state_dir.join("memory").join(format!("{today}.md")); // Security: cap total daily log to 1MB if let Ok(metadata) = std::fs::metadata(&log_path) { if metadata.len() > 1_048_576 { @@ -474,16 +497,18 @@ fn append_daily_memory_log(workspace: &Path, response: &str) { } } -/// Read a workspace identity file with a size cap to prevent prompt stuffing. -/// Returns None if the file doesn't exist or is empty. -fn read_identity_file(workspace: &Path, filename: &str) -> Option { +/// Read an identity file from the agent's private state directory with a size +/// cap to prevent prompt stuffing. Returns None if the file doesn't exist or +/// is empty. Identity files live in `state_dir`, not in the user-facing +/// workspace (see issue #1097), so this is called with the state directory. +fn read_identity_file(state_dir: &Path, filename: &str) -> Option { const MAX_IDENTITY_FILE_BYTES: usize = 32_768; // 32KB cap - let path = workspace.join(filename); - // Security: ensure path stays inside workspace + let path = state_dir.join(filename); + // Security: ensure path stays inside the state directory match path.canonicalize() { Ok(canonical) => { - if let Ok(ws_canonical) = workspace.canonicalize() { - if !canonical.starts_with(&ws_canonical) { + if let Ok(sd_canonical) = state_dir.canonicalize() { + if !canonical.starts_with(&sd_canonical) { return None; // path traversal attempt } } @@ -800,6 +825,9 @@ impl OpenFangKernel { // Initialize model catalog, detect provider auth, and apply URL overrides let mut model_catalog = openfang_runtime::model_catalog::ModelCatalog::new(); model_catalog.detect_auth(); + // Env-var overrides for local providers (OLLAMA_HOST, LMSTUDIO_BASE_URL, etc.). + // Applied before `provider_urls` so explicit config.toml entries win. See #1154. + model_catalog.apply_local_env_overrides(); if !config.provider_urls.is_empty() { model_catalog.apply_url_overrides(&config.provider_urls); info!( @@ -1631,15 +1659,24 @@ impl OpenFangKernel { // Apply global budget defaults to agent resource quotas apply_budget_defaults(&self.config.budget, &mut manifest.resources); - // Create workspace directory for the agent (name-based, so SOUL.md survives recreation) - let workspace_dir = manifest - .workspace + // Agent private state always lives under ~/.openfang/workspaces/{name}/. + // This is name-based so SOUL.md and per-agent memory survive recreation + // and never get dumped into a user-supplied workspace path. See #1097. + let state_dir = manifest + .state_dir .clone() .unwrap_or_else(|| self.config.effective_workspaces_dir().join(&name)); + // The user-facing workspace defaults to the state_dir when the manifest + // does not specify one. When the user sets `workspace = "/path"` in + // agent.toml we leave that path alone — only data/, output/, skills/ + // get created lazily so private state never pollutes the target dir. + let workspace_dir = manifest.workspace.clone().unwrap_or_else(|| state_dir.clone()); + ensure_state_dir(&state_dir, &workspace_dir)?; ensure_workspace(&workspace_dir)?; if manifest.generate_identity_files { - generate_identity_files(&workspace_dir, &manifest); + generate_identity_files(&state_dir, &manifest); } + manifest.state_dir = Some(state_dir); manifest.workspace = Some(workspace_dir); // Register capabilities @@ -2049,16 +2086,27 @@ impl OpenFangKernel { let (tx, rx) = tokio::sync::mpsc::channel::(64); let mut manifest = entry.manifest.clone(); - // Lazy backfill: create workspace for existing agents spawned before workspaces - if manifest.workspace.is_none() { - let workspace_dir = self.config.effective_workspaces_dir().join(&manifest.name); + // Lazy backfill: create state_dir and workspace for existing agents + // spawned before this field existed. Private state always lives under + // ~/.openfang/workspaces/{name}/; the user-facing workspace defaults + // to the same path unless the manifest already pinned one. See #1097. + if manifest.state_dir.is_none() { + let state_dir = self.config.effective_workspaces_dir().join(&manifest.name); + let workspace_dir = manifest.workspace.clone().unwrap_or_else(|| state_dir.clone()); + if let Err(e) = ensure_state_dir(&state_dir, &workspace_dir) { + warn!(agent_id = %agent_id, "Failed to backfill state_dir (streaming): {e}"); + } if let Err(e) = ensure_workspace(&workspace_dir) { warn!(agent_id = %agent_id, "Failed to backfill workspace (streaming): {e}"); } else { + manifest.state_dir = Some(state_dir); manifest.workspace = Some(workspace_dir); let _ = self .registry .update_workspace(agent_id, manifest.workspace.clone()); + let _ = self + .registry + .update_state_dir(agent_id, manifest.state_dir.clone()); } } @@ -2129,17 +2177,17 @@ impl OpenFangKernel { }, workspace_path: manifest.workspace.as_ref().map(|p| p.display().to_string()), soul_md: manifest - .workspace + .state_dir .as_ref() - .and_then(|w| read_identity_file(w, "SOUL.md")), + .and_then(|s| read_identity_file(s, "SOUL.md")), user_md: manifest - .workspace + .state_dir .as_ref() - .and_then(|w| read_identity_file(w, "USER.md")), + .and_then(|s| read_identity_file(s, "USER.md")), memory_md: manifest - .workspace + .state_dir .as_ref() - .and_then(|w| read_identity_file(w, "MEMORY.md")), + .and_then(|s| read_identity_file(s, "MEMORY.md")), canonical_context: self .memory .canonical_context(agent_id, None) @@ -2154,27 +2202,27 @@ impl OpenFangKernel { .unwrap_or(false), is_autonomous: manifest.autonomous.is_some(), agents_md: manifest - .workspace + .state_dir .as_ref() - .and_then(|w| read_identity_file(w, "AGENTS.md")), + .and_then(|s| read_identity_file(s, "AGENTS.md")), bootstrap_md: manifest - .workspace + .state_dir .as_ref() - .and_then(|w| read_identity_file(w, "BOOTSTRAP.md")), + .and_then(|s| read_identity_file(s, "BOOTSTRAP.md")), workspace_context: manifest.workspace.as_ref().map(|w| { let mut ws_ctx = openfang_runtime::workspace_context::WorkspaceContext::detect(w); ws_ctx.build_context_section() }), identity_md: manifest - .workspace + .state_dir .as_ref() - .and_then(|w| read_identity_file(w, "IDENTITY.md")), + .and_then(|s| read_identity_file(s, "IDENTITY.md")), heartbeat_md: if manifest.autonomous.is_some() { manifest - .workspace + .state_dir .as_ref() - .and_then(|w| read_identity_file(w, "HEARTBEAT.md")) + .and_then(|s| read_identity_file(s, "HEARTBEAT.md")) } else { None }, @@ -2315,15 +2363,17 @@ impl OpenFangKernel { } } - // Write JSONL session mirror to workspace - if let Some(ref workspace) = manifest.workspace { + // Write JSONL session mirror and daily memory log to the + // agent's private state directory, not the user-facing + // workspace. See issue #1097. + if let Some(ref state_dir) = manifest.state_dir { if let Err(e) = - memory.write_jsonl_mirror(&session, &workspace.join("sessions")) + memory.write_jsonl_mirror(&session, &state_dir.join("sessions")) { warn!("Failed to write JSONL session mirror (streaming): {e}"); } // Append daily memory log (best-effort) - append_daily_memory_log(workspace, &result.response); + append_daily_memory_log(state_dir, &result.response); } kernel_clone @@ -2608,17 +2658,27 @@ impl OpenFangKernel { // Apply model routing if configured (disabled in Stable mode) let mut manifest = entry.manifest.clone(); - // Lazy backfill: create workspace for existing agents spawned before workspaces - if manifest.workspace.is_none() { - let workspace_dir = self.config.effective_workspaces_dir().join(&manifest.name); + // Lazy backfill: create state_dir and workspace for existing agents. + // Private state lives under ~/.openfang/workspaces/{name}/. User-facing + // workspace stays at whatever the manifest pinned (or defaults to the + // state_dir). See issue #1097. + if manifest.state_dir.is_none() { + let state_dir = self.config.effective_workspaces_dir().join(&manifest.name); + let workspace_dir = manifest.workspace.clone().unwrap_or_else(|| state_dir.clone()); + if let Err(e) = ensure_state_dir(&state_dir, &workspace_dir) { + warn!(agent_id = %agent_id, "Failed to backfill state_dir: {e}"); + } if let Err(e) = ensure_workspace(&workspace_dir) { warn!(agent_id = %agent_id, "Failed to backfill workspace: {e}"); } else { + manifest.state_dir = Some(state_dir); manifest.workspace = Some(workspace_dir); - // Persist updated workspace in registry let _ = self .registry .update_workspace(agent_id, manifest.workspace.clone()); + let _ = self + .registry + .update_state_dir(agent_id, manifest.state_dir.clone()); } } @@ -2697,17 +2757,17 @@ impl OpenFangKernel { }, workspace_path: manifest.workspace.as_ref().map(|p| p.display().to_string()), soul_md: manifest - .workspace + .state_dir .as_ref() - .and_then(|w| read_identity_file(w, "SOUL.md")), + .and_then(|s| read_identity_file(s, "SOUL.md")), user_md: manifest - .workspace + .state_dir .as_ref() - .and_then(|w| read_identity_file(w, "USER.md")), + .and_then(|s| read_identity_file(s, "USER.md")), memory_md: manifest - .workspace + .state_dir .as_ref() - .and_then(|w| read_identity_file(w, "MEMORY.md")), + .and_then(|s| read_identity_file(s, "MEMORY.md")), canonical_context: self .memory .canonical_context(agent_id, None) @@ -2722,27 +2782,27 @@ impl OpenFangKernel { .unwrap_or(false), is_autonomous: manifest.autonomous.is_some(), agents_md: manifest - .workspace + .state_dir .as_ref() - .and_then(|w| read_identity_file(w, "AGENTS.md")), + .and_then(|s| read_identity_file(s, "AGENTS.md")), bootstrap_md: manifest - .workspace + .state_dir .as_ref() - .and_then(|w| read_identity_file(w, "BOOTSTRAP.md")), + .and_then(|s| read_identity_file(s, "BOOTSTRAP.md")), workspace_context: manifest.workspace.as_ref().map(|w| { let mut ws_ctx = openfang_runtime::workspace_context::WorkspaceContext::detect(w); ws_ctx.build_context_section() }), identity_md: manifest - .workspace + .state_dir .as_ref() - .and_then(|w| read_identity_file(w, "IDENTITY.md")), + .and_then(|s| read_identity_file(s, "IDENTITY.md")), heartbeat_md: if manifest.autonomous.is_some() { manifest - .workspace + .state_dir .as_ref() - .and_then(|w| read_identity_file(w, "HEARTBEAT.md")) + .and_then(|s| read_identity_file(s, "HEARTBEAT.md")) } else { None }, @@ -2880,16 +2940,17 @@ impl OpenFangKernel { } } - // Write JSONL session mirror to workspace - if let Some(ref workspace) = manifest.workspace { + // Write JSONL session mirror and daily memory log to the agent's + // private state directory, not the user-facing workspace. See #1097. + if let Some(ref state_dir) = manifest.state_dir { if let Err(e) = self .memory - .write_jsonl_mirror(&session, &workspace.join("sessions")) + .write_jsonl_mirror(&session, &state_dir.join("sessions")) { warn!("Failed to write JSONL session mirror: {e}"); } // Append daily memory log (best-effort) - append_daily_memory_log(workspace, &result.response); + append_daily_memory_log(state_dir, &result.response); } // Record usage in the metering engine (uses catalog pricing as single source of truth) @@ -6326,7 +6387,16 @@ impl OpenFangKernel { summary.push_str(&format!("- {name}: {desc} [tools: {}]\n", tools.join(", "))); } } - summary.push_str("Use these skill tools when they match the user's request."); + // Issue #1038: skill directories (e.g. ~/.openfang/skills/) live OUTSIDE + // the workspace sandbox. Tell the agent to use the dedicated skill_* + // tools instead of falling back to file_read / shell_exec to inspect them. + summary.push_str( + "Use these skill tools when they match the user's request. \ + To inspect a skill's full instructions, call skill_describe with the skill name — \ + do NOT use file_read or shell_exec on the skills directory, those paths are \ + outside the agent workspace and will fail. \ + Use skill_list to enumerate skills and skill_execute to run a skill's tool.", + ); summary } @@ -7856,6 +7926,7 @@ mod tests { autonomous: None, pinned_model: None, workspace: None, + state_dir: None, generate_identity_files: true, exec_policy: None, tool_allowlist: vec![], @@ -7899,6 +7970,7 @@ mod tests { autonomous: None, pinned_model: None, workspace: Some(std::path::PathBuf::from("/var/lib/openfang/agents/demo")), + state_dir: None, generate_identity_files: true, exec_policy: Some(ExecPolicy::default()), tool_allowlist: vec![], @@ -7950,6 +8022,7 @@ mod tests { autonomous: None, pinned_model: None, workspace: Some(std::path::PathBuf::from("/old")), + state_dir: None, generate_identity_files: true, exec_policy: None, tool_allowlist: vec![], @@ -8006,6 +8079,7 @@ mod tests { autonomous: None, pinned_model: None, workspace: None, + state_dir: None, generate_identity_files: true, exec_policy: Some(cached_policy.clone()), tool_allowlist: vec![], @@ -8119,6 +8193,7 @@ mod tests { autonomous: None, pinned_model: None, workspace: None, + state_dir: None, generate_identity_files: true, exec_policy: None, tool_allowlist: vec![], @@ -9230,4 +9305,78 @@ system_prompt = "You are a test agent." kernel2.shutdown(); } + + /// Regression for #1097: when a user points an agent's workspace at an + /// existing directory like `~/Documents`, the runtime must NOT scaffold + /// private state into that directory. Identity files (SOUL.md, AGENT.json, + /// etc.) and `sessions/` / `memory/` / `logs/` must land in the agent's + /// private state directory; only the lightweight user-facing layout + /// (`data/`, `output/`, `skills/`) may appear in the workspace. + #[test] + fn test_workspace_outside_openfang_stays_clean() { + use tempfile::TempDir; + + let user_workspace = TempDir::new().expect("temp user workspace"); + let state_dir = TempDir::new().expect("temp state dir"); + + // Pre-populate the user workspace with an unrelated file to make sure + // we don't trample existing contents either. + std::fs::write(user_workspace.path().join("pre-existing.txt"), b"hello") + .expect("write pre-existing file"); + + let manifest = AgentManifest { + name: "ws-test".to_string(), + description: "x".to_string(), + ..AgentManifest::default() + }; + + // Simulate the spawn path: state dir gets the private layout, user + // workspace only gets the lightweight subdirs. + ensure_state_dir(state_dir.path(), user_workspace.path()).expect("ensure_state_dir"); + ensure_workspace(user_workspace.path()).expect("ensure_workspace"); + generate_identity_files(state_dir.path(), &manifest); + + // Private state files must live in state_dir. + for fname in &[ + "AGENT.json", + "SOUL.md", + "USER.md", + "MEMORY.md", + "AGENTS.md", + "BOOTSTRAP.md", + "IDENTITY.md", + ] { + assert!( + state_dir.path().join(fname).exists(), + "{fname} should be created in state_dir" + ); + assert!( + !user_workspace.path().join(fname).exists(), + "{fname} must NOT pollute the user-facing workspace (issue #1097)" + ); + } + for subdir in &["sessions", "memory", "logs"] { + assert!( + state_dir.path().join(subdir).is_dir(), + "{subdir}/ should be created in state_dir" + ); + assert!( + !user_workspace.path().join(subdir).exists(), + "{subdir}/ must NOT pollute the user-facing workspace (issue #1097)" + ); + } + + // The user-facing workspace gets only the lightweight layout. + for subdir in &["data", "output", "skills"] { + assert!( + user_workspace.path().join(subdir).is_dir(), + "{subdir}/ should be created in workspace" + ); + } + + // The pre-existing file must still be intact. + let contents = std::fs::read_to_string(user_workspace.path().join("pre-existing.txt")) + .expect("read pre-existing"); + assert_eq!(contents, "hello", "must not overwrite user files"); + } } diff --git a/crates/openfang-kernel/src/registry.rs b/crates/openfang-kernel/src/registry.rs index 76501d59..124bbfe7 100644 --- a/crates/openfang-kernel/src/registry.rs +++ b/crates/openfang-kernel/src/registry.rs @@ -134,6 +134,23 @@ impl AgentRegistry { Ok(()) } + /// Update an agent's private state directory path. The state directory + /// holds identity files, sessions, and per-agent memory and is always + /// kept separate from the user-facing workspace. See issue #1097. + pub fn update_state_dir( + &self, + id: AgentId, + state_dir: Option, + ) -> OpenFangResult<()> { + let mut entry = self + .agents + .get_mut(&id) + .ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?; + entry.manifest.state_dir = state_dir; + entry.last_active = chrono::Utc::now(); + Ok(()) + } + /// Update an agent's visual identity (emoji, avatar, color). pub fn update_identity( &self, @@ -391,6 +408,7 @@ mod tests { autonomous: None, pinned_model: None, workspace: None, + state_dir: None, generate_identity_files: true, exec_policy: None, tool_allowlist: vec![], diff --git a/crates/openfang-kernel/src/wizard.rs b/crates/openfang-kernel/src/wizard.rs index bafd5291..4338830a 100644 --- a/crates/openfang-kernel/src/wizard.rs +++ b/crates/openfang-kernel/src/wizard.rs @@ -176,6 +176,7 @@ impl SetupWizard { autonomous: None, pinned_model: None, workspace: None, + state_dir: None, generate_identity_files: true, profile: None, fallback_models: vec![], diff --git a/crates/openfang-types/src/agent.rs b/crates/openfang-types/src/agent.rs index 8f509b25..5589b1e3 100644 --- a/crates/openfang-types/src/agent.rs +++ b/crates/openfang-types/src/agent.rs @@ -474,11 +474,23 @@ pub struct AgentManifest { /// Pinned model override (used in Stable mode). #[serde(default)] pub pinned_model: Option, - /// Agent workspace directory. Auto-created on spawn. - /// Default: `{workspaces_dir}/{agent_name}-{agent_id_prefix}/` + /// Agent workspace directory. User-facing working area for output, data, + /// skills, context.md, and uploads. When the user sets this in agent.toml + /// (e.g. `workspace = "/home/me/Documents"`) the runtime treats it as the + /// agent's working directory and will NOT scaffold private state files + /// there. Multiple agents can share the same workspace to collaborate on + /// files. See issue #1097. + /// Default: `{workspaces_dir}/{agent_name}/` (same as state_dir). #[serde(default)] pub workspace: Option, - /// Whether to generate workspace identity files (SOUL.md, USER.md, etc.) on creation. + /// Agent private state directory. Stores identity files (SOUL.md, etc.), + /// AGENT.json, sessions/, and the daily memory log. Always lives under + /// `~/.openfang/workspaces/{name}/` regardless of where the user-facing + /// workspace points. Auto-derived on spawn. See issue #1097. + #[serde(default)] + pub state_dir: Option, + /// Whether to generate identity files (SOUL.md, USER.md, etc.) in + /// `state_dir` on creation. #[serde(default = "default_true")] pub generate_identity_files: bool, /// Per-agent exec policy override. If None, uses global exec_policy. @@ -550,6 +562,7 @@ impl Default for AgentManifest { autonomous: None, pinned_model: None, workspace: None, + state_dir: None, generate_identity_files: true, exec_policy: None, tool_allowlist: Vec::new(), @@ -809,6 +822,7 @@ mod tests { autonomous: None, pinned_model: None, workspace: None, + state_dir: None, generate_identity_files: true, exec_policy: None, tool_allowlist: Vec::new(),