Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a649b99
fix(dify-plugin): allow full port range and tolerate blank numeric fo…
mislavivanda Jul 13, 2026
efdb9af
fix(dify-plugin): add 100MB file-size guards to upload and download
mislavivanda Jul 13, 2026
cc427b3
perf(dify-plugin): cache the Daytona client per credentials + broaden…
mislavivanda Jul 13, 2026
284a831
feat(dify-plugin): run_command via process.exec with cwd/env/timeout
mislavivanda Jul 13, 2026
3064f14
feat(dify-plugin): deliver run_code chart PNGs as image blobs
mislavivanda Jul 13, 2026
229b97f
feat(dify-plugin): add in-sandbox file operation tools
mislavivanda Jul 13, 2026
055cd7a
feat(dify-plugin): add git clone and sandbox management tools
mislavivanda Jul 13, 2026
a5b8ec5
feat(dify-plugin): add background-service tools (start_service, get_s…
mislavivanda Jul 13, 2026
260bafe
fix(dify-plugin): align ported tools with current Daytona SDK (0.193+)
mislavivanda Jul 13, 2026
0159592
fix(dify-plugin): enforce MAX_FILE_SIZE across read/write/upload
mislavivanda Jul 14, 2026
85ed15a
fix(dify-plugin): redact clone URL credentials and drop branch when c…
mislavivanda Jul 14, 2026
55c580e
fix(dify-plugin): make list_sandboxes actually cap results and valida…
mislavivanda Jul 14, 2026
4c3aabb
fix(dify-plugin): stop sandbox before archiving and use shared get_sa…
mislavivanda Jul 14, 2026
4bf53f6
fix(dify-plugin): use unique session ids and clean up on service laun…
mislavivanda Jul 14, 2026
211b610
fix(dify-plugin): reject negative run_command timeouts
mislavivanda Jul 14, 2026
147e5c1
fix(dify-plugin): clearer service log fallback text and stream joining
mislavivanda Jul 14, 2026
dd93e82
fix(dify-plugin): prevent credential cache key collisions
mislavivanda Jul 14, 2026
efb0550
docs(dify-plugin): align tool metadata and README with actual behavior
mislavivanda Jul 14, 2026
29224ee
docs(dify-plugin): document run_code chart output
mislavivanda Jul 14, 2026
89bb634
fix(dify-plugin): redact clone URL userinfo without corrupting IPv6/port
mislavivanda Jul 14, 2026
61d38ce
fix(dify-plugin): make archiving an already-archived sandbox a no-op
mislavivanda Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/dify-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -39,11 +39,11 @@ Use **Destroy Sandbox** to permanently delete a sandbox you provisioned with **C
| Tool | Inputs | Returns |
|------|--------|---------|
| `create_sandbox` | `name`, `snapshot`, `image`, `language`, `env_vars` (JSON string), `cpu`, `memory`, `disk`, `auto_stop_interval` (all optional) | `sandbox_id` |
| `run_code` | `code` (required), `language` (optional: `python`/`typescript`/`javascript`, default `python`, only used when creating an ephemeral sandbox), `sandbox_id` (optional, ephemeral if omitted) | `exit_code`, `output` (combined stdout+stderr), `sandbox_id` |
| `run_command` | `command` (required), `sandbox_id` (optional, ephemeral if omitted) | `exit_code`, `stdout`, `stderr`, `sandbox_id` |
| `run_code` | `code` (required), `language` (optional: `python`/`typescript`/`javascript`, default `python`, only used when creating an ephemeral sandbox), `sandbox_id` (optional, ephemeral if omitted) | `exit_code`, `output` (combined stdout+stderr), `sandbox_id`, `charts_count`, `charts` (list of `{type, title}`); any generated charts (e.g. matplotlib) are also emitted as PNG image blobs |
| `run_command` | `command` (required), `cwd` (optional), `env_vars` (JSON string, optional), `timeout` (optional, seconds; empty = Daytona default, `0` = no timeout), `sandbox_id` (optional, ephemeral if omitted) | `exit_code`, `output` (combined stdout+stderr), `sandbox_id` |
| `upload_file` | `sandbox_id`, `file` (Dify file picker), `remote_path` (all required) | `success`, `sandbox_id`, `remote_path`, `size_bytes` |
| `download_file` | `sandbox_id`, `remote_path` (both required) | File as Dify blob plus `success`, `sandbox_id`, `remote_path`, `size_bytes`, `mime_type`, `filename` |
| `get_preview_url` | `sandbox_id`, `port` (3000–9999) (both required) | `url`, `token`, `port`, `sandbox_id`. URL persists while the sandbox runs. For private sandboxes, callers must send the token via the `x-daytona-preview-token` header. |
| `get_preview_url` | `sandbox_id`, `port` (1–65535) (both required) | `url`, `token`, `port`, `sandbox_id`. URL persists while the sandbox runs. For private sandboxes, callers must send the token via the `x-daytona-preview-token` header. |
| `destroy_sandbox` | `sandbox_id` (required) | `success`, `sandbox_id` |

### Support
Expand Down
31 changes: 26 additions & 5 deletions apps/dify-plugin/_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import hashlib
from typing import Any

from daytona import Daytona, DaytonaConfig, DaytonaNotFoundError, Sandbox
from daytona import Daytona, DaytonaConfig, DaytonaError, DaytonaNotFoundError, Sandbox

MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB

# The plugin daemon is long-lived, so cache one Daytona client per unique
# credential set to avoid re-creating an HTTP session on every tool call.
_client_cache: dict[str, Daytona] = {}
Comment thread
mislavivanda marked this conversation as resolved.


def _cache_key(credentials: dict[str, Any]) -> str:
# Length-prefix each field so distinct api_key/api_url pairs can't collide
# into the same entry (e.g. "a:" + "b" vs "a" + ":b").
api_key = str(credentials.get("api_key", ""))
api_url = str(credentials.get("api_url", ""))
raw = f"{len(api_key)}:{api_key}{len(api_url)}:{api_url}"
return hashlib.sha256(raw.encode()).hexdigest()


def to_int(value: Any, name: str) -> int:
Expand All @@ -14,10 +30,13 @@ def to_int(value: Any, name: str) -> int:


def build_client(credentials: dict[str, Any]) -> Daytona:
config = DaytonaConfig(api_key=credentials["api_key"])
if api_url := credentials.get("api_url"):
config.api_url = api_url
return Daytona(config)
key = _cache_key(credentials)
if key not in _client_cache:
Comment thread
mislavivanda marked this conversation as resolved.
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:
Expand All @@ -27,6 +46,8 @@ def get_sandbox(client: Daytona, sandbox_id: str) -> Sandbox:
sandbox = client.get(sandbox_id)
except DaytonaNotFoundError as e:
raise ValueError(f"Sandbox '{sandbox_id}' not found") from e
except DaytonaError as e:
raise ValueError(f"Failed to retrieve sandbox '{sandbox_id}': {e}") from e
if sandbox is None:
raise ValueError(f"Sandbox '{sandbox_id}' not found")
return sandbox
10 changes: 10 additions & 0 deletions apps/dify-plugin/provider/daytona.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ tools:
- tools/download_file.yaml
- tools/get_preview_url.yaml
- tools/destroy_sandbox.yaml
- tools/list_files.yaml
- tools/read_file.yaml
- tools/write_file.yaml
- tools/search_files.yaml
- tools/find_in_files.yaml
- tools/git_clone.yaml
- tools/list_sandboxes.yaml
- tools/manage_sandbox.yaml
- tools/start_service.yaml
- tools/get_service_logs.yaml
extra:
python:
source: provider/daytona.py
13 changes: 8 additions & 5 deletions apps/dify-plugin/tools/create_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
15 changes: 14 additions & 1 deletion apps/dify-plugin/tools/download_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Comment thread
mislavivanda marked this conversation as resolved.
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)
Expand Down
44 changes: 44 additions & 0 deletions apps/dify-plugin/tools/find_in_files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from collections.abc import Generator
from typing import Any

