feat(identity): add openclaw extra_files support

This commit is contained in:
argenis de la rosa 2026-02-27 15:11:37 -05:00 committed by Argenis
parent 5981e50514
commit 281236a94d
5 changed files with 251 additions and 4 deletions

View File

@ -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<Box<dyn Tool>> = 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<Box<dyn Tool>> = 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<Box<dyn Tool>> = vec![Box::new(TestTool)];

View File

@ -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();

View File

@ -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<String>,
/// Path to AIEOS JSON file (relative to workspace)
#[serde(default)]
pub aieos_path: Option<String>,
@ -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,
}

View File

@ -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,
};

View File

@ -3649,6 +3649,7 @@ fn setup_identity_backend() -> Result<IdentityConfig> {
);
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> {
);
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,
};