From 85648721818fc940a7728f3e3ca714b694d3ff15 Mon Sep 17 00:00:00 2001 From: jaberjaber23 Date: Tue, 12 May 2026 15:12:16 +0300 Subject: [PATCH] create directory --- crates/openfang-runtime/src/tool_runner.rs | 185 +++++++++++++++++++++ crates/openfang-types/src/tool_compat.rs | 13 ++ 2 files changed, 198 insertions(+) diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index 0f3958d2..10457a9c 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -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 { "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 { + // 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 { + 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( diff --git a/crates/openfang-types/src/tool_compat.rs b/crates/openfang-types/src/tool_compat.rs index eebe132f..6deee740 100644 --- a/crates/openfang-types/src/tool_compat.rs +++ b/crates/openfang-types/src/tool_compat.rs @@ -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",