Files
dify/api/services/agent_app_sandbox_service.py
Asuka Minato 68d8328b9c chore: clean Db session from service (#38227)
Co-authored-by: chariri <w@chariri.moe>
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-07-08 03:07:27 +00:00

331 lines
12 KiB
Python

"""Resolve and proxy sandbox file access for Agent App and workflow Agent sessions.
These services keep product-facing locators (conversation, workflow run, node)
on the API boundary and translate them into the agent backend's
``SandboxLocator`` using persisted non-sensitive runtime layer specs plus the
saved Agenton session snapshot. Upload responses stay console-facing here: the
agent backend still returns a canonical ToolFile mapping, while this API layer
re-resolves that mapping into a signed browser download URL.
"""
from __future__ import annotations
import urllib.parse
from collections.abc import Callable
from typing import Any
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.client import Client
from dify_agent.protocol import RuntimeLayerSpec, SandboxLocator, build_sandbox_locator_from_layer_specs
from pydantic import BaseModel, TypeAdapter
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
from core.app.file_access import DatabaseFileAccessController
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
from factories import file_factory
from models.agent import AgentRuntimeSessionOwnerType, WorkflowAgentRuntimeSession, WorkflowAgentRuntimeSessionStatus
_RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec])
class AgentSandboxInspectorError(Exception):
"""A sandbox inspection failure mapped to an HTTP status by the controller."""
code: str
message: str
status_code: int
def __init__(self, code: str, message: str, *, status_code: int = 400) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
class AgentSandboxInfo(BaseModel):
"""Basic Agent App sandbox metadata returned after a successful availability probe."""
session_id: str
workspace_cwd: str
class AgentSandboxUploadDownload(BaseModel):
"""Signed browser download URL for one sandbox upload result."""
url: str
class AgentAppSandboxService:
"""Inspect and proxy file access for an Agent App conversation sandbox."""
def __init__(
self,
*,
session_store: AgentAppRuntimeSessionStore | None = None,
client_factory: Callable[[], Client] | None = None,
) -> None:
self._session_store = session_store or AgentAppRuntimeSessionStore()
self._client_factory = client_factory or _default_client_factory
def get_info(self, *, tenant_id: str, app_id: str, conversation_id: str) -> AgentSandboxInfo:
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
session_id, workspace_cwd = _extract_shell_workspace_or_raise(
snapshot=locator.session_snapshot,
not_found_message="this conversation's agent has no sandbox workspace",
)
return AgentSandboxInfo(
session_id=session_id,
workspace_cwd=workspace_cwd,
)
def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str):
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
return self._client_factory().list_sandbox_files_sync(locator, path)
def read_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str):
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
return self._client_factory().read_sandbox_file_sync(locator, path)
def upload_file(
self, *, tenant_id: str, app_id: str, conversation_id: str, path: str
) -> AgentSandboxUploadDownload:
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
uploaded = self._client_factory().upload_sandbox_file_sync(locator, path)
return _upload_download_response(
tenant_id=tenant_id,
file_mapping=uploaded.file.model_dump(mode="python"),
)
def _resolve_locator(self, *, tenant_id: str, app_id: str, conversation_id: str) -> SandboxLocator:
stored = self._session_store.load_active_session_for_conversation(
tenant_id=tenant_id,
app_id=app_id,
conversation_id=conversation_id,
)
if stored is None:
raise AgentSandboxInspectorError(
"no_active_session",
"this conversation has no active sandbox session yet",
status_code=404,
)
return _build_locator_or_raise(
snapshot=stored.session_snapshot,
runtime_layer_specs=stored.runtime_layer_specs,
not_found_message="this conversation's agent has no sandbox workspace",
)
class WorkflowAgentSandboxService:
"""List/read/upload files in a workflow Agent node sandbox."""
def __init__(self, *, client_factory: Callable[[], Client] | None = None) -> None:
self._client_factory = client_factory or _default_client_factory
def list_files(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
path: str,
session: Session,
):
locator = self._resolve_locator(
tenant_id=tenant_id,
app_id=app_id,
workflow_run_id=workflow_run_id,
node_id=node_id,
node_execution_id=node_execution_id,
session=session,
)
return self._client_factory().list_sandbox_files_sync(locator, path)
def read_file(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
path: str,
session: Session,
):
locator = self._resolve_locator(
tenant_id=tenant_id,
app_id=app_id,
workflow_run_id=workflow_run_id,
node_id=node_id,
node_execution_id=node_execution_id,
session=session,
)
return self._client_factory().read_sandbox_file_sync(locator, path)
def upload_file(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
path: str,
session: Session,
) -> AgentSandboxUploadDownload:
locator = self._resolve_locator(
tenant_id=tenant_id,
app_id=app_id,
workflow_run_id=workflow_run_id,
node_id=node_id,
node_execution_id=node_execution_id,
session=session,
)
uploaded = self._client_factory().upload_sandbox_file_sync(locator, path)
return _upload_download_response(
tenant_id=tenant_id,
file_mapping=uploaded.file.model_dump(mode="python"),
)
def _resolve_locator(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
session: Session,
) -> SandboxLocator:
"""Resolve one workflow Agent sandbox from product-facing identifiers.
Callers may target either a specific node execution or the current node
as a whole. When ``node_execution_id`` is provided, lookup narrows to
that execution's ACTIVE runtime-session row. When it is omitted, the
service falls back to the most recently updated ACTIVE session for the
same ``workflow_run_id + node_id`` pair so console sandbox inspection can
still work from the broader workflow/node locator.
"""
stmt = select(WorkflowAgentRuntimeSession).where(
WorkflowAgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.WORKFLOW_RUN,
WorkflowAgentRuntimeSession.tenant_id == tenant_id,
WorkflowAgentRuntimeSession.app_id == app_id,
WorkflowAgentRuntimeSession.workflow_run_id == workflow_run_id,
WorkflowAgentRuntimeSession.node_id == node_id,
WorkflowAgentRuntimeSession.status == WorkflowAgentRuntimeSessionStatus.ACTIVE,
)
if node_execution_id:
stmt = stmt.where(WorkflowAgentRuntimeSession.node_execution_id == node_execution_id)
stmt = stmt.order_by(WorkflowAgentRuntimeSession.updated_at.desc()).limit(1)
row = session.scalar(stmt)
if row is None:
raise AgentSandboxInspectorError(
"no_active_session",
"this workflow Agent node has no active sandbox session yet",
status_code=404,
)
return _build_locator_or_raise(
snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs),
not_found_message="this workflow Agent node has no sandbox workspace",
)
def _build_locator_or_raise(
*,
snapshot: CompositorSessionSnapshot,
runtime_layer_specs: list[RuntimeLayerSpec],
not_found_message: str,
) -> SandboxLocator:
try:
return build_sandbox_locator_from_layer_specs(
layer_specs=runtime_layer_specs,
session_snapshot=snapshot,
)
except ValueError as exc:
raise AgentSandboxInspectorError("no_sandbox", not_found_message, status_code=404) from exc
def _extract_shell_workspace_or_raise(
*,
snapshot: CompositorSessionSnapshot,
not_found_message: str,
) -> tuple[str, str]:
shell_layer = next((layer for layer in snapshot.layers if layer.name == "shell"), None)
if shell_layer is None:
raise AgentSandboxInspectorError("no_sandbox", not_found_message, status_code=404)
session_id = shell_layer.runtime_state.get("session_id")
workspace_cwd = shell_layer.runtime_state.get("workspace_cwd")
if not isinstance(session_id, str) or not isinstance(workspace_cwd, str):
raise AgentSandboxInspectorError("no_sandbox", not_found_message, status_code=404)
return session_id, workspace_cwd
def _deserialize_runtime_layer_specs(value: str | None) -> list[RuntimeLayerSpec]:
if not value:
return []
return _RUNTIME_LAYER_SPECS_ADAPTER.validate_json(value)
def _upload_download_response(*, tenant_id: str, file_mapping: dict[str, Any]) -> AgentSandboxUploadDownload:
"""Resolve one uploaded ToolFile mapping into a signed external download URL."""
controller = DatabaseFileAccessController()
runtime = DifyWorkflowFileRuntime(file_access_controller=controller)
try:
file = file_factory.build_from_mapping(
mapping=file_mapping,
tenant_id=tenant_id,
access_controller=controller,
)
url = runtime.resolve_file_url(file=file, for_external=True)
except ValueError as exc:
raise AgentSandboxInspectorError(
"sandbox_upload_download_unavailable",
"uploaded sandbox file could not be converted to a download URL",
status_code=502,
) from exc
if not url:
raise AgentSandboxInspectorError(
"sandbox_upload_download_unavailable",
"uploaded sandbox file does not support download URL generation",
status_code=502,
)
return AgentSandboxUploadDownload(url=_with_as_attachment(url))
def _with_as_attachment(url: str) -> str:
parsed = urllib.parse.urlsplit(url)
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
query.append(("as_attachment", "true"))
return urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query)))
def _default_client_factory() -> Client:
base_url = dify_config.AGENT_BACKEND_BASE_URL
if not base_url:
raise AgentSandboxInspectorError(
"inspector_unavailable",
"the sandbox file inspector is not available (agent backend not configured)",
status_code=503,
)
return Client(base_url=base_url)
__all__ = [
"AgentAppSandboxService",
"AgentSandboxInfo",
"AgentSandboxInspectorError",
"AgentSandboxUploadDownload",
"WorkflowAgentSandboxService",
]