from dify_plugin import Tool
from dify_plugin.entities.tool import ToolInvokeMessage

from _client import build_client, get_sandbox


class FindInFilesTool(Tool):
def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]:
sandbox_id = tool_parameters.get("sandbox_id")
if not sandbox_id:
raise ValueError("sandbox_id is required")

path = tool_parameters.get("path")
if not path:
raise ValueError("path is required")

pattern = tool_parameters.get("pattern")
if not pattern:
raise ValueError("pattern is required")

daytona = build_client(self.runtime.credentials)
sandbox = get_sandbox(daytona, sandbox_id)
# find_files returns list[Match]; each Match has flat .file / .line / .content
results = sandbox.fs.find_files(path, pattern)
Comment thread
mislavivanda marked this conversation as resolved.

matches = [
{
"file": m.file,
"line_number": m.line,
"line_content": m.content,
}
for m in results
]

yield self.create_json_message({
"sandbox_id": sandbox_id,
"path": path,
"pattern": pattern,
"matches": matches,
"count": len(matches),
})
64 changes: 64 additions & 0 deletions apps/dify-plugin/tools/find_in_files.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
identity:
name: find_in_files
author: daytonaio
label:
en_US: Find in Files
zh_Hans: Find in Files
ja_JP: Find in Files
pt_BR: Find in Files
description:
human:
en_US: Search for a text pattern inside file contents in a Daytona sandbox (like grep).
zh_Hans: Search for a text pattern inside file contents in a Daytona sandbox (like grep).
ja_JP: Search for a text pattern inside file contents in a Daytona sandbox (like grep).
pt_BR: Search for a text pattern inside file contents in a Daytona sandbox (like grep).
llm: Search for a text pattern inside the contents of files within an existing Daytona sandbox. Returns matching results with file path, line number, and matching line content. Similar to running grep recursively. Requires a sandbox_id.
parameters:
- name: sandbox_id
type: string
required: true
label:
en_US: Sandbox ID
zh_Hans: Sandbox ID
ja_JP: Sandbox ID
pt_BR: Sandbox ID
human_description:
en_US: The ID of the sandbox to search within.
zh_Hans: The ID of the sandbox to search within.
ja_JP: The ID of the sandbox to search within.
pt_BR: The ID of the sandbox to search within.
llm_description: The ID of the Daytona sandbox to search within.
form: llm
- name: path
type: string
required: true
label:
en_US: Path
zh_Hans: Path
ja_JP: Path
pt_BR: Path
human_description:
en_US: 'Absolute directory path inside the sandbox to search within (e.g. /home/daytona).'
zh_Hans: 'Absolute directory path inside the sandbox to search within (e.g. /home/daytona).'
ja_JP: 'Absolute directory path inside the sandbox to search within (e.g. /home/daytona).'
pt_BR: 'Absolute directory path inside the sandbox to search within (e.g. /home/daytona).'
llm_description: 'Absolute directory path inside the sandbox to search within (e.g. "/home/daytona", "/tmp", "/").'
form: llm
- name: pattern
type: string
required: true
label:
en_US: Pattern
zh_Hans: Pattern
ja_JP: Pattern
pt_BR: Pattern
human_description:
en_US: 'Text pattern to search for inside file contents.'
zh_Hans: 'Text pattern to search for inside file contents.'
ja_JP: 'Text pattern to search for inside file contents.'
pt_BR: 'Text pattern to search for inside file contents.'
llm_description: 'Text pattern to search for inside file contents (e.g. "def main", "ERROR", "TODO").'
form: llm
extra:
python:
source: tools/find_in_files.py
4 changes: 2 additions & 2 deletions apps/dify-plugin/tools/get_preview_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion apps/dify-plugin/tools/get_preview_url.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Comment thread
mislavivanda marked this conversation as resolved.
form: llm
extra:
python:
Expand Down
58 changes: 58 additions & 0 deletions apps/dify-plugin/tools/get_service_logs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from collections.abc import Generator
from typing import Any

