feat(identity): add openclaw extra_files support
This commit is contained in:
parent
5981e50514
commit
281236a94d
@ -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)];
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user