mirror of
https://github.com/langgenius/dify.git
synced 2026-02-09 15:10:13 -05:00
feat(sandbox): add SSH agentbox provider for middleware and docker deployments
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ class SandboxType(StrEnum):
|
||||
DOCKER = "docker"
|
||||
E2B = "e2b"
|
||||
LOCAL = "local"
|
||||
SSH = "ssh"
|
||||
|
||||
@classmethod
|
||||
def get_all(cls) -> list[str]:
|
||||
|
||||
437
api/core/virtual_environment/providers/ssh_sandbox.py
Normal file
437
api/core/virtual_environment/providers/ssh_sandbox.py
Normal 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}")
|
||||
@@ -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')
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
51
api/uv.lock
generated
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>.",
|
||||
|
||||
@@ -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>。",
|
||||
|
||||
Reference in New Issue
Block a user