feat: sandbox session and dify cli

This commit is contained in:
Harry 2026-01-12 01:49:01 +08:00
parent ce0a59b60d
commit 3d2840edb6
12 changed files with 187 additions and 45 deletions

View File

@ -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-<os>-<arch>). Defaults to api/bin when unset.
SANDBOX_DIFY_CLI_ROOT=

BIN
api/bin/dify-cli-darwin-amd64 Executable file

Binary file not shown.

BIN
api/bin/dify-cli-darwin-arm64 Executable file

Binary file not shown.

BIN
api/bin/dify-cli-linux-amd64 Executable file

Binary file not shown.

BIN
api/bin/dify-cli-linux-arm64 Executable file

Binary file not shown.

View File

@ -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-<os>-<arch>. "
"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.

View File

@ -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

View File

@ -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",
]

View File

@ -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"

View File

@ -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",
]

View File

@ -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

View File

@ -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