From a649b99b0d5fb5c91ead96f0a7fc89c93d14d34b Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Mon, 13 Jul 2026 16:20:17 +0200 Subject: [PATCH 01/21] fix(dify-plugin): allow full port range and tolerate blank numeric form fields Ports the still-relevant parts of PR #1 from the archived daytona/dify-plugin. The SDK-compat list() call and SDK version pin were already handled by the monorepo cubic-review fixes, so this ports the remainder: widen get_preview_url to ports 1-65535 (was 3000-9999), and treat blank auto_stop_interval/cpu/memory/disk form values as unset so Dify's empty-string inputs never reach to_int(). Original work by @hjpinheiro (daytona/dify-plugin#1). Co-authored-by: hpiclaranet Signed-off-by: Mislav Ivanda --- apps/dify-plugin/tools/create_sandbox.py | 13 ++++++++----- apps/dify-plugin/tools/get_preview_url.py | 4 ++-- apps/dify-plugin/tools/get_preview_url.yaml | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) 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/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: From efdb9af7c04cd997a1e85c02168a6ef2d4eb6726 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Mon, 13 Jul 2026 16:22:04 +0200 Subject: [PATCH 02/21] fix(dify-plugin): add 100MB file-size guards to upload and download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports PR #2 from the archived daytona/dify-plugin: a shared MAX_FILE_SIZE (100 MB) in _client.py, checked against file.blob before upload and against get_file_info().size (pre-download) plus content length (post-download) — preventing a large file from OOM-crashing the plugin daemon. Original work by @hjpinheiro (daytona/dify-plugin#2). Co-authored-by: hpiclaranet Signed-off-by: Mislav Ivanda --- apps/dify-plugin/_client.py | 2 ++ apps/dify-plugin/tools/download_file.py | 15 ++++++++++++++- apps/dify-plugin/tools/upload_file.py | 13 ++++++++++--- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/dify-plugin/_client.py b/apps/dify-plugin/_client.py index 4a00d1b..bf4db42 100644 --- a/apps/dify-plugin/_client.py +++ b/apps/dify-plugin/_client.py @@ -2,6 +2,8 @@ from daytona import Daytona, DaytonaConfig, DaytonaNotFoundError, Sandbox +MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB + def to_int(value: Any, name: str) -> int: # Dify sends "number" params as float; silent int() truncation (0.5 -> 0) 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/upload_file.py b/apps/dify-plugin/tools/upload_file.py index f4120c2..d45837b 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,20 @@ 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__}") + 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), }) From cc427b3740b8a610b16b50190e5abf8ee9a78e68 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Mon, 13 Jul 2026 16:23:38 +0200 Subject: [PATCH 03/21] perf(dify-plugin): cache the Daytona client per credentials + broaden error handling Ports PR #3 from the archived daytona/dify-plugin: build_client() now caches one Daytona instance per credential hash (the plugin daemon is long-lived, so this avoids re-creating an HTTP session on every tool call), and get_sandbox() also maps a generic DaytonaError to a clear ValueError. Omitted the PR's unused module logger to keep the file lint-clean. Original work by @hjpinheiro (daytona/dify-plugin#3). Co-authored-by: hpiclaranet Signed-off-by: Mislav Ivanda --- apps/dify-plugin/_client.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/dify-plugin/_client.py b/apps/dify-plugin/_client.py index bf4db42..6a34279 100644 --- a/apps/dify-plugin/_client.py +++ b/apps/dify-plugin/_client.py @@ -1,9 +1,19 @@ +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: + raw = f"{credentials.get('api_key', '')}:{credentials.get('api_url', '')}" + return hashlib.sha256(raw.encode()).hexdigest() + def to_int(value: Any, name: str) -> int: # Dify sends "number" params as float; silent int() truncation (0.5 -> 0) @@ -16,10 +26,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: @@ -29,6 +42,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 From 284a8314e591dcaa1e3fe5b32d21adfc6517dc77 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Mon, 13 Jul 2026 16:25:55 +0200 Subject: [PATCH 04/21] feat(dify-plugin): run_command via process.exec with cwd/env/timeout Ports PR #4 from the archived daytona/dify-plugin: replaces the session-based create_session/execute_session_command/delete_session dance with a single sandbox.process.exec() call, adding optional working directory, environment variables (JSON), and timeout. Uses the monorepo's to_int() for the timeout param and guards a missing command. Original work by @hjpinheiro (daytona/dify-plugin#4). Co-authored-by: hpiclaranet Signed-off-by: Mislav Ivanda --- apps/dify-plugin/tools/run_command.py | 49 ++++++++++++++----------- apps/dify-plugin/tools/run_command.yaml | 49 ++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/apps/dify-plugin/tools/run_command.py b/apps/dify-plugin/tools/run_command.py index fa068a0..dbd4033 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,46 @@ 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 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..c620056 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 for no timeout. + zh_Hans: Maximum execution time in seconds. Leave empty for no timeout. + ja_JP: Maximum execution time in seconds. Leave empty for no timeout. + pt_BR: Tempo maximo de execucao em segundos. Deixar vazio para sem limite. + llm_description: 'Optional timeout in seconds. Use a higher value for long operations like pip install or pytest.' + form: llm - name: sandbox_id type: string required: false From 3064f14a472744ebd945735f495207a280f2f23c Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Mon, 13 Jul 2026 16:26:57 +0200 Subject: [PATCH 05/21] feat(dify-plugin): deliver run_code chart PNGs as image blobs Ports PR #5 from the archived daytona/dify-plugin: when Python code produces charts (matplotlib/etc.), the Daytona SDK returns them as base64 PNGs in response.artifacts.charts. These were previously discarded; now each is decoded and yielded as an image/png blob message so users see rendered charts in Dify, with chart metadata added to the JSON result. Original work by @hjpinheiro (daytona/dify-plugin#5). Co-authored-by: hpiclaranet Signed-off-by: Mislav Ivanda --- apps/dify-plugin/tools/run_code.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/dify-plugin/tools/run_code.py b/apps/dify-plugin/tools/run_code.py index 063bd33..c446d5f 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,32 @@ 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: + for element in getattr(chart, "elements", None) or []: + png = getattr(element, "png", None) + if png: + png_bytes = base64.b64decode(png) + yield self.create_blob_message( + blob=png_bytes, meta={"mime_type": "image/png"} + ) + charts_meta.append({ + "type": getattr(element, "type", None), + "title": getattr(element, "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: From 229b97f62d6e7f2487784a214626c8980ce17c7c Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Mon, 13 Jul 2026 16:29:34 +0200 Subject: [PATCH 06/21] feat(dify-plugin): add in-sandbox file operation tools Ports PR #6 from the archived daytona/dify-plugin: adds list_files, read_file, write_file, search_files and find_in_files tools (with yaml manifests) for working with files inside a sandbox, and registers them in provider/daytona.yaml. Original work by @hjpinheiro (daytona/dify-plugin#6). Co-authored-by: hpiclaranet Signed-off-by: Mislav Ivanda --- apps/dify-plugin/provider/daytona.yaml | 5 ++ apps/dify-plugin/tools/find_in_files.py | 43 +++++++++++++++ apps/dify-plugin/tools/find_in_files.yaml | 64 +++++++++++++++++++++++ apps/dify-plugin/tools/list_files.py | 48 +++++++++++++++++ apps/dify-plugin/tools/list_files.yaml | 49 +++++++++++++++++ apps/dify-plugin/tools/read_file.py | 34 ++++++++++++ apps/dify-plugin/tools/read_file.yaml | 49 +++++++++++++++++ apps/dify-plugin/tools/search_files.py | 34 ++++++++++++ apps/dify-plugin/tools/search_files.yaml | 64 +++++++++++++++++++++++ apps/dify-plugin/tools/write_file.py | 41 +++++++++++++++ apps/dify-plugin/tools/write_file.yaml | 64 +++++++++++++++++++++++ 11 files changed, 495 insertions(+) create mode 100644 apps/dify-plugin/tools/find_in_files.py create mode 100644 apps/dify-plugin/tools/find_in_files.yaml create mode 100644 apps/dify-plugin/tools/list_files.py create mode 100644 apps/dify-plugin/tools/list_files.yaml create mode 100644 apps/dify-plugin/tools/read_file.py create mode 100644 apps/dify-plugin/tools/read_file.yaml create mode 100644 apps/dify-plugin/tools/search_files.py create mode 100644 apps/dify-plugin/tools/search_files.yaml create mode 100644 apps/dify-plugin/tools/write_file.py create mode 100644 apps/dify-plugin/tools/write_file.yaml diff --git a/apps/dify-plugin/provider/daytona.yaml b/apps/dify-plugin/provider/daytona.yaml index 8c057ce..94003aa 100644 --- a/apps/dify-plugin/provider/daytona.yaml +++ b/apps/dify-plugin/provider/daytona.yaml @@ -58,6 +58,11 @@ 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 extra: python: source: provider/daytona.py 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..a2900b4 --- /dev/null +++ b/apps/dify-plugin/tools/find_in_files.py @@ -0,0 +1,43 @@ +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) + results = sandbox.fs.find_files(path, pattern) + + matches = [] + for r in results: + for match in r.matches: + matches.append({ + "file": r.file.path, + "line_number": match.line_number, + "line_content": match.lines, + }) + + 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..36e41c6 --- /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 or regex 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 or regex pattern to search for inside file contents.' + zh_Hans: 'Text or regex pattern to search for inside file contents.' + ja_JP: 'Text or regex pattern to search for inside file contents.' + pt_BR: 'Text or regex pattern to search for inside file contents.' + llm_description: 'Text or regex 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/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/read_file.py b/apps/dify-plugin/tools/read_file.py new file mode 100644 index 0000000..2fdb1b4 --- /dev/null +++ b/apps/dify-plugin/tools/read_file.py @@ -0,0 +1,34 @@ +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 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_data = sandbox.fs.download_file(remote_path) + file_info = sandbox.fs.get_file_info(remote_path) + + 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/search_files.py b/apps/dify-plugin/tools/search_files.py new file mode 100644 index 0000000..62987a6 --- /dev/null +++ b/apps/dify-plugin/tools/search_files.py @@ -0,0 +1,34 @@ +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) + matches = sandbox.fs.search_files(path, pattern) + + 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/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/write_file.py b/apps/dify-plugin/tools/write_file.py new file mode 100644 index 0000000..598412c --- /dev/null +++ b/apps/dify-plugin/tools/write_file.py @@ -0,0 +1,41 @@ +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 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") + + daytona = build_client(self.runtime.credentials) + sandbox = get_sandbox(daytona, sandbox_id) + + encoded = content.encode("utf-8") + sandbox.fs.upload_file(encoded, remote_path) + + if encoded: + size_bytes = len(encoded) + else: + file_info = sandbox.fs.get_file_info(remote_path) + size_bytes = file_info.size + + yield self.create_json_message({ + "success": True, + "sandbox_id": sandbox_id, + "remote_path": remote_path, + "size_bytes": size_bytes, + }) 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 From 055cd7a3237248eef97b0ad7e512de712188513e Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Mon, 13 Jul 2026 16:30:23 +0200 Subject: [PATCH 07/21] feat(dify-plugin): add git clone and sandbox management tools Ports PR #7 from the archived daytona/dify-plugin: adds git_clone (clone a repo into a sandbox), list_sandboxes (list with state filter), and manage_sandbox (start/stop/archive), plus yaml manifests, registered in provider/daytona.yaml. Original work by @hjpinheiro (daytona/dify-plugin#7). Co-authored-by: hpiclaranet Signed-off-by: Mislav Ivanda --- apps/dify-plugin/provider/daytona.yaml | 3 + apps/dify-plugin/tools/git_clone.py | 50 ++++++++ apps/dify-plugin/tools/git_clone.yaml | 127 +++++++++++++++++++++ apps/dify-plugin/tools/list_sandboxes.py | 44 +++++++ apps/dify-plugin/tools/list_sandboxes.yaml | 34 ++++++ apps/dify-plugin/tools/manage_sandbox.py | 46 ++++++++ apps/dify-plugin/tools/manage_sandbox.yaml | 68 +++++++++++ 7 files changed, 372 insertions(+) create mode 100644 apps/dify-plugin/tools/git_clone.py create mode 100644 apps/dify-plugin/tools/git_clone.yaml create mode 100644 apps/dify-plugin/tools/list_sandboxes.py create mode 100644 apps/dify-plugin/tools/list_sandboxes.yaml create mode 100644 apps/dify-plugin/tools/manage_sandbox.py create mode 100644 apps/dify-plugin/tools/manage_sandbox.yaml diff --git a/apps/dify-plugin/provider/daytona.yaml b/apps/dify-plugin/provider/daytona.yaml index 94003aa..fdafc5c 100644 --- a/apps/dify-plugin/provider/daytona.yaml +++ b/apps/dify-plugin/provider/daytona.yaml @@ -63,6 +63,9 @@ tools: - 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 extra: python: source: provider/daytona.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..c5d7da2 --- /dev/null +++ b/apps/dify-plugin/tools/git_clone.py @@ -0,0 +1,50 @@ +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 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") + + branch = tool_parameters.get("branch") or None + commit_id = tool_parameters.get("commit_id") 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, + ) + + yield self.create_json_message({ + "success": True, + "sandbox_id": sandbox_id, + "url": url, + "path": path, + "branch": branch, + }) + yield self.create_text_message( + f"Repository '{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..16be26c --- /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: llm +extra: + python: + source: tools/git_clone.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..6d1671c --- /dev/null +++ b/apps/dify-plugin/tools/list_sandboxes.py @@ -0,0 +1,44 @@ +from collections.abc import Generator +from typing import Any + +from daytona import ListSandboxesQuery, SandboxState + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from _client import build_client + + +class ListSandboxesTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + daytona = build_client(self.runtime.credentials) + + limit = tool_parameters.get("limit") + if limit in (None, ""): + limit = 50 + else: + limit = int(limit) + + query = ListSandboxesQuery(limit=limit) + sandboxes = list(daytona.list(query=query)) + + 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..1de0b7a --- /dev/null +++ b/apps/dify-plugin/tools/manage_sandbox.py @@ -0,0 +1,46 @@ +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 + + +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 = daytona.get(sandbox_id) + + if action == "start": + sandbox.start() + sandbox.wait_for_sandbox_start() + elif action == "stop": + sandbox.stop() + elif action == "archive": + sandbox.archive() + else: + raise ValueError( + f"Invalid action: '{action}'. Must be one of: start, stop, archive." + ) + + sandbox = daytona.get(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 From a5b8ec543e1a04a0af29db89eab722280b018f0a Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Mon, 13 Jul 2026 16:31:02 +0200 Subject: [PATCH 08/21] feat(dify-plugin): add background-service tools (start_service, get_service_logs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports PR #8 from the archived daytona/dify-plugin: start_service runs a long-lived command in an async session, and get_service_logs retrieves its output — enabling background processes (web servers, watchers) in a sandbox. Registered in provider/daytona.yaml. Original work by @hjpinheiro (daytona/dify-plugin#8). Co-authored-by: hpiclaranet Signed-off-by: Mislav Ivanda --- apps/dify-plugin/provider/daytona.yaml | 2 + apps/dify-plugin/tools/get_service_logs.py | 50 +++++++++++++++ apps/dify-plugin/tools/get_service_logs.yaml | 65 ++++++++++++++++++++ apps/dify-plugin/tools/start_service.py | 43 +++++++++++++ apps/dify-plugin/tools/start_service.yaml | 49 +++++++++++++++ 5 files changed, 209 insertions(+) create mode 100644 apps/dify-plugin/tools/get_service_logs.py create mode 100644 apps/dify-plugin/tools/get_service_logs.yaml create mode 100644 apps/dify-plugin/tools/start_service.py create mode 100644 apps/dify-plugin/tools/start_service.yaml diff --git a/apps/dify-plugin/provider/daytona.yaml b/apps/dify-plugin/provider/daytona.yaml index fdafc5c..e3c63de 100644 --- a/apps/dify-plugin/provider/daytona.yaml +++ b/apps/dify-plugin/provider/daytona.yaml @@ -66,6 +66,8 @@ tools: - 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/get_service_logs.py b/apps/dify-plugin/tools/get_service_logs.py new file mode 100644 index 0000000..9a6447a --- /dev/null +++ b/apps/dify-plugin/tools/get_service_logs.py @@ -0,0 +1,50 @@ +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 "" + combined = output or (stdout + ("\n" + stderr if stderr else "")) + + yield self.create_text_message(combined or "(no output yet)") + 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/start_service.py b/apps/dify-plugin/tools/start_service.py new file mode 100644 index 0000000..a24a498 --- /dev/null +++ b/apps/dify-plugin/tools/start_service.py @@ -0,0 +1,43 @@ +import time +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) + + session_id = f"svc-{int(time.time())}" + sandbox.process.create_session(session_id) + + req = SessionExecuteRequest(command=command, var_async=True) + response = sandbox.process.execute_session_command(session_id, req) + + 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..e095fb0 --- /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)." + form: llm +extra: + python: + source: tools/start_service.py From 260bafe36af778a308151cf1ab91a9619db4dfe1 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Mon, 13 Jul 2026 18:20:45 +0200 Subject: [PATCH 09/21] fix(dify-plugin): align ported tools with current Daytona SDK (0.193+) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifying the ported PRs against the Daytona SDK reference surfaced drift vs the pinned 0.193+ (the fork targeted ~0.187): - start_service: SessionExecuteRequest field is run_async, not var_async. - find_in_files: fs.find_files returns a flat list[Match] (.file/.line/.content), not nested r.matches/r.file.path. - search_files: fs.search_files returns SearchFilesResponse (.files), not a list (len()/JSON on it would fail). - list_sandboxes: remove unused SandboxState import (lint error). - run_code: chart PNGs are on chart.png (chart.elements are data points, not images) — the old path delivered zero charts; also serialize the ChartType enum. Signed-off-by: Mislav Ivanda --- apps/dify-plugin/tools/find_in_files.py | 17 +++++++++-------- apps/dify-plugin/tools/list_sandboxes.py | 2 +- apps/dify-plugin/tools/run_code.py | 24 +++++++++++++----------- apps/dify-plugin/tools/search_files.py | 8 +++++--- apps/dify-plugin/tools/start_service.py | 2 +- 5 files changed, 29 insertions(+), 24 deletions(-) diff --git a/apps/dify-plugin/tools/find_in_files.py b/apps/dify-plugin/tools/find_in_files.py index a2900b4..3850e17 100644 --- a/apps/dify-plugin/tools/find_in_files.py +++ b/apps/dify-plugin/tools/find_in_files.py @@ -23,16 +23,17 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag 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 = [] - for r in results: - for match in r.matches: - matches.append({ - "file": r.file.path, - "line_number": match.line_number, - "line_content": match.lines, - }) + 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, diff --git a/apps/dify-plugin/tools/list_sandboxes.py b/apps/dify-plugin/tools/list_sandboxes.py index 6d1671c..c5824d5 100644 --- a/apps/dify-plugin/tools/list_sandboxes.py +++ b/apps/dify-plugin/tools/list_sandboxes.py @@ -1,7 +1,7 @@ from collections.abc import Generator from typing import Any -from daytona import ListSandboxesQuery, SandboxState +from daytona import ListSandboxesQuery from dify_plugin import Tool from dify_plugin.entities.tool import ToolInvokeMessage diff --git a/apps/dify-plugin/tools/run_code.py b/apps/dify-plugin/tools/run_code.py index c446d5f..a80fbd3 100644 --- a/apps/dify-plugin/tools/run_code.py +++ b/apps/dify-plugin/tools/run_code.py @@ -34,17 +34,19 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag if charts: for chart in charts: - for element in getattr(chart, "elements", None) or []: - png = getattr(element, "png", None) - if png: - png_bytes = base64.b64decode(png) - yield self.create_blob_message( - blob=png_bytes, meta={"mime_type": "image/png"} - ) - charts_meta.append({ - "type": getattr(element, "type", None), - "title": getattr(element, "title", None), - }) + # 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({ diff --git a/apps/dify-plugin/tools/search_files.py b/apps/dify-plugin/tools/search_files.py index 62987a6..5cb1cfc 100644 --- a/apps/dify-plugin/tools/search_files.py +++ b/apps/dify-plugin/tools/search_files.py @@ -23,12 +23,14 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag daytona = build_client(self.runtime.credentials) sandbox = get_sandbox(daytona, sandbox_id) - matches = sandbox.fs.search_files(path, pattern) + # 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, - "matches": matches, - "count": len(matches), + "files": files, + "count": len(files), }) diff --git a/apps/dify-plugin/tools/start_service.py b/apps/dify-plugin/tools/start_service.py index a24a498..9d5afc0 100644 --- a/apps/dify-plugin/tools/start_service.py +++ b/apps/dify-plugin/tools/start_service.py @@ -26,7 +26,7 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag session_id = f"svc-{int(time.time())}" sandbox.process.create_session(session_id) - req = SessionExecuteRequest(command=command, var_async=True) + req = SessionExecuteRequest(command=command, run_async=True) response = sandbox.process.execute_session_command(session_id, req) cmd_id = getattr(response, "cmd_id", None) From 015959202771e608b11193855c490df0eb5982fd Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Tue, 14 Jul 2026 10:39:25 +0200 Subject: [PATCH 10/21] fix(dify-plugin): enforce MAX_FILE_SIZE across read/write/upload cubic review: read_file bypassed the size guard (loaded arbitrary files into memory) and write_file could upload past the shared limit; both now check size before/after transfer. upload_file now prechecks file.size metadata before materializing the blob. write_file also drops the empty-file second get_file_info lookup in favor of len(encoded). Signed-off-by: Mislav Ivanda --- apps/dify-plugin/tools/read_file.py | 15 +++++++++++++-- apps/dify-plugin/tools/upload_file.py | 8 ++++++++ apps/dify-plugin/tools/write_file.py | 19 +++++++++---------- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/apps/dify-plugin/tools/read_file.py b/apps/dify-plugin/tools/read_file.py index 2fdb1b4..1b2d2d9 100644 --- a/apps/dify-plugin/tools/read_file.py +++ b/apps/dify-plugin/tools/read_file.py @@ -4,7 +4,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 ReadFileTool(Tool): @@ -20,8 +20,19 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag daytona = build_client(self.runtime.credentials) sandbox = get_sandbox(daytona, sandbox_id) - file_data = sandbox.fs.download_file(remote_path) 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") diff --git a/apps/dify-plugin/tools/upload_file.py b/apps/dify-plugin/tools/upload_file.py index d45837b..c91cc5b 100644 --- a/apps/dify-plugin/tools/upload_file.py +++ b/apps/dify-plugin/tools/upload_file.py @@ -24,6 +24,14 @@ 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( diff --git a/apps/dify-plugin/tools/write_file.py b/apps/dify-plugin/tools/write_file.py index 598412c..40b21be 100644 --- a/apps/dify-plugin/tools/write_file.py +++ b/apps/dify-plugin/tools/write_file.py @@ -4,7 +4,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 WriteFileTool(Tool): @@ -21,21 +21,20 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag 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) - - encoded = content.encode("utf-8") sandbox.fs.upload_file(encoded, remote_path) - if encoded: - size_bytes = len(encoded) - else: - file_info = sandbox.fs.get_file_info(remote_path) - size_bytes = file_info.size - yield self.create_json_message({ "success": True, "sandbox_id": sandbox_id, "remote_path": remote_path, - "size_bytes": size_bytes, + "size_bytes": len(encoded), }) From 85ed15a8abdb87e05da9129334833d48fe9ad421 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Tue, 14 Jul 2026 10:40:33 +0200 Subject: [PATCH 11/21] fix(dify-plugin): redact clone URL credentials and drop branch when commit_id is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic review: embedded HTTPS userinfo in the repo URL was echoed back in both JSON and text output — now redacted (raw URL still used for the clone). Also stop sending/reporting branch when commit_id is provided (commit produces a detached checkout and overrides branch). Signed-off-by: Mislav Ivanda --- apps/dify-plugin/tools/git_clone.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/dify-plugin/tools/git_clone.py b/apps/dify-plugin/tools/git_clone.py index c5d7da2..e74086c 100644 --- a/apps/dify-plugin/tools/git_clone.py +++ b/apps/dify-plugin/tools/git_clone.py @@ -1,5 +1,6 @@ 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 @@ -7,6 +8,20 @@ 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 + host = parts.hostname or "" + if parts.port: + host = f"{host}:{parts.port}" + 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") @@ -21,8 +36,9 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag if not path: raise ValueError("path is required") - branch = tool_parameters.get("branch") or None 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 @@ -38,13 +54,16 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag 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": url, + "url": safe_url, "path": path, "branch": branch, + "commit_id": commit_id, }) yield self.create_text_message( - f"Repository '{url}' cloned to '{path}' in sandbox '{sandbox_id}'." + f"Repository '{safe_url}' cloned to '{path}' in sandbox '{sandbox_id}'." ) From 55c580ea29e6ad50e00856d6f5d1480d769bc5dd Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Tue, 14 Jul 2026 10:41:20 +0200 Subject: [PATCH 12/21] fix(dify-plugin): make list_sandboxes actually cap results and validate limit cubic review: ListSandboxesQuery.limit is the page size, and list(daytona.list(...)) paged through the whole account regardless of limit. Use itertools.islice to collect at most 'limit' items, and validate limit via to_int as a positive whole number (rejecting fractional/non-positive values). Signed-off-by: Mislav Ivanda --- apps/dify-plugin/tools/list_sandboxes.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/dify-plugin/tools/list_sandboxes.py b/apps/dify-plugin/tools/list_sandboxes.py index c5824d5..4493ec0 100644 --- a/apps/dify-plugin/tools/list_sandboxes.py +++ b/apps/dify-plugin/tools/list_sandboxes.py @@ -1,4 +1,5 @@ from collections.abc import Generator +from itertools import islice from typing import Any from daytona import ListSandboxesQuery @@ -6,21 +7,22 @@ from dify_plugin import Tool from dify_plugin.entities.tool import ToolInvokeMessage -from _client import build_client +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 = tool_parameters.get("limit") - if limit in (None, ""): - limit = 50 - else: - limit = int(limit) + 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(daytona.list(query=query)) + sandboxes = list(islice(daytona.list(query), limit)) result = [] for sb in sandboxes: From 4c3aabb0959a9269e8acf1fff7e30f94d488a13a Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Tue, 14 Jul 2026 10:48:27 +0200 Subject: [PATCH 13/21] fix(dify-plugin): stop sandbox before archiving and use shared get_sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic review: archiving a running sandbox failed because Daytona requires it to be stopped first — the archive action now stops (which blocks until fully stopped) unless already stopped/archived. Switch both lookups to _client.get_sandbox for consistent not-found errors, and drop the redundant wait_for_sandbox_start() since Sandbox.start() already blocks until ready. Signed-off-by: Mislav Ivanda --- apps/dify-plugin/tools/manage_sandbox.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/dify-plugin/tools/manage_sandbox.py b/apps/dify-plugin/tools/manage_sandbox.py index 1de0b7a..f0e98fc 100644 --- a/apps/dify-plugin/tools/manage_sandbox.py +++ b/apps/dify-plugin/tools/manage_sandbox.py @@ -4,7 +4,7 @@ from dify_plugin import Tool from dify_plugin.entities.tool import ToolInvokeMessage -from _client import build_client +from _client import build_client, get_sandbox class ManageSandboxTool(Tool): @@ -18,21 +18,25 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag raise ValueError("action is required") daytona = build_client(self.runtime.credentials) - sandbox = daytona.get(sandbox_id) + sandbox = get_sandbox(daytona, sandbox_id) if action == "start": sandbox.start() - sandbox.wait_for_sandbox_start() elif action == "stop": sandbox.stop() elif action == "archive": + # Daytona requires a sandbox to be stopped before it can be archived. + # Sandbox.stop() blocks until the sandbox is fully stopped. + current = getattr(sandbox.state, "value", sandbox.state) + if current not in ("stopped", "archived"): + sandbox.stop() sandbox.archive() else: raise ValueError( f"Invalid action: '{action}'. Must be one of: start, stop, archive." ) - sandbox = daytona.get(sandbox_id) + sandbox = get_sandbox(daytona, sandbox_id) state = getattr(sandbox.state, "value", sandbox.state) if sandbox.state else None yield self.create_json_message({ From 4bf53f639bf2fdeed8f2b0da376966573168a8c6 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Tue, 14 Jul 2026 10:49:36 +0200 Subject: [PATCH 14/21] fix(dify-plugin): use unique session ids and clean up on service launch failure cubic review: two services started in the same sandbox within one second shared session id svc-, so the second create_session collided. Use a UUID-based id. Also delete the freshly created session if execute_session_command fails so failed launches don't accumulate stale sessions. Signed-off-by: Mislav Ivanda --- apps/dify-plugin/tools/start_service.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/dify-plugin/tools/start_service.py b/apps/dify-plugin/tools/start_service.py index 9d5afc0..fb34ddb 100644 --- a/apps/dify-plugin/tools/start_service.py +++ b/apps/dify-plugin/tools/start_service.py @@ -1,4 +1,4 @@ -import time +import uuid from collections.abc import Generator from typing import Any @@ -23,11 +23,22 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag daytona = build_client(self.runtime.credentials) sandbox = get_sandbox(daytona, sandbox_id) - session_id = f"svc-{int(time.time())}" + # 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) - req = SessionExecuteRequest(command=command, run_async=True) - response = sandbox.process.execute_session_command(session_id, req) + 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) From 211b610ed91e85043970d343a64db1a30ebe3008 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Tue, 14 Jul 2026 10:49:36 +0200 Subject: [PATCH 15/21] fix(dify-plugin): reject negative run_command timeouts cubic review: negative timeouts were forwarded to the SDK where they become an invalid command/HTTP timeout. Validate timeout >= 0 (0/empty still means no timeout) so callers get a deterministic error. Signed-off-by: Mislav Ivanda --- apps/dify-plugin/tools/run_command.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/dify-plugin/tools/run_command.py b/apps/dify-plugin/tools/run_command.py index dbd4033..5814754 100644 --- a/apps/dify-plugin/tools/run_command.py +++ b/apps/dify-plugin/tools/run_command.py @@ -23,6 +23,8 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag 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) From 147e5c181f35004f79811cd33e0b40bcef137acd Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Tue, 14 Jul 2026 10:49:36 +0200 Subject: [PATCH 16/21] fix(dify-plugin): clearer service log fallback text and stream joining cubic review: report a neutral '(no output)' instead of '(no output yet)' for completed silent commands, and only insert a newline between stdout and stderr when both are non-empty and stdout doesn't already end with one (avoids a leading newline for stderr-only output and doubled newlines). Signed-off-by: Mislav Ivanda --- apps/dify-plugin/tools/get_service_logs.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/dify-plugin/tools/get_service_logs.py b/apps/dify-plugin/tools/get_service_logs.py index 9a6447a..f8feb64 100644 --- a/apps/dify-plugin/tools/get_service_logs.py +++ b/apps/dify-plugin/tools/get_service_logs.py @@ -38,9 +38,17 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag stdout = getattr(logs, "stdout", None) or "" stderr = getattr(logs, "stderr", None) or "" output = getattr(logs, "output", None) or "" - combined = output or (stdout + ("\n" + stderr if stderr else "")) - - yield self.create_text_message(combined or "(no output yet)") + 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, From dd93e82fbb9ab5a82b2c7144e75391cca73f89a8 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Tue, 14 Jul 2026 10:49:36 +0200 Subject: [PATCH 17/21] fix(dify-plugin): prevent credential cache key collisions cubic review: the cache key concatenated api_key/api_url with a single ':' separator, so distinct pairs could hash to the same entry and reuse another credential set's client. Length-prefix each field before hashing. Signed-off-by: Mislav Ivanda --- apps/dify-plugin/_client.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/dify-plugin/_client.py b/apps/dify-plugin/_client.py index 6a34279..5812391 100644 --- a/apps/dify-plugin/_client.py +++ b/apps/dify-plugin/_client.py @@ -11,7 +11,11 @@ def _cache_key(credentials: dict[str, Any]) -> str: - raw = f"{credentials.get('api_key', '')}:{credentials.get('api_url', '')}" + # 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() From efb055037bc1bfb91645e84105e3efa178178786 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Tue, 14 Jul 2026 10:51:43 +0200 Subject: [PATCH 18/21] docs(dify-plugin): align tool metadata and README with actual behavior cubic review, documentation-only fixes: - run_command: describe combined 'output' contract (stdout+stderr) in the README feature list and Tool Reference, and document cwd/env_vars/timeout inputs; timeout help now explains empty = Daytona default and 0 = no timeout - git_clone: set the private-repo password/token to form: form so it is a preset encrypted credential rather than an LLM-inferred argument - start_service: tell the model to bind previewable services to 0.0.0.0 so get_preview_url is reachable - find_in_files: describe the pattern as a text pattern (SDK/Toolbox does not document regex support) - get_preview_url: README port range corrected to 1-65535 to match the schema Signed-off-by: Mislav Ivanda --- apps/dify-plugin/README.md | 6 +++--- apps/dify-plugin/tools/find_in_files.yaml | 12 ++++++------ apps/dify-plugin/tools/git_clone.yaml | 2 +- apps/dify-plugin/tools/run_command.yaml | 10 +++++----- apps/dify-plugin/tools/start_service.yaml | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/dify-plugin/README.md b/apps/dify-plugin/README.md index 12d4b13..5379a8e 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. @@ -40,10 +40,10 @@ Use **Destroy Sandbox** to permanently delete a sandbox you provisioned with **C |------|--------|---------| | `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_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/tools/find_in_files.yaml b/apps/dify-plugin/tools/find_in_files.yaml index 36e41c6..8d944cc 100644 --- a/apps/dify-plugin/tools/find_in_files.yaml +++ b/apps/dify-plugin/tools/find_in_files.yaml @@ -12,7 +12,7 @@ description: 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 or regex 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. + 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 @@ -53,11 +53,11 @@ parameters: ja_JP: Pattern pt_BR: Pattern human_description: - en_US: 'Text or regex pattern to search for inside file contents.' - zh_Hans: 'Text or regex pattern to search for inside file contents.' - ja_JP: 'Text or regex pattern to search for inside file contents.' - pt_BR: 'Text or regex pattern to search for inside file contents.' - llm_description: 'Text or regex pattern to search for inside file contents (e.g. "def main", "ERROR", "TODO").' + 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: diff --git a/apps/dify-plugin/tools/git_clone.yaml b/apps/dify-plugin/tools/git_clone.yaml index 16be26c..5c25943 100644 --- a/apps/dify-plugin/tools/git_clone.yaml +++ b/apps/dify-plugin/tools/git_clone.yaml @@ -121,7 +121,7 @@ parameters: 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: llm + form: form extra: python: source: tools/git_clone.py diff --git a/apps/dify-plugin/tools/run_command.yaml b/apps/dify-plugin/tools/run_command.yaml index c620056..8ec0cb5 100644 --- a/apps/dify-plugin/tools/run_command.yaml +++ b/apps/dify-plugin/tools/run_command.yaml @@ -70,11 +70,11 @@ parameters: ja_JP: Timeout (seconds) pt_BR: Timeout (segundos) human_description: - en_US: Maximum execution time in seconds. Leave empty for no timeout. - zh_Hans: Maximum execution time in seconds. Leave empty for no timeout. - ja_JP: Maximum execution time in seconds. Leave empty for no timeout. - pt_BR: Tempo maximo de execucao em segundos. Deixar vazio para sem limite. - llm_description: 'Optional timeout in seconds. Use a higher value for long operations like pip install or pytest.' + 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 diff --git a/apps/dify-plugin/tools/start_service.yaml b/apps/dify-plugin/tools/start_service.yaml index e095fb0..35da135 100644 --- a/apps/dify-plugin/tools/start_service.yaml +++ b/apps/dify-plugin/tools/start_service.yaml @@ -42,7 +42,7 @@ parameters: 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)." + 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: From 29224ee854b15e0421d48f3d9857ac7ed5fdbf77 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Tue, 14 Jul 2026 10:52:39 +0200 Subject: [PATCH 19/21] docs(dify-plugin): document run_code chart output cubic review: run_code now emits generated charts as PNG blobs and adds charts_count/charts JSON fields, but neither the tool description nor the README output contract mentioned them. Document both so users and the LLM can discover the behavior. Signed-off-by: Mislav Ivanda --- apps/dify-plugin/README.md | 2 +- apps/dify-plugin/tools/run_code.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/dify-plugin/README.md b/apps/dify-plugin/README.md index 5379a8e..e8283fb 100644 --- a/apps/dify-plugin/README.md +++ b/apps/dify-plugin/README.md @@ -39,7 +39,7 @@ 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_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` | 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 From 89bb63426ae7b8601698875bc135b2fc40e51685 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Tue, 14 Jul 2026 11:39:02 +0200 Subject: [PATCH 20/21] fix(dify-plugin): redact clone URL userinfo without corrupting IPv6/port cubic review follow-up: _redact_url used parts.hostname (which strips IPv6 brackets, turning [::1]:8080 into the malformed ::1:8080) and parts.port (which can raise on a malformed port, outside the try). Keep the netloc after the last '@' instead, which drops only the userinfo while preserving valid host:port / IPv6 syntax. Signed-off-by: Mislav Ivanda --- apps/dify-plugin/tools/git_clone.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/dify-plugin/tools/git_clone.py b/apps/dify-plugin/tools/git_clone.py index e74086c..b2acc12 100644 --- a/apps/dify-plugin/tools/git_clone.py +++ b/apps/dify-plugin/tools/git_clone.py @@ -16,9 +16,10 @@ def _redact_url(url: str) -> str: return url if not (parts.username or parts.password): return url - host = parts.hostname or "" - if parts.port: - host = f"{host}:{parts.port}" + # 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)) From 61d38ce5185ac44ad23be9989d33229ae3040e26 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Tue, 14 Jul 2026 11:39:02 +0200 Subject: [PATCH 21/21] fix(dify-plugin): make archiving an already-archived sandbox a no-op cubic review follow-up: archive() requires a stopped sandbox, so calling it on an already-archived sandbox can fail. Treat 'archived' as idempotent success and only stop/archive other states. Signed-off-by: Mislav Ivanda --- apps/dify-plugin/tools/manage_sandbox.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/dify-plugin/tools/manage_sandbox.py b/apps/dify-plugin/tools/manage_sandbox.py index f0e98fc..81b8d8c 100644 --- a/apps/dify-plugin/tools/manage_sandbox.py +++ b/apps/dify-plugin/tools/manage_sandbox.py @@ -25,12 +25,14 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag elif action == "stop": sandbox.stop() elif action == "archive": - # Daytona requires a sandbox to be stopped before it can be archived. + # 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 not in ("stopped", "archived"): - sandbox.stop() - sandbox.archive() + if current != "archived": + if current != "stopped": + sandbox.stop() + sandbox.archive() else: raise ValueError( f"Invalid action: '{action}'. Must be one of: start, stop, archive."