From 281236a94d3771ba244ff8bf6a451a44b0d6dba6 Mon Sep 17 00:00:00 2001 From: argenis de la rosa Date: Fri, 27 Feb 2026 15:11:37 -0500 Subject: [PATCH] feat(identity): add openclaw extra_files support --- src/agent/prompt.rs | 121 ++++++++++++++++++++++++++++++++++++++++++ src/channels/mod.rs | 119 +++++++++++++++++++++++++++++++++++++++-- src/config/schema.rs | 6 +++ src/identity.rs | 5 ++ src/onboard/wizard.rs | 4 ++ 5 files changed, 251 insertions(+), 4 deletions(-) diff --git a/src/agent/prompt.rs b/src/agent/prompt.rs index 40f845856..6d63489a2 100644 --- a/src/agent/prompt.rs +++ b/src/agent/prompt.rs @@ -115,6 +115,13 @@ impl PromptSection for IdentitySection { inject_workspace_file(&mut prompt, ctx.workspace_dir, "MEMORY.md"); } + let extra_files = ctx.identity_config.map_or(&[][..], |cfg| cfg.extra_files.as_slice()); + for file in extra_files { + if let Some(safe_relative) = normalize_openclaw_identity_extra_file(file) { + inject_workspace_file(&mut prompt, ctx.workspace_dir, safe_relative); + } + } + Ok(prompt) } } @@ -260,6 +267,29 @@ fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &s } } +fn normalize_openclaw_identity_extra_file(raw: &str) -> Option<&str> { + use std::path::{Component, Path}; + + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let path = Path::new(trimmed); + if path.is_absolute() { + return None; + } + + for component in path.components() { + match component { + Component::Normal(_) | Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None, + } + } + + Some(trimmed) +} + #[cfg(test)] mod tests { use super::*; @@ -307,6 +337,7 @@ mod tests { let identity_config = crate::config::IdentityConfig { format: "aieos".into(), + extra_files: Vec::new(), aieos_path: None, aieos_inline: Some(r#"{"identity":{"names":{"first":"Nova"}}}"#.into()), }; @@ -337,6 +368,96 @@ mod tests { let _ = std::fs::remove_dir_all(workspace); } + #[test] + fn identity_section_openclaw_injects_extra_files() { + let workspace = std::env::temp_dir().join(format!( + "zeroclaw_prompt_extra_files_test_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(workspace.join("memory")).unwrap(); + std::fs::write(workspace.join("AGENTS.md"), "agent baseline").unwrap(); + std::fs::write(workspace.join("SOUL.md"), "soul baseline").unwrap(); + std::fs::write(workspace.join("TOOLS.md"), "tools baseline").unwrap(); + std::fs::write(workspace.join("IDENTITY.md"), "identity baseline").unwrap(); + std::fs::write(workspace.join("USER.md"), "user baseline").unwrap(); + std::fs::write(workspace.join("FRAMEWORK.md"), "framework context").unwrap(); + std::fs::write(workspace.join("memory").join("notes.md"), "memory notes").unwrap(); + + let identity_config = crate::config::IdentityConfig { + format: "openclaw".into(), + extra_files: vec!["FRAMEWORK.md".into(), "memory/notes.md".into()], + aieos_path: None, + aieos_inline: None, + }; + + let tools: Vec> = vec![]; + let ctx = PromptContext { + workspace_dir: &workspace, + model_name: "test-model", + tools: &tools, + skills: &[], + skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full, + identity_config: Some(&identity_config), + dispatcher_instructions: "", + }; + + let section = IdentitySection; + let output = section.build(&ctx).unwrap(); + + assert!(output.contains("### FRAMEWORK.md")); + assert!(output.contains("framework context")); + assert!(output.contains("### memory/notes.md")); + assert!(output.contains("memory notes")); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[test] + fn identity_section_openclaw_rejects_unsafe_extra_files() { + let workspace = std::env::temp_dir().join(format!( + "zeroclaw_prompt_extra_files_unsafe_test_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write(workspace.join("AGENTS.md"), "agent baseline").unwrap(); + std::fs::write(workspace.join("SOUL.md"), "soul baseline").unwrap(); + std::fs::write(workspace.join("TOOLS.md"), "tools baseline").unwrap(); + std::fs::write(workspace.join("IDENTITY.md"), "identity baseline").unwrap(); + std::fs::write(workspace.join("USER.md"), "user baseline").unwrap(); + std::fs::write(workspace.join("SAFE.md"), "safe context").unwrap(); + + let identity_config = crate::config::IdentityConfig { + format: "openclaw".into(), + extra_files: vec![ + "SAFE.md".into(), + "../outside.md".into(), + "/tmp/absolute.md".into(), + ], + aieos_path: None, + aieos_inline: None, + }; + + let tools: Vec> = vec![]; + let ctx = PromptContext { + workspace_dir: &workspace, + model_name: "test-model", + tools: &tools, + skills: &[], + skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full, + identity_config: Some(&identity_config), + dispatcher_instructions: "", + }; + + let section = IdentitySection; + let output = section.build(&ctx).unwrap(); + + assert!(output.contains("### SAFE.md")); + assert!(!output.contains("outside.md")); + assert!(!output.contains("absolute.md")); + + let _ = std::fs::remove_dir_all(workspace); + } + #[test] fn prompt_builder_assembles_sections() { let tools: Vec> = vec![Box::new(TestTool)]; diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 9572075b5..8b7fbe240 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -3820,6 +3820,7 @@ fn load_openclaw_bootstrap_files( prompt: &mut String, workspace_dir: &std::path::Path, max_chars_per_file: usize, + identity_config: Option<&crate::config::IdentityConfig>, ) { prompt.push_str( "The following workspace files define your identity, behavior, and context. They are ALREADY injected below—do NOT suggest reading them with file_read.\n\n", @@ -3842,6 +3843,44 @@ fn load_openclaw_bootstrap_files( if memory_path.exists() { inject_workspace_file(prompt, workspace_dir, "MEMORY.md", max_chars_per_file); } + + let extra_files = identity_config.map_or(&[][..], |cfg| cfg.extra_files.as_slice()); + for file in extra_files { + match normalize_openclaw_identity_extra_file(file) { + Some(safe_relative) => { + inject_workspace_file(prompt, workspace_dir, safe_relative, max_chars_per_file); + } + None => { + tracing::warn!( + file = file.as_str(), + "Ignoring unsafe identity.extra_files entry; expected workspace-relative path without traversal" + ); + } + } + } +} + +fn normalize_openclaw_identity_extra_file(raw: &str) -> Option<&str> { + use std::path::{Component, Path}; + + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let path = Path::new(trimmed); + if path.is_absolute() { + return None; + } + + for component in path.components() { + match component { + Component::Normal(_) | Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None, + } + } + + Some(trimmed) } /// Load workspace identity files and build a system prompt. @@ -3987,7 +4026,12 @@ pub fn build_system_prompt_with_mode( // No AIEOS identity loaded (shouldn't happen if is_aieos_configured returned true) // Fall back to OpenClaw bootstrap files let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS); - load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars); + load_openclaw_bootstrap_files( + &mut prompt, + workspace_dir, + max_chars, + identity_config, + ); } Err(e) => { // Log error but don't fail - fall back to OpenClaw @@ -3995,18 +4039,23 @@ pub fn build_system_prompt_with_mode( "Warning: Failed to load AIEOS identity: {e}. Using OpenClaw format." ); let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS); - load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars); + load_openclaw_bootstrap_files( + &mut prompt, + workspace_dir, + max_chars, + identity_config, + ); } } } else { // OpenClaw format let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS); - load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars); + load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars, identity_config); } } else { // No identity config - use OpenClaw format let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS); - load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars); + load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars, identity_config); } // ── 6. Date & Time ────────────────────────────────────────── @@ -9970,6 +10019,7 @@ BTC is currently around $65,000 based on latest tool output."#; // Create identity config pointing to the file let config = IdentityConfig { format: "aieos".into(), + extra_files: Vec::new(), aieos_path: Some("aieos_identity.json".into()), aieos_inline: None, }; @@ -10004,6 +10054,7 @@ BTC is currently around $65,000 based on latest tool output."#; let config = IdentityConfig { format: "aieos".into(), + extra_files: Vec::new(), aieos_path: None, aieos_inline: Some(r#"{"identity":{"names":{"first":"Claw"}}}"#.into()), }; @@ -10027,6 +10078,7 @@ BTC is currently around $65,000 based on latest tool output."#; let config = IdentityConfig { format: "aieos".into(), + extra_files: Vec::new(), aieos_path: Some("nonexistent.json".into()), aieos_inline: None, }; @@ -10046,6 +10098,7 @@ BTC is currently around $65,000 based on latest tool output."#; // Format is "aieos" but neither path nor inline is set let config = IdentityConfig { format: "aieos".into(), + extra_files: Vec::new(), aieos_path: None, aieos_inline: None, }; @@ -10064,6 +10117,7 @@ BTC is currently around $65,000 based on latest tool output."#; let config = IdentityConfig { format: "openclaw".into(), + extra_files: Vec::new(), aieos_path: Some("identity.json".into()), aieos_inline: None, }; @@ -10077,6 +10131,63 @@ BTC is currently around $65,000 based on latest tool output."#; assert!(!prompt.contains("## Identity")); } + #[test] + fn openclaw_extra_files_are_injected() { + use crate::config::IdentityConfig; + + let ws = make_workspace(); + std::fs::write( + ws.path().join("FRAMEWORK.md"), + "# Framework\nSession-level context.", + ) + .unwrap(); + std::fs::create_dir_all(ws.path().join("memory")).unwrap(); + std::fs::write( + ws.path().join("memory").join("notes.md"), + "# Notes\nSupplemental context.", + ) + .unwrap(); + + let config = IdentityConfig { + format: "openclaw".into(), + extra_files: vec!["FRAMEWORK.md".into(), "memory/notes.md".into()], + aieos_path: None, + aieos_inline: None, + }; + + let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None); + + assert!(prompt.contains("### FRAMEWORK.md")); + assert!(prompt.contains("Session-level context.")); + assert!(prompt.contains("### memory/notes.md")); + assert!(prompt.contains("Supplemental context.")); + } + + #[test] + fn openclaw_extra_files_reject_unsafe_paths() { + use crate::config::IdentityConfig; + + let ws = make_workspace(); + std::fs::write(ws.path().join("SAFE.md"), "safe").unwrap(); + + let config = IdentityConfig { + format: "openclaw".into(), + extra_files: vec![ + "SAFE.md".into(), + "../outside.md".into(), + "/tmp/absolute.md".into(), + ], + aieos_path: None, + aieos_inline: None, + }; + + let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None); + + assert!(prompt.contains("### SAFE.md")); + assert!(!prompt.contains("outside.md")); + assert!(!prompt.contains("absolute.md")); + } + #[test] fn none_identity_config_uses_openclaw() { let ws = make_workspace(); diff --git a/src/config/schema.rs b/src/config/schema.rs index 7b7af4a5d..7389ff4f2 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -883,6 +883,11 @@ pub struct IdentityConfig { /// Identity format: "openclaw" (default) or "aieos" #[serde(default = "default_identity_format")] pub format: String, + /// Additional workspace files injected for the OpenClaw identity format. + /// + /// Paths are resolved relative to the workspace root. + #[serde(default)] + pub extra_files: Vec, /// Path to AIEOS JSON file (relative to workspace) #[serde(default)] pub aieos_path: Option, @@ -899,6 +904,7 @@ impl Default for IdentityConfig { fn default() -> Self { Self { format: default_identity_format(), + extra_files: Vec::new(), aieos_path: None, aieos_inline: None, } diff --git a/src/identity.rs b/src/identity.rs index 391e5c97b..a1a554d20 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -1316,6 +1316,7 @@ mod tests { fn is_aieos_configured_true_with_path() { let config = IdentityConfig { format: "aieos".into(), + extra_files: Vec::new(), aieos_path: Some("identity.json".into()), aieos_inline: None, }; @@ -1326,6 +1327,7 @@ mod tests { fn is_aieos_configured_true_with_inline() { let config = IdentityConfig { format: "aieos".into(), + extra_files: Vec::new(), aieos_path: None, aieos_inline: Some("{\"identity\":{}}".into()), }; @@ -1336,6 +1338,7 @@ mod tests { fn is_aieos_configured_false_openclaw_format() { let config = IdentityConfig { format: "openclaw".into(), + extra_files: Vec::new(), aieos_path: Some("identity.json".into()), aieos_inline: None, }; @@ -1346,6 +1349,7 @@ mod tests { fn is_aieos_configured_false_no_config() { let config = IdentityConfig { format: "aieos".into(), + extra_files: Vec::new(), aieos_path: None, aieos_inline: None, }; @@ -1520,6 +1524,7 @@ mod tests { let config = IdentityConfig { format: "aieos".into(), + extra_files: Vec::new(), aieos_path: Some("identity.json".into()), aieos_inline: None, }; diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index a07f9bc15..17b15fd4b 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -3649,6 +3649,7 @@ fn setup_identity_backend() -> Result { ); IdentityConfig { format: "aieos".into(), + extra_files: Vec::new(), aieos_path: Some(default_path), aieos_inline: None, } @@ -3660,6 +3661,7 @@ fn setup_identity_backend() -> Result { ); IdentityConfig { format: "openclaw".into(), + extra_files: Vec::new(), aieos_path: None, aieos_inline: None, } @@ -6576,6 +6578,7 @@ mod tests { }; let identity_config = crate::config::IdentityConfig { format: "aieos".into(), + extra_files: Vec::new(), aieos_path: Some("identity.aieos.json".into()), aieos_inline: None, }; @@ -6605,6 +6608,7 @@ mod tests { let ctx = ProjectContext::default(); let identity_config = crate::config::IdentityConfig { format: "aieos".into(), + extra_files: Vec::new(), aieos_path: Some("identity.aieos.json".into()), aieos_inline: None, };