diff --git a/apps/dify-plugin/README.md b/apps/dify-plugin/README.md index 12d4b13..e8283fb 100644 --- a/apps/dify-plugin/README.md +++ b/apps/dify-plugin/README.md @@ -8,7 +8,7 @@ Dify plugin for [Daytona](https://www.daytona.io/), secure sandbox infrastructur - **Create Sandbox**. Provision an isolated sandbox from a Daytona snapshot or a custom Docker image, with optional resource limits, environment variables, and a chosen language runtime. - **Run Code**. Execute a Python, TypeScript, or JavaScript snippet in a sandbox. For larger or multi-file scripts, upload them with **Upload File** and run them via **Run Command**. -- **Run Command**. Run a shell command in a sandbox with separated stdout, stderr, and exit code. +- **Run Command**. Run a shell command in a sandbox and get back its combined output (stdout and stderr) and exit code, with optional working directory and environment variables. - **Upload File**. Upload a file from Dify into a sandbox (e.g. a CSV to analyze, a script to run). - **Download File**. Download a file from a sandbox back into Dify (e.g. a generated chart, processed data). - **Get Preview URL**. Get the public URL that exposes a port from inside a sandbox so users can open a running web app, dashboard, or API in their browser. @@ -39,11 +39,11 @@ Use **Destroy Sandbox** to permanently delete a sandbox you provisioned with **C | Tool | Inputs | Returns | |------|--------|---------| | `create_sandbox` | `name`, `snapshot`, `image`, `language`, `env_vars` (JSON string), `cpu`, `memory`, `disk`, `auto_stop_interval` (all optional) | `sandbox_id` | -| `run_code` | `code` (required), `language` (optional: `python`/`typescript`/`javascript`, default `python`, only used when creating an ephemeral sandbox), `sandbox_id` (optional, ephemeral if omitted) | `exit_code`, `output` (combined stdout+stderr), `sandbox_id` | -| `run_command` | `command` (required), `sandbox_id` (optional, ephemeral if omitted) | `exit_code`, `stdout`, `stderr`, `sandbox_id` | +| `run_code` | `code` (required), `language` (optional: `python`/`typescript`/`javascript`, default `python`, only used when creating an ephemeral sandbox), `sandbox_id` (optional, ephemeral if omitted) | `exit_code`, `output` (combined stdout+stderr), `sandbox_id`, `charts_count`, `charts` (list of `{type, title}`); any generated charts (e.g. matplotlib) are also emitted as PNG image blobs | +| `run_command` | `command` (required), `cwd` (optional), `env_vars` (JSON string, optional), `timeout` (optional, seconds; empty = Daytona default, `0` = no timeout), `sandbox_id` (optional, ephemeral if omitted) | `exit_code`, `output` (combined stdout+stderr), `sandbox_id` | | `upload_file` | `sandbox_id`, `file` (Dify file picker), `remote_path` (all required) | `success`, `sandbox_id`, `remote_path`, `size_bytes` | | `download_file` | `sandbox_id`, `remote_path` (both required) | File as Dify blob plus `success`, `sandbox_id`, `remote_path`, `size_bytes`, `mime_type`, `filename` | -| `get_preview_url` | `sandbox_id`, `port` (3000–9999) (both required) | `url`, `token`, `port`, `sandbox_id`. URL persists while the sandbox runs. For private sandboxes, callers must send the token via the `x-daytona-preview-token` header. | +| `get_preview_url` | `sandbox_id`, `port` (1–65535) (both required) | `url`, `token`, `port`, `sandbox_id`. URL persists while the sandbox runs. For private sandboxes, callers must send the token via the `x-daytona-preview-token` header. | | `destroy_sandbox` | `sandbox_id` (required) | `success`, `sandbox_id` | ### Support diff --git a/apps/dify-plugin/_client.py b/apps/dify-plugin/_client.py index 4a00d1b..5812391 100644 --- a/apps/dify-plugin/_client.py +++ b/apps/dify-plugin/_client.py @@ -1,6 +1,22 @@ +import hashlib from typing import Any -from daytona import Daytona, DaytonaConfig, DaytonaNotFoundError, Sandbox +from daytona import Daytona, DaytonaConfig, DaytonaError, DaytonaNotFoundError, Sandbox + +MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB + +# The plugin daemon is long-lived, so cache one Daytona client per unique +# credential set to avoid re-creating an HTTP session on every tool call. +_client_cache: dict[str, Daytona] = {} + + +def _cache_key(credentials: dict[str, Any]) -> str: + # Length-prefix each field so distinct api_key/api_url pairs can't collide + # into the same entry (e.g. "a:" + "b" vs "a" + ":b"). + api_key = str(credentials.get("api_key", "")) + api_url = str(credentials.get("api_url", "")) + raw = f"{len(api_key)}:{api_key}{len(api_url)}:{api_url}" + return hashlib.sha256(raw.encode()).hexdigest() def to_int(value: Any, name: str) -> int: @@ -14,10 +30,13 @@ def to_int(value: Any, name: str) -> int: def build_client(credentials: dict[str, Any]) -> Daytona: - config = DaytonaConfig(api_key=credentials["api_key"]) - if api_url := credentials.get("api_url"): - config.api_url = api_url - return Daytona(config) + key = _cache_key(credentials) + if key not in _client_cache: + config = DaytonaConfig(api_key=credentials["api_key"]) + if api_url := credentials.get("api_url"): + config.api_url = api_url + _client_cache[key] = Daytona(config) + return _client_cache[key] def get_sandbox(client: Daytona, sandbox_id: str) -> Sandbox: @@ -27,6 +46,8 @@ def get_sandbox(client: Daytona, sandbox_id: str) -> Sandbox: sandbox = client.get(sandbox_id) except DaytonaNotFoundError as e: raise ValueError(f"Sandbox '{sandbox_id}' not found") from e + except DaytonaError as e: + raise ValueError(f"Failed to retrieve sandbox '{sandbox_id}': {e}") from e if sandbox is None: raise ValueError(f"Sandbox '{sandbox_id}' not found") return sandbox diff --git a/apps/dify-plugin/provider/daytona.yaml b/apps/dify-plugin/provider/daytona.yaml index 8c057ce..e3c63de 100644 --- a/apps/dify-plugin/provider/daytona.yaml +++ b/apps/dify-plugin/provider/daytona.yaml @@ -58,6 +58,16 @@ tools: - tools/download_file.yaml - tools/get_preview_url.yaml - tools/destroy_sandbox.yaml + - tools/list_files.yaml + - tools/read_file.yaml + - tools/write_file.yaml + - tools/search_files.yaml + - tools/find_in_files.yaml + - tools/git_clone.yaml + - tools/list_sandboxes.yaml + - tools/manage_sandbox.yaml + - tools/start_service.yaml + - tools/get_service_logs.yaml extra: python: source: provider/daytona.py diff --git a/apps/dify-plugin/tools/create_sandbox.py b/apps/dify-plugin/tools/create_sandbox.py index 4365a3b..979e7bb 100644 --- a/apps/dify-plugin/tools/create_sandbox.py +++ b/apps/dify-plugin/tools/create_sandbox.py @@ -28,7 +28,10 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag env_vars = self._parse_env_vars(tool_parameters.get("env_vars")) - auto_stop = to_int(tool_parameters.get("auto_stop_interval", 15), "auto_stop_interval") + auto_stop_raw = tool_parameters.get("auto_stop_interval") + if auto_stop_raw is None or auto_stop_raw == "": + auto_stop_raw = 15 + auto_stop = to_int(auto_stop_raw, "auto_stop_interval") common_kwargs: dict[str, Any] = { "language": language, @@ -76,13 +79,13 @@ def _build_resources(tool_parameters: dict[str, Any]) -> Resources | None: cpu = tool_parameters.get("cpu") memory = tool_parameters.get("memory") disk = tool_parameters.get("disk") - if cpu is None and memory is None and disk is None: + if cpu in (None, "") and memory in (None, "") and disk in (None, ""): return None kwargs: dict[str, int] = {} - if cpu is not None: + if cpu not in (None, ""): kwargs["cpu"] = to_int(cpu, "cpu") - if memory is not None: + if memory not in (None, ""): kwargs["memory"] = to_int(memory, "memory") - if disk is not None: + if disk not in (None, ""): kwargs["disk"] = to_int(disk, "disk") return Resources(**kwargs) diff --git a/apps/dify-plugin/tools/download_file.py b/apps/dify-plugin/tools/download_file.py index 616da88..f23c45c 100644 --- a/apps/dify-plugin/tools/download_file.py +++ b/apps/dify-plugin/tools/download_file.py @@ -6,7 +6,7 @@ from dify_plugin import Tool from dify_plugin.entities.tool import ToolInvokeMessage -from _client import build_client, get_sandbox +from _client import MAX_FILE_SIZE, build_client, get_sandbox class DownloadFileTool(Tool): @@ -21,7 +21,20 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag daytona = build_client(self.runtime.credentials) sandbox = get_sandbox(daytona, sandbox_id) + + info = sandbox.fs.get_file_info(remote_path) + if info.size and info.size > MAX_FILE_SIZE: + raise ValueError( + f"File size ({info.size} bytes) exceeds maximum allowed size " + f"({MAX_FILE_SIZE} bytes)." + ) + content = sandbox.fs.download_file(remote_path) + if len(content) > MAX_FILE_SIZE: + raise ValueError( + f"Downloaded file size ({len(content)} bytes) exceeds maximum allowed size " + f"({MAX_FILE_SIZE} bytes)." + ) filename = os.path.basename(remote_path) or "downloaded_file" mime_type, _ = mimetypes.guess_type(filename) diff --git a/apps/dify-plugin/tools/find_in_files.py b/apps/dify-plugin/tools/find_in_files.py new file mode 100644 index 0000000..3850e17 --- /dev/null +++ b/apps/dify-plugin/tools/find_in_files.py @@ -0,0 +1,44 @@ +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from _client import build_client, get_sandbox + + +class FindInFilesTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + sandbox_id = tool_parameters.get("sandbox_id") + if not sandbox_id: + raise ValueError("sandbox_id is required") + + path = tool_parameters.get("path") + if not path: + raise ValueError("path is required") + + pattern = tool_parameters.get("pattern") + if not pattern: + raise ValueError("pattern is required") + + daytona = build_client(self.runtime.credentials) + sandbox = get_sandbox(daytona, sandbox_id) + # find_files returns list[Match]; each Match has flat .file / .line / .content + results = sandbox.fs.find_files(path, pattern) + + matches = [ + { + "file": m.file, + "line_number": m.line, + "line_content": m.content, + } + for m in results + ] + + yield self.create_json_message({ + "sandbox_id": sandbox_id, + "path": path, + "pattern": pattern, + "matches": matches, + "count": len(matches), + }) diff --git a/apps/dify-plugin/tools/find_in_files.yaml b/apps/dify-plugin/tools/find_in_files.yaml new file mode 100644 index 0000000..8d944cc --- /dev/null +++ b/apps/dify-plugin/tools/find_in_files.yaml @@ -0,0 +1,64 @@ +identity: + name: find_in_files + author: daytonaio + label: + en_US: Find in Files + zh_Hans: Find in Files + ja_JP: Find in Files + pt_BR: Find in Files +description: + human: + en_US: Search for a text pattern inside file contents in a Daytona sandbox (like grep). + zh_Hans: Search for a text pattern inside file contents in a Daytona sandbox (like grep). + ja_JP: Search for a text pattern inside file contents in a Daytona sandbox (like grep). + pt_BR: Search for a text pattern inside file contents in a Daytona sandbox (like grep). + llm: Search for a text pattern inside the contents of files within an existing Daytona sandbox. Returns matching results with file path, line number, and matching line content. Similar to running grep recursively. Requires a sandbox_id. +parameters: + - name: sandbox_id + type: string + required: true + label: + en_US: Sandbox ID + zh_Hans: Sandbox ID + ja_JP: Sandbox ID + pt_BR: Sandbox ID + human_description: + en_US: The ID of the sandbox to search within. + zh_Hans: The ID of the sandbox to search within. + ja_JP: The ID of the sandbox to search within. + pt_BR: The ID of the sandbox to search within. + llm_description: The ID of the Daytona sandbox to search within. + form: llm + - name: path + type: string + required: true + label: + en_US: Path + zh_Hans: Path + ja_JP: Path + pt_BR: Path + human_description: + en_US: 'Absolute directory path inside the sandbox to search within (e.g. /home/daytona).' + zh_Hans: 'Absolute directory path inside the sandbox to search within (e.g. /home/daytona).' + ja_JP: 'Absolute directory path inside the sandbox to search within (e.g. /home/daytona).' + pt_BR: 'Absolute directory path inside the sandbox to search within (e.g. /home/daytona).' + llm_description: 'Absolute directory path inside the sandbox to search within (e.g. "/home/daytona", "/tmp", "/").' + form: llm + - name: pattern + type: string + required: true + label: + en_US: Pattern + zh_Hans: Pattern + ja_JP: Pattern + pt_BR: Pattern + human_description: + en_US: 'Text pattern to search for inside file contents.' + zh_Hans: 'Text pattern to search for inside file contents.' + ja_JP: 'Text pattern to search for inside file contents.' + pt_BR: 'Text pattern to search for inside file contents.' + llm_description: 'Text pattern to search for inside file contents (e.g. "def main", "ERROR", "TODO").' + form: llm +extra: + python: + source: tools/find_in_files.py diff --git a/apps/dify-plugin/tools/get_preview_url.py b/apps/dify-plugin/tools/get_preview_url.py index 4817983..43311e4 100644 --- a/apps/dify-plugin/tools/get_preview_url.py +++ b/apps/dify-plugin/tools/get_preview_url.py @@ -17,8 +17,8 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag if port is None: raise ValueError("port is required") port = to_int(port, "port") - if not (3000 <= port <= 9999): - raise ValueError(f"Port must be between 3000 and 9999, got {port}") + if not (1 <= port <= 65535): + raise ValueError(f"Invalid port number: {port}") daytona = build_client(self.runtime.credentials) sandbox = get_sandbox(daytona, sandbox_id) diff --git a/apps/dify-plugin/tools/get_preview_url.yaml b/apps/dify-plugin/tools/get_preview_url.yaml index b71733b..dd56f02 100644 --- a/apps/dify-plugin/tools/get_preview_url.yaml +++ b/apps/dify-plugin/tools/get_preview_url.yaml @@ -42,7 +42,7 @@ parameters: zh_Hans: The port number inside the sandbox where the service is listening (e.g. 3000, 5000, 8080). ja_JP: The port number inside the sandbox where the service is listening (e.g. 3000, 5000, 8080). pt_BR: The port number inside the sandbox where the service is listening (e.g. 3000, 5000, 8080). - llm_description: 'The port number inside the sandbox where the service is listening (e.g. 3000 for Next.js, 5000 for Flask, 8080 for generic HTTP, 8501 for Streamlit). Range 3000-9999.' + llm_description: 'The port number inside the sandbox where the service is listening (e.g. 3000 for Next.js, 5000 for Flask, 8080 for generic HTTP, 8501 for Streamlit). Range 1-65535.' form: llm extra: python: diff --git a/apps/dify-plugin/tools/get_service_logs.py b/apps/dify-plugin/tools/get_service_logs.py new file mode 100644 index 0000000..f8feb64 --- /dev/null +++ b/apps/dify-plugin/tools/get_service_logs.py @@ -0,0 +1,58 @@ +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from _client import build_client, get_sandbox + + +class GetServiceLogsTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + sandbox_id = tool_parameters.get("sandbox_id") + if not sandbox_id: + raise ValueError("sandbox_id is required") + + session_id = tool_parameters.get("session_id") + if not session_id: + raise ValueError("session_id is required") + + cmd_id = tool_parameters.get("cmd_id") or None + + daytona = build_client(self.runtime.credentials) + sandbox = get_sandbox(daytona, sandbox_id) + + if not cmd_id: + session = sandbox.process.get_session(session_id) + commands = getattr(session, "commands", None) or [] + if commands: + cmd_id = getattr(commands[-1], "id", None) + if not cmd_id: + raise ValueError( + f"Could not determine cmd_id for session '{session_id}'. " + "Provide cmd_id explicitly (from start_service output)." + ) + + logs = sandbox.process.get_session_command_logs(session_id, cmd_id) + + stdout = getattr(logs, "stdout", None) or "" + stderr = getattr(logs, "stderr", None) or "" + output = getattr(logs, "output", None) or "" + if output: + combined = output + elif stdout and stderr: + # Join the two streams with a single newline, without doubling one + # that stdout already ends with. + separator = "" if stdout.endswith("\n") else "\n" + combined = stdout + separator + stderr + else: + combined = stdout or stderr + + yield self.create_text_message(combined or "(no output)") + yield self.create_json_message({ + "session_id": session_id, + "cmd_id": cmd_id, + "sandbox_id": sandbox_id, + "stdout": stdout, + "stderr": stderr, + }) diff --git a/apps/dify-plugin/tools/get_service_logs.yaml b/apps/dify-plugin/tools/get_service_logs.yaml new file mode 100644 index 0000000..c11238b --- /dev/null +++ b/apps/dify-plugin/tools/get_service_logs.yaml @@ -0,0 +1,65 @@ +identity: + name: get_service_logs + author: daytonaio + label: + en_US: Get Service Logs + zh_Hans: Get Service Logs + ja_JP: Get Service Logs + pt_BR: Obter Logs do Serviço +description: + human: + en_US: Retrieve logs from a background service started with start_service. + zh_Hans: Retrieve logs from a background service started with start_service. + ja_JP: Retrieve logs from a background service started with start_service. + pt_BR: Obtém os logs de um serviço em background iniciado com start_service. + llm: "Retrieve the current stdout and stderr output from a background service started with start_service. Provide the session_id returned by start_service. If cmd_id is omitted, the latest command in the session is used automatically." +parameters: + - name: sandbox_id + type: string + required: true + label: + en_US: Sandbox ID + zh_Hans: Sandbox ID + ja_JP: Sandbox ID + pt_BR: Sandbox ID + human_description: + en_US: The ID of the sandbox running the service. + zh_Hans: The ID of the sandbox running the service. + ja_JP: The ID of the sandbox running the service. + pt_BR: O ID do sandbox onde corre o serviço. + llm_description: The ID of the Daytona sandbox running the background service. + form: llm + - name: session_id + type: string + required: true + label: + en_US: Session ID + zh_Hans: Session ID + ja_JP: Session ID + pt_BR: Session ID + human_description: + en_US: The session ID returned by start_service. + zh_Hans: The session ID returned by start_service. + ja_JP: The session ID returned by start_service. + pt_BR: O ID de sessão devolvido por start_service. + llm_description: The session ID returned by start_service when the background service was launched. + form: llm + - name: cmd_id + type: string + required: false + label: + en_US: Command ID + zh_Hans: Command ID + ja_JP: Command ID + pt_BR: Command ID + human_description: + en_US: The command ID returned by start_service. If omitted, the latest command in the session is used. + zh_Hans: The command ID returned by start_service. If omitted, the latest command in the session is used. + ja_JP: The command ID returned by start_service. If omitted, the latest command in the session is used. + pt_BR: O ID de comando devolvido por start_service. Se omitido, usa o último comando da sessão. + llm_description: Optional command ID returned by start_service. If omitted, auto-discovers the latest command in the session. + form: llm + default: "" +extra: + python: + source: tools/get_service_logs.py diff --git a/apps/dify-plugin/tools/git_clone.py b/apps/dify-plugin/tools/git_clone.py new file mode 100644 index 0000000..b2acc12 --- /dev/null +++ b/apps/dify-plugin/tools/git_clone.py @@ -0,0 +1,70 @@ +from collections.abc import Generator +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from _client import build_client, get_sandbox + + +def _redact_url(url: str) -> str: + """Strip embedded userinfo (user:token@host) so credentials aren't echoed back.""" + try: + parts = urlsplit(url) + except ValueError: + return url + if not (parts.username or parts.password): + return url + # Keep the netloc after the last '@' so only the userinfo is dropped while + # valid host:port / IPv6 syntax (e.g. [::1]:8080) is preserved. parts.hostname + # strips IPv6 brackets and parts.port can raise on a malformed port. + host = parts.netloc.rsplit("@", 1)[-1] + return urlunsplit((parts.scheme, host, parts.path, parts.query, parts.fragment)) + + +class GitCloneTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + sandbox_id = tool_parameters.get("sandbox_id") + if not sandbox_id: + raise ValueError("sandbox_id is required") + + url = tool_parameters.get("url") + if not url: + raise ValueError("url is required") + + path = tool_parameters.get("path") + if not path: + raise ValueError("path is required") + + commit_id = tool_parameters.get("commit_id") or None + # A commit id produces a detached checkout and overrides branch, so don't send both. + branch = None if commit_id else (tool_parameters.get("branch") or None) + username = tool_parameters.get("username") or None + password = tool_parameters.get("password") or None + + daytona = build_client(self.runtime.credentials) + sandbox = get_sandbox(daytona, sandbox_id) + + sandbox.git.clone( + url=url, + path=path, + branch=branch, + commit_id=commit_id, + username=username, + password=password, + ) + + # Never echo embedded credentials back into tool output. + safe_url = _redact_url(url) + yield self.create_json_message({ + "success": True, + "sandbox_id": sandbox_id, + "url": safe_url, + "path": path, + "branch": branch, + "commit_id": commit_id, + }) + yield self.create_text_message( + f"Repository '{safe_url}' cloned to '{path}' in sandbox '{sandbox_id}'." + ) diff --git a/apps/dify-plugin/tools/git_clone.yaml b/apps/dify-plugin/tools/git_clone.yaml new file mode 100644 index 0000000..5c25943 --- /dev/null +++ b/apps/dify-plugin/tools/git_clone.yaml @@ -0,0 +1,127 @@ +identity: + name: git_clone + author: daytonaio + label: + en_US: Git Clone + zh_Hans: Git Clone + ja_JP: Git Clone + pt_BR: Git Clone +description: + human: + en_US: Clone a Git repository into a Daytona sandbox. + zh_Hans: Clone a Git repository into a Daytona sandbox. + ja_JP: Clone a Git repository into a Daytona sandbox. + pt_BR: Clona um repositório Git num sandbox Daytona. + llm: Clone a Git repository into a sandbox. Supports branch selection, specific commit checkout, and authentication for private repositories. After cloning, the code is available at the specified path inside the sandbox for execution or modification. +parameters: + - name: sandbox_id + type: string + required: true + label: + en_US: Sandbox ID + zh_Hans: Sandbox ID + ja_JP: Sandbox ID + pt_BR: Sandbox ID + human_description: + en_US: The ID of the sandbox to clone the repository into. + zh_Hans: The ID of the sandbox to clone the repository into. + ja_JP: The ID of the sandbox to clone the repository into. + pt_BR: O ID do sandbox onde o repositório será clonado. + llm_description: The ID of an existing Daytona sandbox to clone the repository into. + form: llm + - name: url + type: string + required: true + label: + en_US: Repository URL + zh_Hans: Repository URL + ja_JP: Repository URL + pt_BR: URL do Repositório + human_description: + en_US: "The Git repository URL (e.g. https://github.com/user/repo.git)." + zh_Hans: "The Git repository URL (e.g. https://github.com/user/repo.git)." + ja_JP: "The Git repository URL (e.g. https://github.com/user/repo.git)." + pt_BR: "O URL do repositório Git (ex: https://github.com/user/repo.git)." + llm_description: "The Git repository URL to clone (e.g. https://github.com/user/repo.git)." + form: llm + - name: path + type: string + required: true + label: + en_US: Target Path + zh_Hans: Target Path + ja_JP: Target Path + pt_BR: Caminho de Destino + human_description: + en_US: The path inside the sandbox where the repository will be cloned (e.g. /home/daytona/workspace/my-repo). + zh_Hans: The path inside the sandbox where the repository will be cloned (e.g. /home/daytona/workspace/my-repo). + ja_JP: The path inside the sandbox where the repository will be cloned (e.g. /home/daytona/workspace/my-repo). + pt_BR: "O caminho dentro do sandbox onde o repositório será clonado (ex: /home/daytona/workspace/my-repo)." + llm_description: The destination path inside the sandbox where the repository will be cloned. + form: llm + - name: branch + type: string + required: false + label: + en_US: Branch + zh_Hans: Branch + ja_JP: Branch + pt_BR: Branch + human_description: + en_US: The branch to checkout after cloning. Defaults to the repository default branch. + zh_Hans: The branch to checkout after cloning. Defaults to the repository default branch. + ja_JP: The branch to checkout after cloning. Defaults to the repository default branch. + pt_BR: A branch a verificar após clonar. Por defeito usa a branch padrão do repositório. + llm_description: Optional branch to checkout after cloning. Defaults to the repository's default branch. + form: llm + default: "" + - name: commit_id + type: string + required: false + label: + en_US: Commit ID + zh_Hans: Commit ID + ja_JP: Commit ID + pt_BR: Commit ID + human_description: + en_US: A specific commit hash to checkout after cloning. Overrides branch if both are set. + zh_Hans: A specific commit hash to checkout after cloning. Overrides branch if both are set. + ja_JP: A specific commit hash to checkout after cloning. Overrides branch if both are set. + pt_BR: Um hash de commit específico para verificar após clonar. Sobrepõe-se à branch se ambos forem definidos. + llm_description: Optional specific commit hash to checkout after cloning. Overrides branch if both are set. + form: llm + default: "" + - name: username + type: string + required: false + label: + en_US: Username + zh_Hans: Username + ja_JP: Username + pt_BR: Username + human_description: + en_US: Username for authenticating with private repositories. + zh_Hans: Username for authenticating with private repositories. + ja_JP: Username for authenticating with private repositories. + pt_BR: Nome de utilizador para autenticação em repositórios privados. + llm_description: Optional username for authenticating with private Git repositories. + form: llm + default: "" + - name: password + type: secret-input + required: false + label: + en_US: Password / Token + zh_Hans: Password / Token + ja_JP: Password / Token + pt_BR: Password / Token + human_description: + en_US: Password or access token for authenticating with private repositories. + zh_Hans: Password or access token for authenticating with private repositories. + ja_JP: Password or access token for authenticating with private repositories. + pt_BR: Password ou token de acesso para autenticação em repositórios privados. + llm_description: Optional password or access token for authenticating with private Git repositories. + form: form +extra: + python: + source: tools/git_clone.py diff --git a/apps/dify-plugin/tools/list_files.py b/apps/dify-plugin/tools/list_files.py new file mode 100644 index 0000000..91d7979 --- /dev/null +++ b/apps/dify-plugin/tools/list_files.py @@ -0,0 +1,48 @@ +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from _client import build_client, get_sandbox + + +class ListFilesTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + sandbox_id = tool_parameters.get("sandbox_id") + if not sandbox_id: + raise ValueError("sandbox_id is required") + + path = tool_parameters.get("path") + if not path: + raise ValueError("path is required") + + daytona = build_client(self.runtime.credentials) + sandbox = get_sandbox(daytona, sandbox_id) + files = sandbox.fs.list_files(path) + + entries = [] + text_lines = [] + + for f in files: + entry = { + "name": f.name, + "size": f.size, + "is_dir": f.is_dir, + "mode": f.mode, + "owner": f.owner, + } + entries.append(entry) + prefix = "d" if f.is_dir else "-" + text_lines.append(f"{prefix} {f.size:>10} {f.owner:<10} {f.name}") + + text_output = f"Directory listing for '{path}' in sandbox '{sandbox_id}':\n\n" + text_output += "\n".join(text_lines) + + yield self.create_text_message(text_output) + yield self.create_json_message({ + "sandbox_id": sandbox_id, + "path": path, + "entries": entries, + "count": len(entries), + }) diff --git a/apps/dify-plugin/tools/list_files.yaml b/apps/dify-plugin/tools/list_files.yaml new file mode 100644 index 0000000..244275f --- /dev/null +++ b/apps/dify-plugin/tools/list_files.yaml @@ -0,0 +1,49 @@ +identity: + name: list_files + author: daytonaio + label: + en_US: List Files + zh_Hans: List Files + ja_JP: List Files + pt_BR: List Files +description: + human: + en_US: List files and directories at a given path inside a Daytona sandbox. + zh_Hans: List files and directories at a given path inside a Daytona sandbox. + ja_JP: List files and directories at a given path inside a Daytona sandbox. + pt_BR: List files and directories at a given path inside a Daytona sandbox. + llm: List all files and directories at a specified path inside an existing Daytona sandbox. Returns detailed metadata for each entry including name, size, type (file or directory), permissions mode, and owner. Requires a sandbox_id. +parameters: + - name: sandbox_id + type: string + required: true + label: + en_US: Sandbox ID + zh_Hans: Sandbox ID + ja_JP: Sandbox ID + pt_BR: Sandbox ID + human_description: + en_US: The ID of the sandbox to list files from. + zh_Hans: The ID of the sandbox to list files from. + ja_JP: The ID of the sandbox to list files from. + pt_BR: The ID of the sandbox to list files from. + llm_description: The ID of the Daytona sandbox to list files from. + form: llm + - name: path + type: string + required: true + label: + en_US: Path + zh_Hans: Path + ja_JP: Path + pt_BR: Path + human_description: + en_US: 'Absolute directory path inside the sandbox (e.g. /home/daytona or /tmp).' + zh_Hans: 'Absolute directory path inside the sandbox (e.g. /home/daytona or /tmp).' + ja_JP: 'Absolute directory path inside the sandbox (e.g. /home/daytona or /tmp).' + pt_BR: 'Absolute directory path inside the sandbox (e.g. /home/daytona or /tmp).' + llm_description: 'Absolute directory path inside the sandbox to list (e.g. "/home/daytona", "/tmp", "/").' + form: llm +extra: + python: + source: tools/list_files.py diff --git a/apps/dify-plugin/tools/list_sandboxes.py b/apps/dify-plugin/tools/list_sandboxes.py new file mode 100644 index 0000000..4493ec0 --- /dev/null +++ b/apps/dify-plugin/tools/list_sandboxes.py @@ -0,0 +1,46 @@ +from collections.abc import Generator +from itertools import islice +from typing import Any + +from daytona import ListSandboxesQuery + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from _client import build_client, to_int + + +class ListSandboxesTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + daytona = build_client(self.runtime.credentials) + + limit_raw = tool_parameters.get("limit") + limit = 50 if limit_raw in (None, "") else to_int(limit_raw, "limit") + if limit <= 0: + raise ValueError("limit must be a positive whole number") + + # ListSandboxesQuery.limit is the page size, and iterating the result + # pages through the whole account — cap what we actually collect. + query = ListSandboxesQuery(limit=limit) + sandboxes = list(islice(daytona.list(query), limit)) + + result = [] + for sb in sandboxes: + state = getattr(sb.state, "value", sb.state) if sb.state else None + result.append({ + "id": sb.id, + "name": getattr(sb, "name", None), + "state": state, + }) + + yield self.create_json_message({ + "sandboxes": result, + "count": len(result), + }) + if result: + lines = [f"- {sb['id']} ({sb['state']})" for sb in result] + yield self.create_text_message( + f"Found {len(result)} sandbox(es):\n" + "\n".join(lines) + ) + else: + yield self.create_text_message("No sandboxes found.") diff --git a/apps/dify-plugin/tools/list_sandboxes.yaml b/apps/dify-plugin/tools/list_sandboxes.yaml new file mode 100644 index 0000000..a37358c --- /dev/null +++ b/apps/dify-plugin/tools/list_sandboxes.yaml @@ -0,0 +1,34 @@ +identity: + name: list_sandboxes + author: daytonaio + label: + en_US: List Sandboxes + zh_Hans: List Sandboxes + ja_JP: List Sandboxes + pt_BR: Listar Sandboxes +description: + human: + en_US: List all Daytona sandboxes with their IDs and states. + zh_Hans: List all Daytona sandboxes with their IDs and states. + ja_JP: List all Daytona sandboxes with their IDs and states. + pt_BR: Lista todos os sandboxes Daytona com os respetivos IDs e estados. + llm: List all existing Daytona sandboxes. Returns each sandbox's ID, name, and state (started, stopped, archived, etc.). Use this to discover existing sandboxes before creating a new one, or to clean up resources. +parameters: + - name: limit + type: number + required: false + label: + en_US: Limit + zh_Hans: Limit + ja_JP: Limit + pt_BR: Limite + human_description: + en_US: Maximum number of sandboxes to return. Defaults to 50. + zh_Hans: Maximum number of sandboxes to return. Defaults to 50. + ja_JP: Maximum number of sandboxes to return. Defaults to 50. + pt_BR: Número máximo de sandboxes a devolver. Por defeito 50. + llm_description: Maximum number of sandboxes to return. Defaults to 50. + form: llm +extra: + python: + source: tools/list_sandboxes.py diff --git a/apps/dify-plugin/tools/manage_sandbox.py b/apps/dify-plugin/tools/manage_sandbox.py new file mode 100644 index 0000000..81b8d8c --- /dev/null +++ b/apps/dify-plugin/tools/manage_sandbox.py @@ -0,0 +1,52 @@ +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from _client import build_client, get_sandbox + + +class ManageSandboxTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + sandbox_id = tool_parameters.get("sandbox_id") + if not sandbox_id: + raise ValueError("sandbox_id is required") + + action = tool_parameters.get("action") + if not action: + raise ValueError("action is required") + + daytona = build_client(self.runtime.credentials) + sandbox = get_sandbox(daytona, sandbox_id) + + if action == "start": + sandbox.start() + elif action == "stop": + sandbox.stop() + elif action == "archive": + # Daytona requires a sandbox to be stopped before it can be archived; + # an already-archived sandbox is treated as an idempotent no-op. + # Sandbox.stop() blocks until the sandbox is fully stopped. + current = getattr(sandbox.state, "value", sandbox.state) + if current != "archived": + if current != "stopped": + sandbox.stop() + sandbox.archive() + else: + raise ValueError( + f"Invalid action: '{action}'. Must be one of: start, stop, archive." + ) + + sandbox = get_sandbox(daytona, sandbox_id) + state = getattr(sandbox.state, "value", sandbox.state) if sandbox.state else None + + yield self.create_json_message({ + "success": True, + "sandbox_id": sandbox_id, + "action": action, + "state": state, + }) + yield self.create_text_message( + f"Sandbox '{sandbox_id}' {action} completed. Current state: {state}." + ) diff --git a/apps/dify-plugin/tools/manage_sandbox.yaml b/apps/dify-plugin/tools/manage_sandbox.yaml new file mode 100644 index 0000000..7707c93 --- /dev/null +++ b/apps/dify-plugin/tools/manage_sandbox.yaml @@ -0,0 +1,68 @@ +identity: + name: manage_sandbox + author: daytonaio + label: + en_US: Manage Sandbox + zh_Hans: Manage Sandbox + ja_JP: Manage Sandbox + pt_BR: Gerir Sandbox +description: + human: + en_US: Start, stop, or archive a Daytona sandbox to manage its lifecycle and costs. + zh_Hans: Start, stop, or archive a Daytona sandbox to manage its lifecycle and costs. + ja_JP: Start, stop, or archive a Daytona sandbox to manage its lifecycle and costs. + pt_BR: Inicia, para ou arquiva um sandbox Daytona para gerir o seu ciclo de vida e custos. + llm: "Manage the lifecycle of an existing Daytona sandbox. Actions: 'start' (resume a stopped or archived sandbox), 'stop' (pause a running sandbox to reduce costs — filesystem is preserved), 'archive' (compress for long-term storage at minimal cost — slower to resume). Use 'stop' for temporary pauses and 'archive' for longer-term storage." +parameters: + - name: sandbox_id + type: string + required: true + label: + en_US: Sandbox ID + zh_Hans: Sandbox ID + ja_JP: Sandbox ID + pt_BR: Sandbox ID + human_description: + en_US: The ID of the sandbox to manage. + zh_Hans: The ID of the sandbox to manage. + ja_JP: The ID of the sandbox to manage. + pt_BR: O ID do sandbox a gerir. + llm_description: The ID of the Daytona sandbox to start, stop, or archive. + form: llm + - name: action + type: select + required: true + label: + en_US: Action + zh_Hans: Action + ja_JP: Action + pt_BR: Ação + human_description: + en_US: 'The lifecycle action to perform: start (resume), stop (pause), or archive (compress).' + zh_Hans: 'The lifecycle action to perform: start (resume), stop (pause), or archive (compress).' + ja_JP: 'The lifecycle action to perform: start (resume), stop (pause), or archive (compress).' + pt_BR: 'A ação de ciclo de vida a executar: start (retomar), stop (pausar), ou archive (comprimir).' + llm_description: 'Lifecycle action: start (resume a stopped/archived sandbox), stop (pause to reduce costs), or archive (compress for long-term storage).' + form: llm + options: + - value: start + label: + en_US: Start + zh_Hans: Start + ja_JP: Start + pt_BR: Iniciar + - value: stop + label: + en_US: Stop + zh_Hans: Stop + ja_JP: Stop + pt_BR: Parar + - value: archive + label: + en_US: Archive + zh_Hans: Archive + ja_JP: Archive + pt_BR: Arquivar +extra: + python: + source: tools/manage_sandbox.py diff --git a/apps/dify-plugin/tools/read_file.py b/apps/dify-plugin/tools/read_file.py new file mode 100644 index 0000000..1b2d2d9 --- /dev/null +++ b/apps/dify-plugin/tools/read_file.py @@ -0,0 +1,45 @@ +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from _client import MAX_FILE_SIZE, build_client, get_sandbox + + +class ReadFileTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + sandbox_id = tool_parameters.get("sandbox_id") + if not sandbox_id: + raise ValueError("sandbox_id is required") + + remote_path = tool_parameters.get("remote_path") + if not remote_path: + raise ValueError("remote_path is required") + + daytona = build_client(self.runtime.credentials) + sandbox = get_sandbox(daytona, sandbox_id) + + file_info = sandbox.fs.get_file_info(remote_path) + if file_info.size and file_info.size > MAX_FILE_SIZE: + raise ValueError( + f"File size ({file_info.size} bytes) exceeds maximum allowed size " + f"({MAX_FILE_SIZE} bytes)." + ) + + file_data = sandbox.fs.download_file(remote_path) + if len(file_data) > MAX_FILE_SIZE: + raise ValueError( + f"Downloaded file size ({len(file_data)} bytes) exceeds maximum allowed size " + f"({MAX_FILE_SIZE} bytes)." + ) + + content = file_data.decode("utf-8") + + yield self.create_text_message(content) + yield self.create_json_message({ + "success": True, + "sandbox_id": sandbox_id, + "remote_path": remote_path, + "size_bytes": file_info.size, + }) diff --git a/apps/dify-plugin/tools/read_file.yaml b/apps/dify-plugin/tools/read_file.yaml new file mode 100644 index 0000000..edba2be --- /dev/null +++ b/apps/dify-plugin/tools/read_file.yaml @@ -0,0 +1,49 @@ +identity: + name: read_file + author: daytonaio + label: + en_US: Read File + zh_Hans: Read File + ja_JP: Read File + pt_BR: Read File +description: + human: + en_US: Read the text content of a file from a Daytona sandbox. + zh_Hans: Read the text content of a file from a Daytona sandbox. + ja_JP: Read the text content of a file from a Daytona sandbox. + pt_BR: Read the text content of a file from a Daytona sandbox. + llm: Read the full text content of a file (UTF-8 encoded) from an existing Daytona sandbox. Returns the content as text along with file metadata including size. Requires a sandbox_id. +parameters: + - name: sandbox_id + type: string + required: true + label: + en_US: Sandbox ID + zh_Hans: Sandbox ID + ja_JP: Sandbox ID + pt_BR: Sandbox ID + human_description: + en_US: The ID of the sandbox containing the file to read. + zh_Hans: The ID of the sandbox containing the file to read. + ja_JP: The ID of the sandbox containing the file to read. + pt_BR: The ID of the sandbox containing the file to read. + llm_description: The ID of the Daytona sandbox containing the file to read. + form: llm + - name: remote_path + type: string + required: true + label: + en_US: Remote Path + zh_Hans: Remote Path + ja_JP: Remote Path + pt_BR: Remote Path + human_description: + en_US: 'Absolute path of the file inside the sandbox (e.g. /home/daytona/script.py).' + zh_Hans: 'Absolute path of the file inside the sandbox (e.g. /home/daytona/script.py).' + ja_JP: 'Absolute path of the file inside the sandbox (e.g. /home/daytona/script.py).' + pt_BR: 'Absolute path of the file inside the sandbox (e.g. /home/daytona/script.py).' + llm_description: 'Absolute path of the text file inside the sandbox to read (e.g. "/home/daytona/script.py", "/tmp/output.txt").' + form: llm +extra: + python: + source: tools/read_file.py diff --git a/apps/dify-plugin/tools/run_code.py b/apps/dify-plugin/tools/run_code.py index 063bd33..a80fbd3 100644 --- a/apps/dify-plugin/tools/run_code.py +++ b/apps/dify-plugin/tools/run_code.py @@ -1,3 +1,4 @@ +import base64 from collections.abc import Generator from typing import Any @@ -26,10 +27,34 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag try: response = sandbox.process.code_run(tool_parameters["code"]) + + charts_meta: list[dict[str, Any]] = [] + artifacts = getattr(response, "artifacts", None) + charts = getattr(artifacts, "charts", None) if artifacts else None + + if charts: + for chart in charts: + # The rendered PNG lives on chart.png (base64); chart.elements + # are structured data points (BarData/PointData), not images. + png = getattr(chart, "png", None) + if png: + png_bytes = base64.b64decode(png) + yield self.create_blob_message( + blob=png_bytes, meta={"mime_type": "image/png"} + ) + chart_type = getattr(chart, "type", None) + charts_meta.append({ + "type": getattr(chart_type, "value", chart_type), + "title": getattr(chart, "title", None), + }) + + yield self.create_text_message(response.result or "(no output)") yield self.create_json_message({ "exit_code": response.exit_code, "output": response.result, "sandbox_id": sandbox.id, + "charts_count": len(charts_meta), + "charts": charts_meta, }) finally: if ephemeral: diff --git a/apps/dify-plugin/tools/run_code.yaml b/apps/dify-plugin/tools/run_code.yaml index 693924d..eedb51c 100644 --- a/apps/dify-plugin/tools/run_code.yaml +++ b/apps/dify-plugin/tools/run_code.yaml @@ -12,7 +12,7 @@ description: zh_Hans: Run a snippet of code in a Daytona sandbox and return the output. ja_JP: Run a snippet of code in a Daytona sandbox and return the output. pt_BR: Run a snippet of code in a Daytona sandbox and return the output. - llm: Execute a code snippet in a Daytona sandbox using its built-in language runtime. Supports Python, TypeScript, and JavaScript. If no sandbox_id is provided, a new ephemeral sandbox is created with the specified language and destroyed afterward. If a sandbox_id is provided, the language parameter is ignored (the sandbox uses its own configured language). For larger or multi-file scripts, upload them with upload_file and execute via run_command. Returns the combined output (stdout merged with stderr) and the exit code. + llm: Execute a code snippet in a Daytona sandbox using its built-in language runtime. Supports Python, TypeScript, and JavaScript. If no sandbox_id is provided, a new ephemeral sandbox is created with the specified language and destroyed afterward. If a sandbox_id is provided, the language parameter is ignored (the sandbox uses its own configured language). For larger or multi-file scripts, upload them with upload_file and execute via run_command. Returns the combined output (stdout merged with stderr) and the exit code. Any charts produced by the code (e.g. matplotlib figures) are returned as PNG image files plus chart metadata (charts_count and charts). parameters: - name: code type: string diff --git a/apps/dify-plugin/tools/run_command.py b/apps/dify-plugin/tools/run_command.py index fa068a0..5814754 100644 --- a/apps/dify-plugin/tools/run_command.py +++ b/apps/dify-plugin/tools/run_command.py @@ -1,13 +1,11 @@ -import uuid +import json from collections.abc import Generator from typing import Any from dify_plugin import Tool from dify_plugin.entities.tool import ToolInvokeMessage -from daytona import SessionExecuteRequest - -from _client import build_client, get_sandbox +from _client import build_client, get_sandbox, to_int class RunCommandTool(Tool): @@ -17,37 +15,48 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag sandbox_id = tool_parameters.get("sandbox_id", "") ephemeral = not sandbox_id + command = tool_parameters.get("command") + if not command: + raise ValueError("command is required") + cwd = tool_parameters.get("cwd") or None + env_vars = self._parse_env_vars(tool_parameters.get("env_vars")) + + timeout = tool_parameters.get("timeout") + timeout = None if timeout in (None, "") else to_int(timeout, "timeout") + if timeout is not None and timeout < 0: + raise ValueError("timeout must be greater than or equal to 0 (0 means no timeout)") + if sandbox_id: sandbox = get_sandbox(daytona, sandbox_id) else: sandbox = daytona.create() - session_id = f"dify-{uuid.uuid4().hex[:12]}" - session_created = False - try: - sandbox.process.create_session(session_id) - session_created = True - - response = sandbox.process.execute_session_command( - session_id, - SessionExecuteRequest(command=tool_parameters["command"]), - ) + response = sandbox.process.exec(command, cwd=cwd, env=env_vars, timeout=timeout) + yield self.create_text_message(response.result or "(no output)") yield self.create_json_message({ "exit_code": response.exit_code, - "stdout": response.stdout or "", - "stderr": response.stderr or "", + "output": response.result or "", "sandbox_id": sandbox.id, }) finally: - if session_created and not ephemeral: - try: - sandbox.process.delete_session(session_id) - except Exception: - pass if ephemeral: try: sandbox.delete() except Exception: pass + + @staticmethod + def _parse_env_vars(raw: Any) -> dict[str, str] | None: + if not raw: + return None + if isinstance(raw, dict): + return {str(k): str(v) for k, v in raw.items()} + try: + parsed = json.loads(raw) + except (ValueError, TypeError) as e: + raise ValueError(f"env_vars must be a JSON object string: {e}") + if not isinstance(parsed, dict): + raise ValueError("env_vars must be a JSON object") + return {str(k): str(v) for k, v in parsed.items()} diff --git a/apps/dify-plugin/tools/run_command.yaml b/apps/dify-plugin/tools/run_command.yaml index f4270d3..8ec0cb5 100644 --- a/apps/dify-plugin/tools/run_command.yaml +++ b/apps/dify-plugin/tools/run_command.yaml @@ -12,7 +12,7 @@ description: zh_Hans: Run a shell command in a Daytona sandbox and return the output. ja_JP: Run a shell command in a Daytona sandbox and return the output. pt_BR: Run a shell command in a Daytona sandbox and return the output. - llm: Execute a shell command in a Daytona sandbox. Useful for installing packages, running scripts, file operations, and any Linux command. If no sandbox_id is provided, a new ephemeral sandbox is created automatically. Returns stdout, stderr, and exit code. + llm: Execute a shell command in a Daytona sandbox. Useful for installing packages, running scripts, file operations, and any Linux command. Supports optional working directory (cwd) and environment variables. If no sandbox_id is provided, a new ephemeral sandbox is created automatically. Returns combined output (stdout and stderr) and exit code. parameters: - name: command type: string @@ -29,6 +29,53 @@ parameters: pt_BR: The shell command to execute in the sandbox. llm_description: The shell command to execute in the Daytona sandbox. This runs in a standard Linux environment. form: llm + - name: cwd + type: string + required: false + label: + en_US: Working Directory + zh_Hans: Working Directory + ja_JP: Working Directory + pt_BR: Working Directory + human_description: + en_US: Working directory for command execution (e.g. /home/daytona/project). Defaults to sandbox working directory. + zh_Hans: Working directory for command execution (e.g. /home/daytona/project). Defaults to sandbox working directory. + ja_JP: Working directory for command execution (e.g. /home/daytona/project). Defaults to sandbox working directory. + pt_BR: Working directory for command execution (e.g. /home/daytona/project). Defaults to sandbox working directory. + llm_description: Working directory for command execution (e.g. /home/daytona/project). Defaults to sandbox working directory. + form: llm + default: "" + - name: env_vars + type: string + required: false + label: + en_US: Environment Variables (JSON) + zh_Hans: Environment Variables (JSON) + ja_JP: Environment Variables (JSON) + pt_BR: Environment Variables (JSON) + human_description: + en_US: "Environment variables for the command, as a JSON object string. Example: {\"NODE_ENV\": \"production\"}" + zh_Hans: "Environment variables for the command, as a JSON object string. Example: {\"NODE_ENV\": \"production\"}" + ja_JP: "Environment variables for the command, as a JSON object string. Example: {\"NODE_ENV\": \"production\"}" + pt_BR: "Environment variables for the command, as a JSON object string. Example: {\"NODE_ENV\": \"production\"}" + llm_description: 'Environment variables for the command, as a JSON object string. Example: {"NODE_ENV": "production"}' + form: llm + default: "" + - name: timeout + type: number + required: false + label: + en_US: Timeout (seconds) + zh_Hans: Timeout (seconds) + ja_JP: Timeout (seconds) + pt_BR: Timeout (segundos) + human_description: + en_US: Maximum execution time in seconds. Leave empty to use Daytona's default timeout; use 0 for no timeout. + zh_Hans: Maximum execution time in seconds. Leave empty to use Daytona's default timeout; use 0 for no timeout. + ja_JP: Maximum execution time in seconds. Leave empty to use Daytona's default timeout; use 0 for no timeout. + pt_BR: Tempo maximo de execucao em segundos. Deixar vazio para usar o limite padrao do Daytona; usar 0 para sem limite. + llm_description: 'Optional timeout in seconds. Leave empty to use Daytona''s default timeout, or set 0 for no timeout. Use a higher value for long operations like pip install or pytest.' + form: llm - name: sandbox_id type: string required: false diff --git a/apps/dify-plugin/tools/search_files.py b/apps/dify-plugin/tools/search_files.py new file mode 100644 index 0000000..5cb1cfc --- /dev/null +++ b/apps/dify-plugin/tools/search_files.py @@ -0,0 +1,36 @@ +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from _client import build_client, get_sandbox + + +class SearchFilesTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + sandbox_id = tool_parameters.get("sandbox_id") + if not sandbox_id: + raise ValueError("sandbox_id is required") + + path = tool_parameters.get("path") + if not path: + raise ValueError("path is required") + + pattern = tool_parameters.get("pattern") + if not pattern: + raise ValueError("pattern is required") + + daytona = build_client(self.runtime.credentials) + sandbox = get_sandbox(daytona, sandbox_id) + # search_files returns a SearchFilesResponse with a .files list, not a list + result = sandbox.fs.search_files(path, pattern) + files = result.files + + yield self.create_json_message({ + "sandbox_id": sandbox_id, + "path": path, + "pattern": pattern, + "files": files, + "count": len(files), + }) diff --git a/apps/dify-plugin/tools/search_files.yaml b/apps/dify-plugin/tools/search_files.yaml new file mode 100644 index 0000000..2445ad4 --- /dev/null +++ b/apps/dify-plugin/tools/search_files.yaml @@ -0,0 +1,64 @@ +identity: + name: search_files + author: daytonaio + label: + en_US: Search Files + zh_Hans: Search Files + ja_JP: Search Files + pt_BR: Search Files +description: + human: + en_US: Find files by glob pattern inside a Daytona sandbox (like find -name). + zh_Hans: Find files by glob pattern inside a Daytona sandbox (like find -name). + ja_JP: Find files by glob pattern inside a Daytona sandbox (like find -name). + pt_BR: Find files by glob pattern inside a Daytona sandbox (like find -name). + llm: Search for files matching a glob pattern inside an existing Daytona sandbox, starting from a specified directory path. Returns a list of matching file paths (e.g. "*.py" to find Python files). Requires a sandbox_id. +parameters: + - name: sandbox_id + type: string + required: true + label: + en_US: Sandbox ID + zh_Hans: Sandbox ID + ja_JP: Sandbox ID + pt_BR: Sandbox ID + human_description: + en_US: The ID of the sandbox to search within. + zh_Hans: The ID of the sandbox to search within. + ja_JP: The ID of the sandbox to search within. + pt_BR: The ID of the sandbox to search within. + llm_description: The ID of the Daytona sandbox to search within. + form: llm + - name: path + type: string + required: true + label: + en_US: Path + zh_Hans: Path + ja_JP: Path + pt_BR: Path + human_description: + en_US: 'Absolute directory path inside the sandbox to search within (e.g. /home/daytona).' + zh_Hans: 'Absolute directory path inside the sandbox to search within (e.g. /home/daytona).' + ja_JP: 'Absolute directory path inside the sandbox to search within (e.g. /home/daytona).' + pt_BR: 'Absolute directory path inside the sandbox to search within (e.g. /home/daytona).' + llm_description: 'Absolute directory path inside the sandbox to start the search from (e.g. "/home/daytona", "/tmp", "/").' + form: llm + - name: pattern + type: string + required: true + label: + en_US: Pattern + zh_Hans: Pattern + ja_JP: Pattern + pt_BR: Pattern + human_description: + en_US: 'Glob pattern to match filenames (e.g. "*.py", "*.csv", "test_*").' + zh_Hans: 'Glob pattern to match filenames (e.g. "*.py", "*.csv", "test_*").' + ja_JP: 'Glob pattern to match filenames (e.g. "*.py", "*.csv", "test_*").' + pt_BR: 'Glob pattern to match filenames (e.g. "*.py", "*.csv", "test_*").' + llm_description: 'Glob pattern to match filenames (e.g. "*.py", "*.csv", "test_*", "**/*.json").' + form: llm +extra: + python: + source: tools/search_files.py diff --git a/apps/dify-plugin/tools/start_service.py b/apps/dify-plugin/tools/start_service.py new file mode 100644 index 0000000..fb34ddb --- /dev/null +++ b/apps/dify-plugin/tools/start_service.py @@ -0,0 +1,54 @@ +import uuid +from collections.abc import Generator +from typing import Any + +from daytona import SessionExecuteRequest + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from _client import build_client, get_sandbox + + +class StartServiceTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + sandbox_id = tool_parameters.get("sandbox_id") + if not sandbox_id: + raise ValueError("sandbox_id is required") + + command = tool_parameters.get("command") + if not command: + raise ValueError("command is required") + + daytona = build_client(self.runtime.credentials) + sandbox = get_sandbox(daytona, sandbox_id) + + # A UUID keeps sessions unique even when several services are started in + # the same sandbox within the same second (int(time.time()) collided). + session_id = f"svc-{uuid.uuid4().hex}" + sandbox.process.create_session(session_id) + + try: + req = SessionExecuteRequest(command=command, run_async=True) + response = sandbox.process.execute_session_command(session_id, req) + except Exception: + # Keep session creation transactional: drop the empty session if the + # command failed to launch so failures don't accumulate stale sessions. + try: + sandbox.process.delete_session(session_id) + except Exception: + pass + raise + + cmd_id = getattr(response, "cmd_id", None) + + yield self.create_json_message({ + "session_id": session_id, + "cmd_id": cmd_id, + "sandbox_id": sandbox_id, + "command": command, + }) + yield self.create_text_message( + f"Service started in session '{session_id}' (cmd_id: {cmd_id}). " + f"Use get_service_logs with this session_id to retrieve output." + ) diff --git a/apps/dify-plugin/tools/start_service.yaml b/apps/dify-plugin/tools/start_service.yaml new file mode 100644 index 0000000..35da135 --- /dev/null +++ b/apps/dify-plugin/tools/start_service.yaml @@ -0,0 +1,49 @@ +identity: + name: start_service + author: daytonaio + label: + en_US: Start Service + zh_Hans: Start Service + ja_JP: Start Service + pt_BR: Iniciar Serviço +description: + human: + en_US: Start a long-running background process (web server, daemon) in a sandbox. + zh_Hans: Start a long-running background process (web server, daemon) in a sandbox. + ja_JP: Start a long-running background process (web server, daemon) in a sandbox. + pt_BR: Inicia um processo longo em background (servidor web, daemon) num sandbox. + llm: "Start a long-running background command in a sandbox (e.g. web server, API server, build watcher). Returns a session_id and cmd_id for retrieving logs later with get_service_logs. The command runs asynchronously — this tool returns immediately without waiting for completion." +parameters: + - name: sandbox_id + type: string + required: true + label: + en_US: Sandbox ID + zh_Hans: Sandbox ID + ja_JP: Sandbox ID + pt_BR: Sandbox ID + human_description: + en_US: The ID of the sandbox where the service will run. + zh_Hans: The ID of the sandbox where the service will run. + ja_JP: The ID of the sandbox where the service will run. + pt_BR: O ID do sandbox onde o serviço irá correr. + llm_description: The ID of an existing Daytona sandbox where the service will run. + form: llm + - name: command + type: string + required: true + label: + en_US: Command + zh_Hans: Command + ja_JP: Command + pt_BR: Comando + human_description: + en_US: The command to start the service (e.g. "python server.py" or "npm start"). + zh_Hans: The command to start the service (e.g. "python server.py" or "npm start"). + ja_JP: The command to start the service (e.g. "python server.py" or "npm start"). + pt_BR: "O comando para iniciar o serviço (ex: python server.py ou npm start)." + llm_description: "The command to start the background service (e.g. python server.py, npm start, uvicorn app:server). If the service should be reachable via get_preview_url, bind it to 0.0.0.0 instead of localhost/127.0.0.1 (e.g. 'uvicorn app:server --host 0.0.0.0', 'streamlit run app.py --server.address 0.0.0.0')." + form: llm +extra: + python: + source: tools/start_service.py diff --git a/apps/dify-plugin/tools/upload_file.py b/apps/dify-plugin/tools/upload_file.py index f4120c2..c91cc5b 100644 --- a/apps/dify-plugin/tools/upload_file.py +++ b/apps/dify-plugin/tools/upload_file.py @@ -5,7 +5,7 @@ from dify_plugin.entities.tool import ToolInvokeMessage from dify_plugin.file.file import File -from _client import build_client, get_sandbox +from _client import MAX_FILE_SIZE, build_client, get_sandbox class UploadFileTool(Tool): @@ -24,13 +24,28 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag if not isinstance(file, File): raise ValueError(f"Expected file parameter to be a File, got {type(file).__name__}") + # Reject oversized files using metadata before materializing the blob in memory. + size_hint = getattr(file, "size", None) + if size_hint and size_hint > MAX_FILE_SIZE: + raise ValueError( + f"File size ({size_hint} bytes) exceeds maximum allowed size " + f"({MAX_FILE_SIZE} bytes)." + ) + + blob = file.blob + if len(blob) > MAX_FILE_SIZE: + raise ValueError( + f"File size ({len(blob)} bytes) exceeds maximum allowed size " + f"({MAX_FILE_SIZE} bytes)." + ) + daytona = build_client(self.runtime.credentials) sandbox = get_sandbox(daytona, sandbox_id) - sandbox.fs.upload_file(file.blob, remote_path) + sandbox.fs.upload_file(blob, remote_path) yield self.create_json_message({ "success": True, "sandbox_id": sandbox_id, "remote_path": remote_path, - "size_bytes": len(file.blob), + "size_bytes": len(blob), }) diff --git a/apps/dify-plugin/tools/write_file.py b/apps/dify-plugin/tools/write_file.py new file mode 100644 index 0000000..40b21be --- /dev/null +++ b/apps/dify-plugin/tools/write_file.py @@ -0,0 +1,40 @@ +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from _client import MAX_FILE_SIZE, build_client, get_sandbox + + +class WriteFileTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + sandbox_id = tool_parameters.get("sandbox_id") + if not sandbox_id: + raise ValueError("sandbox_id is required") + + remote_path = tool_parameters.get("remote_path") + if not remote_path: + raise ValueError("remote_path is required") + + content = tool_parameters.get("content") + if content is None: + raise ValueError("content is required") + + encoded = content.encode("utf-8") + if len(encoded) > MAX_FILE_SIZE: + raise ValueError( + f"Content size ({len(encoded)} bytes) exceeds maximum allowed size " + f"({MAX_FILE_SIZE} bytes)." + ) + + daytona = build_client(self.runtime.credentials) + sandbox = get_sandbox(daytona, sandbox_id) + sandbox.fs.upload_file(encoded, remote_path) + + yield self.create_json_message({ + "success": True, + "sandbox_id": sandbox_id, + "remote_path": remote_path, + "size_bytes": len(encoded), + }) diff --git a/apps/dify-plugin/tools/write_file.yaml b/apps/dify-plugin/tools/write_file.yaml new file mode 100644 index 0000000..34dcb70 --- /dev/null +++ b/apps/dify-plugin/tools/write_file.yaml @@ -0,0 +1,64 @@ +identity: + name: write_file + author: daytonaio + label: + en_US: Write File + zh_Hans: Write File + ja_JP: Write File + pt_BR: Write File +description: + human: + en_US: Write text content to a file in a Daytona sandbox (creates or overwrites). + zh_Hans: Write text content to a file in a Daytona sandbox (creates or overwrites). + ja_JP: Write text content to a file in a Daytona sandbox (creates or overwrites). + pt_BR: Write text content to a file in a Daytona sandbox (creates or overwrites). + llm: Write text content to a file inside an existing Daytona sandbox. Creates the file if it doesn't exist, or overwrites it if it does. Accepts UTF-8 encoded text as the content parameter. Requires a sandbox_id. +parameters: + - name: sandbox_id + type: string + required: true + label: + en_US: Sandbox ID + zh_Hans: Sandbox ID + ja_JP: Sandbox ID + pt_BR: Sandbox ID + human_description: + en_US: The ID of the sandbox to write the file into. + zh_Hans: The ID of the sandbox to write the file into. + ja_JP: The ID of the sandbox to write the file into. + pt_BR: The ID of the sandbox to write the file into. + llm_description: The ID of the Daytona sandbox to write the file into. + form: llm + - name: remote_path + type: string + required: true + label: + en_US: Remote Path + zh_Hans: Remote Path + ja_JP: Remote Path + pt_BR: Remote Path + human_description: + en_US: 'Absolute destination path inside the sandbox (e.g. /home/daytona/script.py).' + zh_Hans: 'Absolute destination path inside the sandbox (e.g. /home/daytona/script.py).' + ja_JP: 'Absolute destination path inside the sandbox (e.g. /home/daytona/script.py).' + pt_BR: 'Absolute destination path inside the sandbox (e.g. /home/daytona/script.py).' + llm_description: 'Absolute destination path inside the sandbox where the file will be written (e.g. "/home/daytona/script.py", "/tmp/config.json").' + form: llm + - name: content + type: string + required: true + label: + en_US: Content + zh_Hans: Content + ja_JP: Content + pt_BR: Content + human_description: + en_US: The text content to write into the file. + zh_Hans: The text content to write into the file. + ja_JP: The text content to write into the file. + pt_BR: The text content to write into the file. + llm_description: The text content to write into the file. Supports any UTF-8 text including code, configuration, data, etc. + form: llm +extra: + python: + source: tools/write_file.py