From 15c3d712d391efa3f96345e95008e73349f96d1c Mon Sep 17 00:00:00 2001 From: Harry Date: Thu, 8 Jan 2026 11:03:47 +0800 Subject: [PATCH] feat: sandbox provider configuration --- api/controllers/console/__init__.py | 2 + .../console/workspace/sandbox_providers.py | 139 +++++++ .../app/apps/advanced_chat/app_generator.py | 4 +- api/core/app/apps/workflow/app_generator.py | 5 +- api/core/app/layers/sandbox_layer.py | 52 ++- api/core/virtual_environment/__base/exec.py | 6 + .../__base/virtual_environment.py | 10 + api/core/virtual_environment/factory.py | 5 + .../providers/docker_daemon_sandbox.py | 11 +- .../providers/e2b_sandbox.py | 21 +- .../providers/local_without_isolation.py | 4 + ..._08_1031-aab323465866_sandbox_providers.py | 56 +++ api/models/__init__.py | 3 + api/models/sandbox.py | 83 ++++ api/services/sandbox/__init__.py | 3 + api/services/sandbox/encryption.py | 48 +++ .../sandbox/sandbox_provider_service.py | 368 ++++++++++++++++++ api/services/workflow_service.py | 4 +- .../header/account-setting/constants.ts | 1 + .../header/account-setting/index.tsx | 10 + .../sandbox-provider-page/config-modal.tsx | 147 +++++++ .../sandbox-provider-page/constants.ts | 40 ++ .../sandbox-provider-page/index.tsx | 101 +++++ .../sandbox-provider-page/provider-card.tsx | 109 ++++++ .../sandbox-provider-page/switch-modal.tsx | 95 +++++ web/i18n/en-US/common.json | 44 +++ web/i18n/zh-Hans/common.json | 42 ++ web/public/sandbox-providers/docker.svg | 1 + web/public/sandbox-providers/e2b.svg | 3 + web/public/sandbox-providers/local.svg | 8 + web/service/use-sandbox-provider.ts | 96 +++++ 31 files changed, 1501 insertions(+), 20 deletions(-) create mode 100644 api/controllers/console/workspace/sandbox_providers.py create mode 100644 api/migrations/versions/2026_01_08_1031-aab323465866_sandbox_providers.py create mode 100644 api/models/sandbox.py create mode 100644 api/services/sandbox/__init__.py create mode 100644 api/services/sandbox/encryption.py create mode 100644 api/services/sandbox/sandbox_provider_service.py create mode 100644 web/app/components/header/account-setting/sandbox-provider-page/config-modal.tsx create mode 100644 web/app/components/header/account-setting/sandbox-provider-page/constants.ts create mode 100644 web/app/components/header/account-setting/sandbox-provider-page/index.tsx create mode 100644 web/app/components/header/account-setting/sandbox-provider-page/provider-card.tsx create mode 100644 web/app/components/header/account-setting/sandbox-provider-page/switch-modal.tsx create mode 100644 web/public/sandbox-providers/docker.svg create mode 100644 web/public/sandbox-providers/e2b.svg create mode 100644 web/public/sandbox-providers/local.svg create mode 100644 web/service/use-sandbox-provider.ts diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index ad878fc266..fc11b6f8f3 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -126,6 +126,7 @@ from .workspace import ( model_providers, models, plugin, + sandbox_providers, tool_providers, trigger_providers, workspace, @@ -191,6 +192,7 @@ __all__ = [ "rag_pipeline_import", "rag_pipeline_workflow", "recommended_app", + "sandbox_providers", "saved_message", "setup", "site", diff --git a/api/controllers/console/workspace/sandbox_providers.py b/api/controllers/console/workspace/sandbox_providers.py new file mode 100644 index 0000000000..561f2f1cfb --- /dev/null +++ b/api/controllers/console/workspace/sandbox_providers.py @@ -0,0 +1,139 @@ +import logging + +from flask_restx import Resource, fields, reqparse + +from controllers.console import console_ns +from controllers.console.wraps import account_initialization_required, setup_required +from core.model_runtime.utils.encoders import jsonable_encoder +from libs.login import current_account_with_tenant, login_required +from services.sandbox.sandbox_provider_service import SandboxProviderService + +logger = logging.getLogger(__name__) + + +@console_ns.route("/workspaces/current/sandbox-providers") +class SandboxProviderListApi(Resource): + """List all sandbox providers for the current tenant.""" + + @console_ns.doc("list_sandbox_providers") + @console_ns.doc(description="Get list of available sandbox providers with configuration status") + @console_ns.response( + 200, + "Success", + fields.List(fields.Raw(description="Sandbox provider information")), + ) + @setup_required + @login_required + @account_initialization_required + def get(self): + """List all sandbox providers.""" + _, current_tenant_id = current_account_with_tenant() + providers = SandboxProviderService.list_providers(current_tenant_id) + return jsonable_encoder([p.model_dump() for p in providers]) + + +@console_ns.route("/workspaces/current/sandbox-provider/") +class SandboxProviderApi(Resource): + """Get specific sandbox provider details.""" + + @console_ns.doc("get_sandbox_provider") + @console_ns.doc(description="Get specific sandbox provider details") + @console_ns.doc(params={"provider_type": "Sandbox provider type (e2b, docker, local)"}) + @console_ns.response(200, "Success", fields.Raw(description="Sandbox provider details")) + @setup_required + @login_required + @account_initialization_required + def get(self, provider_type: str): + """Get a specific sandbox provider.""" + _, current_tenant_id = current_account_with_tenant() + provider = SandboxProviderService.get_provider(current_tenant_id, provider_type) + if not provider: + return {"message": f"Provider {provider_type} not found"}, 404 + return jsonable_encoder(provider.model_dump()) + + +config_parser = reqparse.RequestParser() +config_parser.add_argument("config", type=dict, required=True, location="json") + + +@console_ns.route("/workspaces/current/sandbox-provider//config") +class SandboxProviderConfigApi(Resource): + @console_ns.doc("save_sandbox_provider_config") + @console_ns.doc(description="Save or update configuration for a sandbox provider") + @console_ns.expect(config_parser) + @console_ns.response(200, "Success") + @setup_required + @login_required + @account_initialization_required + def post(self, provider_type: str): + _, current_tenant_id = current_account_with_tenant() + args = config_parser.parse_args() + + try: + result = SandboxProviderService.save_config( + tenant_id=current_tenant_id, + provider_type=provider_type, + config=args["config"], + ) + return result + except ValueError as e: + return {"message": str(e)}, 400 + + @console_ns.doc("delete_sandbox_provider_config") + @console_ns.doc(description="Delete configuration for a sandbox provider") + @console_ns.response(200, "Success") + @setup_required + @login_required + @account_initialization_required + def delete(self, provider_type: str): + _, current_tenant_id = current_account_with_tenant() + + try: + result = SandboxProviderService.delete_config( + tenant_id=current_tenant_id, + provider_type=provider_type, + ) + return result + except ValueError as e: + return {"message": str(e)}, 400 + + +@console_ns.route("/workspaces/current/sandbox-provider//activate") +class SandboxProviderActivateApi(Resource): + """Activate a sandbox provider.""" + + @console_ns.doc("activate_sandbox_provider") + @console_ns.doc(description="Activate a sandbox provider for the current workspace") + @console_ns.response(200, "Success") + @setup_required + @login_required + @account_initialization_required + def post(self, provider_type: str): + """Activate a sandbox provider.""" + _, current_tenant_id = current_account_with_tenant() + + try: + result = SandboxProviderService.activate_provider( + tenant_id=current_tenant_id, + provider_type=provider_type, + ) + return result + except ValueError as e: + return {"message": str(e)}, 400 + + +@console_ns.route("/workspaces/current/sandbox-provider/active") +class SandboxProviderActiveApi(Resource): + """Get the currently active sandbox provider.""" + + @console_ns.doc("get_active_sandbox_provider") + @console_ns.doc(description="Get the currently active sandbox provider for the workspace") + @console_ns.response(200, "Success") + @setup_required + @login_required + @account_initialization_required + def get(self): + """Get the active sandbox provider.""" + _, current_tenant_id = current_account_with_tenant() + active_provider = SandboxProviderService.get_active_provider(current_tenant_id) + return {"provider_type": active_provider} diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py index 12a18861e0..ac910162b2 100644 --- a/api/core/app/apps/advanced_chat/app_generator.py +++ b/api/core/app/apps/advanced_chat/app_generator.py @@ -515,9 +515,9 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator): # FIXME: Consolidate runtime config checking into a unified location. runtime = workflow.features_dict.get("runtime") - graph_engine_layers = () + graph_engine_layers: tuple = () if isinstance(runtime, dict) and runtime.get("enabled"): - graph_engine_layers = (SandboxLayer(),) + graph_engine_layers = (SandboxLayer(tenant_id=application_generate_entity.app_config.tenant_id),) # Determine system_user_id based on invocation source is_external_api_call = application_generate_entity.invoke_from in { diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index a8aa26fe55..fb0a75ff62 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/api/core/app/apps/workflow/app_generator.py @@ -491,7 +491,10 @@ class WorkflowAppGenerator(BaseAppGenerator): # FIXME: Consolidate runtime config checking into a unified location. runtime = workflow.features_dict.get("runtime") if isinstance(runtime, dict) and runtime.get("enabled"): - graph_engine_layers = (*graph_engine_layers, SandboxLayer()) + graph_engine_layers = ( + *graph_engine_layers, + SandboxLayer(tenant_id=application_generate_entity.app_config.tenant_id), + ) # Determine system_user_id based on invocation source is_external_api_call = application_generate_entity.invoke_from in { diff --git a/api/core/app/layers/sandbox_layer.py b/api/core/app/layers/sandbox_layer.py index 387ead414c..270faf467c 100644 --- a/api/core/app/layers/sandbox_layer.py +++ b/api/core/app/layers/sandbox_layer.py @@ -32,6 +32,10 @@ class SandboxLayer(GraphEngineLayer): - on_graph_end: Release the sandbox environment (cleanup) Example: + # Using tenant-specific configuration (recommended): + layer = SandboxLayer(tenant_id="tenant-uuid") + + # Using explicit configuration (for testing/override): layer = SandboxLayer( sandbox_type=SandboxType.DOCKER, options={"docker_image": "python:3.11-slim"}, @@ -44,8 +48,8 @@ class SandboxLayer(GraphEngineLayer): def __init__( self, - # TODO: read from db table - sandbox_type: SandboxType = SandboxType.DOCKER, + tenant_id: str | None = None, + sandbox_type: SandboxType | None = None, options: Mapping[str, Any] | None = None, environments: Mapping[str, str] | None = None, ) -> None: @@ -53,11 +57,17 @@ class SandboxLayer(GraphEngineLayer): Initialize the SandboxLayer. Args: - sandbox_type: Type of sandbox to create (default: DOCKER) - options: Sandbox-specific configuration options - environments: Environment variables to set in the sandbox + tenant_id: Tenant ID to load sandbox configuration from database. + If provided, sandbox_type and options are ignored and + loaded from the tenant's active sandbox provider. + sandbox_type: Type of sandbox to create (default: DOCKER). + Only used if tenant_id is not provided. + options: Sandbox-specific configuration options. + Only used if tenant_id is not provided. + environments: Environment variables to set in the sandbox. """ super().__init__() + self._tenant_id = tenant_id self._sandbox_type = sandbox_type self._options: Mapping[str, Any] = options or {} self._environments: Mapping[str, str] = environments or {} @@ -82,17 +92,33 @@ class SandboxLayer(GraphEngineLayer): """ Initialize the sandbox when workflow execution starts. + If tenant_id was provided, uses SandboxProviderService to create + the sandbox with the tenant's active provider configuration. + Otherwise, falls back to explicit sandbox_type/options. + Raises: SandboxInitializationError: If sandbox cannot be created """ - logger.info("Initializing sandbox, sandbox_type=%s", self._sandbox_type) - try: - self._sandbox = SandboxFactory.create( - sandbox_type=self._sandbox_type, - options=self._options, - environments=self._environments, - ) + if self._tenant_id: + # Use SandboxProviderService to create sandbox based on tenant config + from services.sandbox.sandbox_provider_service import SandboxProviderService + + logger.info("Initializing sandbox for tenant_id=%s", self._tenant_id) + self._sandbox = SandboxProviderService.create_sandbox( + tenant_id=self._tenant_id, + environments=self._environments, + ) + else: + # Fallback to explicit configuration (backward compatibility) + sandbox_type = self._sandbox_type or SandboxType.DOCKER + logger.info("Initializing sandbox, sandbox_type=%s", sandbox_type) + self._sandbox = SandboxFactory.create( + sandbox_type=sandbox_type, + options=self._options, + environments=self._environments, + ) + logger.info( "Sandbox initialized, sandbox_id=%s, sandbox_arch=%s", self._sandbox.metadata.id, @@ -100,7 +126,7 @@ class SandboxLayer(GraphEngineLayer): ) except Exception as e: logger.exception("Failed to initialize sandbox") - raise SandboxInitializationError(f"Failed to initialize {self._sandbox_type} sandbox: {e}") from e + raise SandboxInitializationError(f"Failed to initialize sandbox: {e}") from e def on_event(self, event: GraphEngineEvent) -> None: """ diff --git a/api/core/virtual_environment/__base/exec.py b/api/core/virtual_environment/__base/exec.py index e4239ee354..2ec420d84b 100644 --- a/api/core/virtual_environment/__base/exec.py +++ b/api/core/virtual_environment/__base/exec.py @@ -14,3 +14,9 @@ class NotSupportedOperationError(Exception): """Exception raised when an operation is not supported.""" pass + + +class SandboxConfigValidationError(ValueError): + """Exception raised when sandbox configuration validation fails.""" + + pass diff --git a/api/core/virtual_environment/__base/virtual_environment.py b/api/core/virtual_environment/__base/virtual_environment.py index 9f6c163cc7..ff28548999 100644 --- a/api/core/virtual_environment/__base/virtual_environment.py +++ b/api/core/virtual_environment/__base/virtual_environment.py @@ -135,6 +135,16 @@ class VirtualEnvironment(ABC): After exuection, the 3 handles will be closed by caller. """ + @classmethod + @abstractmethod + def validate(cls, options: Mapping[str, Any]) -> None: + """ + Validate that options can connect to the provider. + + Raises: + SandboxConfigValidationError: If validation fails + """ + @abstractmethod def get_command_status(self, connection_handle: ConnectionHandle, pid: str) -> CommandStatus: """ diff --git a/api/core/virtual_environment/factory.py b/api/core/virtual_environment/factory.py index 3c04e0b3e0..0f7d6bb231 100644 --- a/api/core/virtual_environment/factory.py +++ b/api/core/virtual_environment/factory.py @@ -76,3 +76,8 @@ class SandboxFactory: return LocalVirtualEnvironment case _: raise ValueError(f"Unsupported sandbox type: {sandbox_type}") + + @classmethod + def validate(cls, sandbox_type: SandboxType, options: Mapping[str, Any]) -> None: + sandbox_class = cls._get_sandbox_class(sandbox_type) + sandbox_class.validate(options) diff --git a/api/core/virtual_environment/providers/docker_daemon_sandbox.py b/api/core/virtual_environment/providers/docker_daemon_sandbox.py index ecbe645e15..86b6c5a9f5 100644 --- a/api/core/virtual_environment/providers/docker_daemon_sandbox.py +++ b/api/core/virtual_environment/providers/docker_daemon_sandbox.py @@ -13,7 +13,7 @@ from docker.models.containers import Container import docker from core.virtual_environment.__base.entities import Arch, CommandStatus, ConnectionHandle, FileState, Metadata -from core.virtual_environment.__base.exec import VirtualEnvironmentLaunchFailedError +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.socket_transport import SocketWriteCloser @@ -204,6 +204,15 @@ class DockerDaemonEnvironment(VirtualEnvironment): DOCKER_IMAGE = "docker_image" DOCKER_COMMAND = "docker_command" + @classmethod + def validate(cls, options: Mapping[str, Any]) -> None: + docker_sock = options.get(cls.OptionsKey.DOCKER_SOCK, cls._DEFAULT_DOCKER_SOCK) + try: + client = docker.DockerClient(base_url=docker_sock) + client.ping() + except docker.errors.DockerException as e: + raise SandboxConfigValidationError(f"Docker connection failed: {e}") from e + def _construct_environment(self, options: Mapping[str, Any], environments: Mapping[str, str]) -> Metadata: """ Construct the Docker daemon virtual environment. diff --git a/api/core/virtual_environment/providers/e2b_sandbox.py b/api/core/virtual_environment/providers/e2b_sandbox.py index 8cf9529ff8..e8c841eebd 100644 --- a/api/core/virtual_environment/providers/e2b_sandbox.py +++ b/api/core/virtual_environment/providers/e2b_sandbox.py @@ -11,7 +11,11 @@ from uuid import uuid4 from e2b_code_interpreter import Sandbox # type: ignore[import-untyped] from core.virtual_environment.__base.entities import Arch, CommandStatus, ConnectionHandle, FileState, Metadata -from core.virtual_environment.__base.exec import ArchNotSupportedError, NotSupportedOperationError +from core.virtual_environment.__base.exec import ( + ArchNotSupportedError, + NotSupportedOperationError, + SandboxConfigValidationError, +) from core.virtual_environment.__base.virtual_environment import VirtualEnvironment from core.virtual_environment.channel.queue_transport import QueueTransportReadCloser from core.virtual_environment.channel.transport import ( @@ -85,6 +89,21 @@ class E2BEnvironment(VirtualEnvironment): class StoreKey(StrEnum): SANDBOX = "sandbox" + @classmethod + def validate(cls, options: Mapping[str, Any]) -> None: + from e2b.exceptions import AuthenticationException # type: ignore[import-untyped] + + api_key = options.get(cls.OptionsKey.API_KEY, "") + if not api_key: + raise SandboxConfigValidationError("E2B API key is required") + + try: + Sandbox.list(api_key=api_key, limit=1).next_items() + except AuthenticationException as e: + raise SandboxConfigValidationError(f"E2B authentication failed: {e}") from e + except Exception as e: + raise SandboxConfigValidationError(f"E2B connection failed: {e}") from e + def _construct_environment(self, options: Mapping[str, Any], environments: Mapping[str, str]) -> Metadata: """ Construct a new E2B virtual environment. diff --git a/api/core/virtual_environment/providers/local_without_isolation.py b/api/core/virtual_environment/providers/local_without_isolation.py index ff7d26e986..da6fded890 100644 --- a/api/core/virtual_environment/providers/local_without_isolation.py +++ b/api/core/virtual_environment/providers/local_without_isolation.py @@ -65,6 +65,10 @@ class LocalVirtualEnvironment(VirtualEnvironment): NEVER USE IT IN PRODUCTION ENVIRONMENTS. """ + @classmethod + def validate(cls, options: Mapping[str, Any]) -> None: + pass + def _construct_environment(self, options: Mapping[str, Any], environments: Mapping[str, str]) -> Metadata: """ Construct the local virtual environment. diff --git a/api/migrations/versions/2026_01_08_1031-aab323465866_sandbox_providers.py b/api/migrations/versions/2026_01_08_1031-aab323465866_sandbox_providers.py new file mode 100644 index 0000000000..8206b3e074 --- /dev/null +++ b/api/migrations/versions/2026_01_08_1031-aab323465866_sandbox_providers.py @@ -0,0 +1,56 @@ +"""sandbox_providers + +Revision ID: aab323465866 +Revises: 03ea244985ce +Create Date: 2026-01-08 10:31:05.062722 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'aab323465866' +down_revision = '03ea244985ce' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('sandbox_provider_system_config', + sa.Column('id', models.types.StringUUID(), nullable=False), + sa.Column('provider_type', sa.String(length=50), nullable=False, comment='e2b, docker, local'), + sa.Column('encrypted_config', models.types.LongText(), nullable=False, comment='Encrypted config JSON'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='sandbox_provider_system_config_pkey'), + sa.UniqueConstraint('provider_type', name='unique_sandbox_provider_system_config_type') + ) + op.create_table('sandbox_providers', + sa.Column('id', models.types.StringUUID(), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('provider_type', sa.String(length=50), nullable=False, comment='e2b, docker, local'), + sa.Column('encrypted_config', models.types.LongText(), nullable=False, comment='Encrypted config JSON'), + sa.Column('is_active', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='sandbox_provider_pkey'), + sa.UniqueConstraint('tenant_id', 'provider_type', name='unique_sandbox_provider_tenant_type') + ) + with op.batch_alter_table('sandbox_providers', schema=None) as batch_op: + batch_op.create_index('idx_sandbox_providers_tenant_active', ['tenant_id', 'is_active'], unique=False) + batch_op.create_index('idx_sandbox_providers_tenant_id', ['tenant_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('sandbox_providers', schema=None) as batch_op: + batch_op.drop_index('idx_sandbox_providers_tenant_id') + batch_op.drop_index('idx_sandbox_providers_tenant_active') + + op.drop_table('sandbox_providers') + op.drop_table('sandbox_provider_system_config') + # ### end Alembic commands ### diff --git a/api/models/__init__.py b/api/models/__init__.py index 906bc3198e..87dbdd6c70 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -75,6 +75,7 @@ from .provider import ( TenantDefaultModel, TenantPreferredModelProvider, ) +from .sandbox import SandboxProvider, SandboxProviderSystemConfig from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding from .task import CeleryTask, CeleryTaskSet from .tools import ( @@ -170,6 +171,8 @@ __all__ = [ "ProviderQuotaType", "ProviderType", "RecommendedApp", + "SandboxProvider", + "SandboxProviderSystemConfig", "SavedMessage", "Site", "Tag", diff --git a/api/models/sandbox.py b/api/models/sandbox.py new file mode 100644 index 0000000000..00dc369d9e --- /dev/null +++ b/api/models/sandbox.py @@ -0,0 +1,83 @@ +import json +from collections.abc import Mapping +from datetime import datetime +from typing import Any, cast +from uuid import uuid4 + +import sqlalchemy as sa +from sqlalchemy import DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from .base import TypeBase +from .types import LongText, StringUUID + + +class SandboxProviderSystemConfig(TypeBase): + """ + System-level sandbox provider configuration. + Stores default configuration for each provider type. + """ + + __tablename__ = "sandbox_provider_system_config" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="sandbox_provider_system_config_pkey"), + sa.UniqueConstraint("provider_type", name="unique_sandbox_provider_system_config_type"), + ) + + 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") + 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 + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.current_timestamp(), + server_onupdate=func.current_timestamp(), + init=False, + ) + + @property + def config(self) -> Mapping[str, Any]: + return cast(Mapping[str, Any], json.loads(self.encrypted_config or "{}")) + + +class SandboxProvider(TypeBase): + """ + Tenant-level sandbox provider configuration. + Each tenant can have one configuration per provider type. + Only one provider can be active at a time per tenant. + """ + + __tablename__ = "sandbox_providers" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="sandbox_provider_pkey"), + sa.UniqueConstraint("tenant_id", "provider_type", name="unique_sandbox_provider_tenant_type"), + sa.Index("idx_sandbox_providers_tenant_id", "tenant_id"), + sa.Index("idx_sandbox_providers_tenant_active", "tenant_id", "is_active"), + ) + + id: Mapped[str] = mapped_column( + 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") + encrypted_config: Mapped[str] = mapped_column(LongText, nullable=False, comment="Encrypted config JSON") + is_active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"), default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.current_timestamp(), + server_onupdate=func.current_timestamp(), + init=False, + ) + + @property + def config(self) -> Mapping[str, Any]: + return cast(Mapping[str, Any], json.loads(self.encrypted_config or "{}")) diff --git a/api/services/sandbox/__init__.py b/api/services/sandbox/__init__.py new file mode 100644 index 0000000000..d450b3aab8 --- /dev/null +++ b/api/services/sandbox/__init__.py @@ -0,0 +1,3 @@ +from .sandbox_provider_service import SandboxProviderService + +__all__ = ["SandboxProviderService"] diff --git a/api/services/sandbox/encryption.py b/api/services/sandbox/encryption.py new file mode 100644 index 0000000000..a6007a8ccf --- /dev/null +++ b/api/services/sandbox/encryption.py @@ -0,0 +1,48 @@ +from collections.abc import Mapping +from typing import Any + +from core.entities.provider_entities import BasicProviderConfig +from core.helper.provider_cache import ProviderCredentialsCache +from core.helper.provider_encryption import ProviderConfigCache, ProviderConfigEncrypter, create_provider_encrypter + + +class SandboxProviderConfigCache(ProviderCredentialsCache): + def __init__(self, tenant_id: str, provider_type: str): + super().__init__(tenant_id=tenant_id, provider_type=provider_type) + + def _generate_cache_key(self, **kwargs) -> str: + tenant_id = kwargs["tenant_id"] + provider_type = kwargs["provider_type"] + return f"sandbox_config:tenant_id:{tenant_id}:provider_type:{provider_type}" + + +def create_sandbox_config_encrypter( + tenant_id: str, + config_schema: list[BasicProviderConfig], + provider_type: str, +) -> tuple[ProviderConfigEncrypter, ProviderConfigCache]: + cache = SandboxProviderConfigCache(tenant_id=tenant_id, provider_type=provider_type) + return create_provider_encrypter(tenant_id=tenant_id, config=config_schema, cache=cache) + + +def masked_config( + schemas: list[BasicProviderConfig], + config: Mapping[str, Any], +) -> Mapping[str, Any]: + masked = dict(config) + configs = {x.name: x for x in schemas} + for key, value in config.items(): + schema = configs.get(key) + if not schema: + masked[key] = value + continue + if schema.type == BasicProviderConfig.Type.SECRET_INPUT: + if not isinstance(value, str): + continue + if len(value) <= 4: + masked[key] = "*" * len(value) + else: + masked[key] = value[:2] + "*" * (len(value) - 4) + value[-2:] + else: + masked[key] = value + return masked diff --git a/api/services/sandbox/sandbox_provider_service.py b/api/services/sandbox/sandbox_provider_service.py new file mode 100644 index 0000000000..64e24cdf6d --- /dev/null +++ b/api/services/sandbox/sandbox_provider_service.py @@ -0,0 +1,368 @@ +""" +Sandbox Provider Service for managing sandbox configurations. + +Supports three provider types: +- e2b: Cloud-based sandbox (requires API key) +- docker: Local Docker-based sandbox (self-hosted) +- local: Local execution without isolation (self-hosted only) +""" + +import json +import logging +from collections.abc import Mapping +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, Field, model_validator +from sqlalchemy.orm import Session + +from configs import dify_config +from constants import HIDDEN_VALUE +from core.entities.provider_entities import BasicProviderConfig +from core.tools.utils.system_oauth_encryption import ( + decrypt_system_oauth_params, +) +from core.virtual_environment.__base.virtual_environment import VirtualEnvironment +from core.virtual_environment.factory import SandboxFactory, SandboxType +from extensions.ext_database import db +from models.sandbox import SandboxProvider, SandboxProviderSystemConfig +from services.sandbox.encryption import create_sandbox_config_encrypter, masked_config + +logger = logging.getLogger(__name__) + + +class SandboxProviderType(StrEnum): + E2B = "e2b" + DOCKER = "docker" + LOCAL = "local" + + +class E2BConfig(BaseModel): + api_key: str = "" + e2b_api_url: str = "https://api.e2b.app" + e2b_default_template: str = "code-interpreter-v1" + + @model_validator(mode="before") + @classmethod + def check_required(cls, values: dict[str, Any]) -> dict[str, Any]: + if not values.get("api_key"): + raise ValueError("api_key is required") + return values + + +class DockerConfig(BaseModel): + docker_sock: str = "unix:///var/run/docker.sock" + docker_image: str = "ubuntu:latest" + + +class LocalConfig(BaseModel): + pass + + +PROVIDER_CONFIG_MODELS: dict[str, type[BaseModel]] = { + SandboxProviderType.E2B: E2BConfig, + SandboxProviderType.DOCKER: DockerConfig, + SandboxProviderType.LOCAL: LocalConfig, +} + +PROVIDER_CONFIG_SCHEMAS: dict[str, list[BasicProviderConfig]] = { + SandboxProviderType.E2B: [ + BasicProviderConfig(type=BasicProviderConfig.Type.SECRET_INPUT, name="api_key"), + BasicProviderConfig(type=BasicProviderConfig.Type.TEXT_INPUT, name="e2b_api_url"), + BasicProviderConfig(type=BasicProviderConfig.Type.TEXT_INPUT, name="e2b_default_template"), + ], + SandboxProviderType.DOCKER: [ + BasicProviderConfig(type=BasicProviderConfig.Type.TEXT_INPUT, name="docker_sock"), + BasicProviderConfig(type=BasicProviderConfig.Type.TEXT_INPUT, name="docker_image"), + ], + SandboxProviderType.LOCAL: [], +} + + +class SandboxProviderInfo(BaseModel): + provider_type: str = Field(..., description="Provider type identifier") + label: str = Field(..., description="Display name") + description: str = Field(..., description="Provider description") + icon: str = Field(..., description="Icon identifier") + is_system_configured: bool = Field(default=False, description="Whether system default is configured") + is_tenant_configured: bool = Field(default=False, description="Whether tenant has custom config") + is_active: bool = Field(default=False, description="Whether this provider is active for the tenant") + config: Mapping[str, Any] = Field(default_factory=dict, description="Masked config") + config_schema: list[dict[str, Any]] = Field(default_factory=list, description="Config form schema") + + +PROVIDER_METADATA: dict[str, dict[str, str]] = { + SandboxProviderType.E2B: { + "label": "E2B", + "description": "Cloud-based sandbox powered by E2B. Secure, scalable, and managed.", + "icon": "e2b", + }, + SandboxProviderType.DOCKER: { + "label": "Docker", + "description": "Local Docker-based sandbox. Requires Docker daemon running on the host.", + "icon": "docker", + }, + SandboxProviderType.LOCAL: { + "label": "Local", + "description": "Local execution without isolation. Only for development/testing.", + "icon": "local", + }, +} + + +class SandboxProviderService: + @classmethod + def get_available_provider_types(cls) -> list[str]: + providers = [SandboxProviderType.E2B, SandboxProviderType.DOCKER] + if dify_config.EDITION == "SELF_HOSTED": + providers.append(SandboxProviderType.LOCAL) + return [provider.value for provider in providers] + + @classmethod + def list_providers(cls, tenant_id: str) -> list[SandboxProviderInfo]: + available_types = cls.get_available_provider_types() + result: list[SandboxProviderInfo] = [] + + with Session(db.engine, expire_on_commit=False) as session: + tenant_configs = session.query(SandboxProvider).filter(SandboxProvider.tenant_id == tenant_id).all() + tenant_config_map = {cfg.provider_type: cfg for cfg in tenant_configs} + + system_defaults = session.query(SandboxProviderSystemConfig).all() + system_default_map = {cfg.provider_type: cfg for cfg in system_defaults} + + for provider_type in available_types: + metadata = PROVIDER_METADATA.get(provider_type, {}) + config_schema = PROVIDER_CONFIG_SCHEMAS.get(provider_type, []) + + tenant_config = tenant_config_map.get(provider_type) + system_default = system_default_map.get(provider_type) + + config: Mapping[str, Any] = {} + if tenant_config and tenant_config.config: + schema = PROVIDER_CONFIG_SCHEMAS.get(provider_type, []) + encrypter, _ = create_sandbox_config_encrypter(tenant_id, schema, provider_type) + decrypted = encrypter.decrypt(tenant_config.config) + config = masked_config(schema, decrypted) + + result.append( + SandboxProviderInfo( + provider_type=provider_type, + label=metadata.get("label", provider_type), + description=metadata.get("description", ""), + icon=metadata.get("icon", provider_type), + is_system_configured=system_default is not None, + is_tenant_configured=tenant_config is not None, + is_active=tenant_config.is_active if tenant_config else False, + config=config, + config_schema=[{"name": c.name, "type": c.type.value} for c in config_schema], + ) + ) + + return result + + @classmethod + def get_provider(cls, tenant_id: str, provider_type: str) -> SandboxProviderInfo | None: + if provider_type not in cls.get_available_provider_types(): + return None + + providers = cls.list_providers(tenant_id) + for provider in providers: + if provider.provider_type == provider_type: + return provider + return None + + @classmethod + def validate_config(cls, provider_type: str, config: Mapping[str, Any]) -> None: + model_class = PROVIDER_CONFIG_MODELS.get(provider_type) + if model_class: + model_class.model_validate(config) + + SandboxFactory.validate(SandboxType(provider_type), config) + + @classmethod + def save_config( + cls, + tenant_id: str, + provider_type: str, + config: Mapping[str, Any], + ) -> dict[str, Any]: + if provider_type not in cls.get_available_provider_types(): + raise ValueError(f"Invalid provider type: {provider_type}") + + with Session(db.engine) as session: + existing = ( + session.query(SandboxProvider) + .filter( + SandboxProvider.tenant_id == tenant_id, + SandboxProvider.provider_type == provider_type, + ) + .first() + ) + + schema = PROVIDER_CONFIG_SCHEMAS.get(provider_type, []) + encrypter, _ = create_sandbox_config_encrypter(tenant_id, schema, provider_type) + + final_config = dict(config) + if existing and existing.config: + existing_config = encrypter.decrypt(existing.config) + for key, value in final_config.items(): + if value == HIDDEN_VALUE: + final_config[key] = existing_config.get(key, "") + + cls.validate_config(provider_type, final_config) + + encrypted = encrypter.encrypt(final_config) + + if existing: + existing.encrypted_config = json.dumps(encrypted) + else: + new_config = SandboxProvider( + tenant_id=tenant_id, + provider_type=provider_type, + encrypted_config=json.dumps(encrypted), + is_active=False, + ) + session.add(new_config) + + session.commit() + + return {"result": "success"} + + @classmethod + def delete_config(cls, tenant_id: str, provider_type: str) -> dict[str, Any]: + with Session(db.engine) as session: + config = ( + session.query(SandboxProvider) + .filter( + SandboxProvider.tenant_id == tenant_id, + SandboxProvider.provider_type == provider_type, + ) + .first() + ) + + if not config: + return {"result": "success"} + + if config.is_active: + raise ValueError("Cannot delete config for the active provider. Switch to another provider first.") + + session.delete(config) + session.commit() + + return {"result": "success"} + + @classmethod + def activate_provider(cls, tenant_id: str, provider_type: str) -> dict[str, Any]: + if provider_type not in cls.get_available_provider_types(): + raise ValueError(f"Invalid provider type: {provider_type}") + + with Session(db.engine) as session: + tenant_config = ( + session.query(SandboxProvider) + .filter( + SandboxProvider.tenant_id == tenant_id, + SandboxProvider.provider_type == provider_type, + ) + .first() + ) + + system_default = ( + session.query(SandboxProviderSystemConfig) + .filter(SandboxProviderSystemConfig.provider_type == provider_type) + .first() + ) + + config_schema = PROVIDER_CONFIG_SCHEMAS.get(provider_type, []) + needs_config = len(config_schema) > 0 + + if needs_config and not tenant_config and not system_default: + raise ValueError(f"Provider {provider_type} is not configured. Please add configuration first.") + + session.query(SandboxProvider).filter( + SandboxProvider.tenant_id == tenant_id, + ).update({"is_active": False}) + + if tenant_config: + tenant_config.is_active = True + else: + new_config = SandboxProvider( + tenant_id=tenant_id, + provider_type=provider_type, + encrypted_config=json.dumps({}), + is_active=True, + ) + session.add(new_config) + + session.commit() + + return {"result": "success"} + + @classmethod + def get_active_provider(cls, tenant_id: str) -> str | None: + with Session(db.engine, expire_on_commit=False) as session: + config = ( + session.query(SandboxProvider) + .filter( + SandboxProvider.tenant_id == tenant_id, + SandboxProvider.is_active.is_(True), + ) + .first() + ) + return config.provider_type if config else None + + @classmethod + def create_sandbox( + cls, + tenant_id: str, + environments: Mapping[str, str] | None = None, + ) -> VirtualEnvironment: + with Session(db.engine, expire_on_commit=False) as session: + tenant_config = ( + session.query(SandboxProvider) + .filter( + SandboxProvider.tenant_id == tenant_id, + SandboxProvider.is_active.is_(True), + ) + .first() + ) + + if tenant_config: + provider_type = tenant_config.provider_type + else: + provider_type = ( + SandboxProviderType.DOCKER if dify_config.EDITION == "SELF_HOSTED" else SandboxProviderType.E2B + ) + logger.warning( + "No active sandbox provider for tenant %s, using default: %s", + tenant_id, + provider_type, + ) + + # Get effective config: tenant config > system default > empty + config: Mapping[str, Any] = {} + provider_config = ( + session.query(SandboxProvider) + .filter( + SandboxProvider.tenant_id == tenant_id, + SandboxProvider.provider_type == provider_type, + ) + .first() + ) + if provider_config and provider_config.config: + schema = PROVIDER_CONFIG_SCHEMAS.get(provider_type, []) + encrypter, _ = create_sandbox_config_encrypter(tenant_id, schema, provider_type) + config = encrypter.decrypt(provider_config.config) + else: + system_default = ( + session.query(SandboxProviderSystemConfig) + .filter(SandboxProviderSystemConfig.provider_type == provider_type) + .first() + ) + if system_default and system_default.encrypted_config: + config = decrypt_system_oauth_params(system_default.encrypted_config) + + return SandboxFactory.create( + sandbox_type=SandboxType(provider_type), + options=dict(config) if config else {}, + environments=environments or {}, + ) diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index c6db6403fd..776866999c 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -16,7 +16,6 @@ from core.file import File from core.repositories import DifyCoreRepositoryFactory from core.variables import Variable from core.variables.variables import VariableUnion -from core.virtual_environment.factory import SandboxFactory, SandboxType from core.workflow.entities import WorkflowNodeExecution from core.workflow.enums import ErrorStrategy, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from core.workflow.errors import WorkflowNodeRunFailedError @@ -43,6 +42,7 @@ from repositories.factory import DifyAPIRepositoryFactory from services.billing_service import BillingService from services.enterprise.plugin_manager_service import PluginCredentialType from services.errors.app import IsDraftWorkflowError, TriggerNodeLimitExceededError, WorkflowHashNotEqualError +from services.sandbox.sandbox_provider_service import SandboxProviderService from services.workflow.workflow_converter import WorkflowConverter from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError @@ -701,7 +701,7 @@ class WorkflowService: runtime = draft_workflow.features_dict.get("runtime") sandbox = None if isinstance(runtime, dict) and runtime.get("enabled"): - sandbox = SandboxFactory.create(sandbox_type=SandboxType.DOCKER) + sandbox = SandboxProviderService.create_sandbox(tenant_id=draft_workflow.tenant_id) try: node, generator = WorkflowEntry.single_step_run( diff --git a/web/app/components/header/account-setting/constants.ts b/web/app/components/header/account-setting/constants.ts index 2bf2f2eff5..dbc0b97041 100644 --- a/web/app/components/header/account-setting/constants.ts +++ b/web/app/components/header/account-setting/constants.ts @@ -1,6 +1,7 @@ export const ACCOUNT_SETTING_MODAL_ACTION = 'showSettings' export const ACCOUNT_SETTING_TAB = { + SANDBOX_PROVIDER: 'sandbox-provider', PROVIDER: 'provider', MEMBERS: 'members', BILLING: 'billing', diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index 5de543c01b..31973ef29b 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -1,6 +1,8 @@ 'use client' import type { AccountSettingTab } from '@/app/components/header/account-setting/constants' import { + RiBox3Fill, + RiBox3Line, RiBrain2Fill, RiBrain2Line, RiCloseLine, @@ -36,6 +38,7 @@ import DataSourcePage from './data-source-page-new' import LanguagePage from './language-page' import MembersPage from './members-page' import ModelProviderPage from './model-provider-page' +import SandboxProviderPage from './sandbox-provider-page' const iconClassName = ` w-5 h-5 mr-2 @@ -79,6 +82,12 @@ export default function AccountSetting({ icon: , activeIcon: , }, + { + key: ACCOUNT_SETTING_TAB.SANDBOX_PROVIDER, + name: t('settings.sandboxProvider', { ns: 'common' }), + icon: , + activeIcon: , + }, { key: ACCOUNT_SETTING_TAB.MEMBERS, name: t('settings.members', { ns: 'common' }), @@ -239,6 +248,7 @@ export default function AccountSetting({
{activeMenu === 'provider' && } + {activeMenu === 'sandbox-provider' && } {activeMenu === 'members' && } {activeMenu === 'billing' && } {activeMenu === 'data-source' && } diff --git a/web/app/components/header/account-setting/sandbox-provider-page/config-modal.tsx b/web/app/components/header/account-setting/sandbox-provider-page/config-modal.tsx new file mode 100644 index 0000000000..0a35261bb9 --- /dev/null +++ b/web/app/components/header/account-setting/sandbox-provider-page/config-modal.tsx @@ -0,0 +1,147 @@ +'use client' + +import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' +import type { SandboxProvider } from '@/service/use-sandbox-provider' +import { RiExternalLinkLine } from '@remixicon/react' +import { memo, useCallback, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import { BaseForm } from '@/app/components/base/form/components/base' +import { FormTypeEnum } from '@/app/components/base/form/types' +import Modal from '@/app/components/base/modal' +import { useToastContext } from '@/app/components/base/toast' +import { + useDeleteSandboxProviderConfig, + useInvalidSandboxProviderList, + useSaveSandboxProviderConfig, +} from '@/service/use-sandbox-provider' +import { PROVIDER_DOC_LINKS, SANDBOX_FIELD_CONFIGS } from './constants' + +type ConfigModalProps = { + provider: SandboxProvider + onClose: () => void +} + +const ConfigModal = ({ + provider, + onClose, +}: ConfigModalProps) => { + const { t } = useTranslation() + const { notify } = useToastContext() + const invalidateList = useInvalidSandboxProviderList() + const formRef = useRef(null) + + const { mutateAsync: saveConfig, isPending: isSaving } = useSaveSandboxProviderConfig() + const { mutateAsync: deleteConfig, isPending: isDeleting } = useDeleteSandboxProviderConfig() + + const formSchemas: FormSchema[] = useMemo(() => { + return provider.config_schema.map((schema) => { + const fieldConfig = SANDBOX_FIELD_CONFIGS[schema.name as keyof typeof SANDBOX_FIELD_CONFIGS] + const fallbackType = schema.type === 'secret' ? FormTypeEnum.secretInput : FormTypeEnum.textInput + + return { + name: schema.name, + label: fieldConfig ? t(fieldConfig.labelKey, { ns: 'common' }) : schema.name, + placeholder: fieldConfig ? t(fieldConfig.placeholderKey, { ns: 'common' }) : '', + type: fieldConfig?.type ?? fallbackType, + required: schema.name === 'api_key', + default: provider.config[schema.name] || '', + } + }) + }, [provider.config_schema, provider.config, t]) + + const handleSave = useCallback(async () => { + const formValues = formRef.current?.getFormValues({ + needTransformWhenSecretFieldIsPristine: true, + }) + + if (!formValues?.isCheckValidated) + return + + try { + await saveConfig({ + providerType: provider.provider_type, + config: formValues.values, + }) + await invalidateList() + notify({ type: 'success', message: t('api.saved', { ns: 'common' }) }) + onClose() + } + catch { + // Error toast is handled by fetch layer + } + }, [saveConfig, provider.provider_type, invalidateList, notify, t, onClose]) + + const handleRevoke = useCallback(async () => { + try { + await deleteConfig(provider.provider_type) + await invalidateList() + notify({ type: 'success', message: t('api.remove', { ns: 'common' }) }) + onClose() + } + catch { + // Error toast is handled by fetch layer + } + }, [deleteConfig, provider.provider_type, invalidateList, notify, t, onClose]) + + const isConfigured = provider.is_tenant_configured + const docLink = PROVIDER_DOC_LINKS[provider.provider_type] + + return ( + +
+ + + {/* Footer Actions */} +
+ +
+ {isConfigured && ( + + )} + +
+
+
+
+ ) +} + +export default memo(ConfigModal) 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 new file mode 100644 index 0000000000..426a966fbe --- /dev/null +++ b/web/app/components/header/account-setting/sandbox-provider-page/constants.ts @@ -0,0 +1,40 @@ +import { FormTypeEnum } from '@/app/components/base/form/types' + +export const SANDBOX_FIELD_CONFIGS = { + api_key: { + labelKey: 'sandboxProvider.configModal.apiKey', + placeholderKey: 'sandboxProvider.configModal.apiKeyPlaceholder', + type: FormTypeEnum.secretInput, + }, + e2b_api_url: { + labelKey: 'sandboxProvider.configModal.e2bApiUrl', + placeholderKey: 'sandboxProvider.configModal.e2bApiUrlPlaceholder', + type: FormTypeEnum.textInput, + }, + e2b_default_template: { + labelKey: 'sandboxProvider.configModal.e2bTemplate', + placeholderKey: 'sandboxProvider.configModal.e2bTemplatePlaceholder', + type: FormTypeEnum.textInput, + }, + docker_sock: { + labelKey: 'sandboxProvider.configModal.dockerSock', + placeholderKey: 'sandboxProvider.configModal.dockerSockPlaceholder', + type: FormTypeEnum.textInput, + }, + docker_image: { + labelKey: 'sandboxProvider.configModal.dockerImage', + placeholderKey: 'sandboxProvider.configModal.dockerImagePlaceholder', + type: FormTypeEnum.textInput, + }, + base_working_path: { + labelKey: 'sandboxProvider.configModal.baseWorkingPath', + placeholderKey: 'sandboxProvider.configModal.baseWorkingPathPlaceholder', + type: FormTypeEnum.textInput, + }, +} as const + +export const PROVIDER_DOC_LINKS: Record = { + e2b: 'https://e2b.dev/docs', + docker: 'https://docs.docker.com/', + local: '', +} diff --git a/web/app/components/header/account-setting/sandbox-provider-page/index.tsx b/web/app/components/header/account-setting/sandbox-provider-page/index.tsx new file mode 100644 index 0000000000..f677fbc793 --- /dev/null +++ b/web/app/components/header/account-setting/sandbox-provider-page/index.tsx @@ -0,0 +1,101 @@ +'use client' + +import type { SandboxProvider } from '@/service/use-sandbox-provider' +import { memo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useAppContext } from '@/context/app-context' +import { useGetSandboxProviderList } from '@/service/use-sandbox-provider' +import ConfigModal from './config-modal' +import ProviderCard from './provider-card' +import SwitchModal from './switch-modal' + +const SandboxProviderPage = () => { + const { t } = useTranslation() + const { isCurrentWorkspaceOwner } = useAppContext() + const { data: providers, isLoading } = useGetSandboxProviderList() + + const [configModalProvider, setConfigModalProvider] = useState(null) + const [switchModalProvider, setSwitchModalProvider] = useState(null) + + const currentProvider = providers?.find(p => p.is_active) + const otherProviders = providers?.filter(p => !p.is_active) || [] + + const handleConfig = (provider: SandboxProvider) => { + setConfigModalProvider(provider) + } + + const handleEnable = (provider: SandboxProvider) => { + setSwitchModalProvider(provider) + } + + if (isLoading) { + return ( +
+
Loading...
+
+ ) + } + + return ( +
+ {/* Current Provider Section */} + {currentProvider && ( +
+
+ {t('sandboxProvider.currentProvider', { ns: 'common' })} +
+ handleConfig(currentProvider)} + disabled={!isCurrentWorkspaceOwner} + /> +
+ )} + + {/* Other Providers Section */} + {otherProviders.length > 0 && ( +
+
+ {t('sandboxProvider.otherProvider', { ns: 'common' })} +
+
+ {otherProviders.map(provider => ( + handleConfig(provider)} + onEnable={() => handleEnable(provider)} + disabled={!isCurrentWorkspaceOwner} + /> + ))} +
+
+ )} + + {!isCurrentWorkspaceOwner && ( +
+ {t('sandboxProvider.noPermission', { ns: 'common' })} +
+ )} + + {/* Config Modal */} + {configModalProvider && ( + setConfigModalProvider(null)} + /> + )} + + {/* Switch Modal */} + {switchModalProvider && ( + setSwitchModalProvider(null)} + /> + )} +
+ ) +} + +export default memo(SandboxProviderPage) diff --git a/web/app/components/header/account-setting/sandbox-provider-page/provider-card.tsx b/web/app/components/header/account-setting/sandbox-provider-page/provider-card.tsx new file mode 100644 index 0000000000..c84dc8ede3 --- /dev/null +++ b/web/app/components/header/account-setting/sandbox-provider-page/provider-card.tsx @@ -0,0 +1,109 @@ +'use client' + +import type { SandboxProvider } from '@/service/use-sandbox-provider' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import Indicator from '@/app/components/header/indicator' +import { cn } from '@/utils/classnames' + +type ProviderCardProps = { + provider: SandboxProvider + isCurrent?: boolean + onConfig: () => void + onEnable?: () => void + disabled?: boolean +} + +const PROVIDER_ICONS: Record = { + e2b: '/sandbox-providers/e2b.svg', + docker: '/sandbox-providers/docker.svg', + local: '/sandbox-providers/local.svg', +} + +const ProviderIcon = ({ providerType }: { providerType: string }) => { + const iconSrc = PROVIDER_ICONS[providerType] || PROVIDER_ICONS.e2b + + return ( + {`${providerType} + ) +} + +const ProviderCard = ({ + provider, + isCurrent = false, + onConfig, + onEnable, + disabled = false, +}: ProviderCardProps) => { + const { t } = useTranslation() + + const isConfigured = provider.is_tenant_configured || provider.is_system_configured + const showEnableButton = !isCurrent && isConfigured && onEnable + + return ( +
+
+ {/* Icon */} +
+ +
+ + {/* Content */} +
+
+ + {provider.label} + + {provider.is_system_configured && ( + + {t('sandboxProvider.managedBySaas', { ns: 'common' })} + + )} +
+
+ {provider.description} +
+
+
+ + {/* Right side: Connected Badge + Actions */} +
+ {isConfigured && ( + + + {t('sandboxProvider.connected', { ns: 'common' })} + + )} + + {showEnableButton && ( + + )} +
+
+ ) +} + +export default memo(ProviderCard) diff --git a/web/app/components/header/account-setting/sandbox-provider-page/switch-modal.tsx b/web/app/components/header/account-setting/sandbox-provider-page/switch-modal.tsx new file mode 100644 index 0000000000..a0d38e1b37 --- /dev/null +++ b/web/app/components/header/account-setting/sandbox-provider-page/switch-modal.tsx @@ -0,0 +1,95 @@ +'use client' + +import type { SandboxProvider } from '@/service/use-sandbox-provider' +import { RiAlertLine } from '@remixicon/react' +import { memo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import Modal from '@/app/components/base/modal' +import { useToastContext } from '@/app/components/base/toast' +import { + useActivateSandboxProvider, + useInvalidSandboxProviderList, +} from '@/service/use-sandbox-provider' + +type SwitchModalProps = { + provider: SandboxProvider + onClose: () => void +} + +const SwitchModal = ({ + provider, + onClose, +}: SwitchModalProps) => { + const { t } = useTranslation() + const { notify } = useToastContext() + const invalidateList = useInvalidSandboxProviderList() + + const { mutateAsync: activateProvider, isPending } = useActivateSandboxProvider() + + const handleConfirm = useCallback(async () => { + try { + await activateProvider(provider.provider_type) + await invalidateList() + notify({ type: 'success', message: t('api.success', { ns: 'common' }) }) + onClose() + } + catch { + // Error toast is handled by fetch layer + } + }, [activateProvider, provider.provider_type, invalidateList, notify, t, onClose]) + + return ( + +
+ {/* Warning Section */} +
+
+ +
+
+
+ {t('sandboxProvider.switchModal.warning', { ns: 'common' })} +
+
+ {t('sandboxProvider.switchModal.warningDesc', { ns: 'common' })} +
+
+
+ + {/* Confirm Text */} +
+ {t('sandboxProvider.switchModal.confirmText', { ns: 'common', provider: provider.label })} +
+ + {/* Footer Actions */} +
+ + +
+
+
+ ) +} + +export default memo(SwitchModal) diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index f971ff1668..52d6f78a2f 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -549,6 +549,49 @@ "provider.saveFailed": "Save api key failed", "provider.validatedError": "Validation failed: ", "provider.validating": "Validating key...", + "sandboxProvider.config": "Config", + "sandboxProvider.configModal.apiEndpoint": "API Endpoint", + "sandboxProvider.configModal.apiEndpointPlaceholder": "https://api.example.com/v1", + "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.cancel": "Cancel", + "sandboxProvider.configModal.confirm": "Confirm", + "sandboxProvider.configModal.dockerImage": "Docker Image", + "sandboxProvider.configModal.dockerImagePlaceholder": "ubuntu:latest", + "sandboxProvider.configModal.dockerSock": "Docker Socket", + "sandboxProvider.configModal.dockerSockPlaceholder": "unix:///var/run/docker.sock", + "sandboxProvider.configModal.e2bApiUrl": "E2B API URL", + "sandboxProvider.configModal.e2bApiUrlPlaceholder": "https://api.e2b.app", + "sandboxProvider.configModal.e2bTemplate": "E2B Template", + "sandboxProvider.configModal.e2bTemplatePlaceholder": "code-interpreter-v1", + "sandboxProvider.configModal.readDoc": "Read Documentation", + "sandboxProvider.configModal.revoke": "Revoke", + "sandboxProvider.configModal.save": "Save", + "sandboxProvider.configModal.title": "Configure - {{provider}}", + "sandboxProvider.connected": "Connected", + "sandboxProvider.currentProvider": "Current Provider", + "sandboxProvider.daytona.description": "Secure sandboxed cloud environments for AI agents. learn more", + "sandboxProvider.daytona.label": "Daytona", + "sandboxProvider.docker.description": "Secure sandboxed cloud environments for AI agents. learn more", + "sandboxProvider.docker.label": "Docker", + "sandboxProvider.e2b.description": "Secure sandboxed cloud environments for AI agents. learn more", + "sandboxProvider.e2b.label": "E2B", + "sandboxProvider.enable": "Enable", + "sandboxProvider.local.description": "This mode will provide the host machine as an agent, and its use in production is not recommended.", + "sandboxProvider.local.label": "Local", + "sandboxProvider.managedBySaas": "Managed by SaaS", + "sandboxProvider.noPermission": "Contact the workspace administrator to make changes.", + "sandboxProvider.notConfigured": "Not Configured", + "sandboxProvider.otherProvider": "Other Provider", + "sandboxProvider.switchModal.cancel": "Cancel", + "sandboxProvider.switchModal.confirm": "Switch Provider", + "sandboxProvider.switchModal.confirmText": "You are about to switch the active sandbox provider to {{provider}}.", + "sandboxProvider.switchModal.title": "Switch Active Provider?", + "sandboxProvider.switchModal.warning": "Impact on running agents", + "sandboxProvider.switchModal.warningDesc": "Switching the provider will affect all newly started agent tasks. Currently running sessions might be interrupted.", + "sandboxProvider.title": "Sandbox Providers", "settings.account": "My account", "settings.accountGroup": "GENERAL", "settings.apiBasedExtension": "API Extension", @@ -560,6 +603,7 @@ "settings.members": "Members", "settings.plugin": "Plugins", "settings.provider": "Model Provider", + "settings.sandboxProvider": "Sandbox Provider", "settings.workplaceGroup": "WORKSPACE", "tag.addNew": "Add new tag", "tag.addTag": "Add tags", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index ca4ecce821..0057b50413 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -549,6 +549,47 @@ "provider.saveFailed": "API 密钥保存失败", "provider.validatedError": "校验失败:", "provider.validating": "验证密钥中...", + "sandboxProvider.config": "配置", + "sandboxProvider.configModal.apiEndpoint": "API 端点", + "sandboxProvider.configModal.apiEndpointPlaceholder": "https://api.example.com/v1", + "sandboxProvider.configModal.apiKey": "API Key / 密钥", + "sandboxProvider.configModal.apiKeyPlaceholder": "输入您的 API Key", + "sandboxProvider.configModal.cancel": "取消", + "sandboxProvider.configModal.confirm": "确认", + "sandboxProvider.configModal.dockerImage": "Docker 镜像", + "sandboxProvider.configModal.dockerImagePlaceholder": "ubuntu:latest", + "sandboxProvider.configModal.dockerSock": "Docker Socket", + "sandboxProvider.configModal.dockerSockPlaceholder": "unix:///var/run/docker.sock", + "sandboxProvider.configModal.e2bApiUrl": "E2B API 地址", + "sandboxProvider.configModal.e2bApiUrlPlaceholder": "https://api.e2b.app", + "sandboxProvider.configModal.e2bTemplate": "E2B 模板", + "sandboxProvider.configModal.e2bTemplatePlaceholder": "code-interpreter-v1", + "sandboxProvider.configModal.readDoc": "查看文档", + "sandboxProvider.configModal.revoke": "撤销", + "sandboxProvider.configModal.save": "保存", + "sandboxProvider.configModal.title": "配置 - {{provider}}", + "sandboxProvider.connected": "已连接", + "sandboxProvider.currentProvider": "当前供应商", + "sandboxProvider.daytona.description": "为 AI 代理提供安全的沙箱云环境。了解更多", + "sandboxProvider.daytona.label": "Daytona", + "sandboxProvider.docker.description": "为 AI 代理提供安全的沙箱云环境。了解更多", + "sandboxProvider.docker.label": "Docker", + "sandboxProvider.e2b.description": "为 AI 代理提供安全的沙箱云环境。了解更多", + "sandboxProvider.e2b.label": "E2B", + "sandboxProvider.enable": "启用", + "sandboxProvider.local.description": "此模式将主机作为代理提供,不建议在生产环境中使用。", + "sandboxProvider.local.label": "本地", + "sandboxProvider.managedBySaas": "由 SaaS 管理", + "sandboxProvider.noPermission": "请联系工作空间管理员进行更改。", + "sandboxProvider.notConfigured": "未配置", + "sandboxProvider.otherProvider": "其他供应商", + "sandboxProvider.switchModal.cancel": "取消", + "sandboxProvider.switchModal.confirm": "切换供应商", + "sandboxProvider.switchModal.confirmText": "您即将将活动沙箱供应商切换为 {{provider}}。", + "sandboxProvider.switchModal.title": "切换活动供应商?", + "sandboxProvider.switchModal.warning": "对运行中的代理的影响", + "sandboxProvider.switchModal.warningDesc": "切换供应商将影响所有新启动的代理任务。当前运行的会话可能会被中断。", + "sandboxProvider.title": "沙箱供应商", "settings.account": "我的账户", "settings.accountGroup": "通用", "settings.apiBasedExtension": "API 扩展", @@ -560,6 +601,7 @@ "settings.members": "成员", "settings.plugin": "插件", "settings.provider": "模型供应商", + "settings.sandboxProvider": "沙箱供应商", "settings.workplaceGroup": "工作空间", "tag.addNew": "创建新标签", "tag.addTag": "添加标签", diff --git a/web/public/sandbox-providers/docker.svg b/web/public/sandbox-providers/docker.svg new file mode 100644 index 0000000000..2b76ab5963 --- /dev/null +++ b/web/public/sandbox-providers/docker.svg @@ -0,0 +1 @@ + diff --git a/web/public/sandbox-providers/e2b.svg b/web/public/sandbox-providers/e2b.svg new file mode 100644 index 0000000000..072bd47b7d --- /dev/null +++ b/web/public/sandbox-providers/e2b.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/public/sandbox-providers/local.svg b/web/public/sandbox-providers/local.svg new file mode 100644 index 0000000000..926f727b0d --- /dev/null +++ b/web/public/sandbox-providers/local.svg @@ -0,0 +1,8 @@ + + + + $ + + + + diff --git a/web/service/use-sandbox-provider.ts b/web/service/use-sandbox-provider.ts new file mode 100644 index 0000000000..6b0ed63b38 --- /dev/null +++ b/web/service/use-sandbox-provider.ts @@ -0,0 +1,96 @@ +import { + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { del, get, post } from './base' +import { useInvalid } from './use-base' + +const NAME_SPACE = 'sandbox-provider' + +export type ConfigSchema = { + name: string + type: string +} + +export type SandboxProvider = { + provider_type: string + label: string + description: string + icon: string + is_system_configured: boolean + is_tenant_configured: boolean + is_active: boolean + config: Record + config_schema: ConfigSchema[] +} + +export const useGetSandboxProviderList = () => { + return useQuery({ + queryKey: [NAME_SPACE, 'list'], + queryFn: () => get('/workspaces/current/sandbox-providers'), + retry: 0, + }) +} + +export const useInvalidSandboxProviderList = () => { + return useInvalid([NAME_SPACE, 'list']) +} + +export const useGetSandboxProvider = (providerType: string) => { + return useQuery({ + queryKey: [NAME_SPACE, 'provider', providerType], + queryFn: () => get(`/workspaces/current/sandbox-provider/${providerType}`), + retry: 0, + enabled: !!providerType, + }) +} + +export const useSaveSandboxProviderConfig = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationKey: [NAME_SPACE, 'save-config'], + mutationFn: ({ providerType, config }: { providerType: string, config: Record }) => { + return post<{ result: string }>(`/workspaces/current/sandbox-provider/${providerType}/config`, { + body: { config }, + }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'list'] }) + }, + }) +} + +export const useDeleteSandboxProviderConfig = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationKey: [NAME_SPACE, 'delete-config'], + mutationFn: (providerType: string) => { + return del<{ result: string }>(`/workspaces/current/sandbox-provider/${providerType}/config`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'list'] }) + }, + }) +} + +export const useActivateSandboxProvider = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationKey: [NAME_SPACE, 'activate'], + mutationFn: (providerType: string) => { + return post<{ result: string }>(`/workspaces/current/sandbox-provider/${providerType}/activate`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'list'] }) + }, + }) +} + +export const useGetActiveSandboxProvider = () => { + return useQuery({ + queryKey: [NAME_SPACE, 'active'], + queryFn: () => get<{ provider_type: string | null }>('/workspaces/current/sandbox-provider/active'), + retry: 0, + }) +}