diff --git a/api/.env.example b/api/.env.example index 446a4aabb7..75726bbfe3 100644 --- a/api/.env.example +++ b/api/.env.example @@ -716,3 +716,11 @@ SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30 # Sandbox Dify CLI configuration # Directory containing dify CLI binaries (dify-cli--). Defaults to api/bin when unset. SANDBOX_DIFY_CLI_ROOT= + + +# CLI API URL for sandbox (dify-sandbox or e2b) to call back to Dify API. +# This URL must be accessible from the sandbox environment. +# For local development: use http://localhost:5001 or http://127.0.0.1:5001 +# For Docker deployment: use http://api:5001 (internal Docker network) +# For external sandbox (e.g., e2b): use a publicly accessible URL +CLI_API_URL=http://localhost:5001 diff --git a/api/bin/dify-cli-darwin-amd64 b/api/bin/dify-cli-darwin-amd64 index 78fcd92b58..74120973e6 100755 Binary files a/api/bin/dify-cli-darwin-amd64 and b/api/bin/dify-cli-darwin-amd64 differ diff --git a/api/bin/dify-cli-darwin-arm64 b/api/bin/dify-cli-darwin-arm64 index 848968fd2a..71ff10f725 100755 Binary files a/api/bin/dify-cli-darwin-arm64 and b/api/bin/dify-cli-darwin-arm64 differ diff --git a/api/bin/dify-cli-linux-amd64 b/api/bin/dify-cli-linux-amd64 index e7716e3dac..311454e20a 100755 Binary files a/api/bin/dify-cli-linux-amd64 and b/api/bin/dify-cli-linux-amd64 differ diff --git a/api/bin/dify-cli-linux-arm64 b/api/bin/dify-cli-linux-arm64 index dbcba7c089..eb4334679b 100755 Binary files a/api/bin/dify-cli-linux-arm64 and b/api/bin/dify-cli-linux-arm64 differ diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 6a04171d2d..5ca5a24461 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -244,6 +244,17 @@ class PluginConfig(BaseSettings): ) +class CliApiConfig(BaseSettings): + """ + Configuration for CLI API (for dify-cli to call back from external sandbox environments) + """ + + CLI_API_URL: str = Field( + description="CLI API URL for external sandbox (e.g., e2b) to call back.", + default="http://localhost:5001", + ) + + class MarketplaceConfig(BaseSettings): """ Configuration for marketplace @@ -1299,6 +1310,7 @@ class FeatureConfig( TriggerConfig, AsyncWorkflowConfig, PluginConfig, + CliApiConfig, MarketplaceConfig, DataSetConfig, EndpointConfig, diff --git a/api/controllers/cli_api/__init__.py b/api/controllers/cli_api/__init__.py new file mode 100644 index 0000000000..52bf9f8d85 --- /dev/null +++ b/api/controllers/cli_api/__init__.py @@ -0,0 +1,27 @@ +from flask import Blueprint +from flask_restx import Namespace + +from libs.external_api import ExternalApi + +bp = Blueprint("cli_api", __name__, url_prefix="/cli/api") + +api = ExternalApi( + bp, + version="1.0", + title="CLI API", + description="APIs for Dify CLI to call back from external sandbox environments (e.g., e2b)", +) + +# Create namespace +cli_api_ns = Namespace("cli_api", description="CLI API operations", path="/") + +from .plugin import plugin as _plugin + +api.add_namespace(cli_api_ns) + +__all__ = [ + "_plugin", + "api", + "bp", + "cli_api_ns", +] diff --git a/api/controllers/cli_api/plugin/__init__.py b/api/controllers/cli_api/plugin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/controllers/cli_api/plugin/plugin.py b/api/controllers/cli_api/plugin/plugin.py new file mode 100644 index 0000000000..920e65f237 --- /dev/null +++ b/api/controllers/cli_api/plugin/plugin.py @@ -0,0 +1,137 @@ +from flask_restx import Resource + +from controllers.cli_api import cli_api_ns +from controllers.cli_api.plugin.wraps import get_user_tenant, plugin_data +from controllers.cli_api.wraps import cli_api_only +from controllers.console.wraps import setup_required +from core.file.helpers import get_signed_file_url_for_plugin +from core.plugin.backwards_invocation.app import PluginAppBackwardsInvocation +from core.plugin.backwards_invocation.base import BaseBackwardsInvocationResponse +from core.plugin.backwards_invocation.model import PluginModelBackwardsInvocation +from core.plugin.backwards_invocation.tool import PluginToolBackwardsInvocation +from core.plugin.entities.request import ( + RequestInvokeApp, + RequestInvokeLLM, + RequestInvokeTool, + RequestRequestUploadFile, +) +from core.tools.entities.tool_entities import ToolProviderType +from libs.helper import length_prefixed_response +from models import Account, Tenant +from models.model import EndUser + + +@cli_api_ns.route("/invoke/llm") +class CliInvokeLLMApi(Resource): + @get_user_tenant + @setup_required + @cli_api_only + @plugin_data(payload_type=RequestInvokeLLM) + def post(self, user_model: Account | EndUser, tenant_model: Tenant, payload: RequestInvokeLLM): + def generator(): + response = PluginModelBackwardsInvocation.invoke_llm(user_model.id, tenant_model, payload) + return PluginModelBackwardsInvocation.convert_to_event_stream(response) + + return length_prefixed_response(0xF, generator()) + + +@cli_api_ns.route("/invoke/tool") +class CliInvokeToolApi(Resource): + @get_user_tenant + @setup_required + @cli_api_only + @plugin_data(payload_type=RequestInvokeTool) + def post(self, user_model: Account | EndUser, tenant_model: Tenant, payload: RequestInvokeTool): + def generator(): + return PluginToolBackwardsInvocation.convert_to_event_stream( + PluginToolBackwardsInvocation.invoke_tool( + tenant_id=tenant_model.id, + user_id=user_model.id, + tool_type=ToolProviderType.value_of(payload.tool_type), + provider=payload.provider, + tool_name=payload.tool, + tool_parameters=payload.tool_parameters, + credential_id=payload.credential_id, + ), + ) + + return length_prefixed_response(0xF, generator()) + + +@cli_api_ns.route("/invoke/app") +class CliInvokeAppApi(Resource): + @get_user_tenant + @setup_required + @cli_api_only + @plugin_data(payload_type=RequestInvokeApp) + def post(self, user_model: Account | EndUser, tenant_model: Tenant, payload: RequestInvokeApp): + response = PluginAppBackwardsInvocation.invoke_app( + app_id=payload.app_id, + user_id=user_model.id, + tenant_id=tenant_model.id, + conversation_id=payload.conversation_id, + query=payload.query, + stream=payload.response_mode == "streaming", + inputs=payload.inputs, + files=payload.files, + ) + + return length_prefixed_response(0xF, PluginAppBackwardsInvocation.convert_to_event_stream(response)) + + +@cli_api_ns.route("/upload/file/request") +class CliUploadFileRequestApi(Resource): + @get_user_tenant + @setup_required + @cli_api_only + @plugin_data(payload_type=RequestRequestUploadFile) + def post(self, user_model: Account | EndUser, tenant_model: Tenant, payload: RequestRequestUploadFile): + # generate signed url + url = get_signed_file_url_for_plugin( + filename=payload.filename, + mimetype=payload.mimetype, + tenant_id=tenant_model.id, + user_id=user_model.id, + ) + return BaseBackwardsInvocationResponse(data={"url": url}).model_dump() + + +@cli_api_ns.route("/fetch/tools/list") +class CliFetchToolsListApi(Resource): + @get_user_tenant + @setup_required + @cli_api_only + def post(self, user_model: Account | EndUser, tenant_model: Tenant): + from sqlalchemy.orm import Session + + from extensions.ext_database import db + from services.tools.api_tools_manage_service import ApiToolManageService + from services.tools.builtin_tools_manage_service import BuiltinToolManageService + from services.tools.mcp_tools_manage_service import MCPToolManageService + from services.tools.workflow_tools_manage_service import WorkflowToolManageService + + providers = [] + + # Get builtin tools + builtin_providers = BuiltinToolManageService.list_builtin_tools(user_model.id, tenant_model.id) + for provider in builtin_providers: + providers.append(provider.to_dict()) + + # Get API tools + api_providers = ApiToolManageService.list_api_tools(tenant_model.id) + for provider in api_providers: + providers.append(provider.to_dict()) + + # Get workflow tools + workflow_providers = WorkflowToolManageService.list_tenant_workflow_tools(user_model.id, tenant_model.id) + for provider in workflow_providers: + providers.append(provider.to_dict()) + + # Get MCP tools + with Session(db.engine) as session: + mcp_service = MCPToolManageService(session) + mcp_providers = mcp_service.list_providers(tenant_id=tenant_model.id, for_list=True) + for provider in mcp_providers: + providers.append(provider.to_dict()) + + return BaseBackwardsInvocationResponse(data={"providers": providers}).model_dump() diff --git a/api/controllers/cli_api/plugin/wraps.py b/api/controllers/cli_api/plugin/wraps.py new file mode 100644 index 0000000000..0d6146aac7 --- /dev/null +++ b/api/controllers/cli_api/plugin/wraps.py @@ -0,0 +1,145 @@ +from collections.abc import Callable +from functools import wraps +from typing import ParamSpec, TypeVar + +from flask import current_app, request +from flask_login import user_logged_in +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from core.session.cli_api import CliApiSession, CliApiSessionManager +from extensions.ext_database import db +from libs.login import current_user +from models.account import Tenant +from models.model import DefaultEndUserSessionID, EndUser + +P = ParamSpec("P") +R = TypeVar("R") + + +class TenantUserPayload(BaseModel): + tenant_id: str + user_id: str + + +def get_user(tenant_id: str, user_id: str | None) -> EndUser: + """ + Get current user + + NOTE: user_id is not trusted, it could be maliciously set to any value. + As a result, it could only be considered as an end user id. + """ + if not user_id: + user_id = DefaultEndUserSessionID.DEFAULT_SESSION_ID + is_anonymous = user_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID + try: + with Session(db.engine) as session: + user_model = None + + if is_anonymous: + user_model = ( + session.query(EndUser) + .where( + EndUser.session_id == user_id, + EndUser.tenant_id == tenant_id, + ) + .first() + ) + else: + user_model = ( + session.query(EndUser) + .where( + EndUser.id == user_id, + EndUser.tenant_id == tenant_id, + ) + .first() + ) + + if not user_model: + user_model = EndUser( + tenant_id=tenant_id, + type="service_api", + is_anonymous=is_anonymous, + session_id=user_id, + ) + session.add(user_model) + session.commit() + session.refresh(user_model) + + except Exception: + raise ValueError("user not found") + + return user_model + + +def get_user_tenant(view_func: Callable[P, R]): + @wraps(view_func) + def decorated_view(*args: P.args, **kwargs: P.kwargs): + session_id = request.headers.get("X-Cli-Api-Session-Id") + + if session_id: + session: CliApiSession | None = CliApiSessionManager().get(session_id) + if not session: + raise ValueError("session not found") + user_id = session.user_id + tenant_id = session.tenant_id + else: + payload = TenantUserPayload.model_validate(request.get_json(silent=True) or {}) + user_id = payload.user_id + tenant_id = payload.tenant_id + + if not tenant_id: + raise ValueError("tenant_id is required") + + if not user_id: + user_id = DefaultEndUserSessionID.DEFAULT_SESSION_ID + + try: + tenant_model = ( + db.session.query(Tenant) + .where( + Tenant.id == tenant_id, + ) + .first() + ) + except Exception: + raise ValueError("tenant not found") + + if not tenant_model: + raise ValueError("tenant not found") + + kwargs["tenant_model"] = tenant_model + + user = get_user(tenant_id, user_id) + kwargs["user_model"] = user + + current_app.login_manager._update_request_context_with_user(user) # type: ignore + user_logged_in.send(current_app._get_current_object(), user=current_user) # type: ignore + + return view_func(*args, **kwargs) + + return decorated_view + + +def plugin_data(view: Callable[P, R] | None = None, *, payload_type: type[BaseModel]): + def decorator(view_func: Callable[P, R]): + def decorated_view(*args: P.args, **kwargs: P.kwargs): + try: + data = request.get_json() + except Exception: + raise ValueError("invalid json") + + try: + payload = payload_type.model_validate(data) + except Exception as e: + raise ValueError(f"invalid payload: {str(e)}") + + kwargs["payload"] = payload + return view_func(*args, **kwargs) + + return decorated_view + + if view is None: + return decorator + else: + return decorator(view) diff --git a/api/controllers/cli_api/wraps.py b/api/controllers/cli_api/wraps.py new file mode 100644 index 0000000000..c75022fa2c --- /dev/null +++ b/api/controllers/cli_api/wraps.py @@ -0,0 +1,54 @@ +import hashlib +import hmac +import time +from collections.abc import Callable +from functools import wraps +from typing import ParamSpec, TypeVar + +from flask import abort, request + +from core.session.cli_api import CliApiSessionManager + +P = ParamSpec("P") +R = TypeVar("R") + +SIGNATURE_TTL_SECONDS = 300 + + +def _verify_signature(session_secret: str, timestamp: str, body: bytes, signature: str) -> bool: + expected = hmac.new( + session_secret.encode(), + f"{timestamp}.".encode() + body, + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(f"sha256={expected}", signature) + + +def cli_api_only(view: Callable[P, R]): + @wraps(view) + def decorated(*args: P.args, **kwargs: P.kwargs): + session_id = request.headers.get("X-Cli-Api-Session-Id") + timestamp = request.headers.get("X-Cli-Api-Timestamp") + signature = request.headers.get("X-Cli-Api-Signature") + + if not session_id or not timestamp or not signature: + abort(401) + + try: + ts = int(timestamp) + if abs(time.time() - ts) > SIGNATURE_TTL_SECONDS: + abort(401) + except ValueError: + abort(401) + + session = CliApiSessionManager().get(session_id) + if not session: + abort(401) + + body = request.get_data() + if not _verify_signature(session.secret, timestamp, body, signature): + abort(401) + + return view(*args, **kwargs) + + return decorated diff --git a/api/controllers/inner_api/plugin/wraps.py b/api/controllers/inner_api/plugin/wraps.py index 5f8dae5f87..4b9574fe4a 100644 --- a/api/controllers/inner_api/plugin/wraps.py +++ b/api/controllers/inner_api/plugin/wraps.py @@ -7,7 +7,6 @@ from flask_login import user_logged_in from pydantic import BaseModel from sqlalchemy.orm import Session -from core.session.inner_api import InnerApiSession, InnerApiSessionManager from extensions.ext_database import db from libs.login import current_user from models.account import Tenant @@ -75,18 +74,9 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser: def get_user_tenant(view_func: Callable[P, R]): @wraps(view_func) def decorated_view(*args: P.args, **kwargs: P.kwargs): - session_id = request.headers.get("X-Inner-Api-Session-Id") - - if session_id: - session: InnerApiSession | None = InnerApiSessionManager().get(session_id) - if not session: - raise ValueError("session not found") - user_id = session.user_id - tenant_id = session.tenant_id - else: - payload = TenantUserPayload.model_validate(request.get_json(silent=True) or {}) - user_id = payload.user_id - tenant_id = payload.tenant_id + payload = TenantUserPayload.model_validate(request.get_json(silent=True) or {}) + user_id = payload.user_id + tenant_id = payload.tenant_id if not tenant_id: raise ValueError("tenant_id is required") diff --git a/api/controllers/inner_api/wraps.py b/api/controllers/inner_api/wraps.py index 9c859462db..d4cd9c176e 100644 --- a/api/controllers/inner_api/wraps.py +++ b/api/controllers/inner_api/wraps.py @@ -5,16 +5,15 @@ from hashlib import sha1 from hmac import new as hmac_new from typing import ParamSpec, TypeVar -from core.session.inner_api import InnerApiSessionManager - -P = ParamSpec("P") -R = TypeVar("R") from flask import abort, request from configs import dify_config from extensions.ext_database import db from models.model import EndUser +P = ParamSpec("P") +R = TypeVar("R") + def billing_inner_api_only(view: Callable[P, R]): @wraps(view) @@ -87,19 +86,14 @@ def enterprise_inner_api_user_auth(view: Callable[P, R]): def plugin_inner_api_only(view: Callable[P, R]): @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): - # if session id is provided, using session id to validate - session_id = request.headers.get("X-Inner-Api-Session-Id") - if session_id and InnerApiSessionManager().exists(session_id): - return view(*args, **kwargs) - if not dify_config.PLUGIN_DAEMON_KEY: abort(404) - # if inner api key is provided, using inner api key to validate + # validate using inner api key inner_api_key = request.headers.get("X-Inner-Api-Key") if inner_api_key and inner_api_key == dify_config.INNER_API_KEY_FOR_PLUGIN: return view(*args, **kwargs) - abort(404) + abort(401) return decorated diff --git a/api/core/sandbox/dify_cli.py b/api/core/sandbox/dify_cli.py index 23123b4626..c8d502f148 100644 --- a/api/core/sandbox/dify_cli.py +++ b/api/core/sandbox/dify_cli.py @@ -6,6 +6,8 @@ from typing import TYPE_CHECKING, Any from pydantic import BaseModel, Field from core.sandbox.constants import DIFY_CLI_PATH_PATTERN +from core.session.cli_api import CliApiSession +from core.tools.entities.tool_entities import ToolProviderType from core.virtual_environment.__base.entities import Arch, OperatingSystem if TYPE_CHECKING: @@ -48,8 +50,9 @@ class DifyCliLocator: class DifyCliEnvConfig(BaseModel): files_url: str - inner_api_url: str - inner_api_session_id: str + cli_api_url: str + cli_api_session_id: str + cli_api_secret: str class DifyCliToolConfig(BaseModel): @@ -58,10 +61,22 @@ class DifyCliToolConfig(BaseModel): description: dict[str, Any] parameters: list[dict[str, Any]] + @classmethod + def transform_provider_type(cls, tool_provider_type: ToolProviderType) -> str: + provider_type = tool_provider_type + match tool_provider_type: + case ToolProviderType.BUILT_IN | ToolProviderType.PLUGIN: + provider_type = "builtin" + case ToolProviderType.MCP | ToolProviderType.WORKFLOW | ToolProviderType.API: + provider_type = provider_type + case _: + raise ValueError(f"Invalid tool provider type: {tool_provider_type}") + return provider_type + @classmethod def create_from_tool(cls, tool: Tool) -> DifyCliToolConfig: return cls( - provider_type=tool.tool_provider_type().value, + provider_type=cls.transform_provider_type(tool.tool_provider_type()), identity=tool.entity.identity.model_dump(), description=tool.entity.description.model_dump() if tool.entity.description else {}, parameters=[param.model_dump() for param in tool.entity.parameters], @@ -73,14 +88,17 @@ class DifyCliConfig(BaseModel): tools: list[DifyCliToolConfig] @classmethod - def create(cls, session_id: str, tools: list[Tool]) -> DifyCliConfig: + def create(cls, session: CliApiSession, tools: list[Tool]) -> DifyCliConfig: from configs import dify_config + cli_api_url = dify_config.CLI_API_URL + return cls( env=DifyCliEnvConfig( files_url=dify_config.FILES_URL, - inner_api_url=dify_config.CONSOLE_API_URL, - inner_api_session_id=session_id, + cli_api_url=cli_api_url, + cli_api_session_id=session.id, + cli_api_secret=session.secret, ), tools=[DifyCliToolConfig.create_from_tool(tool) for tool in tools], ) diff --git a/api/core/sandbox/session.py b/api/core/sandbox/session.py index f91b425724..b3ca581e62 100644 --- a/api/core/sandbox/session.py +++ b/api/core/sandbox/session.py @@ -9,7 +9,7 @@ from core.sandbox.bash_tool import SandboxBashTool from core.sandbox.constants import DIFY_CLI_CONFIG_PATH, DIFY_CLI_PATH from core.sandbox.dify_cli import DifyCliConfig from core.sandbox.manager import SandboxManager -from core.session.inner_api import InnerApiSessionManager +from core.session.cli_api import CliApiSessionManager from core.tools.__base.tool import Tool from core.virtual_environment.__base.virtual_environment import VirtualEnvironment @@ -39,11 +39,11 @@ class SandboxSession: if sandbox is None: raise RuntimeError(f"Sandbox not found for workflow_execution_id={self._workflow_execution_id}") - session = InnerApiSessionManager().create(tenant_id=self._tenant_id, user_id=self._user_id) + session = CliApiSessionManager().create(tenant_id=self._tenant_id, user_id=self._user_id) self._session_id = session.id try: - config = DifyCliConfig.create(self._session_id, self._tools) + config = DifyCliConfig.create(session, self._tools) config_json = json.dumps(config.model_dump(mode="json"), ensure_ascii=False) sandbox.upload_file(DIFY_CLI_CONFIG_PATH, BytesIO(config_json.encode("utf-8"))) @@ -58,7 +58,7 @@ class SandboxSession: sandbox.release_connection(connection_handle) except Exception: - InnerApiSessionManager().delete(session.id) + CliApiSessionManager().delete(session.id) self._session_id = None raise @@ -85,11 +85,9 @@ class SandboxSession: return self._bash_tool def cleanup(self) -> None: - from core.session.inner_api import InnerApiSessionManager - if self._session_id is None: return - InnerApiSessionManager().delete(self._session_id) + CliApiSessionManager().delete(self._session_id) logger.debug("Cleaned up SandboxSession session_id=%s", self._session_id) self._session_id = None diff --git a/api/core/session/__init__.py b/api/core/session/__init__.py index bb3f2c97f6..3c669a52ca 100644 --- a/api/core/session/__init__.py +++ b/api/core/session/__init__.py @@ -1,10 +1,10 @@ -from .inner_api import InnerApiSession, InnerApiSessionManager +from .cli_api import CliApiSession, CliApiSessionManager from .session import BaseSession, RedisSessionStorage, SessionManager, SessionStorage __all__ = [ "BaseSession", - "InnerApiSession", - "InnerApiSessionManager", + "CliApiSession", + "CliApiSessionManager", "RedisSessionStorage", "SessionManager", "SessionStorage", diff --git a/api/core/session/cli_api.py b/api/core/session/cli_api.py new file mode 100644 index 0000000000..a9da5d16b5 --- /dev/null +++ b/api/core/session/cli_api.py @@ -0,0 +1,21 @@ +import secrets +from typing import Any + +from pydantic import Field + +from .session import BaseSession, SessionManager + + +class CliApiSession(BaseSession): + secret: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) + secret: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) + + +class CliApiSessionManager(SessionManager[CliApiSession]): + def __init__(self, ttl: int | None = None): + super().__init__(key_prefix="cli_api_session", session_class=CliApiSession, ttl=ttl) + + def create(self, tenant_id: str, user_id: str, context: dict[str, Any] | None = None) -> CliApiSession: + session = CliApiSession(tenant_id=tenant_id, user_id=user_id, context=context or {}) + self.save(session) + return session diff --git a/api/core/session/inner_api.py b/api/core/session/inner_api.py deleted file mode 100644 index 7b033397ec..0000000000 --- a/api/core/session/inner_api.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Any - -from .session import BaseSession, SessionManager - - -class InnerApiSession(BaseSession): - """Inner API Session""" - - pass - - -class InnerApiSessionManager(SessionManager[InnerApiSession]): - def __init__(self, ttl: int | None = None): - super().__init__(key_prefix="inner_api_session", session_class=InnerApiSession, ttl=ttl) - - def create(self, tenant_id: str, user_id: str, context: dict[str, Any] | None = None) -> InnerApiSession: - session = InnerApiSession(tenant_id=tenant_id, user_id=user_id, context=context or {}) - self.save(session) - return session diff --git a/api/extensions/ext_blueprints.py b/api/extensions/ext_blueprints.py index 7d13f0c061..5f65ad3a48 100644 --- a/api/extensions/ext_blueprints.py +++ b/api/extensions/ext_blueprints.py @@ -25,6 +25,7 @@ def _apply_cors_once(bp, /, **cors_kwargs): def init_app(app: DifyApp): # register blueprint routers + from controllers.cli_api import bp as cli_api_bp from controllers.console import bp as console_app_bp from controllers.files import bp as files_bp from controllers.inner_api import bp as inner_api_bp @@ -88,6 +89,7 @@ def init_app(app: DifyApp): app.register_blueprint(files_bp) app.register_blueprint(inner_api_bp) + app.register_blueprint(cli_api_bp) app.register_blueprint(mcp_bp) # Register trigger blueprint with CORS for webhook calls diff --git a/docker/.env.example b/docker/.env.example index 09ee1060e2..8dae727e4e 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -989,6 +989,12 @@ EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 +# CLI API URL for sandbox (dify-sandbox or e2b) to call back to Dify API. +# This URL must be accessible from the sandbox environment. +# For Docker deployment: use http://api:5001 (internal Docker network) +# For external sandbox (e.g., e2b): use a publicly accessible URL +CLI_API_URL=http://api:5001 + # The sandbox service endpoint. CODE_EXECUTION_ENDPOINT=http://sandbox:8194 CODE_EXECUTION_API_KEY=dify-sandbox diff --git a/docker/nginx/conf.d/default.conf.template b/docker/nginx/conf.d/default.conf.template index 1d63c1b97d..8058deafee 100644 --- a/docker/nginx/conf.d/default.conf.template +++ b/docker/nginx/conf.d/default.conf.template @@ -50,6 +50,11 @@ server { include proxy.conf; } + location /cli { + proxy_pass http://api:5001; + include proxy.conf; + } + # placeholder for acme challenge location ${ACME_CHALLENGE_LOCATION}