feat(sandbox): add SSH agentbox provider for middleware and docker deployments

This commit is contained in:
Harry
2026-02-09 16:37:01 +08:00
parent b014e91740
commit 3c0b50ee77
19 changed files with 750 additions and 145 deletions

View File

@@ -728,6 +728,15 @@ SANDBOX_DIFY_CLI_ROOT=
# CLI API URL for sandbox (dify-sandbox or e2b) to call back to Dify API.
# This URL must be accessible from the sandbox environment.
# For local development: use http://localhost:5001 or http://127.0.0.1:5001
# For middleware docker stack (api on host): keep localhost/127.0.0.1 and use agentbox via 127.0.0.1:2222
# For Docker deployment: use http://api:5001 (internal Docker network)
# For external sandbox (e.g., e2b): use a publicly accessible URL
CLI_API_URL=http://localhost:5001
# Optional defaults for SSH sandbox provider setup (for manual config/CLI usage).
# Middleware/local dev usually uses 127.0.0.1:2222; full docker deployment usually uses agentbox:22.
SSH_SANDBOX_HOST=127.0.0.1
SSH_SANDBOX_PORT=2222
SSH_SANDBOX_USERNAME=agentbox
SSH_SANDBOX_PASSWORD=agentbox
SSH_SANDBOX_BASE_WORKING_PATH=/workspace/sandboxes

View File

@@ -1867,7 +1867,7 @@ def file_usage(
@click.command("setup-sandbox-system-config", help="Setup system-level sandbox provider configuration.")
@click.option(
"--provider-type", prompt=True, type=click.Choice(["e2b", "docker", "local"]), help="Sandbox provider type"
"--provider-type", prompt=True, type=click.Choice(["e2b", "docker", "local", "ssh"]), help="Sandbox provider type"
)
@click.option("--config", prompt=True, help='Configuration JSON (e.g., {"api_key": "xxx"} for e2b)')
def setup_sandbox_system_config(provider_type: str, config: str):
@@ -1878,6 +1878,8 @@ def setup_sandbox_system_config(provider_type: str, config: str):
flask setup-sandbox-system-config --provider-type e2b --config '{"api_key": "e2b_xxx"}'
flask setup-sandbox-system-config --provider-type docker --config '{"docker_sock": "unix:///var/run/docker.sock"}'
flask setup-sandbox-system-config --provider-type local --config '{}'
flask setup-sandbox-system-config --provider-type ssh --config \
'{"ssh_host": "agentbox", "ssh_port": "22", "ssh_username": "agentbox", "ssh_password": "agentbox", "base_working_path": "/workspace/sandboxes"}'
"""
from models.sandbox import SandboxProviderSystemConfig

View File

@@ -34,6 +34,10 @@ def _get_sandbox_class(sandbox_type: SandboxType) -> type[VirtualEnvironment]:
from core.virtual_environment.providers.local_without_isolation import LocalVirtualEnvironment
return LocalVirtualEnvironment
case SandboxType.SSH:
from core.virtual_environment.providers.ssh_sandbox import SSHSandboxEnvironment
return SSHSandboxEnvironment
case _:
raise ValueError(f"Unsupported sandbox type: {sandbox_type}")

View File

@@ -7,6 +7,7 @@ class SandboxType(StrEnum):
DOCKER = "docker"
E2B = "e2b"
LOCAL = "local"
SSH = "ssh"
@classmethod
def get_all(cls) -> list[str]:

View File

@@ -0,0 +1,437 @@
from __future__ import annotations
import contextlib
import shlex
import stat
import threading
import time
from collections.abc import Mapping, Sequence
from enum import StrEnum
from io import BytesIO
from pathlib import PurePosixPath
from typing import Any
from uuid import uuid4
from core.entities.provider_entities import BasicProviderConfig
from core.virtual_environment.__base.entities import (
Arch,
CommandStatus,
ConnectionHandle,
FileState,
Metadata,
OperatingSystem,
)
from core.virtual_environment.__base.exec import SandboxConfigValidationError, VirtualEnvironmentLaunchFailedError
from core.virtual_environment.__base.virtual_environment import VirtualEnvironment
from core.virtual_environment.channel.exec import TransportEOFError
from core.virtual_environment.channel.queue_transport import QueueTransportReadCloser
from core.virtual_environment.channel.transport import TransportWriteCloser
class _SSHStdinTransport(TransportWriteCloser):
def __init__(self, channel: Any):
self._channel = channel
self._closed = False
def write(self, data: bytes) -> None:
if self._closed:
raise TransportEOFError("Transport is closed")
if not data:
return
self._channel.sendall(data)
def close(self) -> None:
if self._closed:
return
self._closed = True
with contextlib.suppress(Exception):
self._channel.shutdown_write()
class SSHSandboxEnvironment(VirtualEnvironment):
_DEFAULT_SSH_HOST = "agentbox"
_DEFAULT_SSH_PORT = 22
_DEFAULT_BASE_WORKING_PATH = "/workspace/sandboxes"
class OptionsKey(StrEnum):
SSH_HOST = "ssh_host"
SSH_PORT = "ssh_port"
SSH_USERNAME = "ssh_username"
SSH_PASSWORD = "ssh_password"
BASE_WORKING_PATH = "base_working_path"
def __init__(
self,
tenant_id: str,
options: Mapping[str, Any],
environments: Mapping[str, str] | None = None,
user_id: str | None = None,
) -> None:
self._connections: dict[str, Any] = {}
self._commands: dict[str, CommandStatus] = {}
self._lock = threading.Lock()
super().__init__(tenant_id=tenant_id, options=options, environments=environments, user_id=user_id)
@classmethod
def get_config_schema(cls) -> list[BasicProviderConfig]:
return [
BasicProviderConfig(type=BasicProviderConfig.Type.TEXT_INPUT, name=cls.OptionsKey.SSH_HOST),
BasicProviderConfig(type=BasicProviderConfig.Type.TEXT_INPUT, name=cls.OptionsKey.SSH_PORT),
BasicProviderConfig(type=BasicProviderConfig.Type.TEXT_INPUT, name=cls.OptionsKey.SSH_USERNAME),
BasicProviderConfig(type=BasicProviderConfig.Type.SECRET_INPUT, name=cls.OptionsKey.SSH_PASSWORD),
BasicProviderConfig(type=BasicProviderConfig.Type.TEXT_INPUT, name=cls.OptionsKey.BASE_WORKING_PATH),
]
@classmethod
def validate(cls, options: Mapping[str, Any]) -> None:
cls._require_non_empty_option(options, cls.OptionsKey.SSH_USERNAME)
cls._require_non_empty_option(options, cls.OptionsKey.SSH_PASSWORD)
with cls._create_ssh_client(options):
return
def _construct_environment(self, options: Mapping[str, Any], environments: Mapping[str, str]) -> Metadata:
environment_id = uuid4().hex
working_path = self._workspace_path_from_id(environment_id)
try:
with self._client() as client:
self._run_command(client, f"mkdir -p {shlex.quote(working_path)}")
arch_stdout = self._run_command(client, "uname -m")
os_stdout = self._run_command(client, "uname -s")
except Exception as e:
raise VirtualEnvironmentLaunchFailedError(f"Failed to construct SSH environment: {e}") from e
return Metadata(
id=environment_id,
arch=self._parse_arch(arch_stdout.decode("utf-8", errors="replace").strip()),
os=self._parse_os(os_stdout.decode("utf-8", errors="replace").strip()),
store={"working_path": working_path},
)
def establish_connection(self) -> ConnectionHandle:
connection_id = uuid4().hex
client = self._create_ssh_client(self.options)
with self._lock:
self._connections[connection_id] = client
return ConnectionHandle(id=connection_id)
def release_connection(self, connection_handle: ConnectionHandle) -> None:
with self._lock:
client = self._connections.pop(connection_handle.id, None)
if client is not None:
with contextlib.suppress(Exception):
client.close()
def release_environment(self) -> None:
working_path = self.get_working_path()
with contextlib.suppress(Exception):
with self._client() as client:
self._run_command(client, f"rm -rf {shlex.quote(working_path)}")
def execute_command(
self,
connection_handle: ConnectionHandle,
command: list[str],
environments: Mapping[str, str] | None = None,
cwd: str | None = None,
) -> tuple[str, TransportWriteCloser, QueueTransportReadCloser, QueueTransportReadCloser]:
client = self._get_connection(connection_handle)
transport = client.get_transport()
if transport is None:
raise RuntimeError("SSH transport is not available")
channel = transport.open_session()
channel.set_combine_stderr(False)
execution_command = self._build_exec_command(command, environments, cwd)
channel.exec_command(execution_command)
pid = uuid4().hex
stdin_transport = _SSHStdinTransport(channel)
stdout_transport = QueueTransportReadCloser()
stderr_transport = QueueTransportReadCloser()
with self._lock:
self._commands[pid] = CommandStatus(status=CommandStatus.Status.RUNNING, exit_code=None)
threading.Thread(
target=self._consume_channel_output,
args=(pid, channel, stdout_transport, stderr_transport),
daemon=True,
).start()
return pid, stdin_transport, stdout_transport, stderr_transport
def get_command_status(self, connection_handle: ConnectionHandle, pid: str) -> CommandStatus:
with self._lock:
status = self._commands.get(pid)
if status is None:
return CommandStatus(status=CommandStatus.Status.COMPLETED, exit_code=None)
return status
def upload_file(self, path: str, content: BytesIO) -> None:
destination_path = self._workspace_path(path)
with self._client() as client:
sftp = client.open_sftp()
try:
self._sftp_mkdirs(sftp, str(PurePosixPath(destination_path).parent))
with sftp.file(destination_path, "wb") as remote_file:
remote_file.write(content.getvalue())
finally:
sftp.close()
def download_file(self, path: str) -> BytesIO:
source_path = self._workspace_path(path)
with self._client() as client:
sftp = client.open_sftp()
try:
with sftp.file(source_path, "rb") as remote_file:
return BytesIO(remote_file.read())
finally:
sftp.close()
def list_files(self, directory_path: str, limit: int) -> Sequence[FileState]:
if limit <= 0:
return []
root_directory = self._workspace_path(directory_path)
files: list[FileState] = []
with self._client() as client:
sftp = client.open_sftp()
try:
pending = [root_directory]
while pending and len(files) < limit:
current_directory = pending.pop(0)
with contextlib.suppress(FileNotFoundError):
for attr in sftp.listdir_attr(current_directory):
current_path = str(PurePosixPath(current_directory) / attr.filename)
mode = attr.st_mode
if stat.S_ISDIR(mode):
pending.append(current_path)
continue
files.append(
FileState(
path=self._to_relative_workspace_path(current_path),
size=attr.st_size,
created_at=int(attr.st_mtime),
updated_at=int(attr.st_mtime),
)
)
if len(files) >= limit:
break
finally:
sftp.close()
return files
@classmethod
def _require_non_empty_option(cls, options: Mapping[str, Any], key: OptionsKey) -> str:
value = options.get(key)
if not isinstance(value, str) or not value.strip():
raise SandboxConfigValidationError(f"Missing required option: {key}")
return value.strip()
@classmethod
def _create_ssh_client(cls, options: Mapping[str, Any]) -> Any:
import paramiko
host = options.get(cls.OptionsKey.SSH_HOST, cls._DEFAULT_SSH_HOST)
port = options.get(cls.OptionsKey.SSH_PORT, cls._DEFAULT_SSH_PORT)
username = cls._require_non_empty_option(options, cls.OptionsKey.SSH_USERNAME)
password = cls._require_non_empty_option(options, cls.OptionsKey.SSH_PASSWORD)
if not isinstance(host, str) or not host.strip():
raise SandboxConfigValidationError(f"Invalid option value: {cls.OptionsKey.SSH_HOST}")
try:
port_int = int(port)
except (TypeError, ValueError) as e:
raise SandboxConfigValidationError(f"Invalid option value: {cls.OptionsKey.SSH_PORT}") from e
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(
hostname=host.strip(),
port=port_int,
username=username,
password=password,
look_for_keys=False,
allow_agent=False,
timeout=10,
)
except Exception as e:
with contextlib.suppress(Exception):
client.close()
raise SandboxConfigValidationError(f"SSH connection failed: {e}") from e
return client
@contextlib.contextmanager
def _client(self):
client = self._create_ssh_client(self.options)
try:
yield client
finally:
with contextlib.suppress(Exception):
client.close()
def _get_connection(self, connection_handle: ConnectionHandle) -> Any:
with self._lock:
client = self._connections.get(connection_handle.id)
if client is None:
raise ValueError(f"Connection handle not found: {connection_handle.id}")
return client
def _workspace_path_from_id(self, environment_id: str) -> str:
base_path = self.options.get(self.OptionsKey.BASE_WORKING_PATH, self._DEFAULT_BASE_WORKING_PATH)
if not isinstance(base_path, str) or not base_path.strip():
base_path = self._DEFAULT_BASE_WORKING_PATH
return str(PurePosixPath(base_path) / environment_id)
def get_working_path(self) -> str:
working_path = self.metadata.store.get("working_path")
if not isinstance(working_path, str) or not working_path:
return self._workspace_path_from_id(self.metadata.id)
return working_path
def _workspace_path(self, path: str | None) -> str:
if not path:
return self.get_working_path()
normalized = PurePosixPath(path)
if normalized.is_absolute():
return str(normalized)
return str(PurePosixPath(self.get_working_path()) / self._normalize_relative_path(path))
@staticmethod
def _normalize_relative_path(path: str) -> PurePosixPath:
parts: list[str] = []
for part in PurePosixPath(path).parts:
if part in ("", ".", "/"):
continue
if part == "..":
if not parts:
raise ValueError("Path escapes the workspace.")
parts.pop()
continue
parts.append(part)
return PurePosixPath(*parts)
def _to_relative_workspace_path(self, path: str) -> str:
workspace = PurePosixPath(self.get_working_path())
target = PurePosixPath(path)
if target.is_relative_to(workspace):
return target.relative_to(workspace).as_posix()
return target.as_posix()
def _build_exec_command(
self, command: list[str], environments: Mapping[str, str] | None = None, cwd: str | None = None
) -> str:
working_path = self._workspace_path(cwd)
command_body = f"cd {shlex.quote(working_path)} && "
if environments:
env_clause = " ".join(f"{key}={shlex.quote(value)}" for key, value in environments.items())
command_body += f"{env_clause} "
command_body += shlex.join(command)
return f"sh -lc {shlex.quote(command_body)}"
@staticmethod
def _run_command(client: Any, command: str) -> bytes:
_, stdout, stderr = client.exec_command(command)
exit_code = stdout.channel.recv_exit_status()
stdout_data = stdout.read()
stderr_data = stderr.read()
if exit_code != 0:
stderr_text = stderr_data.decode("utf-8", errors="replace")
raise RuntimeError(f"SSH command failed ({exit_code}): {stderr_text}")
return stdout_data
def _consume_channel_output(
self,
pid: str,
channel: Any,
stdout_transport: QueueTransportReadCloser,
stderr_transport: QueueTransportReadCloser,
) -> None:
stdout_writer = stdout_transport.get_write_handler()
stderr_writer = stderr_transport.get_write_handler()
exit_code: int | None = None
try:
while True:
if channel.recv_ready():
stdout_writer.write(channel.recv(4096))
if channel.recv_stderr_ready():
stderr_writer.write(channel.recv_stderr(4096))
if channel.exit_status_ready() and not channel.recv_ready() and not channel.recv_stderr_ready():
exit_code = int(channel.recv_exit_status())
break
time.sleep(0.05)
finally:
with contextlib.suppress(Exception):
stdout_transport.close()
with contextlib.suppress(Exception):
stderr_transport.close()
with contextlib.suppress(Exception):
channel.close()
with self._lock:
self._commands[pid] = CommandStatus(status=CommandStatus.Status.COMPLETED, exit_code=exit_code)
@staticmethod
def _parse_arch(raw_arch: str) -> Arch:
arch = raw_arch.lower()
if arch in {"x86_64", "amd64"}:
return Arch.AMD64
if arch in {"arm64", "aarch64"}:
return Arch.ARM64
return Arch.AMD64
@staticmethod
def _parse_os(raw_os: str) -> OperatingSystem:
system_name = raw_os.lower()
if system_name == "darwin":
return OperatingSystem.DARWIN
return OperatingSystem.LINUX
@staticmethod
def _sftp_mkdirs(sftp: Any, directory: str) -> None:
if not directory or directory == "/":
return
path = PurePosixPath(directory)
current = PurePosixPath("/") if path.is_absolute() else PurePosixPath()
for part in path.parts:
if part in ("", "/"):
continue
current = current / part
current_path = str(current)
try:
attrs = sftp.stat(current_path)
if not stat.S_ISDIR(attrs.st_mode):
raise OSError(f"Path exists but is not a directory: {current_path}")
continue
except OSError as e:
missing = isinstance(e, FileNotFoundError) or getattr(e, "errno", None) == 2
missing = missing or "no such file" in str(e).lower()
if not missing:
raise
try:
sftp.mkdir(current_path)
except OSError:
# Some SFTP servers report generic "Failure" when directory already exists.
attrs = sftp.stat(current_path)
if not stat.S_ISDIR(attrs.st_mode):
raise OSError(f"Failed to create directory: {current_path}")

View File

@@ -1,4 +1,4 @@
"""add_default_docker_sandbox_system_config
"""add_default_sandbox_system_config
Revision ID: 201d71cc4f34
Revises: 45471e916693
@@ -23,19 +23,22 @@ def upgrade():
# Import encryption utility
from core.tools.utils.system_encryption import encrypt_system_params
# Define the default Docker configuration
docker_config = {
"docker_image": "langgenius/dify-agentbox:latest",
"docker_sock": "unix:///var/run/docker.sock"
# Define the default SSH configuration for agentbox
ssh_config = {
"ssh_host": "agentbox",
"ssh_port": "22",
"ssh_username": "agentbox",
"ssh_password": "agentbox",
"base_working_path": "/workspace/sandboxes",
}
# Encrypt the configuration
encrypted_config = encrypt_system_params(docker_config)
encrypted_config = encrypt_system_params(ssh_config)
# Generate UUID for the record
record_id = str(uuid4())
# Insert the default Docker sandbox system config if it doesn't exist
# Insert the default SSH sandbox system config if it doesn't exist
op.execute(
sa.text(
"""
@@ -46,19 +49,19 @@ def upgrade():
"""
).bindparams(
id=record_id,
provider_type='docker',
provider_type='ssh',
encrypted_config=encrypted_config
)
)
def downgrade():
# Delete the default Docker sandbox system config
# Delete the default SSH sandbox system config
op.execute(
sa.text(
"""
DELETE FROM sandbox_provider_system_config
WHERE provider_type = :provider_type
"""
).bindparams(provider_type='docker')
).bindparams(provider_type='ssh')
)

View File

@@ -27,7 +27,7 @@ class SandboxProviderSystemConfig(TypeBase):
id: Mapped[str] = mapped_column(
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
)
provider_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="e2b, docker, local")
provider_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="e2b, docker, local, ssh")
encrypted_config: Mapped[str] = mapped_column(LongText, nullable=False, comment="Encrypted config JSON")
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
@@ -60,7 +60,7 @@ class SandboxProvider(TypeBase):
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
provider_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="e2b, docker, local")
provider_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="e2b, docker, local, ssh")
encrypted_config: Mapped[str] = mapped_column(LongText, nullable=False, comment="Encrypted config JSON")
configure_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default="user", default="user")
is_active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"), default=False)

View File

@@ -65,6 +65,7 @@ dependencies = [
"opentelemetry-semantic-conventions==0.48b0",
"opentelemetry-util-http==0.48b0",
"pandas[excel,output-formatting,performance]~=2.2.2",
"paramiko>=3.5.1",
"psycogreen~=1.0.2",
"psycopg2-binary~=2.9.6",
"pycryptodome==3.23.0",

View File

@@ -1,120 +0,0 @@
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from core.sandbox import SandboxBuilder, SandboxType
from core.virtual_environment.__base.virtual_environment import VirtualEnvironment
class TestVMType:
def test_values(self):
assert SandboxType.DOCKER == "docker"
assert SandboxType.E2B == "e2b"
assert SandboxType.LOCAL == "local"
def test_is_string_enum(self):
assert isinstance(SandboxType.DOCKER.value, str)
assert isinstance(SandboxType.E2B.value, str)
assert isinstance(SandboxType.LOCAL.value, str)
class TestVMBuilder:
def test_build_docker(self):
mock_instance = MagicMock(spec=VirtualEnvironment)
mock_class = MagicMock(return_value=mock_instance)
with patch(
"core.virtual_environment.providers.docker_daemon_sandbox.DockerDaemonEnvironment",
mock_class,
):
result = (
SandboxBuilder("test-tenant", SandboxType.DOCKER)
.options({"docker_image": "python:3.11-slim"})
.environments({"PYTHONUNBUFFERED": "1"})
.build()
)
mock_class.assert_called_once_with(
tenant_id="test-tenant",
options={"docker_image": "python:3.11-slim"},
environments={"PYTHONUNBUFFERED": "1"},
user_id=None,
)
assert result is mock_instance
def test_build_with_user(self):
mock_instance = MagicMock(spec=VirtualEnvironment)
mock_class = MagicMock(return_value=mock_instance)
with patch(
"core.virtual_environment.providers.docker_daemon_sandbox.DockerDaemonEnvironment",
mock_class,
):
SandboxBuilder("test-tenant", SandboxType.DOCKER).user("user-123").build()
mock_class.assert_called_once_with(
tenant_id="test-tenant",
options={},
environments={},
user_id="user-123",
)
def test_build_with_initializers(self):
mock_instance = MagicMock(spec=VirtualEnvironment)
mock_class = MagicMock(return_value=mock_instance)
mock_initializer = MagicMock()
with patch(
"core.virtual_environment.providers.docker_daemon_sandbox.DockerDaemonEnvironment",
mock_class,
):
SandboxBuilder("test-tenant", SandboxType.DOCKER).initializer(mock_initializer).build()
mock_initializer.initialize.assert_called_once_with(mock_instance)
def test_build_local(self):
mock_instance = MagicMock(spec=VirtualEnvironment)
with patch(
"core.virtual_environment.providers.local_without_isolation.LocalVirtualEnvironment",
return_value=mock_instance,
) as mock_class:
SandboxBuilder("test-tenant", SandboxType.LOCAL).build()
mock_class.assert_called_once()
def test_build_e2b(self):
mock_instance = MagicMock(spec=VirtualEnvironment)
with patch(
"core.virtual_environment.providers.e2b_sandbox.E2BEnvironment",
return_value=mock_instance,
) as mock_class:
SandboxBuilder("test-tenant", SandboxType.E2B).build()
mock_class.assert_called_once()
def test_build_unsupported_type_raises(self):
with pytest.raises(ValueError, match="Unsupported VM type"):
SandboxBuilder("test-tenant", "unsupported").build() # type: ignore[arg-type]
def test_validate(self):
mock_class = MagicMock()
with patch(
"core.virtual_environment.providers.docker_daemon_sandbox.DockerDaemonEnvironment",
mock_class,
):
SandboxBuilder.validate(SandboxType.DOCKER, {"key": "value"})
mock_class.validate.assert_called_once_with({"key": "value"})
class TestVMBuilderIntegration:
def test_local_sandbox(self, tmp_path: Path):
sandbox = SandboxBuilder("test-tenant", SandboxType.LOCAL).options({"base_working_path": str(tmp_path)}).build()
try:
assert sandbox is not None
assert sandbox.metadata.id is not None
assert sandbox.metadata.arch is not None
finally:
sandbox.release_environment()

51
api/uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.11, <3.13"
resolution-markers = [
"python_full_version >= '3.12.4' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'",
@@ -1554,6 +1554,7 @@ dependencies = [
{ name = "opik" },
{ name = "packaging" },
{ name = "pandas", extra = ["excel", "output-formatting", "performance"] },
{ name = "paramiko" },
{ name = "psycogreen" },
{ name = "psycopg2-binary" },
{ name = "pycryptodome" },
@@ -1760,6 +1761,7 @@ requires-dist = [
{ name = "opik", specifier = "~=1.8.72" },
{ name = "packaging", specifier = "==24.1" },
{ name = "pandas", extras = ["excel", "output-formatting", "performance"], specifier = "~=2.2.2" },
{ name = "paramiko", specifier = ">=3.5.1" },
{ name = "psycogreen", specifier = "~=1.0.2" },
{ name = "psycopg2-binary", specifier = "~=2.9.6" },
{ name = "pycryptodome", specifier = "==3.23.0" },
@@ -3190,6 +3192,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/7f/8a80a1c7c2ed05822b5a2b312d2995f30c533641f8198366ba2e26a7bb03/intervaltree-3.2.1-py2.py3-none-any.whl", hash = "sha256:a8a8381bbd35d48ceebee932c77ffc988492d22fb1d27d0ba1d74a7694eb8f0b", size = 25929, upload-time = "2025-12-24T04:25:05.298Z" },
]
[[package]]
name = "invoke"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762, upload-time = "2025-10-11T00:36:35.172Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" },
]
[[package]]
name = "isodate"
version = "0.7.2"
@@ -4648,6 +4659,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/f8/46141ba8c9d7064dc5008bfb4a6ae5bd3c30e4c61c28b5c5ed485bf358ba/pandas_stubs-2.2.3.250527-py3-none-any.whl", hash = "sha256:cd0a49a95b8c5f944e605be711042a4dd8550e2c559b43d70ba2c4b524b66163", size = 159683, upload-time = "2025-05-27T15:24:28.4Z" },
]
[[package]]
name = "paramiko"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bcrypt" },
{ name = "cryptography" },
{ name = "invoke" },
{ name = "pynacl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" },
]
[[package]]
name = "pathspec"
version = "1.0.4"
@@ -5203,6 +5229,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300, upload-time = "2025-08-24T12:55:53.394Z" },
]
[[package]]
name = "pynacl"
version = "1.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" },
{ url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" },
{ url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" },
{ url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" },
{ url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" },
{ url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" },
{ url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" },
{ url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" },
{ url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" },
{ url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" },
]
[[package]]
name = "pyobvector"
version = "0.2.23"

View File

@@ -998,8 +998,13 @@ EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
# Sandbox Dify CLI configuration
# Directory containing dify CLI binaries (dify-cli-<os>-<arch>). Defaults to api/bin when unset.
SANDBOX_DIFY_CLI_ROOT=
# CLI API URL for sandbox (dify-sandbox or e2b) to call back to Dify API.
# This URL must be accessible from the sandbox environment.
# For local development: use http://localhost:5001 or http://127.0.0.1:5001
# For Docker deployment: use http://api:5001 (internal Docker network)
# For external sandbox (e.g., e2b): use a publicly accessible URL
CLI_API_URL=http://api:5001
@@ -1190,6 +1195,21 @@ SANDBOX_HTTPS_PROXY=http://ssrf_proxy:3128
# The port on which the sandbox service runs
SANDBOX_PORT=8194
# ------------------------------
# Environment Variables for agentbox Service
# ------------------------------
# SSH username used by the agentbox service
AGENTBOX_SSH_USERNAME=agentbox
# SSH password used by the agentbox service
AGENTBOX_SSH_PASSWORD=agentbox
# SSH port exposed inside the docker network
AGENTBOX_SSH_PORT=22
# socat target host for localhost forwarding inside agentbox
AGENTBOX_SOCAT_TARGET_HOST=api
# socat target port for localhost forwarding inside agentbox
AGENTBOX_SOCAT_TARGET_PORT=5001
# ------------------------------
# Environment Variables for weaviate Service
# (only used when VECTOR_STORE is weaviate)

View File

@@ -21,7 +21,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.12.1
image: langgenius/dify-api:deploy-agent-dev
restart: always
environment:
# Use the shared environment variables.
@@ -63,7 +63,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.12.1
image: langgenius/dify-api:deploy-agent-dev
restart: always
environment:
# Use the shared environment variables.
@@ -102,7 +102,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.12.1
image: langgenius/dify-api:deploy-agent-dev
restart: always
environment:
# Use the shared environment variables.
@@ -132,7 +132,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.12.1
image: langgenius/dify-web:deploy-agent-dev
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@@ -269,6 +269,41 @@ services:
networks:
- ssrf_proxy_network
# SSH sandbox runtime for agent execution.
agentbox:
image: langgenius/dify-agentbox:latest
user: "0:0"
restart: always
environment:
AGENTBOX_SSH_USERNAME: ${AGENTBOX_SSH_USERNAME:-agentbox}
AGENTBOX_SSH_PASSWORD: ${AGENTBOX_SSH_PASSWORD:-agentbox}
AGENTBOX_SSH_PORT: ${AGENTBOX_SSH_PORT:-22}
AGENTBOX_SOCAT_TARGET_HOST: ${AGENTBOX_SOCAT_TARGET_HOST:-api}
AGENTBOX_SOCAT_TARGET_PORT: ${AGENTBOX_SOCAT_TARGET_PORT:-5001}
command: >
sh -c "
set -e;
if ! command -v sshd >/dev/null 2>&1; then
apt-get update;
DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server;
rm -rf /var/lib/apt/lists/*;
fi;
mkdir -p /run/sshd;
ssh-keygen -A;
if [ \"$${AGENTBOX_SSH_USERNAME}\" = \"root\" ]; then
echo \"root:$${AGENTBOX_SSH_PASSWORD}\" | chpasswd;
grep -q '^PermitRootLogin' /etc/ssh/sshd_config && sed -i 's/^PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config || echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config;
else
id -u \"$${AGENTBOX_SSH_USERNAME}\" >/dev/null 2>&1 || useradd -m -s /bin/bash \"$${AGENTBOX_SSH_USERNAME}\";
echo \"$${AGENTBOX_SSH_USERNAME}:$${AGENTBOX_SSH_PASSWORD}\" | chpasswd;
fi;
grep -q '^PasswordAuthentication' /etc/ssh/sshd_config && sed -i 's/^PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config || echo 'PasswordAuthentication yes' >> /etc/ssh/sshd_config;
nohup socat TCP-LISTEN:$${AGENTBOX_SOCAT_TARGET_PORT},bind=127.0.0.1,fork,reuseaddr TCP:$${AGENTBOX_SOCAT_TARGET_HOST}:$${AGENTBOX_SOCAT_TARGET_PORT} >/tmp/socat.log 2>&1 &
exec /usr/sbin/sshd -D -p $${AGENTBOX_SSH_PORT}
"
depends_on:
- api
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.3-local

View File

@@ -121,6 +121,45 @@ services:
networks:
- ssrf_proxy_network
# SSH sandbox runtime for agent execution.
agentbox:
image: langgenius/dify-agentbox:latest
user: "0:0"
restart: always
env_file:
- ./middleware.env
environment:
AGENTBOX_SSH_USERNAME: ${AGENTBOX_SSH_USERNAME:-agentbox}
AGENTBOX_SSH_PASSWORD: ${AGENTBOX_SSH_PASSWORD:-agentbox}
AGENTBOX_SSH_PORT: ${AGENTBOX_SSH_PORT:-22}
AGENTBOX_SOCAT_TARGET_HOST: ${AGENTBOX_SOCAT_TARGET_HOST:-host.docker.internal}
AGENTBOX_SOCAT_TARGET_PORT: ${AGENTBOX_SOCAT_TARGET_PORT:-5001}
command: >
sh -c "
set -e;
if ! command -v sshd >/dev/null 2>&1; then
apt-get update;
DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server;
rm -rf /var/lib/apt/lists/*;
fi;
mkdir -p /run/sshd;
ssh-keygen -A;
if [ \"$${AGENTBOX_SSH_USERNAME}\" = \"root\" ]; then
echo \"root:$${AGENTBOX_SSH_PASSWORD}\" | chpasswd;
grep -q '^PermitRootLogin' /etc/ssh/sshd_config && sed -i 's/^PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config || echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config;
else
id -u \"$${AGENTBOX_SSH_USERNAME}\" >/dev/null 2>&1 || useradd -m -s /bin/bash \"$${AGENTBOX_SSH_USERNAME}\";
echo \"$${AGENTBOX_SSH_USERNAME}:$${AGENTBOX_SSH_PASSWORD}\" | chpasswd;
fi;
grep -q '^PasswordAuthentication' /etc/ssh/sshd_config && sed -i 's/^PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config || echo 'PasswordAuthentication yes' >> /etc/ssh/sshd_config;
nohup socat TCP-LISTEN:$${AGENTBOX_SOCAT_TARGET_PORT},bind=127.0.0.1,fork,reuseaddr TCP:$${AGENTBOX_SOCAT_TARGET_HOST}:$${AGENTBOX_SOCAT_TARGET_PORT} >/tmp/socat.log 2>&1 &
exec /usr/sbin/sshd -D -p $${AGENTBOX_SSH_PORT}
"
ports:
- "${EXPOSE_AGENTBOX_SSH_PORT:-2222}:${AGENTBOX_SSH_PORT:-22}"
networks:
- default
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.3-local

View File

@@ -435,6 +435,8 @@ x-shared-env: &shared-api-worker-env
EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES: ${EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES:-5}
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: ${CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES:-5}
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES: ${OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES:-5}
SANDBOX_DIFY_CLI_ROOT: ${SANDBOX_DIFY_CLI_ROOT:-}
CLI_API_URL: ${CLI_API_URL:-http://api:5001}
CODE_EXECUTION_ENDPOINT: ${CODE_EXECUTION_ENDPOINT:-http://sandbox:8194}
CODE_EXECUTION_API_KEY: ${CODE_EXECUTION_API_KEY:-dify-sandbox}
CODE_EXECUTION_SSL_VERIFY: ${CODE_EXECUTION_SSL_VERIFY:-True}
@@ -505,6 +507,11 @@ x-shared-env: &shared-api-worker-env
SANDBOX_HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128}
SANDBOX_HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
AGENTBOX_SSH_USERNAME: ${AGENTBOX_SSH_USERNAME:-agentbox}
AGENTBOX_SSH_PASSWORD: ${AGENTBOX_SSH_PASSWORD:-agentbox}
AGENTBOX_SSH_PORT: ${AGENTBOX_SSH_PORT:-22}
AGENTBOX_SOCAT_TARGET_HOST: ${AGENTBOX_SOCAT_TARGET_HOST:-api}
AGENTBOX_SOCAT_TARGET_PORT: ${AGENTBOX_SOCAT_TARGET_PORT:-5001}
WEAVIATE_PERSISTENCE_DATA_PATH: ${WEAVIATE_PERSISTENCE_DATA_PATH:-/var/lib/weaviate}
WEAVIATE_QUERY_DEFAULTS_LIMIT: ${WEAVIATE_QUERY_DEFAULTS_LIMIT:-25}
WEAVIATE_AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: ${WEAVIATE_AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED:-true}
@@ -709,7 +716,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.12.1
image: langgenius/dify-api:deploy-agent-dev
restart: always
environment:
# Use the shared environment variables.
@@ -751,7 +758,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.12.1
image: langgenius/dify-api:deploy-agent-dev
restart: always
environment:
# Use the shared environment variables.
@@ -790,7 +797,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.12.1
image: langgenius/dify-api:deploy-agent-dev
restart: always
environment:
# Use the shared environment variables.
@@ -820,7 +827,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.12.1
image: langgenius/dify-web:deploy-agent-dev
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@@ -957,6 +964,41 @@ services:
networks:
- ssrf_proxy_network
# SSH sandbox runtime for agent execution.
agentbox:
image: langgenius/dify-agentbox:latest
user: "0:0"
restart: always
environment:
AGENTBOX_SSH_USERNAME: ${AGENTBOX_SSH_USERNAME:-agentbox}
AGENTBOX_SSH_PASSWORD: ${AGENTBOX_SSH_PASSWORD:-agentbox}
AGENTBOX_SSH_PORT: ${AGENTBOX_SSH_PORT:-22}
AGENTBOX_SOCAT_TARGET_HOST: ${AGENTBOX_SOCAT_TARGET_HOST:-api}
AGENTBOX_SOCAT_TARGET_PORT: ${AGENTBOX_SOCAT_TARGET_PORT:-5001}
command: >
sh -c "
set -e;
if ! command -v sshd >/dev/null 2>&1; then
apt-get update;
DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server;
rm -rf /var/lib/apt/lists/*;
fi;
mkdir -p /run/sshd;
ssh-keygen -A;
if [ \"$${AGENTBOX_SSH_USERNAME}\" = \"root\" ]; then
echo \"root:$${AGENTBOX_SSH_PASSWORD}\" | chpasswd;
grep -q '^PermitRootLogin' /etc/ssh/sshd_config && sed -i 's/^PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config || echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config;
else
id -u \"$${AGENTBOX_SSH_USERNAME}\" >/dev/null 2>&1 || useradd -m -s /bin/bash \"$${AGENTBOX_SSH_USERNAME}\";
echo \"$${AGENTBOX_SSH_USERNAME}:$${AGENTBOX_SSH_PASSWORD}\" | chpasswd;
fi;
grep -q '^PasswordAuthentication' /etc/ssh/sshd_config && sed -i 's/^PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config || echo 'PasswordAuthentication yes' >> /etc/ssh/sshd_config;
nohup socat TCP-LISTEN:$${AGENTBOX_SOCAT_TARGET_PORT},bind=127.0.0.1,fork,reuseaddr TCP:$${AGENTBOX_SOCAT_TARGET_HOST}:$${AGENTBOX_SOCAT_TARGET_PORT} >/tmp/socat.log 2>&1 &
exec /usr/sbin/sshd -D -p $${AGENTBOX_SSH_PORT}
"
depends_on:
- api
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.3-local

View File

@@ -103,6 +103,15 @@ SANDBOX_HTTP_PROXY=http://ssrf_proxy:3128
SANDBOX_HTTPS_PROXY=http://ssrf_proxy:3128
SANDBOX_PORT=8194
# ------------------------------
# Environment Variables for agentbox Service
# ------------------------------
AGENTBOX_SSH_USERNAME=agentbox
AGENTBOX_SSH_PASSWORD=agentbox
AGENTBOX_SSH_PORT=22
AGENTBOX_SOCAT_TARGET_HOST=host.docker.internal
AGENTBOX_SOCAT_TARGET_PORT=5001
# ------------------------------
# Environment Variables for ssrf_proxy Service
# ------------------------------
@@ -140,6 +149,7 @@ EXPOSE_POSTGRES_PORT=5432
EXPOSE_MYSQL_PORT=3306
EXPOSE_REDIS_PORT=6379
EXPOSE_SANDBOX_PORT=8194
EXPOSE_AGENTBOX_SSH_PORT=2222
EXPOSE_SSRF_PROXY_PORT=3128
EXPOSE_WEAVIATE_PORT=8080
@@ -237,4 +247,4 @@ LOGSTORE_DUAL_READ_ENABLED=true
# Control flag for whether to write the `graph` field to LogStore.
# If LOGSTORE_ENABLE_PUT_GRAPH_FIELD is "true", write the full `graph` field;
# otherwise write an empty {} instead. Defaults to writing the `graph` field.
LOGSTORE_ENABLE_PUT_GRAPH_FIELD=true
LOGSTORE_ENABLE_PUT_GRAPH_FIELD=true

View File

@@ -5,6 +5,7 @@ export const PROVIDER_ICONS: Record<string, string> = {
daytona: '/sandbox-providers/daytona.svg',
docker: '/sandbox-providers/docker.svg',
local: '/sandbox-providers/local.svg',
ssh: '/sandbox-providers/docker.svg',
}
export const PROVIDER_LABEL_KEYS = {
@@ -12,6 +13,7 @@ export const PROVIDER_LABEL_KEYS = {
daytona: 'sandboxProvider.daytona.label',
docker: 'sandboxProvider.docker.label',
local: 'sandboxProvider.local.label',
ssh: 'sandboxProvider.ssh.label',
} as const
export const PROVIDER_DESCRIPTION_KEYS = {
@@ -19,6 +21,7 @@ export const PROVIDER_DESCRIPTION_KEYS = {
daytona: 'sandboxProvider.daytona.description',
docker: 'sandboxProvider.docker.description',
local: 'sandboxProvider.local.description',
ssh: 'sandboxProvider.ssh.description',
} as const
export const SANDBOX_FIELD_CONFIGS = {
@@ -52,6 +55,26 @@ export const SANDBOX_FIELD_CONFIGS = {
placeholderKey: 'sandboxProvider.configModal.baseWorkingPathPlaceholder',
type: FormTypeEnum.textInput,
},
ssh_host: {
labelKey: 'sandboxProvider.configModal.sshHost',
placeholderKey: 'sandboxProvider.configModal.sshHostPlaceholder',
type: FormTypeEnum.textInput,
},
ssh_port: {
labelKey: 'sandboxProvider.configModal.sshPort',
placeholderKey: 'sandboxProvider.configModal.sshPortPlaceholder',
type: FormTypeEnum.textInput,
},
ssh_username: {
labelKey: 'sandboxProvider.configModal.sshUsername',
placeholderKey: 'sandboxProvider.configModal.sshUsernamePlaceholder',
type: FormTypeEnum.textInput,
},
ssh_password: {
labelKey: 'sandboxProvider.configModal.sshPassword',
placeholderKey: 'sandboxProvider.configModal.sshPasswordPlaceholder',
type: FormTypeEnum.secretInput,
},
} as const
export const PROVIDER_DOC_LINKS: Record<string, string> = {
@@ -59,4 +82,5 @@ export const PROVIDER_DOC_LINKS: Record<string, string> = {
daytona: 'https://www.daytona.io/docs',
docker: 'https://docs.docker.com/',
local: '',
ssh: 'https://www.openssh.com/manual.html',
}

View File

@@ -1,3 +1,4 @@
import { RiTerminalBoxLine } from '@remixicon/react'
import DockerMarkWhite from '@/app/components/base/icons/src/public/common/DockerMarkWhite'
import E2B from '@/app/components/base/icons/src/public/common/E2B'
import SandboxLocal from '@/app/components/base/icons/src/public/common/SandboxLocal'
@@ -11,6 +12,7 @@ type ProviderIconProps = {
}
const DOCKER_BRAND_BLUE = '#1D63ED'
const SSH_CONSOLE_BG = '#0F172A'
const ProviderIcon = ({
providerType,
@@ -83,6 +85,33 @@ const ProviderIcon = ({
return <SandboxLocal className="h-full w-full" />
}
if (providerType === 'ssh') {
const inner = (
<div
className={cn(
'flex h-full w-full items-center justify-center',
withBorder ? '' : 'rounded-[10px]',
)}
style={{ backgroundColor: SSH_CONSOLE_BG }}
>
<RiTerminalBoxLine className="h-4 w-4 text-white" />
</div>
)
if (withBorder) {
return (
<div
className={cn(
'shrink-0 overflow-hidden rounded border-[0.5px] border-divider-subtle',
sizeClass,
)}
>
{inner}
</div>
)
}
return inner
}
const iconSrc = PROVIDER_ICONS[providerType] || PROVIDER_ICONS.e2b
if (withBorder) {
return (

View File

@@ -569,7 +569,7 @@
"sandboxProvider.configModal.apiKey": "API Key / Secret",
"sandboxProvider.configModal.apiKeyPlaceholder": "Enter your API key",
"sandboxProvider.configModal.baseWorkingPath": "Base Working Path",
"sandboxProvider.configModal.baseWorkingPathPlaceholder": "/tmp/sandbox",
"sandboxProvider.configModal.baseWorkingPathPlaceholder": "/workspace/sandboxes",
"sandboxProvider.configModal.bringYourOwnKey": "Bring Your Own E2B API Key",
"sandboxProvider.configModal.bringYourOwnKeyDesc": "Connect using your own E2B account. No usage limits from Dify, with full control over resources and billing.",
"sandboxProvider.configModal.cancel": "Cancel",
@@ -591,6 +591,14 @@
"sandboxProvider.configModal.save": "Save",
"sandboxProvider.configModal.securityTip": "Your API Token will be encrypted and stored using",
"sandboxProvider.configModal.securityTipTechnology": "technology.",
"sandboxProvider.configModal.sshHost": "SSH Host",
"sandboxProvider.configModal.sshHostPlaceholder": "e.g. 127.0.0.1 or agentbox",
"sandboxProvider.configModal.sshPassword": "SSH Password",
"sandboxProvider.configModal.sshPasswordPlaceholder": "Enter SSH password",
"sandboxProvider.configModal.sshPort": "SSH Port",
"sandboxProvider.configModal.sshPortPlaceholder": "22",
"sandboxProvider.configModal.sshUsername": "SSH Username",
"sandboxProvider.configModal.sshUsernamePlaceholder": "agentbox",
"sandboxProvider.configModal.title": "Configure Sandbox Provider",
"sandboxProvider.connected": "CONNECTED",
"sandboxProvider.currentProvider": "CURRENT ACTIVE",
@@ -608,6 +616,8 @@
"sandboxProvider.notConfigured": "Not Configured",
"sandboxProvider.otherProvider": "OTHER PROVIDERS",
"sandboxProvider.setAsActive": "Set as Active",
"sandboxProvider.ssh.description": "Run agent workloads in a remote SSH VM with file transfer support.",
"sandboxProvider.ssh.label": "SSH VM",
"sandboxProvider.switchModal.cancel": "Cancel",
"sandboxProvider.switchModal.confirm": "Switch",
"sandboxProvider.switchModal.confirmText": "You are about to switch the active sandbox provider to <bold>{{provider}}</bold>.",

View File

@@ -569,7 +569,7 @@
"sandboxProvider.configModal.apiKey": "API Key / 密钥",
"sandboxProvider.configModal.apiKeyPlaceholder": "输入您的 API Key",
"sandboxProvider.configModal.baseWorkingPath": "基础工作路径",
"sandboxProvider.configModal.baseWorkingPathPlaceholder": "/tmp/sandbox",
"sandboxProvider.configModal.baseWorkingPathPlaceholder": "/workspace/sandboxes",
"sandboxProvider.configModal.bringYourOwnKey": "使用自己的 E2B API Key",
"sandboxProvider.configModal.bringYourOwnKeyDesc": "使用您自己的 E2B 账户连接。无 Dify 使用限制,完全控制资源和计费。",
"sandboxProvider.configModal.cancel": "取消",
@@ -591,6 +591,14 @@
"sandboxProvider.configModal.save": "保存",
"sandboxProvider.configModal.securityTip": "您的 API Token 将使用",
"sandboxProvider.configModal.securityTipTechnology": "技术加密存储。",
"sandboxProvider.configModal.sshHost": "SSH 主机",
"sandboxProvider.configModal.sshHostPlaceholder": "例如 127.0.0.1 或 agentbox",
"sandboxProvider.configModal.sshPassword": "SSH 密码",
"sandboxProvider.configModal.sshPasswordPlaceholder": "请输入 SSH 密码",
"sandboxProvider.configModal.sshPort": "SSH 端口",
"sandboxProvider.configModal.sshPortPlaceholder": "22",
"sandboxProvider.configModal.sshUsername": "SSH 用户名",
"sandboxProvider.configModal.sshUsernamePlaceholder": "agentbox",
"sandboxProvider.configModal.title": "配置 Sandbox Provider",
"sandboxProvider.connected": "已连接",
"sandboxProvider.currentProvider": "当前激活",
@@ -608,6 +616,8 @@
"sandboxProvider.notConfigured": "未配置",
"sandboxProvider.otherProvider": "其他供应商",
"sandboxProvider.setAsActive": "设为激活",
"sandboxProvider.ssh.description": "通过 SSH 连接远程虚拟机运行代理任务,并支持文件传输。",
"sandboxProvider.ssh.label": "SSH 虚拟机",
"sandboxProvider.switchModal.cancel": "取消",
"sandboxProvider.switchModal.confirm": "切换",
"sandboxProvider.switchModal.confirmText": "您即将将活动沙箱供应商切换为 <bold>{{provider}}</bold>。",