diff --git a/api/.env.example b/api/.env.example index 44d770ed70..446a4aabb7 100644 --- a/api/.env.example +++ b/api/.env.example @@ -712,3 +712,7 @@ ANNOTATION_IMPORT_MAX_CONCURRENT=5 SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21 SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000 SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30 + +# Sandbox Dify CLI configuration +# Directory containing dify CLI binaries (dify-cli--). Defaults to api/bin when unset. +SANDBOX_DIFY_CLI_ROOT= diff --git a/api/bin/dify-cli-darwin-amd64 b/api/bin/dify-cli-darwin-amd64 new file mode 100755 index 0000000000..78fcd92b58 Binary files /dev/null and b/api/bin/dify-cli-darwin-amd64 differ diff --git a/api/bin/dify-cli-darwin-arm64 b/api/bin/dify-cli-darwin-arm64 new file mode 100755 index 0000000000..848968fd2a Binary files /dev/null and b/api/bin/dify-cli-darwin-arm64 differ diff --git a/api/bin/dify-cli-linux-amd64 b/api/bin/dify-cli-linux-amd64 new file mode 100755 index 0000000000..e7716e3dac Binary files /dev/null and b/api/bin/dify-cli-linux-amd64 differ diff --git a/api/bin/dify-cli-linux-arm64 b/api/bin/dify-cli-linux-arm64 new file mode 100755 index 0000000000..dbcba7c089 Binary files /dev/null and b/api/bin/dify-cli-linux-arm64 differ diff --git a/api/configs/app_config.py b/api/configs/app_config.py index d3b1cf9d5b..51e3738580 100644 --- a/api/configs/app_config.py +++ b/api/configs/app_config.py @@ -2,6 +2,7 @@ import logging from pathlib import Path from typing import Any +from pydantic import Field from pydantic.fields import FieldInfo from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource @@ -82,6 +83,14 @@ class DifyConfig( extra="ignore", ) + SANDBOX_DIFY_CLI_ROOT: str | None = Field( + default=None, + description=( + "Filesystem directory containing dify CLI binaries named dify-cli--. " + "Defaults to api/bin when unset." + ), + ) + # Before adding any config, # please consider to arrange it in the proper config group of existed or added # for better readability and maintainability. diff --git a/api/core/app/layers/sandbox_layer.py b/api/core/app/layers/sandbox_layer.py index 61e1a06e8c..e6e5e95565 100644 --- a/api/core/app/layers/sandbox_layer.py +++ b/api/core/app/layers/sandbox_layer.py @@ -1,7 +1,9 @@ import logging from collections.abc import Mapping +from io import BytesIO from typing import Any +from core.sandbox import DIFY_CLI_PATH, DifyCliLocator from core.virtual_environment.__base.virtual_environment import VirtualEnvironment from core.virtual_environment.sandbox_manager import SandboxManager from core.workflow.graph_engine.layers.base import GraphEngineLayer @@ -62,10 +64,29 @@ class SandboxLayer(GraphEngineLayer): sandbox.metadata.id, sandbox.metadata.arch, ) + self._upload_cli(sandbox) except Exception as e: logger.exception("Failed to initialize sandbox") raise SandboxInitializationError(f"Failed to initialize sandbox: {e}") from e + def _upload_cli(self, sandbox: VirtualEnvironment) -> None: + locator = DifyCliLocator() + binary = locator.resolve(sandbox.metadata.os, sandbox.metadata.arch) + + sandbox.upload_file(DIFY_CLI_PATH, BytesIO(binary.path.read_bytes())) + + connection_handle = sandbox.establish_connection() + try: + future = sandbox.run_command(connection_handle, ["chmod", "+x", DIFY_CLI_PATH]) + result = future.result(timeout=10) + if result.exit_code not in (0, None): + stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else "" + raise RuntimeError(f"Failed to mark dify CLI as executable: {stderr}") + + logger.info("Dify CLI uploaded to sandbox, path=%s", DIFY_CLI_PATH) + finally: + sandbox.release_connection(connection_handle) + def on_event(self, event: GraphEngineEvent) -> None: pass diff --git a/api/core/sandbox/__init__.py b/api/core/sandbox/__init__.py new file mode 100644 index 0000000000..77dad10541 --- /dev/null +++ b/api/core/sandbox/__init__.py @@ -0,0 +1,27 @@ +from core.sandbox.constants import ( + DIFY_CLI_CONFIG_PATH, + DIFY_CLI_PATH, + DIFY_CLI_PATH_PATTERN, + SANDBOX_WORK_DIR, +) +from core.sandbox.dify_cli import ( + DifyCliBinary, + DifyCliConfig, + DifyCliEnvConfig, + DifyCliLocator, + DifyCliToolConfig, +) +from core.sandbox.session import SandboxSession + +__all__ = [ + "DIFY_CLI_CONFIG_PATH", + "DIFY_CLI_PATH", + "DIFY_CLI_PATH_PATTERN", + "SANDBOX_WORK_DIR", + "DifyCliBinary", + "DifyCliConfig", + "DifyCliEnvConfig", + "DifyCliLocator", + "DifyCliToolConfig", + "SandboxSession", +] diff --git a/api/core/sandbox/constants.py b/api/core/sandbox/constants.py new file mode 100644 index 0000000000..8b9ab79bc2 --- /dev/null +++ b/api/core/sandbox/constants.py @@ -0,0 +1,9 @@ +from typing import Final + +SANDBOX_WORK_DIR: Final[str] = "/work" + +DIFY_CLI_PATH: Final[str] = "/work/.dify/bin/dify" + +DIFY_CLI_PATH_PATTERN: Final[str] = "dify-cli-{os}-{arch}" + +DIFY_CLI_CONFIG_PATH: Final[str] = "/work/config.json" diff --git a/api/core/sandbox/dify_cli.py b/api/core/sandbox/dify_cli.py new file mode 100644 index 0000000000..23123b4626 --- /dev/null +++ b/api/core/sandbox/dify_cli.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, Field + +from core.sandbox.constants import DIFY_CLI_PATH_PATTERN +from core.virtual_environment.__base.entities import Arch, OperatingSystem + +if TYPE_CHECKING: + from core.tools.__base.tool import Tool + + +class DifyCliBinary(BaseModel): + operating_system: OperatingSystem = Field(alias="os") + arch: Arch + path: Path + + model_config = { + "populate_by_name": True, + "arbitrary_types_allowed": True, + } + + +class DifyCliLocator: + def __init__(self, root: str | Path | None = None) -> None: + from configs import dify_config + + if root is not None: + self._root = Path(root) + elif dify_config.SANDBOX_DIFY_CLI_ROOT: + self._root = Path(dify_config.SANDBOX_DIFY_CLI_ROOT) + else: + api_root = Path(__file__).resolve().parents[2] + self._root = api_root / "bin" + + def resolve(self, operating_system: OperatingSystem, arch: Arch) -> DifyCliBinary: + filename = DIFY_CLI_PATH_PATTERN.format(os=operating_system.value, arch=arch.value) + candidate = self._root / filename + if not candidate.is_file(): + raise FileNotFoundError( + f"dify CLI binary not found: {candidate}. Configure SANDBOX_DIFY_CLI_ROOT or ensure the file exists." + ) + + return DifyCliBinary(os=operating_system, arch=arch, path=candidate) + + +class DifyCliEnvConfig(BaseModel): + files_url: str + inner_api_url: str + inner_api_session_id: str + + +class DifyCliToolConfig(BaseModel): + provider_type: str + identity: dict[str, Any] + description: dict[str, Any] + parameters: list[dict[str, Any]] + + @classmethod + def create_from_tool(cls, tool: Tool) -> DifyCliToolConfig: + return cls( + provider_type=tool.tool_provider_type().value, + identity=tool.entity.identity.model_dump(), + description=tool.entity.description.model_dump() if tool.entity.description else {}, + parameters=[param.model_dump() for param in tool.entity.parameters], + ) + + +class DifyCliConfig(BaseModel): + env: DifyCliEnvConfig + tools: list[DifyCliToolConfig] + + @classmethod + def create(cls, session_id: str, tools: list[Tool]) -> DifyCliConfig: + from configs import dify_config + + return cls( + env=DifyCliEnvConfig( + files_url=dify_config.FILES_URL, + inner_api_url=dify_config.CONSOLE_API_URL, + inner_api_session_id=session_id, + ), + tools=[DifyCliToolConfig.create_from_tool(tool) for tool in tools], + ) + + +__all__ = [ + "DifyCliBinary", + "DifyCliConfig", + "DifyCliEnvConfig", + "DifyCliLocator", + "DifyCliToolConfig", +] diff --git a/api/core/agent/sandbox_session.py b/api/core/sandbox/session.py similarity index 60% rename from api/core/agent/sandbox_session.py rename to api/core/sandbox/session.py index 833e9cbcf4..d58b16bce4 100644 --- a/api/core/agent/sandbox_session.py +++ b/api/core/sandbox/session.py @@ -3,7 +3,10 @@ from __future__ import annotations import json import logging from io import BytesIO -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING + +from core.sandbox.constants import DIFY_CLI_CONFIG_PATH, DIFY_CLI_PATH +from core.sandbox.dify_cli import DifyCliConfig if TYPE_CHECKING: from types import TracebackType @@ -43,15 +46,30 @@ class SandboxSession: raise RuntimeError(f"Sandbox not found for workflow_execution_id={self._workflow_execution_id}") session = InnerApiSessionManager().create(tenant_id=self._tenant_id, user_id=self._user_id) + self._session_id = session.id try: - _upload_and_init_dify_cli(sandbox, self._tools, session.id) + config = DifyCliConfig.create(self._session_id, self._tools) + config_json = json.dumps(config.model_dump(mode="json"), ensure_ascii=False) + + sandbox.upload_file(DIFY_CLI_CONFIG_PATH, BytesIO(config_json.encode("utf-8"))) + + connection_handle = sandbox.establish_connection() + try: + future = sandbox.run_command(connection_handle, [DIFY_CLI_PATH, "init"]) + result = future.result(timeout=30) + if result.exit_code not in (0, None): + stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else "" + raise RuntimeError(f"Failed to initialize Dify CLI in sandbox: {stderr}") + finally: + sandbox.release_connection(connection_handle) + except Exception: InnerApiSessionManager().delete(session.id) + self._session_id = None raise self._sandbox = sandbox - self._session_id = session.id self._bash_tool = SandboxBashTool(sandbox=sandbox, tenant_id=self._tenant_id) return self @@ -82,44 +100,3 @@ class SandboxSession: InnerApiSessionManager().delete(self._session_id) logger.debug("Cleaned up SandboxSession session_id=%s", self._session_id) self._session_id = None - - -def _upload_and_init_dify_cli(sandbox: VirtualEnvironment, tools: list[Tool], session_id: str) -> None: - from configs import dify_config - - config = { - "env": { - "files_url": dify_config.FILES_URL, - "inner_api_url": dify_config.CONSOLE_API_URL, - "inner_api_session_id": session_id, - }, - "tools": _serialize_tools(tools), - } - - config_json = json.dumps(config, ensure_ascii=False) - config_path = f"/tmp/dify-init-{session_id}.json" - - sandbox.upload_file(config_path, BytesIO(config_json.encode("utf-8"))) - - connection_handle = sandbox.establish_connection() - try: - future = sandbox.run_command(connection_handle, ["dify", "init", config_path]) - result = future.result(timeout=30) - if result.exit_code != 0: - stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else "" - raise RuntimeError(f"Failed to initialize Dify CLI in sandbox: {stderr}") - finally: - sandbox.release_connection(connection_handle) - - -def _serialize_tools(tools: list[Tool]) -> list[dict[str, Any]]: - result: list[dict[str, Any]] = [] - - for tool in tools: - tool_config = tool.entity.model_dump() - tool_config["provider_type"] = tool.tool_provider_type().value - tool_config["credential_type"] = tool.runtime.credential_type.value if tool.runtime else "default" - tool_config["credential_id"] = tool.runtime.tool_id if tool.runtime else tool.entity.identity.provider - result.append(tool_config) - - return result diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py index d766d0a2e0..2b46d7a4da 100644 --- a/api/core/workflow/nodes/llm/node.py +++ b/api/core/workflow/nodes/llm/node.py @@ -13,7 +13,6 @@ from sqlalchemy import select from core.agent.entities import AgentLog, AgentResult, AgentToolEntity, ExecutionContext from core.agent.patterns import StrategyFactory -from core.agent.sandbox_session import SandboxSession from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity from core.file import File, FileTransferMethod, FileType, file_manager from core.helper.code_executor import CodeExecutor, CodeLanguage @@ -51,6 +50,7 @@ from core.model_runtime.utils.encoders import jsonable_encoder from core.prompt.entities.advanced_prompt_entities import CompletionModelPromptTemplate, MemoryConfig from core.prompt.utils.prompt_message_util import PromptMessageUtil from core.rag.entities.citation_metadata import RetrievalSourceMetadata +from core.sandbox import SandboxSession from core.tools.__base.tool import Tool from core.tools.signature import sign_upload_file from core.tools.tool_manager import ToolManager