from dify_plugin import Tool
from dify_plugin.entities.tool import ToolInvokeMessage

from _client import build_client, get_sandbox


class GetServiceLogsTool(Tool):
def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]:
sandbox_id = tool_parameters.get("sandbox_id")
if not sandbox_id:
raise ValueError("sandbox_id is required")

session_id = tool_parameters.get("session_id")
if not session_id:
raise ValueError("session_id is required")

cmd_id = tool_parameters.get("cmd_id") or None

daytona = build_client(self.runtime.credentials)
sandbox = get_sandbox(daytona, sandbox_id)

if not cmd_id:
session = sandbox.process.get_session(session_id)
commands = getattr(session, "commands", None) or []
if commands:
cmd_id = getattr(commands[-1], "id", None)
if not cmd_id:
raise ValueError(
f"Could not determine cmd_id for session '{session_id}'. "
"Provide cmd_id explicitly (from start_service output)."
)

logs = sandbox.process.get_session_command_logs(session_id, cmd_id)
Comment thread
mislavivanda marked this conversation as resolved.

stdout = getattr(logs, "stdout", None) or ""
stderr = getattr(logs, "stderr", None) or ""
output = getattr(logs, "output", None) or ""
if output:
combined = output
elif stdout and stderr:
# Join the two streams with a single newline, without doubling one
# that stdout already ends with.
separator = "" if stdout.endswith("\n") else "\n"
combined = stdout + separator + stderr
else:
combined = stdout or stderr

yield self.create_text_message(combined or "(no output)")
yield self.create_json_message({
"session_id": session_id,
"cmd_id": cmd_id,
"sandbox_id": sandbox_id,
"stdout": stdout,
"stderr": stderr,
})
Loading
Loading