mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-30 23:05:08 +00:00
create directory
This commit is contained in:
@@ -205,6 +205,7 @@ pub async fn execute_tool(
|
||||
"file_read" => tool_file_read(input, workspace_root).await,
|
||||
"file_write" => tool_file_write(input, workspace_root).await,
|
||||
"file_list" => tool_file_list(input, workspace_root).await,
|
||||
"create_directory" => tool_create_directory(input, workspace_root).await,
|
||||
"apply_patch" => tool_apply_patch(input, workspace_root).await,
|
||||
|
||||
// Web tools (upgraded: multi-provider search, SSRF-protected fetch)
|
||||
@@ -595,6 +596,17 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
|
||||
"required": ["path"]
|
||||
}),
|
||||
},
|
||||
ToolDefinition {
|
||||
name: "create_directory".to_string(),
|
||||
description: "Create a directory (and any missing parent directories) at the given path. Paths are relative to the agent workspace. Idempotent: succeeds if the directory already exists.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string", "description": "The directory path to create" }
|
||||
},
|
||||
"required": ["path"]
|
||||
}),
|
||||
},
|
||||
ToolDefinition {
|
||||
name: "apply_patch".to_string(),
|
||||
description: "Apply a multi-hunk diff patch to add, update, move, or delete files. Use this for targeted edits instead of full file overwrites.".to_string(),
|
||||
@@ -1358,6 +1370,80 @@ async fn tool_file_write(
|
||||
))
|
||||
}
|
||||
|
||||
/// Resolve a directory path for creation. Unlike `resolve_file_path`, this walks
|
||||
/// up the path to find the nearest existing ancestor, canonicalizes that, and
|
||||
/// re-appends the missing segments. This lets `create_directory` accept nested
|
||||
/// paths like `a/b/c/d` even when none of `a`, `b`, `c` exist yet.
|
||||
fn resolve_directory_path_for_create(
|
||||
raw_path: &str,
|
||||
workspace_root: Option<&Path>,
|
||||
) -> Result<PathBuf, String> {
|
||||
// Reject `..` components regardless of workspace.
|
||||
let _ = validate_path(raw_path)?;
|
||||
|
||||
let Some(root) = workspace_root else {
|
||||
return Ok(PathBuf::from(raw_path));
|
||||
};
|
||||
|
||||
let path = Path::new(raw_path);
|
||||
let candidate = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
root.join(path)
|
||||
};
|
||||
|
||||
let canon_root = root
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Failed to resolve workspace root: {e}"))?;
|
||||
|
||||
// Walk up to find the nearest existing ancestor, canonicalize it, then
|
||||
// re-append the missing tail.
|
||||
let mut existing: PathBuf = candidate.clone();
|
||||
let mut tail: Vec<&std::ffi::OsStr> = Vec::new();
|
||||
while !existing.exists() {
|
||||
let Some(parent) = existing.parent() else {
|
||||
return Err("Invalid path: no existing ancestor".to_string());
|
||||
};
|
||||
let Some(name) = existing.file_name() else {
|
||||
return Err("Invalid path: no filename component".to_string());
|
||||
};
|
||||
tail.push(name);
|
||||
existing = parent.to_path_buf();
|
||||
}
|
||||
|
||||
let canon_existing = existing
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Failed to resolve ancestor directory: {e}"))?;
|
||||
|
||||
let mut resolved = canon_existing;
|
||||
for segment in tail.into_iter().rev() {
|
||||
resolved.push(segment);
|
||||
}
|
||||
|
||||
if !resolved.starts_with(&canon_root) {
|
||||
return Err(format!(
|
||||
"Access denied: path '{raw_path}' resolves outside workspace"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
async fn tool_create_directory(
|
||||
input: &serde_json::Value,
|
||||
workspace_root: Option<&Path>,
|
||||
) -> Result<String, String> {
|
||||
let raw_path = input["path"].as_str().ok_or("Missing 'path' parameter")?;
|
||||
if raw_path.is_empty() {
|
||||
return Err("'path' parameter is empty".to_string());
|
||||
}
|
||||
let resolved = resolve_directory_path_for_create(raw_path, workspace_root)?;
|
||||
tokio::fs::create_dir_all(&resolved)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create directory: {e}"))?;
|
||||
Ok(format!("Created directory {}", resolved.display()))
|
||||
}
|
||||
|
||||
async fn tool_file_list(
|
||||
input: &serde_json::Value,
|
||||
workspace_root: Option<&Path>,
|
||||
@@ -3448,6 +3534,9 @@ mod tests {
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
|
||||
// Original 12
|
||||
assert!(names.contains(&"file_read"));
|
||||
assert!(names.contains(&"file_write"));
|
||||
assert!(names.contains(&"file_list"));
|
||||
assert!(names.contains(&"create_directory"));
|
||||
assert!(names.contains(&"shell_exec"));
|
||||
assert!(names.contains(&"agent_send"));
|
||||
assert!(names.contains(&"agent_spawn"));
|
||||
@@ -3619,6 +3708,102 @@ mod tests {
|
||||
assert!(result.content.contains("traversal"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_directory_path_traversal_blocked() {
|
||||
let result = execute_tool(
|
||||
"test-id",
|
||||
"create_directory",
|
||||
&serde_json::json!({"path": "../../etc/evil"}),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None, // media_engine
|
||||
None, // exec_policy
|
||||
None, // tts_engine
|
||||
None, // docker_config
|
||||
None, // process_manager
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_error);
|
||||
assert!(result.content.contains("traversal"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_directory_creates_nested() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
let result = tool_create_directory(
|
||||
&serde_json::json!({"path": "a/b/c"}),
|
||||
Some(root),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
|
||||
let expected = root.join("a").join("b").join("c");
|
||||
assert!(expected.is_dir(), "Expected directory to exist: {}", expected.display());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_directory_idempotent() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
// First create
|
||||
let r1 = tool_create_directory(
|
||||
&serde_json::json!({"path": "data/logs"}),
|
||||
Some(root),
|
||||
)
|
||||
.await;
|
||||
assert!(r1.is_ok());
|
||||
// Second create on existing dir should also succeed
|
||||
let r2 = tool_create_directory(
|
||||
&serde_json::json!({"path": "data/logs"}),
|
||||
Some(root),
|
||||
)
|
||||
.await;
|
||||
assert!(r2.is_ok(), "Expected idempotent success, got: {:?}", r2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_directory_missing_path_param() {
|
||||
let result = tool_create_directory(&serde_json::json!({}), None).await;
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err();
|
||||
assert!(msg.contains("Missing 'path'"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_directory_dispatch_via_execute_tool() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
let result = execute_tool(
|
||||
"test-id",
|
||||
"create_directory",
|
||||
&serde_json::json!({"path": "nested/folder"}),
|
||||
None, // kernel
|
||||
None, // allowed_tools
|
||||
None, // caller_agent_id
|
||||
None, // skill_registry
|
||||
None, // mcp_connections
|
||||
None, // web_ctx
|
||||
None, // browser_ctx
|
||||
None, // allowed_env_vars
|
||||
Some(root.as_path()), // workspace_root
|
||||
None, // media_engine
|
||||
None, // exec_policy
|
||||
None, // tts_engine
|
||||
None, // docker_config
|
||||
None, // process_manager
|
||||
)
|
||||
.await;
|
||||
assert!(!result.is_error, "Expected success, got: {}", result.content);
|
||||
assert!(root.join("nested").join("folder").is_dir());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_file_list_path_traversal_blocked() {
|
||||
let result = execute_tool(
|
||||
|
||||
@@ -15,6 +15,9 @@ pub fn map_tool_name(openclaw_name: &str) -> Option<&'static str> {
|
||||
"Edit" | "edit" => Some("file_write"),
|
||||
"Glob" | "glob" | "list_files" => Some("file_list"),
|
||||
"Grep" | "grep" => Some("file_list"),
|
||||
"Mkdir" | "mkdir" | "make_directory" | "makeDirectory" | "make_dir" | "makedir"
|
||||
| "create_dir" | "createDir" | "createDirectory" | "new_directory" | "new_folder"
|
||||
| "create_folder" | "createFolder" => Some("create_directory"),
|
||||
"Bash" | "bash" | "exec" | "execute_command" => Some("shell_exec"),
|
||||
"WebSearch" | "web_search" => Some("web_search"),
|
||||
"WebFetch" | "fetch_url" | "web_fetch" => Some("web_fetch"),
|
||||
@@ -56,6 +59,7 @@ pub fn is_known_openfang_tool(name: &str) -> bool {
|
||||
"file_read"
|
||||
| "file_write"
|
||||
| "file_list"
|
||||
| "create_directory"
|
||||
| "shell_exec"
|
||||
| "web_search"
|
||||
| "web_fetch"
|
||||
@@ -147,6 +151,14 @@ mod tests {
|
||||
assert_eq!(map_tool_name("execute"), Some("shell_exec"));
|
||||
assert_eq!(map_tool_name("shell"), Some("shell_exec"));
|
||||
|
||||
// create_directory aliases
|
||||
assert_eq!(map_tool_name("Mkdir"), Some("create_directory"));
|
||||
assert_eq!(map_tool_name("mkdir"), Some("create_directory"));
|
||||
assert_eq!(map_tool_name("make_directory"), Some("create_directory"));
|
||||
assert_eq!(map_tool_name("create_dir"), Some("create_directory"));
|
||||
assert_eq!(map_tool_name("createDirectory"), Some("create_directory"));
|
||||
assert_eq!(map_tool_name("create_folder"), Some("create_directory"));
|
||||
|
||||
// Unknown
|
||||
assert_eq!(map_tool_name("unknown_tool"), None);
|
||||
assert_eq!(map_tool_name(""), None);
|
||||
@@ -180,6 +192,7 @@ mod tests {
|
||||
"file_read",
|
||||
"file_write",
|
||||
"file_list",
|
||||
"create_directory",
|
||||
"shell_exec",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
|
||||
Reference in New Issue
Block a user