Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
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] = {}

@cubic-dev-ai cubic-dev-ai Bot Jul 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A long-lived, multi-user plugin daemon will retain every Daytona client and its HTTP connection resources indefinitely because _client_cache has no eviction or cleanup. This grows with credential churn and tenant count; a bounded cache with an eviction callback that closes the Daytona client would preserve session reuse without turning credentials/clients into process-lifetime state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dify-plugin/_client.py, line 10:

<comment>A long-lived, multi-user plugin daemon will retain every Daytona client and its HTTP connection resources indefinitely because `_client_cache` has no eviction or cleanup. This grows with credential churn and tenant count; a bounded cache with an eviction callback that closes the `Daytona` client would preserve session reuse without turning credentials/clients into process-lifetime state.</comment>

<file context>
@@ -1,6 +1,18 @@
+
+# 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] = {}
+
+
</file context>
Fix with cubic

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't fix for now. In practice a Dify install authorizes this plugin with one (or a few) credential sets, so the cache holds a handful of clients for the process lifetime rather than growing unbounded. The Daytona client is a thin HTTP wrapper with no persistent session to leak. A bounded LRU with a close-on-evict callback is a reasonable future enhancement, but not needed for correctness now.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parent comment is too broad for this PR: the cache is effectively bounded by a small number of credential sets, and the Daytona client here doesn’t hold a persistent session to leak. An LRU with close-on-evict could be a nice enhancement later, but it isn’t needed for correctness now.



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:

@cubic-dev-ai cubic-dev-ai Bot Jul 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Concurrent first calls for the same credentials can create and use multiple Daytona clients, defeating the one-client-per-credential invariant and potentially abandoning the overwritten session. Protect the check/create/store sequence with a lock.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dify-plugin/_client.py, line 30:

<comment>Concurrent first calls for the same credentials can create and use multiple Daytona clients, defeating the one-client-per-credential invariant and potentially abandoning the overwritten session. Protect the check/create/store sequence with a lock.</comment>

<file context>
@@ -14,10 +26,13 @@ def to_int(value: Any, name: str) -> int:
-        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"):
</file context>
Fix with cubic

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't fix. A brief first-call race would at worst create a second client that replaces the cached one; both are valid authenticated clients and the Daytona client holds no exclusive session that would leak. A global lock around every build_client call would serialize all tool invocations for marginal benefit, so we're leaving it lock-free.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parent comment is wrong here: a brief first-call race only produces another valid authenticated client, and there’s no exclusive Daytona session to leak. A global lock would just serialize all build_client calls for little benefit, so leaving this lock-free is fine.

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:

@cubic-dev-ai cubic-dev-ai Bot Jul 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: An oversized or concurrently growing sandbox file can still exhaust the plugin worker's memory because the size limit is checked only after download_file() has materialized all bytes. Consider downloading through a bounded streaming/temp-file path and aborting once MAX_FILE_SIZE is exceeded, while retaining the metadata precheck as an optimization.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dify-plugin/tools/download_file.py, line 33:

<comment>An oversized or concurrently growing sandbox file can still exhaust the plugin worker's memory because the size limit is checked only after `download_file()` has materialized all bytes. Consider downloading through a bounded streaming/temp-file path and aborting once `MAX_FILE_SIZE` is exceeded, while retaining the metadata precheck as an optimization.</comment>

<file context>
@@ -21,7 +21,20 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag
+            )
+
         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 "
</file context>
Fix with cubic

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't fix for now. download_file already prechecks get_file_info(remote_path).size before downloading (and re-checks len(content) afterwards), so oversized files are rejected without materializing them. The SDK's fs.download_file returns the full bytes in a single call with no streaming/range API to abort mid-transfer, so a true bounded stream would require SDK support.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parent comment is wrong for this PR: download_file already checks get_file_info(remote_path).size before the transfer and len(content) after it, so oversized files are rejected here without needing a stream. A bounded-stream fix would require SDK support, since fs.download_file returns the full bytes in one call.

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)

