From bddba44720e6bc9a72e4c63fd674c9f2b5b49fc2 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Thu, 7 May 2026 14:38:17 +0200 Subject: [PATCH 1/3] feat: improve Daytona sandbox tools Signed-off-by: Mislav Ivanda --- docs/ar/tools/ai-ml/daytona.mdx | 78 +++++- docs/docs.json | 42 +-- docs/en/tools/ai-ml/daytona.mdx | 78 +++++- docs/ko/tools/ai-ml/daytona.mdx | 78 +++++- docs/pt-BR/tools/ai-ml/daytona.mdx | 78 +++++- lib/crewai-tools/pyproject.toml | 2 +- .../tools/daytona_sandbox_tool/README.md | 22 +- .../daytona_sandbox_tool/daytona_base_tool.py | 24 ++ .../daytona_sandbox_tool/daytona_file_tool.py | 261 ++++++++++++++++-- 9 files changed, 555 insertions(+), 108 deletions(-) diff --git a/docs/ar/tools/ai-ml/daytona.mdx b/docs/ar/tools/ai-ml/daytona.mdx index 9447c6a3f7..5c112b0d1e 100644 --- a/docs/ar/tools/ai-ml/daytona.mdx +++ b/docs/ar/tools/ai-ml/daytona.mdx @@ -13,7 +13,7 @@ The Daytona sandbox tools give CrewAI agents access to isolated, ephemeral compu - **`DaytonaExecTool`** — run any shell command inside a sandbox. - **`DaytonaPythonTool`** — execute a block of Python source code inside a sandbox. -- **`DaytonaFileTool`** — read, write, append, list, delete, and inspect files inside a sandbox. +- **`DaytonaFileTool`** — read, write, append, list, delete, and inspect files inside a sandbox; also supports `move`, `find` (content grep), `search` (filename glob), `chmod` (permissions), `replace` (bulk find-and-replace), and `exists`. All three tools share the same sandbox lifecycle controls, so you can mix and match them while keeping state in a single persistent sandbox. @@ -55,7 +55,7 @@ from crewai_tools import DaytonaPythonTool tool = DaytonaPythonTool() result = tool.run(code="print(sum(range(10)))") print(result) -# {"exit_code": 0, "result": "45\n", "artifacts": None} +# {"exit_code": 0, "result": "45\n", "artifacts": ExecutionArtifacts(stdout="45\n", charts=[])} ``` ### Multi-step shell session (persistent) @@ -63,17 +63,22 @@ print(result) ```python Code from crewai_tools import DaytonaExecTool, DaytonaFileTool +# Create the persistent sandbox via the first tool, then attach the second +# tool to it so both share state (installed packages, files, env vars). exec_tool = DaytonaExecTool(persistent=True) -file_tool = DaytonaFileTool(persistent=True) - -# Install a package, then write and run a script — all in the same sandbox exec_tool.run(command="pip install httpx -q") -file_tool.run(action="write", path="/workspace/fetch.py", content="import httpx; print(httpx.get('https://httpbin.org/get').status_code)") -exec_tool.run(command="python /workspace/fetch.py") +file_tool = DaytonaFileTool(sandbox_id=exec_tool.active_sandbox_id) + +file_tool.run( + action="write", + path="workspace/script.py", + content="import httpx; print(f'httpx loaded, version {httpx.__version__}')", +) +exec_tool.run(command="python workspace/script.py") ``` -Each tool instance maintains its own persistent sandbox. To share **one** sandbox across two tools, create the first tool, grab its sandbox id via `tool._persistent_sandbox.id`, and pass it to the second tool via `sandbox_id=...`. +By default, each tool with `persistent=True` lazily creates its **own** sandbox on first use. The pattern above shares a single sandbox across multiple tools by reading the first tool's `active_sandbox_id` after a `.run()` call and passing it to the others via `sandbox_id=...`. With `persistent=False` (the default), every `.run()` call gets a fresh sandbox that's deleted at the end of that call. ### Attach to an existing sandbox @@ -82,7 +87,7 @@ Each tool instance maintains its own persistent sandbox. To share **one** sandbo from crewai_tools import DaytonaExecTool tool = DaytonaExecTool(sandbox_id="my-long-lived-sandbox") -result = tool.run(command="ls /workspace") +result = tool.run(command="ls workspace") ``` ### Custom sandbox parameters @@ -102,6 +107,41 @@ tool = DaytonaExecTool( ) ``` +### Searching, moving, and modifying files + +```python Code +from crewai_tools import DaytonaFileTool + +file_tool = DaytonaFileTool(persistent=True) + +# Find every TODO in the source tree (grep file contents recursively) +file_tool.run(action="find", path="workspace/src", pattern="TODO:") + +# Find all Python files (glob match on filenames) +file_tool.run(action="search", path="workspace", pattern="*.py") + +# Make a script executable +file_tool.run(action="chmod", path="workspace/run.sh", mode="755") + +# Rename or move a file +file_tool.run( + action="move", + path="workspace/draft.md", + destination="workspace/final.md", +) + +# Bulk find-and-replace across multiple files +file_tool.run( + action="replace", + paths=["workspace/src/a.py", "workspace/src/b.py"], + pattern="old_function", + replacement="new_function", +) + +# Quick existence check before a destructive op +file_tool.run(action="exists", path="workspace/cache.db") +``` + ### Agent integration ```python Code @@ -121,7 +161,7 @@ coder = Agent( ) task = Task( - description="Write a Python script that prints the first 10 Fibonacci numbers, save it to /workspace/fib.py, and run it.", + description="Write a Python script that prints the first 10 Fibonacci numbers, save it to workspace/fib.py, and run it.", expected_output="The first 10 Fibonacci numbers printed to stdout.", agent=coder, ) @@ -168,12 +208,22 @@ All three tools accept these parameters at initialization: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `action` | `str` | ✓ | One of: `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`. | -| `path` | `str` | ✓ | Absolute path inside the sandbox. | -| `content` | `str \| None` | | Content to write or append. Required for `append`. | +| `action` | `str` | ✓ | One of: `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`, `exists`, `move`, `find`, `search`, `chmod`, `replace`. | +| `path` | `str \| None` | ✓ for all actions except `replace` | Absolute path inside the sandbox. | +| `content` | `str \| None` | ✓ for `append` | Content to write or append. | | `binary` | `bool` | | If `True`, `content` is base64 on write; returns base64 on read. | | `recursive` | `bool` | | For `delete`: remove directories recursively. | -| `mode` | `str` | | For `mkdir`: octal permission string (default `"0755"`). | +| `mode` | `str \| None` | | For `mkdir`: octal permissions for the new directory (defaults to `"0755"`). For `chmod`: octal permissions to apply to the target. | +| `destination` | `str \| None` | ✓ for `move` | Destination path for `move`. | +| `pattern` | `str \| None` | ✓ for `find`, `search`, `replace` | For `find`: substring matched against file CONTENTS. For `search`: glob matched against file NAMES (e.g. `*.py`). For `replace`: text to replace inside files. | +| `replacement` | `str \| None` | ✓ for `replace` | Replacement text for `pattern`. | +| `paths` | `list[str] \| None` | ✓ for `replace` | List of file paths in which to replace text. | +| `owner` | `str \| None` | | For `chmod`: new file owner. | +| `group` | `str \| None` | | For `chmod`: new file group. | + + +For `chmod`, pass at least one of `mode`, `owner`, or `group` — any field left as `None` is left unchanged on the target. + For files larger than a few KB, create the file first with `action="write"` and empty content, then send the body via multiple `action="append"` calls of ~4 KB each to stay within tool-call payload limits. diff --git a/docs/docs.json b/docs/docs.json index 4454aa5db3..60fe1b9dad 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -283,6 +283,7 @@ "en/tools/ai-ml/langchaintool", "en/tools/ai-ml/ragtool", "en/tools/ai-ml/codeinterpretertool", + "en/tools/ai-ml/daytona", "en/tools/ai-ml/e2bsandboxtools" ] }, @@ -764,6 +765,7 @@ "en/tools/ai-ml/langchaintool", "en/tools/ai-ml/ragtool", "en/tools/ai-ml/codeinterpretertool", + "en/tools/ai-ml/daytona", "en/tools/ai-ml/e2bsandboxtools" ] }, @@ -1724,8 +1726,8 @@ "en/tools/ai-ml/langchaintool", "en/tools/ai-ml/ragtool", "en/tools/ai-ml/codeinterpretertool", - "en/tools/ai-ml/e2bsandboxtools", - "en/tools/ai-ml/daytona" + "en/tools/ai-ml/daytona", + "en/tools/ai-ml/e2bsandboxtools" ] }, { @@ -2205,8 +2207,8 @@ "en/tools/ai-ml/langchaintool", "en/tools/ai-ml/ragtool", "en/tools/ai-ml/codeinterpretertool", - "en/tools/ai-ml/e2bsandboxtools", - "en/tools/ai-ml/daytona" + "en/tools/ai-ml/daytona", + "en/tools/ai-ml/e2bsandboxtools" ] }, { @@ -2686,8 +2688,8 @@ "en/tools/ai-ml/langchaintool", "en/tools/ai-ml/ragtool", "en/tools/ai-ml/codeinterpretertool", - "en/tools/ai-ml/e2bsandboxtools", - "en/tools/ai-ml/daytona" + "en/tools/ai-ml/daytona", + "en/tools/ai-ml/e2bsandboxtools" ] }, { @@ -3167,8 +3169,8 @@ "en/tools/ai-ml/langchaintool", "en/tools/ai-ml/ragtool", "en/tools/ai-ml/codeinterpretertool", - "en/tools/ai-ml/e2bsandboxtools", - "en/tools/ai-ml/daytona" + "en/tools/ai-ml/daytona", + "en/tools/ai-ml/e2bsandboxtools" ] }, { @@ -3647,8 +3649,8 @@ "en/tools/ai-ml/langchaintool", "en/tools/ai-ml/ragtool", "en/tools/ai-ml/codeinterpretertool", - "en/tools/ai-ml/e2bsandboxtools", - "en/tools/ai-ml/daytona" + "en/tools/ai-ml/daytona", + "en/tools/ai-ml/e2bsandboxtools" ] }, { @@ -4126,8 +4128,8 @@ "en/tools/ai-ml/langchaintool", "en/tools/ai-ml/ragtool", "en/tools/ai-ml/codeinterpretertool", - "en/tools/ai-ml/e2bsandboxtools", - "en/tools/ai-ml/daytona" + "en/tools/ai-ml/daytona", + "en/tools/ai-ml/e2bsandboxtools" ] }, { @@ -4605,8 +4607,8 @@ "en/tools/ai-ml/langchaintool", "en/tools/ai-ml/ragtool", "en/tools/ai-ml/codeinterpretertool", - "en/tools/ai-ml/e2bsandboxtools", - "en/tools/ai-ml/daytona" + "en/tools/ai-ml/daytona", + "en/tools/ai-ml/e2bsandboxtools" ] }, { @@ -5084,8 +5086,8 @@ "en/tools/ai-ml/langchaintool", "en/tools/ai-ml/ragtool", "en/tools/ai-ml/codeinterpretertool", - "en/tools/ai-ml/e2bsandboxtools", - "en/tools/ai-ml/daytona" + "en/tools/ai-ml/daytona", + "en/tools/ai-ml/e2bsandboxtools" ] }, { @@ -5565,8 +5567,8 @@ "en/tools/ai-ml/langchaintool", "en/tools/ai-ml/ragtool", "en/tools/ai-ml/codeinterpretertool", - "en/tools/ai-ml/e2bsandboxtools", - "en/tools/ai-ml/daytona" + "en/tools/ai-ml/daytona", + "en/tools/ai-ml/e2bsandboxtools" ] }, { @@ -6045,8 +6047,8 @@ "en/tools/ai-ml/langchaintool", "en/tools/ai-ml/ragtool", "en/tools/ai-ml/codeinterpretertool", - "en/tools/ai-ml/e2bsandboxtools", - "en/tools/ai-ml/daytona" + "en/tools/ai-ml/daytona", + "en/tools/ai-ml/e2bsandboxtools" ] }, { diff --git a/docs/en/tools/ai-ml/daytona.mdx b/docs/en/tools/ai-ml/daytona.mdx index 9447c6a3f7..5c112b0d1e 100644 --- a/docs/en/tools/ai-ml/daytona.mdx +++ b/docs/en/tools/ai-ml/daytona.mdx @@ -13,7 +13,7 @@ The Daytona sandbox tools give CrewAI agents access to isolated, ephemeral compu - **`DaytonaExecTool`** — run any shell command inside a sandbox. - **`DaytonaPythonTool`** — execute a block of Python source code inside a sandbox. -- **`DaytonaFileTool`** — read, write, append, list, delete, and inspect files inside a sandbox. +- **`DaytonaFileTool`** — read, write, append, list, delete, and inspect files inside a sandbox; also supports `move`, `find` (content grep), `search` (filename glob), `chmod` (permissions), `replace` (bulk find-and-replace), and `exists`. All three tools share the same sandbox lifecycle controls, so you can mix and match them while keeping state in a single persistent sandbox. @@ -55,7 +55,7 @@ from crewai_tools import DaytonaPythonTool tool = DaytonaPythonTool() result = tool.run(code="print(sum(range(10)))") print(result) -# {"exit_code": 0, "result": "45\n", "artifacts": None} +# {"exit_code": 0, "result": "45\n", "artifacts": ExecutionArtifacts(stdout="45\n", charts=[])} ``` ### Multi-step shell session (persistent) @@ -63,17 +63,22 @@ print(result) ```python Code from crewai_tools import DaytonaExecTool, DaytonaFileTool +# Create the persistent sandbox via the first tool, then attach the second +# tool to it so both share state (installed packages, files, env vars). exec_tool = DaytonaExecTool(persistent=True) -file_tool = DaytonaFileTool(persistent=True) - -# Install a package, then write and run a script — all in the same sandbox exec_tool.run(command="pip install httpx -q") -file_tool.run(action="write", path="/workspace/fetch.py", content="import httpx; print(httpx.get('https://httpbin.org/get').status_code)") -exec_tool.run(command="python /workspace/fetch.py") +file_tool = DaytonaFileTool(sandbox_id=exec_tool.active_sandbox_id) + +file_tool.run( + action="write", + path="workspace/script.py", + content="import httpx; print(f'httpx loaded, version {httpx.__version__}')", +) +exec_tool.run(command="python workspace/script.py") ``` -Each tool instance maintains its own persistent sandbox. To share **one** sandbox across two tools, create the first tool, grab its sandbox id via `tool._persistent_sandbox.id`, and pass it to the second tool via `sandbox_id=...`. +By default, each tool with `persistent=True` lazily creates its **own** sandbox on first use. The pattern above shares a single sandbox across multiple tools by reading the first tool's `active_sandbox_id` after a `.run()` call and passing it to the others via `sandbox_id=...`. With `persistent=False` (the default), every `.run()` call gets a fresh sandbox that's deleted at the end of that call. ### Attach to an existing sandbox @@ -82,7 +87,7 @@ Each tool instance maintains its own persistent sandbox. To share **one** sandbo from crewai_tools import DaytonaExecTool tool = DaytonaExecTool(sandbox_id="my-long-lived-sandbox") -result = tool.run(command="ls /workspace") +result = tool.run(command="ls workspace") ``` ### Custom sandbox parameters @@ -102,6 +107,41 @@ tool = DaytonaExecTool( ) ``` +### Searching, moving, and modifying files + +```python Code +from crewai_tools import DaytonaFileTool + +file_tool = DaytonaFileTool(persistent=True) + +# Find every TODO in the source tree (grep file contents recursively) +file_tool.run(action="find", path="workspace/src", pattern="TODO:") + +# Find all Python files (glob match on filenames) +file_tool.run(action="search", path="workspace", pattern="*.py") + +# Make a script executable +file_tool.run(action="chmod", path="workspace/run.sh", mode="755") + +# Rename or move a file +file_tool.run( + action="move", + path="workspace/draft.md", + destination="workspace/final.md", +) + +# Bulk find-and-replace across multiple files +file_tool.run( + action="replace", + paths=["workspace/src/a.py", "workspace/src/b.py"], + pattern="old_function", + replacement="new_function", +) + +# Quick existence check before a destructive op +file_tool.run(action="exists", path="workspace/cache.db") +``` + ### Agent integration ```python Code @@ -121,7 +161,7 @@ coder = Agent( ) task = Task( - description="Write a Python script that prints the first 10 Fibonacci numbers, save it to /workspace/fib.py, and run it.", + description="Write a Python script that prints the first 10 Fibonacci numbers, save it to workspace/fib.py, and run it.", expected_output="The first 10 Fibonacci numbers printed to stdout.", agent=coder, ) @@ -168,12 +208,22 @@ All three tools accept these parameters at initialization: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `action` | `str` | ✓ | One of: `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`. | -| `path` | `str` | ✓ | Absolute path inside the sandbox. | -| `content` | `str \| None` | | Content to write or append. Required for `append`. | +| `action` | `str` | ✓ | One of: `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`, `exists`, `move`, `find`, `search`, `chmod`, `replace`. | +| `path` | `str \| None` | ✓ for all actions except `replace` | Absolute path inside the sandbox. | +| `content` | `str \| None` | ✓ for `append` | Content to write or append. | | `binary` | `bool` | | If `True`, `content` is base64 on write; returns base64 on read. | | `recursive` | `bool` | | For `delete`: remove directories recursively. | -| `mode` | `str` | | For `mkdir`: octal permission string (default `"0755"`). | +| `mode` | `str \| None` | | For `mkdir`: octal permissions for the new directory (defaults to `"0755"`). For `chmod`: octal permissions to apply to the target. | +| `destination` | `str \| None` | ✓ for `move` | Destination path for `move`. | +| `pattern` | `str \| None` | ✓ for `find`, `search`, `replace` | For `find`: substring matched against file CONTENTS. For `search`: glob matched against file NAMES (e.g. `*.py`). For `replace`: text to replace inside files. | +| `replacement` | `str \| None` | ✓ for `replace` | Replacement text for `pattern`. | +| `paths` | `list[str] \| None` | ✓ for `replace` | List of file paths in which to replace text. | +| `owner` | `str \| None` | | For `chmod`: new file owner. | +| `group` | `str \| None` | | For `chmod`: new file group. | + + +For `chmod`, pass at least one of `mode`, `owner`, or `group` — any field left as `None` is left unchanged on the target. + For files larger than a few KB, create the file first with `action="write"` and empty content, then send the body via multiple `action="append"` calls of ~4 KB each to stay within tool-call payload limits. diff --git a/docs/ko/tools/ai-ml/daytona.mdx b/docs/ko/tools/ai-ml/daytona.mdx index 9447c6a3f7..5c112b0d1e 100644 --- a/docs/ko/tools/ai-ml/daytona.mdx +++ b/docs/ko/tools/ai-ml/daytona.mdx @@ -13,7 +13,7 @@ The Daytona sandbox tools give CrewAI agents access to isolated, ephemeral compu - **`DaytonaExecTool`** — run any shell command inside a sandbox. - **`DaytonaPythonTool`** — execute a block of Python source code inside a sandbox. -- **`DaytonaFileTool`** — read, write, append, list, delete, and inspect files inside a sandbox. +- **`DaytonaFileTool`** — read, write, append, list, delete, and inspect files inside a sandbox; also supports `move`, `find` (content grep), `search` (filename glob), `chmod` (permissions), `replace` (bulk find-and-replace), and `exists`. All three tools share the same sandbox lifecycle controls, so you can mix and match them while keeping state in a single persistent sandbox. @@ -55,7 +55,7 @@ from crewai_tools import DaytonaPythonTool tool = DaytonaPythonTool() result = tool.run(code="print(sum(range(10)))") print(result) -# {"exit_code": 0, "result": "45\n", "artifacts": None} +# {"exit_code": 0, "result": "45\n", "artifacts": ExecutionArtifacts(stdout="45\n", charts=[])} ``` ### Multi-step shell session (persistent) @@ -63,17 +63,22 @@ print(result) ```python Code from crewai_tools import DaytonaExecTool, DaytonaFileTool +# Create the persistent sandbox via the first tool, then attach the second +# tool to it so both share state (installed packages, files, env vars). exec_tool = DaytonaExecTool(persistent=True) -file_tool = DaytonaFileTool(persistent=True) - -# Install a package, then write and run a script — all in the same sandbox exec_tool.run(command="pip install httpx -q") -file_tool.run(action="write", path="/workspace/fetch.py", content="import httpx; print(httpx.get('https://httpbin.org/get').status_code)") -exec_tool.run(command="python /workspace/fetch.py") +file_tool = DaytonaFileTool(sandbox_id=exec_tool.active_sandbox_id) + +file_tool.run( + action="write", + path="workspace/script.py", + content="import httpx; print(f'httpx loaded, version {httpx.__version__}')", +) +exec_tool.run(command="python workspace/script.py") ``` -Each tool instance maintains its own persistent sandbox. To share **one** sandbox across two tools, create the first tool, grab its sandbox id via `tool._persistent_sandbox.id`, and pass it to the second tool via `sandbox_id=...`. +By default, each tool with `persistent=True` lazily creates its **own** sandbox on first use. The pattern above shares a single sandbox across multiple tools by reading the first tool's `active_sandbox_id` after a `.run()` call and passing it to the others via `sandbox_id=...`. With `persistent=False` (the default), every `.run()` call gets a fresh sandbox that's deleted at the end of that call. ### Attach to an existing sandbox @@ -82,7 +87,7 @@ Each tool instance maintains its own persistent sandbox. To share **one** sandbo from crewai_tools import DaytonaExecTool tool = DaytonaExecTool(sandbox_id="my-long-lived-sandbox") -result = tool.run(command="ls /workspace") +result = tool.run(command="ls workspace") ``` ### Custom sandbox parameters @@ -102,6 +107,41 @@ tool = DaytonaExecTool( ) ``` +### Searching, moving, and modifying files + +```python Code +from crewai_tools import DaytonaFileTool + +file_tool = DaytonaFileTool(persistent=True) + +# Find every TODO in the source tree (grep file contents recursively) +file_tool.run(action="find", path="workspace/src", pattern="TODO:") + +# Find all Python files (glob match on filenames) +file_tool.run(action="search", path="workspace", pattern="*.py") + +# Make a script executable +file_tool.run(action="chmod", path="workspace/run.sh", mode="755") + +# Rename or move a file +file_tool.run( + action="move", + path="workspace/draft.md", + destination="workspace/final.md", +) + +# Bulk find-and-replace across multiple files +file_tool.run( + action="replace", + paths=["workspace/src/a.py", "workspace/src/b.py"], + pattern="old_function", + replacement="new_function", +) + +# Quick existence check before a destructive op +file_tool.run(action="exists", path="workspace/cache.db") +``` + ### Agent integration ```python Code @@ -121,7 +161,7 @@ coder = Agent( ) task = Task( - description="Write a Python script that prints the first 10 Fibonacci numbers, save it to /workspace/fib.py, and run it.", + description="Write a Python script that prints the first 10 Fibonacci numbers, save it to workspace/fib.py, and run it.", expected_output="The first 10 Fibonacci numbers printed to stdout.", agent=coder, ) @@ -168,12 +208,22 @@ All three tools accept these parameters at initialization: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `action` | `str` | ✓ | One of: `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`. | -| `path` | `str` | ✓ | Absolute path inside the sandbox. | -| `content` | `str \| None` | | Content to write or append. Required for `append`. | +| `action` | `str` | ✓ | One of: `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`, `exists`, `move`, `find`, `search`, `chmod`, `replace`. | +| `path` | `str \| None` | ✓ for all actions except `replace` | Absolute path inside the sandbox. | +| `content` | `str \| None` | ✓ for `append` | Content to write or append. | | `binary` | `bool` | | If `True`, `content` is base64 on write; returns base64 on read. | | `recursive` | `bool` | | For `delete`: remove directories recursively. | -| `mode` | `str` | | For `mkdir`: octal permission string (default `"0755"`). | +| `mode` | `str \| None` | | For `mkdir`: octal permissions for the new directory (defaults to `"0755"`). For `chmod`: octal permissions to apply to the target. | +| `destination` | `str \| None` | ✓ for `move` | Destination path for `move`. | +| `pattern` | `str \| None` | ✓ for `find`, `search`, `replace` | For `find`: substring matched against file CONTENTS. For `search`: glob matched against file NAMES (e.g. `*.py`). For `replace`: text to replace inside files. | +| `replacement` | `str \| None` | ✓ for `replace` | Replacement text for `pattern`. | +| `paths` | `list[str] \| None` | ✓ for `replace` | List of file paths in which to replace text. | +| `owner` | `str \| None` | | For `chmod`: new file owner. | +| `group` | `str \| None` | | For `chmod`: new file group. | + + +For `chmod`, pass at least one of `mode`, `owner`, or `group` — any field left as `None` is left unchanged on the target. + For files larger than a few KB, create the file first with `action="write"` and empty content, then send the body via multiple `action="append"` calls of ~4 KB each to stay within tool-call payload limits. diff --git a/docs/pt-BR/tools/ai-ml/daytona.mdx b/docs/pt-BR/tools/ai-ml/daytona.mdx index 9447c6a3f7..5c112b0d1e 100644 --- a/docs/pt-BR/tools/ai-ml/daytona.mdx +++ b/docs/pt-BR/tools/ai-ml/daytona.mdx @@ -13,7 +13,7 @@ The Daytona sandbox tools give CrewAI agents access to isolated, ephemeral compu - **`DaytonaExecTool`** — run any shell command inside a sandbox. - **`DaytonaPythonTool`** — execute a block of Python source code inside a sandbox. -- **`DaytonaFileTool`** — read, write, append, list, delete, and inspect files inside a sandbox. +- **`DaytonaFileTool`** — read, write, append, list, delete, and inspect files inside a sandbox; also supports `move`, `find` (content grep), `search` (filename glob), `chmod` (permissions), `replace` (bulk find-and-replace), and `exists`. All three tools share the same sandbox lifecycle controls, so you can mix and match them while keeping state in a single persistent sandbox. @@ -55,7 +55,7 @@ from crewai_tools import DaytonaPythonTool tool = DaytonaPythonTool() result = tool.run(code="print(sum(range(10)))") print(result) -# {"exit_code": 0, "result": "45\n", "artifacts": None} +# {"exit_code": 0, "result": "45\n", "artifacts": ExecutionArtifacts(stdout="45\n", charts=[])} ``` ### Multi-step shell session (persistent) @@ -63,17 +63,22 @@ print(result) ```python Code from crewai_tools import DaytonaExecTool, DaytonaFileTool +# Create the persistent sandbox via the first tool, then attach the second +# tool to it so both share state (installed packages, files, env vars). exec_tool = DaytonaExecTool(persistent=True) -file_tool = DaytonaFileTool(persistent=True) - -# Install a package, then write and run a script — all in the same sandbox exec_tool.run(command="pip install httpx -q") -file_tool.run(action="write", path="/workspace/fetch.py", content="import httpx; print(httpx.get('https://httpbin.org/get').status_code)") -exec_tool.run(command="python /workspace/fetch.py") +file_tool = DaytonaFileTool(sandbox_id=exec_tool.active_sandbox_id) + +file_tool.run( + action="write", + path="workspace/script.py", + content="import httpx; print(f'httpx loaded, version {httpx.__version__}')", +) +exec_tool.run(command="python workspace/script.py") ``` -Each tool instance maintains its own persistent sandbox. To share **one** sandbox across two tools, create the first tool, grab its sandbox id via `tool._persistent_sandbox.id`, and pass it to the second tool via `sandbox_id=...`. +By default, each tool with `persistent=True` lazily creates its **own** sandbox on first use. The pattern above shares a single sandbox across multiple tools by reading the first tool's `active_sandbox_id` after a `.run()` call and passing it to the others via `sandbox_id=...`. With `persistent=False` (the default), every `.run()` call gets a fresh sandbox that's deleted at the end of that call. ### Attach to an existing sandbox @@ -82,7 +87,7 @@ Each tool instance maintains its own persistent sandbox. To share **one** sandbo from crewai_tools import DaytonaExecTool tool = DaytonaExecTool(sandbox_id="my-long-lived-sandbox") -result = tool.run(command="ls /workspace") +result = tool.run(command="ls workspace") ``` ### Custom sandbox parameters @@ -102,6 +107,41 @@ tool = DaytonaExecTool( ) ``` +### Searching, moving, and modifying files + +```python Code +from crewai_tools import DaytonaFileTool + +file_tool = DaytonaFileTool(persistent=True) + +# Find every TODO in the source tree (grep file contents recursively) +file_tool.run(action="find", path="workspace/src", pattern="TODO:") + +# Find all Python files (glob match on filenames) +file_tool.run(action="search", path="workspace", pattern="*.py") + +# Make a script executable +file_tool.run(action="chmod", path="workspace/run.sh", mode="755") + +# Rename or move a file +file_tool.run( + action="move", + path="workspace/draft.md", + destination="workspace/final.md", +) + +# Bulk find-and-replace across multiple files +file_tool.run( + action="replace", + paths=["workspace/src/a.py", "workspace/src/b.py"], + pattern="old_function", + replacement="new_function", +) + +# Quick existence check before a destructive op +file_tool.run(action="exists", path="workspace/cache.db") +``` + ### Agent integration ```python Code @@ -121,7 +161,7 @@ coder = Agent( ) task = Task( - description="Write a Python script that prints the first 10 Fibonacci numbers, save it to /workspace/fib.py, and run it.", + description="Write a Python script that prints the first 10 Fibonacci numbers, save it to workspace/fib.py, and run it.", expected_output="The first 10 Fibonacci numbers printed to stdout.", agent=coder, ) @@ -168,12 +208,22 @@ All three tools accept these parameters at initialization: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `action` | `str` | ✓ | One of: `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`. | -| `path` | `str` | ✓ | Absolute path inside the sandbox. | -| `content` | `str \| None` | | Content to write or append. Required for `append`. | +| `action` | `str` | ✓ | One of: `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`, `exists`, `move`, `find`, `search`, `chmod`, `replace`. | +| `path` | `str \| None` | ✓ for all actions except `replace` | Absolute path inside the sandbox. | +| `content` | `str \| None` | ✓ for `append` | Content to write or append. | | `binary` | `bool` | | If `True`, `content` is base64 on write; returns base64 on read. | | `recursive` | `bool` | | For `delete`: remove directories recursively. | -| `mode` | `str` | | For `mkdir`: octal permission string (default `"0755"`). | +| `mode` | `str \| None` | | For `mkdir`: octal permissions for the new directory (defaults to `"0755"`). For `chmod`: octal permissions to apply to the target. | +| `destination` | `str \| None` | ✓ for `move` | Destination path for `move`. | +| `pattern` | `str \| None` | ✓ for `find`, `search`, `replace` | For `find`: substring matched against file CONTENTS. For `search`: glob matched against file NAMES (e.g. `*.py`). For `replace`: text to replace inside files. | +| `replacement` | `str \| None` | ✓ for `replace` | Replacement text for `pattern`. | +| `paths` | `list[str] \| None` | ✓ for `replace` | List of file paths in which to replace text. | +| `owner` | `str \| None` | | For `chmod`: new file owner. | +| `group` | `str \| None` | | For `chmod`: new file group. | + + +For `chmod`, pass at least one of `mode`, `owner`, or `group` — any field left as `None` is left unchanged on the target. + For files larger than a few KB, create the file first with `action="write"` and empty content, then send the body via multiple `action="append"` calls of ~4 KB each to stay within tool-call payload limits. diff --git a/lib/crewai-tools/pyproject.toml b/lib/crewai-tools/pyproject.toml index ca3b79d271..b22201ce55 100644 --- a/lib/crewai-tools/pyproject.toml +++ b/lib/crewai-tools/pyproject.toml @@ -140,7 +140,7 @@ contextual = [ "nest-asyncio>=1.6.0", ] daytona = [ - "daytona~=0.140.0", + "daytona~=0.171", ] e2b = [ diff --git a/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/README.md b/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/README.md index a2365049e0..c5163884d6 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/README.md @@ -55,10 +55,11 @@ from crewai_tools import DaytonaExecTool, DaytonaFileTool exec_tool = DaytonaExecTool(persistent=True) file_tool = DaytonaFileTool(persistent=True) -# Agent writes a script, then runs it — both share the same sandbox instance -# because they each keep their own persistent sandbox. If you need the *same* -# sandbox across two tools, create one tool, grab the sandbox id via -# `tool._persistent_sandbox.id`, and pass it to the other via `sandbox_id=...`. +# Agent writes a script, then runs it — but each tool keeps its OWN persistent +# sandbox. To share the *same* sandbox across two tools, create and use the +# first tool, then read its `active_sandbox_id` and pass it to the second: +# exec_tool.run(command="pip install httpx") +# file_tool = DaytonaFileTool(sandbox_id=exec_tool.active_sandbox_id) ``` ### Attach to an existing sandbox @@ -99,9 +100,14 @@ tool = DaytonaExecTool( - `timeout: int | None` — seconds. ### `DaytonaFileTool` -- `action: "read" | "write" | "list" | "delete" | "mkdir" | "info"` -- `path: str` — absolute path inside the sandbox. -- `content: str | None` — required for `write`. +- `action`: one of `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`, `exists`, `move`, `find`, `search`, `chmod`, `replace`. +- `path: str | None` — absolute path inside the sandbox. Required for all actions except `replace`. +- `content: str | None` — required for `append`; optional for `write`. - `binary: bool` — if `True`, `content` is base64 on write / returned as base64 on read. - `recursive: bool` — for `delete`, removes directories recursively. -- `mode: str` — for `mkdir`, octal permission string (default `"0755"`). +- `mode: str | None` — for `mkdir` (defaults to `"0755"`) or for `chmod` (e.g. `"755"`). +- `destination: str | None` — required for `move`. +- `pattern: str | None` — required for `find` (content grep), `search` (filename glob), and `replace`. +- `replacement: str | None` — required for `replace`. +- `paths: list[str] | None` — required for `replace`; list of files to operate on. +- `owner: str | None` / `group: str | None` — for `chmod`. Pass at least one of `mode`, `owner`, or `group`. diff --git a/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/daytona_base_tool.py b/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/daytona_base_tool.py index b601e4309d..042245bf77 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/daytona_base_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/daytona_base_tool.py @@ -196,3 +196,27 @@ def close(self) -> None: "the sandbox may need manual deletion.", exc_info=True, ) + + @property + def active_sandbox_id(self) -> str | None: + """The id of the sandbox this tool is currently bound to, if any. + + Returns: + - the explicitly attached `sandbox_id`, if set at construction; + - the id of the lazily-created persistent sandbox, once a call has + triggered creation; + - None for ephemeral mode (where no sandbox lives between calls). + + Use this to share one sandbox across multiple tool instances: + + exec_tool = DaytonaExecTool(persistent=True) + exec_tool.run(command="pip install httpx") + file_tool = DaytonaFileTool(sandbox_id=exec_tool.active_sandbox_id) + """ + if self.sandbox_id: + return self.sandbox_id + with self._lock: + sandbox = self._persistent_sandbox + if sandbox is None: + return None + return getattr(sandbox, "id", None) diff --git a/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/daytona_file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/daytona_file_tool.py index e019419b3e..ab95097f4b 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/daytona_file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/daytona_file_tool.py @@ -4,6 +4,8 @@ from builtins import type as type_ import logging import posixpath +import shlex +import uuid from typing import Any, Literal from pydantic import BaseModel, Field, model_validator @@ -14,22 +16,54 @@ logger = logging.getLogger(__name__) -FileAction = Literal["read", "write", "append", "list", "delete", "mkdir", "info"] +FileAction = Literal[ + "read", + "write", + "append", + "list", + "delete", + "mkdir", + "info", + "exists", + "move", + "find", + "search", + "chmod", + "replace", +] class DaytonaFileToolSchema(BaseModel): action: FileAction = Field( ..., description=( - "The filesystem action to perform: 'read' (returns file contents), " - "'write' (create or replace a file with content), 'append' (append " - "content to an existing file — use this for writing large files in " - "chunks to avoid hitting tool-call size limits), 'list' (lists a " - "directory), 'delete' (removes a file/dir), 'mkdir' (creates a " - "directory), 'info' (returns file metadata)." + "The filesystem action to perform: " + "'read' (returns file contents); " + "'write' (create or replace a file with content); " + "'append' (append content to an existing file — use this for " + "writing large files in chunks to avoid hitting tool-call size " + "limits); " + "'list' (lists a directory); " + "'delete' (removes a file/dir); " + "'mkdir' (creates a directory); " + "'info' (returns file metadata); " + "'exists' (returns whether a path exists); " + "'move' (rename or relocate a file/dir; requires 'destination'); " + "'find' (grep file CONTENTS recursively; requires 'pattern'); " + "'search' (find files by NAME pattern; requires 'pattern'); " + "'chmod' (change permissions/owner/group; pass at least one of " + "'mode', 'owner', 'group'); " + "'replace' (find-and-replace text across files; requires " + "'paths', 'pattern', and 'replacement')." + ), + ) + path: str | None = Field( + default=None, + description=( + "Absolute path inside the sandbox. Required for all actions " + "except 'replace' (which uses 'paths' instead)." ), ) - path: str = Field(..., description="Absolute path inside the sandbox.") content: str | None = Field( default=None, description=( @@ -50,18 +84,78 @@ class DaytonaFileToolSchema(BaseModel): default=False, description="For action='delete': remove directories recursively.", ) - mode: str = Field( - default="0755", - description="For action='mkdir': octal permission string (default 0755).", + mode: str | None = Field( + default=None, + description=( + "Octal permission string. For 'mkdir' it sets the new directory " + "permissions (defaults to '0755' if omitted). For 'chmod' it sets " + "the target's mode (e.g. '755' to make a script executable). " + "Ignored for other actions." + ), + ) + destination: str | None = Field( + default=None, + description="For action='move': absolute destination path.", + ) + pattern: str | None = Field( + default=None, + description=( + "For 'find': substring matched against file CONTENTS. " + "For 'search': glob-style pattern matched against file NAMES " + "(e.g. '*.py'). " + "For 'replace': text to replace inside files." + ), + ) + replacement: str | None = Field( + default=None, + description="For action='replace': replacement text for 'pattern'.", + ) + paths: list[str] | None = Field( + default=None, + description=( + "For action='replace': list of absolute file paths in which to " + "replace 'pattern' with 'replacement'." + ), + ) + owner: str | None = Field( + default=None, + description="For action='chmod': new file owner (user name).", + ) + group: str | None = Field( + default=None, + description="For action='chmod': new file group.", ) @model_validator(mode="after") def _validate_action_args(self) -> DaytonaFileToolSchema: + if self.action != "replace" and not self.path: + raise ValueError(f"action={self.action!r} requires 'path'.") if self.action == "append" and self.content is None: raise ValueError( "action='append' requires 'content'. Pass the chunk to append " "in the 'content' field." ) + if self.action == "move" and not self.destination: + raise ValueError("action='move' requires 'destination'.") + if self.action == "find" and not self.pattern: + raise ValueError( + "action='find' requires 'pattern' (text to search for inside files)." + ) + if self.action == "search" and not self.pattern: + raise ValueError("action='search' requires 'pattern' (glob, e.g. '*.py').") + if self.action == "chmod" and not (self.mode or self.owner or self.group): + raise ValueError( + "action='chmod' requires at least one of 'mode', 'owner', or 'group'." + ) + if self.action == "replace": + if not self.paths: + raise ValueError( + "action='replace' requires 'paths' (list of file paths)." + ) + if not self.pattern: + raise ValueError("action='replace' requires 'pattern'.") + if self.replacement is None: + raise ValueError("action='replace' requires 'replacement'.") return self @@ -75,9 +169,10 @@ class DaytonaFileTool(DaytonaBaseTool): name: str = "Daytona Sandbox Files" description: str = ( - "Perform filesystem operations inside a Daytona sandbox: read a file, " - "write content to a path, append content to an existing file, list a " - "directory, delete a path, make a directory, or fetch file metadata. " + "Perform filesystem operations inside a Daytona sandbox: read, " + "write, append, list, delete, mkdir, info, exists, move, find " + "(content grep), search (filename glob), chmod (permissions/owner/" + "group), and replace (bulk find-and-replace across files). " "For files larger than a few KB, create the file with action='write' " "and empty content, then send the body via multiple 'append' calls of " "~4KB each to stay within tool-call payload limits." @@ -87,11 +182,17 @@ class DaytonaFileTool(DaytonaBaseTool): def _run( self, action: FileAction, - path: str, + path: str | None = None, content: str | None = None, binary: bool = False, recursive: bool = False, - mode: str = "0755", + mode: str | None = None, + destination: str | None = None, + pattern: str | None = None, + replacement: str | None = None, + paths: list[str] | None = None, + owner: str | None = None, + group: str | None = None, ) -> Any: sandbox, should_delete = self._acquire_sandbox() try: @@ -107,10 +208,24 @@ def _run( sandbox.fs.delete_file(path, recursive=recursive) return {"status": "deleted", "path": path} if action == "mkdir": - sandbox.fs.create_folder(path, mode) - return {"status": "created", "path": path, "mode": mode} + mkdir_mode = mode or "0755" + sandbox.fs.create_folder(path, mkdir_mode) + return {"status": "created", "path": path, "mode": mkdir_mode} if action == "info": return self._info(sandbox, path) + if action == "exists": + return self._exists(sandbox, path) + if action == "move": + sandbox.fs.move_files(path, destination) + return {"status": "moved", "from": path, "to": destination} + if action == "find": + return self._find(sandbox, path, pattern) + if action == "search": + return self._search(sandbox, path, pattern) + if action == "chmod": + return self._chmod(sandbox, path, mode=mode, owner=owner, group=group) + if action == "replace": + return self._replace(sandbox, paths, pattern, replacement) raise ValueError(f"Unknown action: {action}") finally: self._release_sandbox(sandbox, should_delete) @@ -146,17 +261,46 @@ def _append( ) -> dict[str, Any]: chunk = base64.b64decode(content) if binary else content.encode("utf-8") self._ensure_parent_dir(sandbox, path) + + # Server-side `cat >>` keeps this O(chunk_size) per call. The naive + # download-concat-reupload alternative is O(N^2) in total transfer. + # /tmp/ is on the sandbox's ephemeral filesystem, not the host. + temp_path = f"/tmp/.crewai-append-{uuid.uuid4().hex}" # noqa: S108 + sandbox.fs.upload_file(chunk, temp_path) + + quoted_temp = shlex.quote(temp_path) + quoted_target = shlex.quote(path) + response = sandbox.process.exec( + f"cat {quoted_temp} >> {quoted_target}; " + f"rc=$?; rm -f {quoted_temp}; exit $rc" + ) + + exit_code = getattr(response, "exit_code", 0) + if exit_code != 0: + try: + sandbox.fs.delete_file(temp_path) + except Exception: + logger.debug( + "Best-effort temp-file cleanup failed after append " + "error; the file may need manual deletion.", + exc_info=True, + ) + raise RuntimeError( + f"append failed: exit_code={exit_code}, " + f"output={getattr(response, 'result', '')!r}" + ) + try: - existing: bytes = sandbox.fs.download_file(path) + info = sandbox.fs.get_file_info(path) + total_bytes = getattr(info, "size", None) except Exception: - existing = b"" - payload = existing + chunk - sandbox.fs.upload_file(payload, path) + total_bytes = None + return { "status": "appended", "path": path, "appended_bytes": len(chunk), - "total_bytes": len(payload), + "total_bytes": total_bytes, } @staticmethod @@ -190,6 +334,77 @@ def _list(self, sandbox: Any, path: str) -> dict[str, Any]: def _info(self, sandbox: Any, path: str) -> dict[str, Any]: return self._file_info_to_dict(sandbox.fs.get_file_info(path)) + def _exists(self, sandbox: Any, path: str) -> dict[str, Any]: + try: + info = sandbox.fs.get_file_info(path) + except Exception: + return {"path": path, "exists": False} + return { + "path": path, + "exists": True, + "is_dir": getattr(info, "is_dir", False), + } + + def _find(self, sandbox: Any, path: str, pattern: str) -> dict[str, Any]: + matches = sandbox.fs.find_files(path, pattern) + return { + "path": path, + "pattern": pattern, + "matches": [ + { + "file": getattr(m, "file", None), + "line": getattr(m, "line", None), + "content": getattr(m, "content", None), + } + for m in matches + ], + } + + def _search(self, sandbox: Any, path: str, pattern: str) -> dict[str, Any]: + response = sandbox.fs.search_files(path, pattern) + files = getattr(response, "files", None) or [] + return {"path": path, "pattern": pattern, "files": list(files)} + + def _chmod( + self, + sandbox: Any, + path: str, + *, + mode: str | None, + owner: str | None, + group: str | None, + ) -> dict[str, Any]: + kwargs: dict[str, str] = {} + if mode is not None: + kwargs["mode"] = mode + if owner is not None: + kwargs["owner"] = owner + if group is not None: + kwargs["group"] = group + sandbox.fs.set_file_permissions(path, **kwargs) + return {"status": "permissions_set", "path": path, **kwargs} + + def _replace( + self, + sandbox: Any, + paths: list[str], + pattern: str, + replacement: str, + ) -> dict[str, Any]: + results = sandbox.fs.replace_in_files(paths, pattern, replacement) + return { + "pattern": pattern, + "replacement": replacement, + "results": [ + { + "file": getattr(r, "file", None), + "success": getattr(r, "success", None), + "error": getattr(r, "error", None), + } + for r in (results or []) + ], + } + @staticmethod def _file_info_to_dict(info: Any) -> dict[str, Any]: fields = ( From f242646988f2b2faa9589359570b9ccd73494fa1 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Fri, 8 May 2026 14:08:19 +0200 Subject: [PATCH 2/3] fix: revert sdk bump to ~=0.140.0 to avoid otel conflict Signed-off-by: Mislav Ivanda --- lib/crewai-tools/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/crewai-tools/pyproject.toml b/lib/crewai-tools/pyproject.toml index 9a8da29eb8..5e37d9b565 100644 --- a/lib/crewai-tools/pyproject.toml +++ b/lib/crewai-tools/pyproject.toml @@ -140,7 +140,7 @@ contextual = [ "nest-asyncio>=1.6.0", ] daytona = [ - "daytona~=0.171", + "daytona~=0.140.0", ] e2b = [ From 40bb50067e94a3b3a861342eac70ac76ab12a7f7 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Fri, 8 May 2026 19:13:55 +0200 Subject: [PATCH 3/3] fix(daytona): satisfy mypy with explicit None checks in _run dispatch Signed-off-by: Mislav Ivanda --- .../daytona_sandbox_tool/daytona_file_tool.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/daytona_file_tool.py b/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/daytona_file_tool.py index ab95097f4b..da8ea77342 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/daytona_file_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/daytona_file_tool.py @@ -197,34 +197,63 @@ def _run( sandbox, should_delete = self._acquire_sandbox() try: if action == "read": + if path is None: + raise ValueError("action='read' requires 'path'") return self._read(sandbox, path, binary=binary) if action == "write": + if path is None: + raise ValueError("action='write' requires 'path'") return self._write(sandbox, path, content or "", binary=binary) if action == "append": + if path is None: + raise ValueError("action='append' requires 'path'") return self._append(sandbox, path, content or "", binary=binary) if action == "list": + if path is None: + raise ValueError("action='list' requires 'path'") return self._list(sandbox, path) if action == "delete": + if path is None: + raise ValueError("action='delete' requires 'path'") sandbox.fs.delete_file(path, recursive=recursive) return {"status": "deleted", "path": path} if action == "mkdir": + if path is None: + raise ValueError("action='mkdir' requires 'path'") mkdir_mode = mode or "0755" sandbox.fs.create_folder(path, mkdir_mode) return {"status": "created", "path": path, "mode": mkdir_mode} if action == "info": + if path is None: + raise ValueError("action='info' requires 'path'") return self._info(sandbox, path) if action == "exists": + if path is None: + raise ValueError("action='exists' requires 'path'") return self._exists(sandbox, path) if action == "move": + if path is None or destination is None: + raise ValueError("action='move' requires 'path' and 'destination'") sandbox.fs.move_files(path, destination) return {"status": "moved", "from": path, "to": destination} if action == "find": + if path is None or pattern is None: + raise ValueError("action='find' requires 'path' and 'pattern'") return self._find(sandbox, path, pattern) if action == "search": + if path is None or pattern is None: + raise ValueError("action='search' requires 'path' and 'pattern'") return self._search(sandbox, path, pattern) if action == "chmod": + if path is None: + raise ValueError("action='chmod' requires 'path'") return self._chmod(sandbox, path, mode=mode, owner=owner, group=group) if action == "replace": + if paths is None or pattern is None or replacement is None: + raise ValueError( + "action='replace' requires 'paths', 'pattern', and " + "'replacement'" + ) return self._replace(sandbox, paths, pattern, replacement) raise ValueError(f"Unknown action: {action}") finally: