From 3c0b50ee7722c5d4a8f323eedda1da370072dcf5 Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 9 Feb 2026 16:37:01 +0800 Subject: [PATCH] feat(sandbox): add SSH agentbox provider for middleware and docker deployments --- api/.env.example | 9 + api/commands.py | 4 +- api/core/sandbox/builder.py | 4 + api/core/sandbox/entities/sandbox_type.py | 1 + .../providers/ssh_sandbox.py | 437 ++++++++++++++++++ ...4f34_add_default_sandbox_system_config.py} | 23 +- api/models/sandbox.py | 4 +- api/pyproject.toml | 1 + .../core/virtual_environment/test_factory.py | 120 ----- api/uv.lock | 51 +- docker/.env.example | 20 + docker/docker-compose-template.yaml | 43 +- docker/docker-compose.middleware.yaml | 39 ++ docker/docker-compose.yaml | 50 +- docker/middleware.env.example | 12 +- .../sandbox-provider-page/constants.ts | 24 + .../sandbox-provider-page/provider-icon.tsx | 29 ++ web/i18n/en-US/common.json | 12 +- web/i18n/zh-Hans/common.json | 12 +- 19 files changed, 750 insertions(+), 145 deletions(-) create mode 100644 api/core/virtual_environment/providers/ssh_sandbox.py rename api/migrations/versions/{2026_01_21_0030-201d71cc4f34_add_default_docker_sandbox_system_config.py => 2026_01_21_0030-201d71cc4f34_add_default_sandbox_system_config.py} (68%) delete mode 100644 api/tests/unit_tests/core/virtual_environment/test_factory.py diff --git a/api/.env.example b/api/.env.example index 6804ffb822..5b9ef778df 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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 diff --git a/api/commands.py b/api/commands.py index ad5550b369..c35ddac62b 100644 --- a/api/commands.py +++ b/api/commands.py @@ -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 diff --git a/api/core/sandbox/builder.py b/api/core/sandbox/builder.py index d035c4861b..666a4ac58f 100644 --- a/api/core/sandbox/builder.py +++ b/api/core/sandbox/builder.py @@ -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}") diff --git a/api/core/sandbox/entities/sandbox_type.py b/api/core/sandbox/entities/sandbox_type.py index 3ac7e0a94e..5e36485de8 100644 --- a/api/core/sandbox/entities/sandbox_type.py +++ b/api/core/sandbox/entities/sandbox_type.py @@ -7,6 +7,7 @@ class SandboxType(StrEnum): DOCKER = "docker" E2B = "e2b" LOCAL = "local" + SSH = "ssh" @classmethod def get_all(cls) -> list[str]: diff --git a/api/core/virtual_environment/providers/ssh_sandbox.py b/api/core/virtual_environment/providers/ssh_sandbox.py new file mode 100644 index 0000000000..fa3b90754b --- /dev/null +++ b/api/core/virtual_environment/providers/ssh_sandbox.py @@ -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}") diff --git a/api/migrations/versions/2026_01_21_0030-201d71cc4f34_add_default_docker_sandbox_system_config.py b/api/migrations/versions/2026_01_21_0030-201d71cc4f34_add_default_sandbox_system_config.py similarity index 68% rename from api/migrations/versions/2026_01_21_0030-201d71cc4f34_add_default_docker_sandbox_system_config.py rename to api/migrations/versions/2026_01_21_0030-201d71cc4f34_add_default_sandbox_system_config.py index e5aae446dc..f6a6eb5da8 100644 --- a/api/migrations/versions/2026_01_21_0030-201d71cc4f34_add_default_docker_sandbox_system_config.py +++ b/api/migrations/versions/2026_01_21_0030-201d71cc4f34_add_default_sandbox_system_config.py @@ -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') ) diff --git a/api/models/sandbox.py b/api/models/sandbox.py index 2e64cfbfd9..e384ab8853 100644 --- a/api/models/sandbox.py +++ b/api/models/sandbox.py @@ -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) diff --git a/api/pyproject.toml b/api/pyproject.toml index f6f47403df..c4feef9c2d 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -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", diff --git a/api/tests/unit_tests/core/virtual_environment/test_factory.py b/api/tests/unit_tests/core/virtual_environment/test_factory.py deleted file mode 100644 index edf8e3e499..0000000000 --- a/api/tests/unit_tests/core/virtual_environment/test_factory.py +++ /dev/null @@ -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() diff --git a/api/uv.lock b/api/uv.lock index 0a746e7df1..eaac82db1b 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -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" diff --git a/docker/.env.example b/docker/.env.example index 10cd4c1414..030c01d1da 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -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--). 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) diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 58328895b0..37ad85a0aa 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -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 diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml index 4a739bbbe0..7d300d5c60 100644 --- a/docker/docker-compose.middleware.yaml +++ b/docker/docker-compose.middleware.yaml @@ -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 diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 8bacec2a57..33af3f2071 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -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 diff --git a/docker/middleware.env.example b/docker/middleware.env.example index c88dbe5511..5b6827c05e 100644 --- a/docker/middleware.env.example +++ b/docker/middleware.env.example @@ -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 \ No newline at end of file +LOGSTORE_ENABLE_PUT_GRAPH_FIELD=true diff --git a/web/app/components/header/account-setting/sandbox-provider-page/constants.ts b/web/app/components/header/account-setting/sandbox-provider-page/constants.ts index a5d60aad88..99c70b657c 100644 --- a/web/app/components/header/account-setting/sandbox-provider-page/constants.ts +++ b/web/app/components/header/account-setting/sandbox-provider-page/constants.ts @@ -5,6 +5,7 @@ export const PROVIDER_ICONS: Record = { 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 = { @@ -59,4 +82,5 @@ export const PROVIDER_DOC_LINKS: Record = { daytona: 'https://www.daytona.io/docs', docker: 'https://docs.docker.com/', local: '', + ssh: 'https://www.openssh.com/manual.html', } diff --git a/web/app/components/header/account-setting/sandbox-provider-page/provider-icon.tsx b/web/app/components/header/account-setting/sandbox-provider-page/provider-icon.tsx index 4bd6252f76..82767146b0 100644 --- a/web/app/components/header/account-setting/sandbox-provider-page/provider-icon.tsx +++ b/web/app/components/header/account-setting/sandbox-provider-page/provider-icon.tsx @@ -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 } + if (providerType === 'ssh') { + const inner = ( +
+ +
+ ) + if (withBorder) { + return ( +
+ {inner} +
+ ) + } + return inner + } + const iconSrc = PROVIDER_ICONS[providerType] || PROVIDER_ICONS.e2b if (withBorder) { return ( diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index 49a25d42d6..ceb11c43c5 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -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 {{provider}}.", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index b16a904f74..61a89ef99e 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -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": "您即将将活动沙箱供应商切换为 {{provider}}。",