@cubic-dev-ai cubic-dev-ai Bot Jul 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A broad recursive content search can exhaust the plugin's 256 MiB process or produce an unusably large Dify tool result because every match is copied and returned without a result limit. The Daytona endpoint has no pagination/limit argument, so the wrapper should expose and enforce a bounded max_results (and report truncation); if large API responses are expected, a narrower search strategy is also needed before constructing the output.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dify-plugin/tools/find_in_files.py, line 27:

<comment>A broad recursive content search can exhaust the plugin's 256 MiB process or produce an unusably large Dify tool result because every match is copied and returned without a result limit. The Daytona endpoint has no pagination/limit argument, so the wrapper should expose and enforce a bounded `max_results` (and report truncation); if large API responses are expected, a narrower search strategy is also needed before constructing the output.</comment>

<file context>
@@ -0,0 +1,44 @@
+        daytona = build_client(self.runtime.credentials)
+        sandbox = get_sandbox(daytona, sandbox_id)
+        # find_files returns list[Match]; each Match has flat .file / .line / .content
+        results = sandbox.fs.find_files(path, pattern)
+
+        matches = [
</file context>
Fix with cubic

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't fix for now. Daytona's fs.find_files has no limit/pagination argument, so any cap would be client-side truncation of an already-returned list. Consistent with the other search/list tools; we'll add a bound if oversized responses turn out to be a real problem.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parent comment is too broad for this PR: fs.find_files has no server-side limit/pagination, so a bounded max_results can only be client-side truncation. Understood on leaving it as-is for now.


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.'

@cubic-dev-ai cubic-dev-ai Bot Jul 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The README Tool Reference still advertises the old 3000–9999 restriction, so users will not know that ports 1–65535 are now accepted. The documented get_preview_url range should be updated with this schema change.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dify-plugin/tools/get_preview_url.yaml, line 45:

<comment>The README Tool Reference still advertises the old 3000–9999 restriction, so users will not know that ports 1–65535 are now accepted. The documented `get_preview_url` range should be updated with this schema change.</comment>

<file context>
@@ -42,7 +42,7 @@ parameters:
       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:
</file context>
Fix with cubic

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in efb0550 — the README Tool Reference now lists the port range as 1-65535 to match the widened schema.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parent comment was addressed in efb0550, so this no longer applies for this PR. Thanks!

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)

@cubic-dev-ai cubic-dev-ai Bot Jul 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Log retrieval is unbounded for the long-running processes this tool targets: it loads the command's complete history and then sends it once as text and again as stdout/stderr JSON. A noisy service can therefore make each poll consume an ever-growing amount of memory and exceed Dify tool-message limits. Consider adding a bounded tail/maximum-output contract and avoid duplicating the full log in both messages.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dify-plugin/tools/get_service_logs.py, line 36:

<comment>Log retrieval is unbounded for the long-running processes this tool targets: it loads the command's complete history and then sends it once as text and again as `stdout`/`stderr` JSON. A noisy service can therefore make each poll consume an ever-growing amount of memory and exceed Dify tool-message limits. Consider adding a bounded tail/maximum-output contract and avoid duplicating the full log in both messages.</comment>

<file context>
@@ -0,0 +1,50 @@
+                    "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 ""
</file context>
Fix with cubic

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't fix for now. get_session_command_logs returns the command's full log with no tail/range parameter, so we can't bound it at the source and a client-side tail would still transfer the whole log. The separate stdout/stderr in the JSON is intentional for structured consumers. Revisit if long-running services produce problematic log sizes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parent comment is too broad for this PR: get_session_command_logs has no tail/range parameter, so we can’t bound it at the source, and client-side tailing would still transfer the full log. The separate stdout/stderr JSON is intentional for structured consumers. Revisit this only if long-running services start producing problematic log sizes.

Thanks for the feedback! I've saved this as a new learning to improve future reviews.


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