feat: sandbox provider configuration

This commit is contained in:
Harry 2026-01-08 11:03:47 +08:00
parent 5b01f544d1
commit 15c3d712d3
31 changed files with 1501 additions and 20 deletions

View File

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

View File

@ -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/<string:provider_type>")
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/<string:provider_type>/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/<string:provider_type>/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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

83
api/models/sandbox.py Normal file
View File

@ -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 "{}"))

View File

@ -0,0 +1,3 @@
from .sandbox_provider_service import SandboxProviderService
__all__ = ["SandboxProviderService"]

View File

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

View File

@ -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 {},
)

View File

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

View File

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

View File

@ -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: <RiBrain2Line className={iconClassName} />,
activeIcon: <RiBrain2Fill className={iconClassName} />,
},
{
key: ACCOUNT_SETTING_TAB.SANDBOX_PROVIDER,
name: t('settings.sandboxProvider', { ns: 'common' }),
icon: <RiBox3Line className={iconClassName} />,
activeIcon: <RiBox3Fill className={iconClassName} />,
},
{
key: ACCOUNT_SETTING_TAB.MEMBERS,
name: t('settings.members', { ns: 'common' }),
@ -239,6 +248,7 @@ export default function AccountSetting({
</div>
<div className="px-4 pt-2 sm:px-8">
{activeMenu === 'provider' && <ModelProviderPage searchText={searchValue} />}
{activeMenu === 'sandbox-provider' && <SandboxProviderPage />}
{activeMenu === 'members' && <MembersPage />}
{activeMenu === 'billing' && <BillingPage />}
{activeMenu === 'data-source' && <DataSourcePage />}

View File

@ -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<FormRefObject>(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 (
<Modal
isShow
onClose={onClose}
title={t('sandboxProvider.configModal.title', { ns: 'common', provider: provider.label })}
closable
className="w-[480px]"
>
<div className="mt-4">
<BaseForm
formSchemas={formSchemas}
ref={formRef}
labelClassName="system-sm-semibold mb-1 flex items-center gap-1 text-text-secondary"
formClassName="space-y-4"
/>
{/* Footer Actions */}
<div className="mt-6 flex items-center justify-between">
<div>
{docLink && (
<a
href={docLink}
target="_blank"
rel="noopener noreferrer"
className="system-sm-medium inline-flex items-center gap-1 text-text-accent hover:underline"
>
{t('sandboxProvider.configModal.readDoc', { ns: 'common' })}
<RiExternalLinkLine className="h-3.5 w-3.5" />
</a>
)}
</div>
<div className="flex items-center gap-2">
{isConfigured && (
<Button
variant="warning"
size="medium"
onClick={handleRevoke}
disabled={isDeleting || isSaving}
>
{t('sandboxProvider.configModal.revoke', { ns: 'common' })}
</Button>
)}
<Button
variant="primary"
size="medium"
onClick={handleSave}
disabled={isSaving || isDeleting}
>
{t('sandboxProvider.configModal.confirm', { ns: 'common' })}
</Button>
</div>
</div>
</div>
</Modal>
)
}
export default memo(ConfigModal)

View File

@ -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<string, string> = {
e2b: 'https://e2b.dev/docs',
docker: 'https://docs.docker.com/',
local: '',
}

View File

@ -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<SandboxProvider | null>(null)
const [switchModalProvider, setSwitchModalProvider] = useState<SandboxProvider | null>(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 (
<div className="flex h-40 items-center justify-center">
<div className="system-sm-regular text-text-tertiary">Loading...</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Current Provider Section */}
{currentProvider && (
<div>
<div className="system-sm-semibold mb-2 text-text-secondary">
{t('sandboxProvider.currentProvider', { ns: 'common' })}
</div>
<ProviderCard
provider={currentProvider}
isCurrent
onConfig={() => handleConfig(currentProvider)}
disabled={!isCurrentWorkspaceOwner}
/>
</div>
)}
{/* Other Providers Section */}
{otherProviders.length > 0 && (
<div>
<div className="system-sm-semibold mb-2 text-text-secondary">
{t('sandboxProvider.otherProvider', { ns: 'common' })}
</div>
<div className="space-y-2">
{otherProviders.map(provider => (
<ProviderCard
key={provider.provider_type}
provider={provider}
onConfig={() => handleConfig(provider)}
onEnable={() => handleEnable(provider)}
disabled={!isCurrentWorkspaceOwner}
/>
))}
</div>
</div>
)}
{!isCurrentWorkspaceOwner && (
<div className="system-xs-regular text-text-tertiary">
{t('sandboxProvider.noPermission', { ns: 'common' })}
</div>
)}
{/* Config Modal */}
{configModalProvider && (
<ConfigModal
provider={configModalProvider}
onClose={() => setConfigModalProvider(null)}
/>
)}
{/* Switch Modal */}
{switchModalProvider && (
<SwitchModal
provider={switchModalProvider}
onClose={() => setSwitchModalProvider(null)}
/>
)}
</div>
)
}
export default memo(SandboxProviderPage)

View File

@ -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<string, string> = {
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 (
<img
src={iconSrc}
alt={`${providerType} icon`}
className="h-5 w-5"
/>
)
}
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 (
<div className={cn(
'flex items-center justify-between rounded-xl p-4',
isCurrent ? 'bg-background-section' : 'bg-background-section-burn',
)}
>
<div className="flex items-center">
{/* Icon */}
<div className="mr-3 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-divider-subtle bg-background-default-subtle">
<ProviderIcon providerType={provider.provider_type} />
</div>
{/* Content */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="system-md-semibold text-text-primary">
{provider.label}
</span>
{provider.is_system_configured && (
<span className="system-2xs-medium-uppercase rounded border border-divider-regular px-1.5 py-0.5 text-text-tertiary">
{t('sandboxProvider.managedBySaas', { ns: 'common' })}
</span>
)}
</div>
<div className="system-xs-regular text-text-tertiary">
{provider.description}
</div>
</div>
</div>
{/* Right side: Connected Badge + Actions */}
<div className="flex shrink-0 items-center gap-2">
{isConfigured && (
<span className="system-xs-medium flex items-center gap-1 rounded-md bg-util-colors-green-green-50 px-1.5 py-0.5 text-util-colors-green-green-600">
<Indicator color="green" />
{t('sandboxProvider.connected', { ns: 'common' })}
</span>
)}
<Button
variant="secondary"
size="small"
onClick={onConfig}
disabled={disabled}
>
{t('sandboxProvider.config', { ns: 'common' })}
</Button>
{showEnableButton && (
<Button
variant="secondary"
size="small"
onClick={onEnable}
disabled={disabled}
>
{t('sandboxProvider.enable', { ns: 'common' })}
</Button>
)}
</div>
</div>
)
}
export default memo(ProviderCard)

View File

@ -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 (
<Modal
isShow
onClose={onClose}
title={t('sandboxProvider.switchModal.title', { ns: 'common' })}
closable
className="w-[480px]"
>
<div className="mt-4">
{/* Warning Section */}
<div className="flex gap-3 rounded-xl bg-state-warning-hover p-3">
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-state-warning-solid">
<RiAlertLine className="h-3 w-3 text-text-warning-secondary" />
</div>
<div>
<div className="system-sm-semibold text-text-primary">
{t('sandboxProvider.switchModal.warning', { ns: 'common' })}
</div>
<div className="system-xs-regular mt-1 text-text-secondary">
{t('sandboxProvider.switchModal.warningDesc', { ns: 'common' })}
</div>
</div>
</div>
{/* Confirm Text */}
<div className="system-sm-regular mt-4 text-text-secondary">
{t('sandboxProvider.switchModal.confirmText', { ns: 'common', provider: provider.label })}
</div>
{/* Footer Actions */}
<div className="mt-6 flex items-center justify-end gap-2">
<Button
variant="secondary"
size="medium"
onClick={onClose}
disabled={isPending}
>
{t('sandboxProvider.switchModal.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
size="medium"
onClick={handleConfirm}
disabled={isPending}
>
{t('sandboxProvider.switchModal.confirm', { ns: 'common' })}
</Button>
</div>
</div>
</Modal>
)
}
export default memo(SwitchModal)

View File

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

View File

@ -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": "添加标签",

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" clip-rule="evenodd" fill="#3A4D54" d="M73.8 50.8h11.3v11.5h5.7c2.6 0 5.3-.5 7.8-1.3 1.2-.4 2.6-1 3.8-1.7-1.6-2.1-2.4-4.7-2.6-7.3-.3-3.5.4-8.1 2.8-10.8l1.2-1.4 1.4 1.1c3.6 2.9 6.5 6.8 7.1 11.4 4.3-1.3 9.3-1 13.1 1.2l1.5.9-.8 1.6c-3.2 6.2-9.9 8.2-16.4 7.8-9.8 24.3-31 35.8-56.8 35.8-13.3 0-25.5-5-32.5-16.8l-.1-.2-1-2.1c-2.4-5.2-3.1-10.9-2.6-16.6l.2-1.7h9.6V50.8h11.3V39.6h22.5V28.3h13.5v22.5z"/><path fill="#00AADA" d="M110.4 55.1c.8-5.9-3.6-10.5-6.4-12.7-3.1 3.6-3.6 13.2 1.3 17.2-2.8 2.4-8.5 4.7-14.5 4.7H18.6c-.6 6.2.5 11.9 3 16.8l.8 1.5c.5.9 1.1 1.7 1.7 2.6 3 .2 5.7.3 8.2.2 4.9-.1 8.9-.7 12-1.7.5-.2.9.1 1.1.5.2.5-.1.9-.5 1.1-.4.1-.8.3-1.3.4-2.4.7-5 1.1-8.3 1.3h-.6c-1.3.1-2.7.1-4.2.1-1.6 0-3.1 0-4.9-.1 6 6.8 15.4 10.8 27.2 10.8 25 0 46.2-11.1 55.5-35.9 6.7.7 13.1-1 16-6.7-4.5-2.7-10.5-1.8-13.9-.1z"/><path fill="#28B8EB" d="M110.4 55.1c.8-5.9-3.6-10.5-6.4-12.7-3.1 3.6-3.6 13.2 1.3 17.2-2.8 2.4-8.5 4.7-14.5 4.7h-68c-.3 9.5 3.2 16.7 9.5 21 4.9-.1 8.9-.7 12-1.7.5-.2.9.1 1.1.5.2.5-.1.9-.5 1.1-.4.1-.8.3-1.3.4-2.4.7-5.2 1.2-8.5 1.4l-.1-.1c8.5 4.4 20.8 4.3 35-1.1 15.8-6.1 30.6-17.7 40.9-30.9-.2.1-.4.1-.5.2z"/><path fill="#028BB8" d="M18.7 71.8c.4 3.3 1.4 6.4 2.9 9.3l.8 1.5c.5.9 1.1 1.7 1.7 2.6 3 .2 5.7.3 8.2.2 4.9-.1 8.9-.7 12-1.7.5-.2.9.1 1.1.5.2.5-.1.9-.5 1.1-.4.1-.8.3-1.3.4-2.4.7-5.2 1.2-8.5 1.4h-.4c-1.3.1-2.7.1-4.1.1-1.6 0-3.2 0-4.9-.1 6 6.8 15.5 10.8 27.3 10.8 21.4 0 40-8.1 50.8-26H18.7v-.1z"/><path fill="#019BC6" d="M23.5 71.8c1.3 5.8 4.3 10.4 8.8 13.5 4.9-.1 8.9-.7 12-1.7.5-.2.9.1 1.1.5.2.5-.1.9-.5 1.1-.4.1-.8.3-1.3.4-2.4.7-5.2 1.2-8.6 1.4 8.5 4.4 20.8 4.3 34.9-1.1 8.5-3.3 16.8-8.2 24.2-14.1H23.5z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#00ACD3" d="M28.4 52.7h9.8v9.8h-9.8v-9.8zm.8.8h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm3-12h9.8v9.8h-9.8v-9.8zm.9.8h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#23C2EE" d="M39.6 52.7h9.8v9.8h-9.8v-9.8zm.9.8h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#00ACD3" d="M50.9 52.7h9.8v9.8h-9.8v-9.8zm.8.8h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#23C2EE" d="M50.9 41.5h9.8v9.8h-9.8v-9.8zm.8.8h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm3.1 10.4H72v9.8h-9.8v-9.8zm.8.8h.8v8.1H63v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#00ACD3" d="M62.2 41.5H72v9.8h-9.8v-9.8zm.8.8h.8v8.1H63v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#23C2EE" d="M62.2 30.2H72V40h-9.8v-9.8zm.8.8h.8v8.1H63V31zm1.5 0h.8v8.1h-.8V31zm1.4 0h.8v8.1h-.8V31zm1.5 0h.8v8.1h-.8V31zm1.5 0h.8v8.1h-.8V31zm1.5 0h.8v8.1h-.8V31z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#00ACD3" d="M73.5 52.7h9.8v9.8h-9.8v-9.8zm.8.8h.8v8.1h-.8v-8.1zm1.4 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1zm1.5 0h.8v8.1h-.8v-8.1z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#D4EEF1" d="M48.8 78.3c1.5 0 2.7 1.2 2.7 2.7 0 1.5-1.2 2.7-2.7 2.7-1.5 0-2.7-1.2-2.7-2.7 0-1.5 1.2-2.7 2.7-2.7"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#3A4D54" d="M48.8 79.1c.2 0 .5 0 .7.1-.2.1-.4.4-.4.7 0 .4.4.8.8.8.3 0 .6-.2.7-.4.1.2.1.5.1.7 0 1.1-.9 1.9-1.9 1.9-1.1 0-1.9-.9-1.9-1.9 0-1 .8-1.9 1.9-1.9M1.1 72.8h125.4c-2.7-.7-8.6-1.6-7.7-5.2-5 5.7-16.9 4-20 1.2-3.4 4.9-23 3-24.3-.8-4.2 5-17.3 5-21.5 0-1.4 3.8-21 5.7-24.3.8-3 2.8-15 4.5-20-1.2 1.1 3.5-4.9 4.5-7.6 5.2"/><path fill="#BFDBE0" d="M56 97.8c-6.7-3.2-10.3-7.5-12.4-12.2-2.5.7-5.5 1.2-8.9 1.4-1.3.1-2.7.1-4.1.1-1.7 0-3.4 0-5.2-.1 6 6 13.6 10.7 27.5 10.8H56z"/><path fill="#D4EEF1" d="M46.1 89.9c-.9-1.3-1.8-2.8-2.5-4.3-2.5.7-5.5 1.2-8.9 1.4 2.3 1.2 5.7 2.4 11.4 2.9z"/></svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,3 @@
<svg width="128" height="128" viewBox="0 0 26 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.8458 19.3029C21.6671 19.3029 21.5555 19.4963 21.6448 19.6511L23.5141 22.889C23.6175 23.0681 23.4528 23.2828 23.253 23.2293L17.5836 21.7101C17.3359 21.6437 17.0813 21.7907 17.0149 22.0384L15.4958 27.7079C15.4422 27.9077 15.1739 27.943 15.0705 27.7639L13.2008 24.5254C13.1115 24.3707 12.8881 24.3707 12.7987 24.5254L10.929 27.7639C10.8256 27.943 10.5573 27.9077 10.5038 27.7079L8.9846 22.0384C8.91824 21.7907 8.66365 21.6437 8.41597 21.7101L2.74652 23.2293C2.54675 23.2828 2.38199 23.0681 2.4854 22.889L4.35472 19.6511C4.44406 19.4963 4.33238 19.3029 4.15368 19.3029L0.415222 19.3028C0.208406 19.3028 0.104834 19.0528 0.251077 18.9066L4.40145 14.7563C4.58277 14.5749 4.58277 14.281 4.40145 14.0997L0.251079 9.94927C0.104837 9.80302 0.208414 9.55297 0.415232 9.55297L4.15328 9.55302C4.33198 9.55302 4.44368 9.35957 4.35433 9.20481L2.4854 5.96763C2.38199 5.78852 2.54676 5.5738 2.74652 5.62733L8.41597 7.14652C8.66365 7.21288 8.91824 7.0659 8.98461 6.81822L10.5038 1.14869C10.5573 0.948918 10.8256 0.913592 10.929 1.0927L12.7987 4.33116C12.8881 4.48593 13.1114 4.48593 13.2008 4.33116L15.0705 1.0927C15.1739 0.913592 15.4422 0.948917 15.4957 1.14869L17.0149 6.81822C17.0813 7.0659 17.3359 7.21288 17.5835 7.14652L23.253 5.62733C23.4528 5.5738 23.6175 5.78852 23.5141 5.96763L21.6452 9.20481C21.5558 9.35957 21.6675 9.55302 21.8462 9.55302L25.5844 9.55297C25.7912 9.55297 25.8948 9.80302 25.7486 9.94927L21.5982 14.0997C21.4169 14.281 21.4169 14.5749 21.5982 14.7563L25.7486 18.9066C25.8948 19.0528 25.7912 19.3028 25.5844 19.3028L21.8458 19.3029ZM20.419 10.404C20.5869 10.236 20.4241 9.9541 20.1947 10.0156L15.1461 11.3684C14.8984 11.4348 14.6438 11.2878 14.5775 11.0401L13.224 5.98888C13.1625 5.75947 12.837 5.75947 12.7755 5.98888L11.422 11.0401C11.3557 11.2878 11.1011 11.4348 10.8534 11.3684L5.80496 10.0156C5.57555 9.95414 5.41278 10.2361 5.58072 10.404L9.27643 14.0997C9.45774 14.281 9.45774 14.575 9.27643 14.7563L5.57985 18.4528C5.41191 18.6208 5.57467 18.9027 5.80409 18.8412L10.8534 17.4882C11.1011 17.4218 11.3557 17.5688 11.422 17.8165L12.7755 22.8677C12.837 23.0972 13.1625 23.0972 13.224 22.8677L14.5775 17.8165C14.6439 17.5688 14.8984 17.4218 15.1461 17.4882L20.1956 18.8413C20.425 18.9027 20.5878 18.6208 20.4198 18.4529L16.7232 14.7563C16.5419 14.575 16.5419 14.281 16.7232 14.0997L20.419 10.404Z" fill="#FF8800"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect x="8" y="16" width="112" height="96" rx="8" fill="#374151"/>
<rect x="16" y="24" width="96" height="80" rx="4" fill="#1F2937"/>
<text x="28" y="56" fill="#10B981" font-family="monospace" font-size="20" font-weight="bold">$</text>
<rect x="44" y="42" width="48" height="4" rx="2" fill="#10B981"/>
<rect x="28" y="68" width="64" height="4" rx="2" fill="#6B7280"/>
<rect x="28" y="84" width="40" height="4" rx="2" fill="#6B7280"/>
</svg>

After

Width:  |  Height:  |  Size: 516 B

View File

@ -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<string, string>
config_schema: ConfigSchema[]
}
export const useGetSandboxProviderList = () => {
return useQuery({
queryKey: [NAME_SPACE, 'list'],
queryFn: () => get<SandboxProvider[]>('/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<SandboxProvider>(`/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<string, string> }) => {
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,
})
}