diff --git a/api/clients/agent_backend/__init__.py b/api/clients/agent_backend/__init__.py index f8d15f85676..238c48a9de3 100644 --- a/api/clients/agent_backend/__init__.py +++ b/api/clients/agent_backend/__init__.py @@ -5,6 +5,8 @@ API adapters: request building from Dify product concepts, a thin client wrapper event adaptation for future workflow integration, and deterministic fakes. """ +from dify_agent.protocol import RuntimeLayerSpec, extract_runtime_layer_specs + from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient from clients.agent_backend.errors import ( AgentBackendError, @@ -39,8 +41,6 @@ from clients.agent_backend.request_builder import ( AgentBackendOutputConfig, AgentBackendRunRequestBuilder, AgentBackendWorkflowNodeRunInput, - CleanupLayerSpec, - extract_cleanup_layer_specs, redact_for_agent_backend_log, ) @@ -72,11 +72,11 @@ __all__ = [ "AgentBackendTransportError", "AgentBackendValidationError", "AgentBackendWorkflowNodeRunInput", - "CleanupLayerSpec", "DifyAgentBackendRunClient", "FakeAgentBackendRunClient", "FakeAgentBackendScenario", + "RuntimeLayerSpec", "create_agent_backend_run_client", - "extract_cleanup_layer_specs", + "extract_runtime_layer_specs", "redact_for_agent_backend_log", ] diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index c9f042db496..34b2db12e7a 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -12,7 +12,7 @@ composition-driven. from __future__ import annotations from collections.abc import Mapping -from typing import ClassVar, cast +from typing import ClassVar from agenton.compositor import CompositorSessionSnapshot from agenton.compositor.schemas import LayerSessionSnapshot @@ -41,6 +41,7 @@ from dify_agent.protocol import ( RunComposition, RunLayerSpec, RunPurpose, + RuntimeLayerSpec, ) from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator @@ -52,71 +53,10 @@ DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context" DIFY_PLUGIN_TOOLS_LAYER_ID = "tools" DIFY_SHELL_LAYER_ID = "shell" -# Layer types that hold credentials in their per-run config. These are excluded -# from the cleanup-replay composition (and from the snapshot that is sent with -# the cleanup request) because we deliberately do not persist plaintext -# credentials between runs. -_CLEANUP_EXCLUDED_LAYER_TYPES: tuple[str, ...] = ( - DIFY_PLUGIN_LLM_LAYER_TYPE_ID, - DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, -) - - -class CleanupLayerSpec(BaseModel): - """One layer node replayed by an Agent backend cleanup-only run. - - Cleanup composition cannot include credential-bearing plugin layers, so we - persist only the non-plugin layer specs together with the original config. - Storing the config (rather than just ``name``/``type``) means cleanup does - not depend on the original build-time inputs being re-derivable. - """ - - name: str - type: str - deps: dict[str, str] = Field(default_factory=dict) - metadata: dict[str, JsonValue] = Field(default_factory=dict) - config: JsonValue = None - - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - -def extract_cleanup_layer_specs(composition: RunComposition) -> list[CleanupLayerSpec]: - """Project the in-flight composition into the persistable cleanup spec list. - - Plugin layers are intentionally dropped (their configs hold credentials and - the lifecycle contract says "do not include an LLM layer" during cleanup). - The filtered names must later drive snapshot filtering so the agenton - compositor's name-order check still passes for the cleanup run. - """ - excluded = set(_CLEANUP_EXCLUDED_LAYER_TYPES) - specs: list[CleanupLayerSpec] = [] - for layer in composition.layers: - if layer.type in excluded: - continue - config_value: JsonValue = None - if isinstance(layer.config, BaseModel): - config_value = layer.config.model_dump(mode="json", warnings=False) - else: - # ``RunLayerSpec.config`` is typed as ``LayerConfigInput`` which - # includes ``Mapping[str, object] | bytes``. In the cleanup-replay - # pipeline our builder only emits BaseModel-derived configs or - # ``None``, so the wider input alias narrows safely here. - config_value = cast(JsonValue, layer.config) - specs.append( - CleanupLayerSpec( - name=layer.name, - type=layer.type, - deps=dict(layer.deps), - metadata=dict(layer.metadata), - config=config_value, - ) - ) - return specs - def _filter_snapshot_to_specs( snapshot: CompositorSessionSnapshot, - specs: list[CleanupLayerSpec], + specs: list[RuntimeLayerSpec], ) -> CompositorSessionSnapshot: """Keep only snapshot layers whose names appear in the cleanup spec list. @@ -367,7 +307,7 @@ class AgentBackendRunRequestBuilder: self, *, session_snapshot: CompositorSessionSnapshot, - composition_layer_specs: list[CleanupLayerSpec], + runtime_layer_specs: list[RuntimeLayerSpec], idempotency_key: str | None = None, metadata: dict[str, JsonValue] | None = None, ) -> CreateRunRequest: @@ -380,9 +320,9 @@ class AgentBackendRunRequestBuilder: composition and the snapshot before submission because their configs require credentials that are not persisted between runs. """ - if not composition_layer_specs: + if not runtime_layer_specs: raise ValueError( - "build_cleanup_request requires composition_layer_specs; an empty " + "build_cleanup_request requires runtime_layer_specs; an empty " "composition would fail the agent backend's snapshot validation." ) request_metadata = dict(metadata or {}) @@ -395,9 +335,9 @@ class AgentBackendRunRequestBuilder: metadata=dict(spec.metadata), config=spec.config, ) - for spec in composition_layer_specs + for spec in runtime_layer_specs ] - filtered_snapshot = _filter_snapshot_to_specs(session_snapshot, composition_layer_specs) + filtered_snapshot = _filter_snapshot_to_specs(session_snapshot, runtime_layer_specs) return CreateRunRequest( composition=RunComposition(layers=layers), purpose="workflow_node", diff --git a/api/clients/agent_backend/workspace_files_client.py b/api/clients/agent_backend/workspace_files_client.py deleted file mode 100644 index bd41f457ba9..00000000000 --- a/api/clients/agent_backend/workspace_files_client.py +++ /dev/null @@ -1,135 +0,0 @@ -"""API-side client for the agent backend's read-only workspace file endpoints. - -The agent backend exposes ``/workspaces/{session_id}/files{,/preview,/download}`` -to inspect a shell-layer sandbox workspace. This thin synchronous client proxies -those reads for the console FS inspector and normalizes transport/HTTP failures -into the API backend's ``AgentBackendError`` boundary, preserving the backend's -status code and ``{code, message}`` detail so the controller can relay them. -""" - -from __future__ import annotations - -import base64 -import binascii -from dataclasses import dataclass -from typing import Literal - -import httpx -from pydantic import BaseModel - -from clients.agent_backend.errors import AgentBackendHTTPError, AgentBackendTransportError - -_DEFAULT_TIMEOUT_SECONDS = 30.0 - - -class WorkspaceFileEntry(BaseModel): - """One entry in a workspace directory listing.""" - - name: str - type: Literal["file", "dir", "symlink"] - size: int - mtime: int - - -class WorkspaceListResult(BaseModel): - """Directory listing of a workspace path.""" - - path: str - entries: list[WorkspaceFileEntry] - truncated: bool - - -class WorkspacePreviewResult(BaseModel): - """Inline preview of a workspace file.""" - - path: str - size: int - truncated: bool - binary: bool - text: str | None = None - - -@dataclass(frozen=True, slots=True) -class WorkspaceDownloadResult: - """Decoded bytes of a workspace file for download.""" - - path: str - size: int - truncated: bool - content: bytes - - -class WorkspaceFilesBackendClient: - """Synchronous proxy to the agent backend workspace file endpoints.""" - - def __init__( - self, - base_url: str, - *, - timeout: float = _DEFAULT_TIMEOUT_SECONDS, - transport: httpx.BaseTransport | None = None, - ) -> None: - self._base_url = base_url.rstrip("/") - self._timeout = timeout - self._transport = transport - - def list_files(self, session_id: str, path: str) -> WorkspaceListResult: - data = self._get(f"/workspaces/{session_id}/files", params={"path": path}) - return WorkspaceListResult.model_validate(data) - - def preview(self, session_id: str, path: str) -> WorkspacePreviewResult: - data = self._get(f"/workspaces/{session_id}/files/preview", params={"path": path}) - return WorkspacePreviewResult.model_validate(data) - - def download(self, session_id: str, path: str) -> WorkspaceDownloadResult: - data = self._get(f"/workspaces/{session_id}/files/download", params={"path": path}) - encoded = data.get("content_base64") - if not isinstance(encoded, str): - raise AgentBackendHTTPError("agent backend download response missing content", status_code=502, detail=data) - try: - content = base64.b64decode(encoded, validate=True) - except (binascii.Error, ValueError) as exc: - raise AgentBackendHTTPError( - "agent backend returned undecodable download content", status_code=502, detail=str(exc) - ) from exc - size = data.get("size") - return WorkspaceDownloadResult( - path=str(data.get("path", path)), - size=int(size) if isinstance(size, (int, float)) else len(content), - truncated=bool(data.get("truncated")), - content=content, - ) - - def _get(self, route: str, *, params: dict[str, str]) -> dict[str, object]: - url = f"{self._base_url}{route}" - try: - with httpx.Client(timeout=self._timeout, transport=self._transport, trust_env=False) as client: - response = client.get(url, params=params) - except httpx.HTTPError as exc: - raise AgentBackendTransportError(f"failed to reach agent backend workspace endpoint: {exc}") from exc - if response.status_code >= 400: - detail: object - try: - detail = response.json().get("detail", response.text) - except ValueError: - detail = response.text - raise AgentBackendHTTPError( - f"agent backend workspace request failed ({response.status_code})", - status_code=response.status_code, - detail=detail, - ) - body = response.json() - if not isinstance(body, dict): - raise AgentBackendHTTPError( - "agent backend workspace response was not an object", status_code=502, detail=body - ) - return body - - -__all__ = [ - "WorkspaceDownloadResult", - "WorkspaceFileEntry", - "WorkspaceFilesBackendClient", - "WorkspaceListResult", - "WorkspacePreviewResult", -] diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 3af02f37dcf..eb6bc7e3e44 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -53,7 +53,7 @@ from .app import ( agent, agent_app_access, agent_app_feature, - agent_app_workspace, + agent_app_sandbox, annotation, app, audio, @@ -153,7 +153,7 @@ __all__ = [ "agent", "agent_app_access", "agent_app_feature", - "agent_app_workspace", + "agent_app_sandbox", "agent_composer", "agent_providers", "agent_roster", diff --git a/api/controllers/console/app/agent_app_sandbox.py b/api/controllers/console/app/agent_app_sandbox.py new file mode 100644 index 00000000000..b59d66c7439 --- /dev/null +++ b/api/controllers/console/app/agent_app_sandbox.py @@ -0,0 +1,306 @@ +"""Console routes for Agent App and workflow Agent sandbox file access. + +The API keeps product-facing locators (conversation or workflow node identity) +on this public boundary and proxies list/read/upload to the agent backend's new +``/sandbox`` contract. +""" + +from __future__ import annotations + +from typing import Literal +from uuid import UUID + +from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError +from flask import request +from flask_restx import Resource +from pydantic import BaseModel, Field + +from controllers.common.schema import ( + query_params_from_model, + query_params_from_request, + register_response_schema_models, + register_schema_models, +) +from controllers.console import console_ns +from controllers.console.app.wraps import get_app_model +from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id +from fields.base import ResponseModel +from libs.login import login_required +from models.model import App, AppMode +from services.agent_app_sandbox_service import ( + AgentAppSandboxService, + AgentSandboxInspectorError, + WorkflowAgentSandboxService, +) + +_NODE_EXECUTION_ID_DESCRIPTION = ( + "Optional workflow node execution ID. When omitted, the latest active session for the node is used." +) + + +class AgentSandboxListQuery(BaseModel): + conversation_id: str = Field(min_length=1, description="Agent App conversation ID") + path: str = Field(default=".", description="Directory path relative to the sandbox workspace") + + +class AgentSandboxFileQuery(BaseModel): + conversation_id: str = Field(min_length=1, description="Agent App conversation ID") + path: str = Field(min_length=1, description="File path relative to the sandbox workspace") + + +class AgentSandboxUploadPayload(BaseModel): + conversation_id: str = Field(min_length=1, description="Agent App conversation ID") + path: str = Field(min_length=1, description="File path relative to the sandbox workspace") + + +class WorkflowAgentSandboxListQuery(BaseModel): + path: str = Field(default=".", description="Directory path relative to the sandbox workspace") + node_execution_id: str | None = Field( + default=None, + description=_NODE_EXECUTION_ID_DESCRIPTION, + ) + + +class WorkflowAgentSandboxFileQuery(BaseModel): + path: str = Field(min_length=1, description="File path relative to the sandbox workspace") + node_execution_id: str | None = Field( + default=None, + description=_NODE_EXECUTION_ID_DESCRIPTION, + ) + + +class WorkflowAgentSandboxUploadPayload(BaseModel): + path: str = Field(min_length=1, description="File path relative to the sandbox workspace") + node_execution_id: str | None = Field( + default=None, + description=_NODE_EXECUTION_ID_DESCRIPTION, + ) + + +class SandboxFileEntryResponse(ResponseModel): + name: str + type: Literal["file", "dir", "symlink", "other"] + size: int | None = None + mtime: int | None = None + + +class SandboxListResponse(ResponseModel): + path: str + entries: list[SandboxFileEntryResponse] = Field(default_factory=list) + truncated: bool = False + + +class SandboxReadResponse(ResponseModel): + path: str + size: int | None = None + truncated: bool + binary: bool + text: str | None = None + + +class SandboxToolFileResponse(ResponseModel): + transfer_method: Literal["tool_file"] = "tool_file" + reference: str + + +class SandboxUploadResponse(ResponseModel): + path: str + file: SandboxToolFileResponse + + +register_schema_models( + console_ns, + AgentSandboxUploadPayload, + WorkflowAgentSandboxUploadPayload, +) +register_response_schema_models(console_ns, SandboxListResponse, SandboxReadResponse, SandboxUploadResponse) + + +def _handle(exc: Exception) -> tuple[dict[str, object], int]: + if isinstance(exc, AgentSandboxInspectorError): + return {"code": exc.code, "message": exc.message}, exc.status_code + if isinstance(exc, DifyAgentHTTPError): + detail = exc.detail + if isinstance(detail, dict): + return { + "code": detail.get("code", "agent_backend_error"), + "message": detail.get("message", str(exc)), + }, exc.status_code + return {"code": "agent_backend_error", "message": str(detail)}, exc.status_code + if isinstance(exc, DifyAgentTimeoutError | DifyAgentClientError): + return {"code": "agent_backend_unreachable", "message": str(exc)}, 502 + raise exc + + +@console_ns.route("/apps//agent-sandbox/files") +class AgentAppSandboxListResource(Resource): + @console_ns.doc("list_agent_app_sandbox_files") + @console_ns.doc(description="List a directory in an Agent App conversation sandbox") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentSandboxListQuery)}) + @console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.AGENT]) + @with_current_tenant_id + def get(self, tenant_id: str, app_model: App): + query = query_params_from_request(AgentSandboxListQuery) + try: + result = AgentAppSandboxService().list_files( + tenant_id=tenant_id, + app_id=app_model.id, + conversation_id=query.conversation_id, + path=query.path, + ) + except Exception as exc: + return _handle(exc) + return result.model_dump() + + +@console_ns.route("/apps//agent-sandbox/files/read") +class AgentAppSandboxReadResource(Resource): + @console_ns.doc("read_agent_app_sandbox_file") + @console_ns.doc(description="Read a text/binary preview file in an Agent App conversation sandbox") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentSandboxFileQuery)}) + @console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.AGENT]) + @with_current_tenant_id + def get(self, tenant_id: str, app_model: App): + query = query_params_from_request(AgentSandboxFileQuery) + try: + result = AgentAppSandboxService().read_file( + tenant_id=tenant_id, + app_id=app_model.id, + conversation_id=query.conversation_id, + path=query.path, + ) + except Exception as exc: + return _handle(exc) + return result.model_dump() + + +@console_ns.route("/apps//agent-sandbox/files/upload") +class AgentAppSandboxUploadResource(Resource): + @console_ns.doc("upload_agent_app_sandbox_file") + @console_ns.doc(description="Upload one Agent App sandbox file as a Dify ToolFile mapping") + @console_ns.expect(console_ns.models[AgentSandboxUploadPayload.__name__]) + @console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.AGENT]) + @with_current_tenant_id + def post(self, tenant_id: str, app_model: App): + payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {}) + try: + result = AgentAppSandboxService().upload_file( + tenant_id=tenant_id, + app_id=app_model.id, + conversation_id=payload.conversation_id, + path=payload.path, + ) + except Exception as exc: + return _handle(exc) + return result.model_dump() + + +@console_ns.route("/apps//workflow-runs//agent-nodes//sandbox/files") +class WorkflowAgentSandboxListResource(Resource): + @console_ns.doc("list_workflow_agent_sandbox_files") + @console_ns.doc(description="List a directory in a workflow Agent node sandbox") + @console_ns.doc( + params={ + "app_id": "Application ID", + "workflow_run_id": "Workflow run ID", + "node_id": "Workflow Agent node ID", + **query_params_from_model(WorkflowAgentSandboxListQuery), + } + ) + @console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + @with_current_tenant_id + def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str): + query = query_params_from_request(WorkflowAgentSandboxListQuery) + try: + result = WorkflowAgentSandboxService().list_files( + tenant_id=tenant_id, + app_id=app_model.id, + workflow_run_id=str(workflow_run_id), + node_id=node_id, + node_execution_id=query.node_execution_id, + path=query.path, + ) + except Exception as exc: + return _handle(exc) + return result.model_dump() + + +@console_ns.route( + "/apps//workflow-runs//agent-nodes//sandbox/files/read" +) +class WorkflowAgentSandboxReadResource(Resource): + @console_ns.doc("read_workflow_agent_sandbox_file") + @console_ns.doc(description="Read a text/binary preview file in a workflow Agent node sandbox") + @console_ns.doc( + params={ + "app_id": "Application ID", + "workflow_run_id": "Workflow run ID", + "node_id": "Workflow Agent node ID", + **query_params_from_model(WorkflowAgentSandboxFileQuery), + } + ) + @console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + @with_current_tenant_id + def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str): + query = query_params_from_request(WorkflowAgentSandboxFileQuery) + try: + result = WorkflowAgentSandboxService().read_file( + tenant_id=tenant_id, + app_id=app_model.id, + workflow_run_id=str(workflow_run_id), + node_id=node_id, + node_execution_id=query.node_execution_id, + path=query.path, + ) + except Exception as exc: + return _handle(exc) + return result.model_dump() + + +@console_ns.route( + "/apps//workflow-runs//agent-nodes//sandbox/files/upload" +) +class WorkflowAgentSandboxUploadResource(Resource): + @console_ns.doc("upload_workflow_agent_sandbox_file") + @console_ns.doc(description="Upload one workflow Agent sandbox file as a Dify ToolFile mapping") + @console_ns.expect(console_ns.models[WorkflowAgentSandboxUploadPayload.__name__]) + @console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + @with_current_tenant_id + def post(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str): + payload = WorkflowAgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {}) + try: + result = WorkflowAgentSandboxService().upload_file( + tenant_id=tenant_id, + app_id=app_model.id, + workflow_run_id=str(workflow_run_id), + node_id=node_id, + node_execution_id=payload.node_execution_id, + path=payload.path, + ) + except Exception as exc: + return _handle(exc) + return result.model_dump() diff --git a/api/controllers/console/app/agent_app_workspace.py b/api/controllers/console/app/agent_app_workspace.py deleted file mode 100644 index 0699d08bf79..00000000000 --- a/api/controllers/console/app/agent_app_workspace.py +++ /dev/null @@ -1,319 +0,0 @@ -"""Agent App sandbox file-system inspector (read-only). - -Exposes the PRD "rc1-like sandbox file system, downloadable not editable" view -for an Agent App conversation: list a directory, preview a file, or download a -file from the conversation's shell-layer workspace. The API never touches -shellctl directly — it resolves the conversation's sandbox ``session_id`` from -the stored session snapshot and proxies to the agent backend's read-only -workspace endpoints. -""" - -from typing import Literal -from uuid import UUID - -from flask import Response -from flask_restx import Resource, fields -from pydantic import BaseModel, Field - -from clients.agent_backend.errors import AgentBackendHTTPError, AgentBackendTransportError -from clients.agent_backend.workspace_files_client import WorkspaceDownloadResult -from controllers.common.schema import ( - query_params_from_model, - query_params_from_request, - register_response_schema_models, -) -from controllers.console import console_ns -from controllers.console.app.wraps import get_app_model -from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id -from fields.base import ResponseModel -from libs.login import login_required -from models.model import App, AppMode -from services.agent_app_workspace_service import ( - AgentAppWorkspaceService, - AgentWorkspaceInspectorError, - WorkflowAgentWorkspaceService, -) - - -class _WorkspaceFileDownloadField(fields.Raw): - __schema_type__ = "string" - __schema_format__ = "binary" - - -class AgentWorkspaceListQuery(BaseModel): - conversation_id: str = Field(min_length=1, description="Agent App conversation ID") - path: str = Field(default=".", description="Directory path relative to the sandbox workspace") - - -class AgentWorkspaceFileQuery(BaseModel): - conversation_id: str = Field(min_length=1, description="Agent App conversation ID") - path: str = Field(min_length=1, description="File path relative to the sandbox workspace") - - -class WorkflowAgentWorkspaceListQuery(BaseModel): - path: str = Field(default=".", description="Directory path relative to the sandbox workspace") - node_execution_id: str | None = Field( - default=None, - description=( - "Optional workflow node execution ID. When omitted, the latest active session for the node is used." - ), - ) - - -class WorkflowAgentWorkspaceFileQuery(BaseModel): - path: str = Field(min_length=1, description="File path relative to the sandbox workspace") - node_execution_id: str | None = Field( - default=None, - description=( - "Optional workflow node execution ID. When omitted, the latest active session for the node is used." - ), - ) - - -class WorkspaceFileEntryResponse(ResponseModel): - name: str - type: Literal["file", "dir", "symlink"] - size: int - mtime: int - - -class WorkspaceListResponse(ResponseModel): - path: str - entries: list[WorkspaceFileEntryResponse] = Field(default_factory=list) - truncated: bool = False - - -class WorkspacePreviewResponse(ResponseModel): - path: str - size: int - truncated: bool - binary: bool - text: str | None = None - - -register_response_schema_models(console_ns, WorkspaceListResponse) -register_response_schema_models(console_ns, WorkspacePreviewResponse) - - -def _handle(exc: Exception) -> tuple[dict[str, object], int]: - if isinstance(exc, AgentWorkspaceInspectorError): - return {"code": exc.code, "message": exc.message}, exc.status_code - if isinstance(exc, AgentBackendHTTPError): - detail = exc.detail - if isinstance(detail, dict): - return { - "code": detail.get("code", "agent_backend_error"), - "message": detail.get("message", str(exc)), - }, exc.status_code - return {"code": "agent_backend_error", "message": str(detail)}, exc.status_code - if isinstance(exc, AgentBackendTransportError): - return {"code": "agent_backend_unreachable", "message": str(exc)}, 502 - raise exc - - -def _download_response(result: WorkspaceDownloadResult) -> Response | tuple[dict[str, object], int]: - if result.truncated: - return { - "code": "workspace_file_too_large", - "message": ( - "file exceeds the workspace download limit; use preview for partial text or download a smaller file" - ), - "size": result.size, - }, 413 - filename = result.path.rsplit("/", 1)[-1] or "download" - return Response( - result.content, - mimetype="application/octet-stream", - headers={ - "Content-Disposition": f'attachment; filename="{filename}"', - "Content-Length": str(len(result.content)), - "X-Workspace-File-Size": str(result.size), - }, - ) - - -@console_ns.route("/apps//agent-workspace/files") -class AgentAppWorkspaceListResource(Resource): - @console_ns.doc("list_agent_app_workspace_files") - @console_ns.doc(description="List a directory in an Agent App conversation's sandbox workspace (read-only)") - @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentWorkspaceListQuery)}) - @console_ns.response(200, "Listing returned", console_ns.models[WorkspaceListResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) - @with_current_tenant_id - def get(self, tenant_id: str, app_model: App): - query = query_params_from_request(AgentWorkspaceListQuery) - try: - result = AgentAppWorkspaceService().list_files( - tenant_id=tenant_id, - app_id=app_model.id, - conversation_id=query.conversation_id, - path=query.path, - ) - except Exception as exc: # normalized to an HTTP response below - return _handle(exc) - return result.model_dump() - - -@console_ns.route("/apps//agent-workspace/files/preview") -class AgentAppWorkspacePreviewResource(Resource): - @console_ns.doc("preview_agent_app_workspace_file") - @console_ns.doc(description="Preview a text/binary file in an Agent App conversation's sandbox workspace") - @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentWorkspaceFileQuery)}) - @console_ns.response(200, "Preview returned", console_ns.models[WorkspacePreviewResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) - @with_current_tenant_id - def get(self, tenant_id: str, app_model: App): - query = query_params_from_request(AgentWorkspaceFileQuery) - try: - result = AgentAppWorkspaceService().preview( - tenant_id=tenant_id, - app_id=app_model.id, - conversation_id=query.conversation_id, - path=query.path, - ) - except Exception as exc: # normalized to an HTTP response below - return _handle(exc) - return result.model_dump() - - -@console_ns.route("/apps//agent-workspace/files/download") -class AgentAppWorkspaceDownloadResource(Resource): - @console_ns.doc("download_agent_app_workspace_file") - @console_ns.doc(description="Download a file from an Agent App conversation's sandbox workspace (read-only)") - @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentWorkspaceFileQuery)}) - @console_ns.doc(produces=["application/octet-stream"]) - @console_ns.response(200, "File bytes", _WorkspaceFileDownloadField) - @console_ns.response(413, "File exceeds the workspace download limit") - @setup_required - @login_required - @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) - @with_current_tenant_id - def get(self, tenant_id: str, app_model: App): - query = query_params_from_request(AgentWorkspaceFileQuery) - try: - result = AgentAppWorkspaceService().download( - tenant_id=tenant_id, - app_id=app_model.id, - conversation_id=query.conversation_id, - path=query.path, - ) - except Exception as exc: # normalized to an HTTP response below - return _handle(exc) - return _download_response(result) - - -@console_ns.route( - "/apps//workflow-runs//agent-nodes//workspace/files" -) -class WorkflowAgentWorkspaceListResource(Resource): - @console_ns.doc("list_workflow_agent_workspace_files") - @console_ns.doc(description="List a directory in a Workflow Agent node's sandbox workspace (read-only)") - @console_ns.doc( - params={ - "app_id": "Application ID", - "workflow_run_id": "Workflow run ID", - "node_id": "Workflow Agent node ID", - **query_params_from_model(WorkflowAgentWorkspaceListQuery), - } - ) - @console_ns.response(200, "Listing returned", console_ns.models[WorkspaceListResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @with_current_tenant_id - def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str): - query = query_params_from_request(WorkflowAgentWorkspaceListQuery) - try: - result = WorkflowAgentWorkspaceService().list_files( - tenant_id=tenant_id, - app_id=app_model.id, - workflow_run_id=str(workflow_run_id), - node_id=node_id, - node_execution_id=query.node_execution_id, - path=query.path, - ) - except Exception as exc: # normalized to an HTTP response below - return _handle(exc) - return result.model_dump() - - -@console_ns.route( - "/apps//workflow-runs//agent-nodes//workspace/files/preview" -) -class WorkflowAgentWorkspacePreviewResource(Resource): - @console_ns.doc("preview_workflow_agent_workspace_file") - @console_ns.doc(description="Preview a text/binary file in a Workflow Agent node's sandbox workspace") - @console_ns.doc( - params={ - "app_id": "Application ID", - "workflow_run_id": "Workflow run ID", - "node_id": "Workflow Agent node ID", - **query_params_from_model(WorkflowAgentWorkspaceFileQuery), - } - ) - @console_ns.response(200, "Preview returned", console_ns.models[WorkspacePreviewResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @with_current_tenant_id - def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str): - query = query_params_from_request(WorkflowAgentWorkspaceFileQuery) - try: - result = WorkflowAgentWorkspaceService().preview( - tenant_id=tenant_id, - app_id=app_model.id, - workflow_run_id=str(workflow_run_id), - node_id=node_id, - node_execution_id=query.node_execution_id, - path=query.path, - ) - except Exception as exc: # normalized to an HTTP response below - return _handle(exc) - return result.model_dump() - - -@console_ns.route( - "/apps//workflow-runs//agent-nodes//workspace/files/download" -) -class WorkflowAgentWorkspaceDownloadResource(Resource): - @console_ns.doc("download_workflow_agent_workspace_file") - @console_ns.doc(description="Download a file from a Workflow Agent node's sandbox workspace (read-only)") - @console_ns.doc( - params={ - "app_id": "Application ID", - "workflow_run_id": "Workflow run ID", - "node_id": "Workflow Agent node ID", - **query_params_from_model(WorkflowAgentWorkspaceFileQuery), - } - ) - @console_ns.doc(produces=["application/octet-stream"]) - @console_ns.response(200, "File bytes", _WorkspaceFileDownloadField) - @console_ns.response(413, "File exceeds the workspace download limit") - @setup_required - @login_required - @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @with_current_tenant_id - def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str): - query = query_params_from_request(WorkflowAgentWorkspaceFileQuery) - try: - result = WorkflowAgentWorkspaceService().download( - tenant_id=tenant_id, - app_id=app_model.id, - workflow_run_id=str(workflow_run_id), - node_id=node_id, - node_execution_id=query.node_execution_id, - path=query.path, - ) - except Exception as exc: # normalized to an HTTP response below - return _handle(exc) - return _download_response(result) diff --git a/api/core/app/apps/agent_app/app_runner.py b/api/core/app/apps/agent_app/app_runner.py index 5f7d9f76249..7b767e50881 100644 --- a/api/core/app/apps/agent_app/app_runner.py +++ b/api/core/app/apps/agent_app/app_runner.py @@ -23,6 +23,7 @@ from clients.agent_backend import ( AgentBackendRunEventAdapter, AgentBackendRunSucceededInternalEvent, AgentBackendStreamInternalEvent, + extract_runtime_layer_specs, ) from core.app.apps.agent_app.runtime_request_builder import ( AgentAppRuntimeBuildContext, @@ -127,7 +128,12 @@ class AgentAppRunner: answer = self._extract_answer(terminal.output) self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer) - self._save_session(scope=scope, backend_run_id=terminal.run_id, snapshot=terminal.session_snapshot) + self._save_session( + scope=scope, + backend_run_id=terminal.run_id, + snapshot=terminal.session_snapshot, + runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition), + ) def _consume_stream(self, run_id: str, *, queue_manager: AppQueueManager): terminal = None @@ -165,9 +171,21 @@ class AgentAppRunner: # task pipeline streams the chunk over SSE and persists the message. publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=answer) - def _save_session(self, *, scope: AgentAppSessionScope, backend_run_id: str, snapshot: Any) -> None: + def _save_session( + self, + *, + scope: AgentAppSessionScope, + backend_run_id: str, + snapshot: Any, + runtime_layer_specs: Any, + ) -> None: try: - self._session_store.save_active_snapshot(scope=scope, backend_run_id=backend_run_id, snapshot=snapshot) + self._session_store.save_active_snapshot( + scope=scope, + backend_run_id=backend_run_id, + snapshot=snapshot, + runtime_layer_specs=runtime_layer_specs, + ) except Exception: logger.warning( "Failed to persist Agent App conversation session snapshot: " diff --git a/api/core/app/apps/agent_app/session_store.py b/api/core/app/apps/agent_app/session_store.py index 62c14c33b95..22af2f2068f 100644 --- a/api/core/app/apps/agent_app/session_store.py +++ b/api/core/app/apps/agent_app/session_store.py @@ -9,9 +9,11 @@ phase-2 concern and not modeled here. from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from agenton.compositor import CompositorSessionSnapshot +from dify_agent.protocol import RuntimeLayerSpec +from pydantic import TypeAdapter from sqlalchemy import select from core.db.session_factory import session_factory @@ -22,6 +24,18 @@ from models.agent import ( AgentRuntimeSessionStatus, ) +_RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec]) + + +def _serialize_runtime_layer_specs(specs: list[RuntimeLayerSpec]) -> str: + return _RUNTIME_LAYER_SPECS_ADAPTER.dump_json(specs).decode() + + +def _deserialize_runtime_layer_specs(value: str | None) -> list[RuntimeLayerSpec]: + if not value: + return [] + return _RUNTIME_LAYER_SPECS_ADAPTER.validate_json(value) + @dataclass(frozen=True, slots=True) class AgentAppSessionScope: @@ -34,24 +48,47 @@ class AgentAppSessionScope: agent_config_snapshot_id: str +@dataclass(frozen=True, slots=True) +class StoredAgentAppSession: + """Persisted Agent App conversation session with reusable runtime specs.""" + + scope: AgentAppSessionScope + session_snapshot: CompositorSessionSnapshot + backend_run_id: str | None + runtime_layer_specs: list[RuntimeLayerSpec] = field(default_factory=list) + + class AgentAppRuntimeSessionStore: """Persists Agent backend session snapshots for Agent App conversations.""" def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None: + stored = self.load_active_session(scope) + return stored.session_snapshot if stored is not None else None + + def load_active_session(self, scope: AgentAppSessionScope) -> StoredAgentAppSession | None: with session_factory.create_session() as session: row = session.scalar(self._active_stmt(scope)) if row is None: return None - return CompositorSessionSnapshot.model_validate_json(row.session_snapshot) + return StoredAgentAppSession( + scope=scope, + session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot), + backend_run_id=row.backend_run_id, + runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs), + ) - def load_active_snapshot_for_conversation( + def load_active_session_for_conversation( self, *, tenant_id: str, app_id: str, conversation_id: str - ) -> CompositorSessionSnapshot | None: - """Load a conversation's active snapshot without the agent/config scope. + ) -> StoredAgentAppSession | None: + """Load the latest ACTIVE session for one conversation-level sandbox lookup. - One Agent App conversation maps to one active session, so the workspace - inspector can resolve it from the conversation alone (it does not know - which agent config version a past turn ran under). + Sandbox inspection only knows the product locator + ``tenant_id + app_id + conversation_id``; it does not know which + ``agent_id`` or Agent Soul snapshot produced the active shell session. + This method therefore resolves the newest ACTIVE conversation-owned row + for that conversation and returns both the resumable snapshot and the + persisted non-sensitive runtime layer specs needed to build a + ``SandboxLocator``. """ stmt = ( select(AgentRuntimeSession) @@ -68,7 +105,18 @@ class AgentAppRuntimeSessionStore: row = session.scalar(stmt) if row is None: return None - return CompositorSessionSnapshot.model_validate_json(row.session_snapshot) + return StoredAgentAppSession( + scope=AgentAppSessionScope( + tenant_id=row.tenant_id, + app_id=row.app_id, + conversation_id=row.conversation_id or "", + agent_id=row.agent_id, + agent_config_snapshot_id=row.agent_config_snapshot_id or "", + ), + session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot), + backend_run_id=row.backend_run_id, + runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs), + ) def save_active_snapshot( self, @@ -76,10 +124,12 @@ class AgentAppRuntimeSessionStore: scope: AgentAppSessionScope, backend_run_id: str, snapshot: CompositorSessionSnapshot | None, + runtime_layer_specs: list[RuntimeLayerSpec], ) -> None: if snapshot is None: return snapshot_json = snapshot.model_dump_json() + runtime_layer_specs_json = _serialize_runtime_layer_specs(runtime_layer_specs) with session_factory.create_session() as session: row = session.scalar(self._scope_stmt(scope)) if row is None: @@ -92,13 +142,14 @@ class AgentAppRuntimeSessionStore: conversation_id=scope.conversation_id, backend_run_id=backend_run_id, session_snapshot=snapshot_json, - composition_layer_specs="[]", + composition_layer_specs=runtime_layer_specs_json, status=AgentRuntimeSessionStatus.ACTIVE, ) session.add(row) else: row.backend_run_id = backend_run_id row.session_snapshot = snapshot_json + row.composition_layer_specs = runtime_layer_specs_json row.status = AgentRuntimeSessionStatus.ACTIVE row.cleaned_at = None session.flush() @@ -143,4 +194,4 @@ class AgentAppRuntimeSessionStore: return cls._scope_stmt(scope).where(AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE) -__all__ = ["AgentAppRuntimeSessionStore", "AgentAppSessionScope"] +__all__ = ["AgentAppRuntimeSessionStore", "AgentAppSessionScope", "StoredAgentAppSession"] diff --git a/api/core/workflow/nodes/agent_v2/agent_node.py b/api/core/workflow/nodes/agent_v2/agent_node.py index f15f431dc80..1e22a35fc97 100644 --- a/api/core/workflow/nodes/agent_v2/agent_node.py +++ b/api/core/workflow/nodes/agent_v2/agent_node.py @@ -20,8 +20,8 @@ from clients.agent_backend import ( AgentBackendStreamInternalEvent, AgentBackendTransportError, AgentBackendValidationError, - CleanupLayerSpec, - extract_cleanup_layer_specs, + RuntimeLayerSpec, + extract_runtime_layer_specs, ) from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext from core.workflow.system_variables import SystemVariableKey, get_system_text @@ -255,7 +255,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]): session_scope=session_scope, backend_run_id=terminal_event.run_id, snapshot=terminal_event.session_snapshot, - composition_layer_specs=extract_cleanup_layer_specs(runtime_request.request.composition), + runtime_layer_specs=extract_runtime_layer_specs(runtime_request.request.composition), metadata=metadata, ) yield PauseRequestedEvent( @@ -293,7 +293,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]): session_scope=session_scope, backend_run_id=terminal_event.run_id, snapshot=terminal_event.session_snapshot, - composition_layer_specs=extract_cleanup_layer_specs(runtime_request.request.composition), + runtime_layer_specs=extract_runtime_layer_specs(runtime_request.request.composition), metadata=metadata, ) @@ -454,7 +454,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]): session_scope: WorkflowAgentSessionScope, backend_run_id: str, snapshot: CompositorSessionSnapshot | None, - composition_layer_specs: list[CleanupLayerSpec], + runtime_layer_specs: list[RuntimeLayerSpec], metadata: dict[str, Any], ) -> None: if self._session_store is None: @@ -464,7 +464,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]): scope=session_scope, backend_run_id=backend_run_id, snapshot=snapshot, - composition_layer_specs=composition_layer_specs, + runtime_layer_specs=runtime_layer_specs, ) agent_backend = dict(metadata.get("agent_backend") or {}) agent_backend["session_snapshot_persisted"] = snapshot is not None diff --git a/api/core/workflow/nodes/agent_v2/session_cleanup_layer.py b/api/core/workflow/nodes/agent_v2/session_cleanup_layer.py index 3c225ac4704..809f63b556f 100644 --- a/api/core/workflow/nodes/agent_v2/session_cleanup_layer.py +++ b/api/core/workflow/nodes/agent_v2/session_cleanup_layer.py @@ -40,7 +40,7 @@ class WorkflowAgentSessionCleanupLayer(GraphEngineLayer): the composition, the run fails asynchronously with ``run_failed`` — but the initial ``POST /runs`` already returned 202, so the API side has no visibility of the failure unless it waits for terminal status. The - ``composition_layer_specs`` persistence in A.1–A.4 plus the + ``runtime_layer_specs`` persistence in A.1–A.4 plus the ``_filter_snapshot_to_specs`` shape in ``build_cleanup_request`` keeps the two name lists in sync. @@ -144,13 +144,13 @@ class WorkflowAgentSessionCleanupLayer(GraphEngineLayer): ) return - if not stored_session.composition_layer_specs: + if not stored_session.runtime_layer_specs: # Sessions persisted before A.1 landed do not carry the spec list, # so we cannot replay a valid cleanup composition. Leave the row # ACTIVE and warn so the absence shows up in observability rather # than being silently swallowed by a doomed cleanup run. logger.warning( - "Skipping Agent backend cleanup: no composition_layer_specs persisted. " + "Skipping Agent backend cleanup: no runtime_layer_specs persisted. " "workflow_run_id=%s node_id=%s agent_id=%s", scope.workflow_run_id, scope.node_id, @@ -160,7 +160,7 @@ class WorkflowAgentSessionCleanupLayer(GraphEngineLayer): request = self._request_builder.build_cleanup_request( session_snapshot=stored_session.session_snapshot, - composition_layer_specs=stored_session.composition_layer_specs, + runtime_layer_specs=stored_session.runtime_layer_specs, idempotency_key=f"{scope.workflow_run_id}:{scope.node_id}:{scope.binding_id}:agent-session-cleanup", metadata={ "tenant_id": scope.tenant_id, diff --git a/api/core/workflow/nodes/agent_v2/session_store.py b/api/core/workflow/nodes/agent_v2/session_store.py index f3625cb7367..08a83ad5319 100644 --- a/api/core/workflow/nodes/agent_v2/session_store.py +++ b/api/core/workflow/nodes/agent_v2/session_store.py @@ -3,10 +3,10 @@ from __future__ import annotations from dataclasses import dataclass, field from agenton.compositor import CompositorSessionSnapshot +from dify_agent.protocol import RuntimeLayerSpec from pydantic import TypeAdapter from sqlalchemy import select -from clients.agent_backend.request_builder import CleanupLayerSpec from core.db.session_factory import session_factory from libs.datetime_utils import naive_utc_now from models.agent import ( @@ -15,14 +15,14 @@ from models.agent import ( WorkflowAgentRuntimeSessionStatus, ) -_SPECS_ADAPTER: TypeAdapter[list[CleanupLayerSpec]] = TypeAdapter(list[CleanupLayerSpec]) +_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec]) -def _serialize_specs(specs: list[CleanupLayerSpec]) -> str: +def _serialize_specs(specs: list[RuntimeLayerSpec]) -> str: return _SPECS_ADAPTER.dump_json(specs).decode() -def _deserialize_specs(value: str | None) -> list[CleanupLayerSpec]: +def _deserialize_specs(value: str | None) -> list[RuntimeLayerSpec]: if not value: return [] return _SPECS_ADAPTER.validate_json(value) @@ -46,7 +46,7 @@ class StoredWorkflowAgentSession: scope: WorkflowAgentSessionScope session_snapshot: CompositorSessionSnapshot backend_run_id: str | None - composition_layer_specs: list[CleanupLayerSpec] = field(default_factory=list) + runtime_layer_specs: list[RuntimeLayerSpec] = field(default_factory=list) class WorkflowAgentRuntimeSessionStore: @@ -97,7 +97,7 @@ class WorkflowAgentRuntimeSessionStore: ), session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot), backend_run_id=row.backend_run_id, - composition_layer_specs=_deserialize_specs(row.composition_layer_specs), + runtime_layer_specs=_deserialize_specs(row.composition_layer_specs), ) for row in rows ] @@ -108,13 +108,13 @@ class WorkflowAgentRuntimeSessionStore: scope: WorkflowAgentSessionScope, backend_run_id: str, snapshot: CompositorSessionSnapshot | None, - composition_layer_specs: list[CleanupLayerSpec], + runtime_layer_specs: list[RuntimeLayerSpec], ) -> None: if scope.workflow_run_id is None or snapshot is None: return snapshot_json = snapshot.model_dump_json() - specs_json = _serialize_specs(composition_layer_specs) + specs_json = _serialize_specs(runtime_layer_specs) with session_factory.create_session() as session: row = session.scalar( select(WorkflowAgentRuntimeSession).where( diff --git a/api/models/agent.py b/api/models/agent.py index 8062ca6fe0a..9624bf53359 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -372,10 +372,9 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base): node_execution_id: Mapped[str | None] = mapped_column(String(255), nullable=True) binding_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) agent_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) - # JSON-encoded list of cleanup layer specs ({name, type, deps, config}). - # Drives Agent backend cleanup-only runs: the agenton compositor rejects a - # session snapshot whose layer names do not match the cleanup composition, - # so we replay the same layer graph (minus credential-bearing plugin layers). + # JSON-encoded list of non-sensitive runtime layer specs ({name, type, deps, + # config}). The persisted schema keeps its original name because the sandbox + # refactor intentionally avoids a storage migration. composition_layer_specs: Mapped[str] = mapped_column(LongText, nullable=False, server_default="[]") # Conversation-owner column (NULL for workflow owner). conversation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index 56b6b78093b..02a0109d938 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -1103,12 +1103,12 @@ List workflow apps that reference this Agent App's bound Agent (read-only) | 200 | Referencing workflows listed successfully | [AgentReferencingWorkflowsResponse](#agentreferencingworkflowsresponse) | | 404 | App not found | | -### /apps/{app_id}/agent-workspace/files +### /apps/{app_id}/agent-sandbox/files #### GET ##### Description -List a directory in an Agent App conversation's sandbox workspace (read-only) +List a directory in an Agent App conversation sandbox ##### Parameters @@ -1122,14 +1122,14 @@ List a directory in an Agent App conversation's sandbox workspace (read-only) | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Listing returned | [WorkspaceListResponse](#workspacelistresponse) | +| 200 | Listing returned | [SandboxListResponse](#sandboxlistresponse) | -### /apps/{app_id}/agent-workspace/files/download +### /apps/{app_id}/agent-sandbox/files/read #### GET ##### Description -Download a file from an Agent App conversation's sandbox workspace (read-only) +Read a text/binary preview file in an Agent App conversation sandbox ##### Parameters @@ -1143,29 +1143,27 @@ Download a file from an Agent App conversation's sandbox workspace (read-only) | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | File bytes | binary | -| 413 | File exceeds the workspace download limit | | +| 200 | Preview returned | [SandboxReadResponse](#sandboxreadresponse) | -### /apps/{app_id}/agent-workspace/files/preview +### /apps/{app_id}/agent-sandbox/files/upload -#### GET +#### POST ##### Description -Preview a text/binary file in an Agent App conversation's sandbox workspace +Upload one Agent App sandbox file as a Dify ToolFile mapping ##### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| conversation_id | query | Agent App conversation ID | Yes | string | -| path | query | File path relative to the sandbox workspace | Yes | string | +| app_id | path | | Yes | string | +| payload | body | | Yes | [AgentSandboxUploadPayload](#agentsandboxuploadpayload) | ##### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Preview returned | [WorkspacePreviewResponse](#workspacepreviewresponse) | +| 200 | Uploaded | [SandboxUploadResponse](#sandboxuploadresponse) | ### /apps/{app_id}/agent/logs @@ -2737,12 +2735,12 @@ Get workflow run node execution list | 200 | Node executions retrieved successfully | [WorkflowRunNodeExecutionListResponse](#workflowrunnodeexecutionlistresponse) | | 404 | Workflow run not found | | -### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files +### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files #### GET ##### Description -List a directory in a Workflow Agent node's sandbox workspace (read-only) +List a directory in a workflow Agent node sandbox ##### Parameters @@ -2758,14 +2756,14 @@ List a directory in a Workflow Agent node's sandbox workspace (read-only) | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Listing returned | [WorkspaceListResponse](#workspacelistresponse) | +| 200 | Listing returned | [SandboxListResponse](#sandboxlistresponse) | -### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/download +### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/read #### GET ##### Description -Download a file from a Workflow Agent node's sandbox workspace (read-only) +Read a text/binary preview file in a workflow Agent node sandbox ##### Parameters @@ -2781,31 +2779,29 @@ Download a file from a Workflow Agent node's sandbox workspace (read-only) | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | File bytes | binary | -| 413 | File exceeds the workspace download limit | | +| 200 | Preview returned | [SandboxReadResponse](#sandboxreadresponse) | -### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/preview +### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/upload -#### GET +#### POST ##### Description -Preview a text/binary file in a Workflow Agent node's sandbox workspace +Upload one workflow Agent sandbox file as a Dify ToolFile mapping ##### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| node_id | path | Workflow Agent node ID | Yes | string | -| workflow_run_id | path | Workflow run ID | Yes | string | -| node_execution_id | query | Optional workflow node execution ID. When omitted, the latest active session for the node is used. | No | string | -| path | query | File path relative to the sandbox workspace | Yes | string | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | +| workflow_run_id | path | | Yes | string | +| payload | body | | Yes | [WorkflowAgentSandboxUploadPayload](#workflowagentsandboxuploadpayload) | ##### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Preview returned | [WorkspacePreviewResponse](#workspacepreviewresponse) | +| 200 | Uploaded | [SandboxUploadResponse](#sandboxuploadresponse) | ### /apps/{app_id}/workflow/comments @@ -12320,6 +12316,13 @@ the current roster/workflow APIs scoped to Dify Agent. | image | string | | No | | working_dir | string | | No | +#### AgentSandboxUploadPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | Agent App conversation ID | Yes | +| path | string | File path relative to the sandbox workspace | Yes | + #### AgentScope Visibility and lifecycle scope of an Agent record. @@ -16598,6 +16601,47 @@ Payload for publishing snippet workflow. | instruction | string | Structured output generation instruction | Yes | | model_config | [ModelConfig](#modelconfig) | Model configuration | Yes | +#### SandboxFileEntryResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| mtime | integer | | No | +| name | string | | Yes | +| size | integer | | No | +| type | string | *Enum:* `"dir"`, `"file"`, `"other"`, `"symlink"` | Yes | + +#### SandboxListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| entries | [ [SandboxFileEntryResponse](#sandboxfileentryresponse) ] | | No | +| path | string | | Yes | +| truncated | boolean | | No | + +#### SandboxReadResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| binary | boolean | | Yes | +| path | string | | Yes | +| size | integer | | No | +| text | string | | No | +| truncated | boolean | | Yes | + +#### SandboxToolFileResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| reference | string | | Yes | +| transfer_method | string | | No | + +#### SandboxUploadResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| file | [SandboxToolFileResponse](#sandboxtoolfileresponse) | | Yes | +| path | string | | Yes | + #### SavedMessageCreatePayload | Name | Type | Description | Required | @@ -17641,6 +17685,13 @@ How a workflow node is bound to an Agent. | variant | string | | Yes | | workflow_id | string | | No | +#### WorkflowAgentSandboxUploadPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| node_execution_id | string | Optional workflow node execution ID. When omitted, the latest active session for the node is used. | No | +| path | string | File path relative to the sandbox workspace | Yes | + #### WorkflowAppLogPaginationResponse | Name | Type | Description | Required | @@ -18351,15 +18402,6 @@ Workflow tool configuration | remove_webapp_brand | boolean | | No | | replace_webapp_logo | string | | No | -#### WorkspaceFileEntryResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| mtime | integer | | Yes | -| name | string | | Yes | -| size | integer | | Yes | -| type | string | *Enum:* `"dir"`, `"file"`, `"symlink"` | Yes | - #### WorkspaceInfoPayload | Name | Type | Description | Required | @@ -18373,14 +18415,6 @@ Workflow tool configuration | limit | integer | | No | | page | integer | | No | -#### WorkspaceListResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| entries | [ [WorkspaceFileEntryResponse](#workspacefileentryresponse) ] | | No | -| path | string | | Yes | -| truncated | boolean | | No | - #### WorkspacePermissionResponse | Name | Type | Description | Required | @@ -18389,16 +18423,6 @@ Workflow tool configuration | allow_owner_transfer | boolean | | Yes | | workspace_id | string | | Yes | -#### WorkspacePreviewResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| binary | boolean | | Yes | -| path | string | | Yes | -| size | integer | | Yes | -| text | string | | No | -| truncated | boolean | | Yes | - #### _AnonymousInlineModel_7b67ac8a4db8 | Name | Type | Description | Required | diff --git a/api/services/agent_app_sandbox_service.py b/api/services/agent_app_sandbox_service.py new file mode 100644 index 00000000000..8836c02291c --- /dev/null +++ b/api/services/agent_app_sandbox_service.py @@ -0,0 +1,225 @@ +"""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. +""" + +from __future__ import annotations + +from collections.abc import Callable + +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 TypeAdapter +from sqlalchemy import select + +from configs import dify_config +from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore +from core.db.session_factory import session_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 AgentAppSandboxService: + """List/read/upload files in 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 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): + locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id) + return self._client_factory().upload_sandbox_file_sync(locator, path) + + 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, + ): + 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, + ) + 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, + ): + 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, + ) + 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, + ): + 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, + ) + return self._client_factory().upload_sandbox_file_sync(locator, path) + + def _resolve_locator( + self, + *, + tenant_id: str, + app_id: str, + workflow_run_id: str, + node_id: str, + node_execution_id: str | None, + ) -> 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) + + with session_factory.create_session() as session: + 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 _deserialize_runtime_layer_specs(value: str | None) -> list[RuntimeLayerSpec]: + if not value: + return [] + return _RUNTIME_LAYER_SPECS_ADAPTER.validate_json(value) + + +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", "AgentSandboxInspectorError", "WorkflowAgentSandboxService"] diff --git a/api/services/agent_app_workspace_service.py b/api/services/agent_app_workspace_service.py deleted file mode 100644 index d16d5e4d45d..00000000000 --- a/api/services/agent_app_workspace_service.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Resolve and proxy read-only access to an Agent App conversation's sandbox. - -The Agent App's shell layer runs bash in a per-conversation sandbox workspace on -the agent backend. The workspace identity (``session_id``) is generated inside -the shell layer and rides the conversation's ``session_snapshot``. This service -extracts that id and proxies list/preview/download to the agent backend's -read-only workspace endpoints, so the console can show a "sandbox file system" -inspector without the API ever touching shellctl directly. -""" - -from __future__ import annotations - -from collections.abc import Callable - -from agenton.compositor import CompositorSessionSnapshot -from sqlalchemy import select - -from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID -from clients.agent_backend.workspace_files_client import ( - WorkspaceDownloadResult, - WorkspaceFilesBackendClient, - WorkspaceListResult, - WorkspacePreviewResult, -) -from configs import dify_config -from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore -from core.db.session_factory import session_factory -from models.agent import ( - AgentRuntimeSessionOwnerType, - WorkflowAgentRuntimeSession, - WorkflowAgentRuntimeSessionStatus, -) - - -class AgentWorkspaceInspectorError(Exception): - """A workspace 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 AgentAppWorkspaceService: - """List/preview/download files in an Agent App conversation's sandbox workspace.""" - - def __init__( - self, - *, - session_store: AgentAppRuntimeSessionStore | None = None, - client_factory: Callable[[], WorkspaceFilesBackendClient] | None = None, - ) -> None: - self._session_store = session_store or AgentAppRuntimeSessionStore() - self._client_factory = client_factory or _default_client_factory - - def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspaceListResult: - session_id = self._resolve_session_id(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id) - return self._client_factory().list_files(session_id, path) - - def preview(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspacePreviewResult: - session_id = self._resolve_session_id(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id) - return self._client_factory().preview(session_id, path) - - def download(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspaceDownloadResult: - session_id = self._resolve_session_id(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id) - return self._client_factory().download(session_id, path) - - def _resolve_session_id(self, *, tenant_id: str, app_id: str, conversation_id: str) -> str: - snapshot = self._session_store.load_active_snapshot_for_conversation( - tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id - ) - if snapshot is None: - raise AgentWorkspaceInspectorError( - "no_active_session", - "this conversation has no active sandbox session yet", - status_code=404, - ) - session_id = _shell_session_id(snapshot) - if not session_id: - raise AgentWorkspaceInspectorError( - "no_sandbox", - "this conversation's agent has no sandbox workspace", - status_code=404, - ) - return session_id - - -class WorkflowAgentWorkspaceService: - """List/preview/download files in a Workflow Agent node sandbox workspace.""" - - def __init__( - self, - *, - client_factory: Callable[[], WorkspaceFilesBackendClient] | 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, - ) -> WorkspaceListResult: - session_id = self._resolve_session_id( - tenant_id=tenant_id, - app_id=app_id, - workflow_run_id=workflow_run_id, - node_id=node_id, - node_execution_id=node_execution_id, - ) - return self._client_factory().list_files(session_id, path) - - def preview( - self, - *, - tenant_id: str, - app_id: str, - workflow_run_id: str, - node_id: str, - node_execution_id: str | None, - path: str, - ) -> WorkspacePreviewResult: - session_id = self._resolve_session_id( - tenant_id=tenant_id, - app_id=app_id, - workflow_run_id=workflow_run_id, - node_id=node_id, - node_execution_id=node_execution_id, - ) - return self._client_factory().preview(session_id, path) - - def download( - self, - *, - tenant_id: str, - app_id: str, - workflow_run_id: str, - node_id: str, - node_execution_id: str | None, - path: str, - ) -> WorkspaceDownloadResult: - session_id = self._resolve_session_id( - tenant_id=tenant_id, - app_id=app_id, - workflow_run_id=workflow_run_id, - node_id=node_id, - node_execution_id=node_execution_id, - ) - return self._client_factory().download(session_id, path) - - def _resolve_session_id( - self, - *, - tenant_id: str, - app_id: str, - workflow_run_id: str, - node_id: str, - node_execution_id: str | None, - ) -> str: - 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) - - with session_factory.create_session() as session: - row = session.scalar(stmt) - - if row is None: - raise AgentWorkspaceInspectorError( - "no_active_session", - "this workflow Agent node has no active sandbox session yet", - status_code=404, - ) - snapshot = CompositorSessionSnapshot.model_validate_json(row.session_snapshot) - session_id = _shell_session_id(snapshot) - if not session_id: - raise AgentWorkspaceInspectorError( - "no_sandbox", - "this workflow Agent node has no sandbox workspace", - status_code=404, - ) - return session_id - - -def _shell_session_id(snapshot: CompositorSessionSnapshot) -> str | None: - for layer in snapshot.layers: - if layer.name == DIFY_SHELL_LAYER_ID: - session_id = layer.runtime_state.get("session_id") - return session_id if isinstance(session_id, str) and session_id else None - return None - - -def _default_client_factory() -> WorkspaceFilesBackendClient: - base_url = dify_config.AGENT_BACKEND_BASE_URL - if not base_url: - raise AgentWorkspaceInspectorError( - "inspector_unavailable", - "the sandbox file inspector is not available (agent backend not configured)", - status_code=503, - ) - return WorkspaceFilesBackendClient(base_url) - - -__all__ = ["AgentAppWorkspaceService", "AgentWorkspaceInspectorError", "WorkflowAgentWorkspaceService"] diff --git a/api/tests/unit_tests/clients/agent_backend/test_cleanup_composition_compositor_integration.py b/api/tests/unit_tests/clients/agent_backend/test_cleanup_composition_compositor_integration.py index d78bfe76536..00e74b1b659 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_cleanup_composition_compositor_integration.py +++ b/api/tests/unit_tests/clients/agent_backend/test_cleanup_composition_compositor_integration.py @@ -20,7 +20,7 @@ from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID from agenton_collections.layers.plain.basic import PromptLayer from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, PydanticAIHistoryLayer -from clients.agent_backend import AgentBackendRunRequestBuilder, CleanupLayerSpec +from clients.agent_backend import AgentBackendRunRequestBuilder, RuntimeLayerSpec def test_cleanup_request_passes_agenton_snapshot_validation(): @@ -35,12 +35,12 @@ def test_cleanup_request_passes_agenton_snapshot_validation(): # which is purely structural and does not depend on which non-plugin layer # types appear. persisted_specs = [ - CleanupLayerSpec( + RuntimeLayerSpec( name="workflow_node_job_prompt", type=PLAIN_PROMPT_LAYER_TYPE_ID, config={"prefix": "Do the cleanup."}, ), - CleanupLayerSpec(name="history", type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID), + RuntimeLayerSpec(name="history", type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID), ] # Saved snapshot still carries the LLM layer entry — cleanup's # ``_filter_snapshot_to_specs`` must drop it so names match. @@ -66,7 +66,7 @@ def test_cleanup_request_passes_agenton_snapshot_validation(): cleanup_request = AgentBackendRunRequestBuilder().build_cleanup_request( session_snapshot=full_snapshot, - composition_layer_specs=persisted_specs, + runtime_layer_specs=persisted_specs, ) # Drive the real agenton compositor through ``from_config`` + ``_create_run`` diff --git a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py index 833c400db44..5ab1912d4c3 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py +++ b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py @@ -36,7 +36,8 @@ from clients.agent_backend import ( AgentBackendOutputConfig, AgentBackendRunRequestBuilder, AgentBackendWorkflowNodeRunInput, - CleanupLayerSpec, + RuntimeLayerSpec, + extract_runtime_layer_specs, redact_for_agent_backend_log, ) from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID @@ -173,11 +174,11 @@ def test_request_builder_builds_cleanup_request_replays_persisted_layer_specs(): LayerSessionSnapshot(name="llm", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), ] ) - specs = [CleanupLayerSpec(name="history", type="pydantic_ai.history")] + specs = [RuntimeLayerSpec(name="history", type="pydantic_ai.history")] request = AgentBackendRunRequestBuilder().build_cleanup_request( session_snapshot=session_snapshot, - composition_layer_specs=specs, + runtime_layer_specs=specs, idempotency_key="run-1:node-1:binding-1:agent-session-cleanup", metadata={"workflow_run_id": "run-1"}, ) @@ -190,21 +191,19 @@ def test_request_builder_builds_cleanup_request_replays_persisted_layer_specs(): assert request.metadata["agent_backend_lifecycle"] == "session_cleanup" -def test_request_builder_rejects_empty_composition_layer_specs(): +def test_request_builder_rejects_empty_runtime_layer_specs(): """Empty specs would put us back in the original ``layers=[]`` trap that fails on agenton's snapshot-vs-composition validation.""" - with pytest.raises(ValueError, match="composition_layer_specs"): + with pytest.raises(ValueError, match="runtime_layer_specs"): AgentBackendRunRequestBuilder().build_cleanup_request( session_snapshot=CompositorSessionSnapshot(layers=[]), - composition_layer_specs=[], + runtime_layer_specs=[], ) -def test_extract_cleanup_layer_specs_drops_plugin_layers_keeps_configs(): +def test_extract_runtime_layer_specs_drops_plugin_layers_keeps_configs(): from dify_agent.protocol import RunComposition, RunLayerSpec - from clients.agent_backend import extract_cleanup_layer_specs - composition = RunComposition( layers=[ RunLayerSpec( @@ -228,7 +227,7 @@ def test_extract_cleanup_layer_specs_drops_plugin_layers_keeps_configs(): ] ) - specs = extract_cleanup_layer_specs(composition) + specs = extract_runtime_layer_specs(composition) assert [spec.name for spec in specs] == ["agent_soul_prompt", "history"] # Non-plugin configs are dumped as JSON-compatible dicts so the persisted diff --git a/api/tests/unit_tests/clients/agent_backend/test_workspace_files_client.py b/api/tests/unit_tests/clients/agent_backend/test_workspace_files_client.py deleted file mode 100644 index 65e8ae16454..00000000000 --- a/api/tests/unit_tests/clients/agent_backend/test_workspace_files_client.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Unit tests for the API-side workspace files backend client.""" - -from __future__ import annotations - -import base64 -import json -from collections.abc import Callable - -import httpx -import pytest - -from clients.agent_backend.errors import AgentBackendHTTPError, AgentBackendTransportError -from clients.agent_backend.workspace_files_client import WorkspaceFilesBackendClient - - -def _client(handler: Callable[[httpx.Request], httpx.Response]) -> WorkspaceFilesBackendClient: - return WorkspaceFilesBackendClient("http://backend", transport=httpx.MockTransport(handler)) - - -def test_list_files_parses_entries() -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/workspaces/abc1234/files" - assert request.url.params.get("path") == "sub" - return httpx.Response( - 200, - json={ - "path": "sub", - "entries": [{"name": "a.txt", "type": "file", "size": 3, "mtime": 10}], - "truncated": False, - }, - ) - - result = _client(handler).list_files("abc1234", "sub") - - assert result.path == "sub" - assert result.entries[0].name == "a.txt" - assert result.entries[0].type == "file" - assert result.truncated is False - - -def test_preview_parses_text() -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/workspaces/abc1234/files/preview" - return httpx.Response( - 200, json={"path": "n.txt", "size": 5, "truncated": False, "binary": False, "text": "hello"} - ) - - result = _client(handler).preview("abc1234", "n.txt") - - assert result.binary is False - assert result.text == "hello" - - -def test_download_decodes_base64_to_bytes() -> None: - raw = bytes(range(64)) - - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/workspaces/abc1234/files/download" - return httpx.Response( - 200, - json={ - "path": "b.bin", - "size": len(raw), - "truncated": False, - "content_base64": base64.b64encode(raw).decode(), - }, - ) - - result = _client(handler).download("abc1234", "b.bin") - - assert result.content == raw - assert result.size == 64 - - -def test_http_error_preserves_status_and_detail() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(404, json={"detail": {"code": "not_found", "message": "path not found in workspace"}}) - - with pytest.raises(AgentBackendHTTPError) as exc_info: - _client(handler).list_files("abc1234", "missing") - - assert exc_info.value.status_code == 404 - assert exc_info.value.detail == {"code": "not_found", "message": "path not found in workspace"} - - -def test_http_error_with_non_json_body_uses_response_text() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(500, text="backend exploded") - - with pytest.raises(AgentBackendHTTPError) as exc_info: - _client(handler).preview("abc1234", "note.txt") - - assert exc_info.value.status_code == 500 - assert exc_info.value.detail == "backend exploded" - - -def test_transport_failure_becomes_transport_error() -> None: - def handler(request: httpx.Request) -> httpx.Response: - raise httpx.ConnectError("connection refused") - - with pytest.raises(AgentBackendTransportError): - _client(handler).list_files("abc1234", ".") - - -def test_download_without_content_is_502() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"path": "b.bin", "size": 0, "truncated": False}) - - with pytest.raises(AgentBackendHTTPError) as exc_info: - _client(handler).download("abc1234", "b.bin") - - assert exc_info.value.status_code == 502 - - -def test_download_with_invalid_base64_is_502() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"path": "b.bin", "size": 3, "truncated": False, "content_base64": "not-@@@"}) - - with pytest.raises(AgentBackendHTTPError) as exc_info: - _client(handler).download("abc1234", "b.bin") - - assert exc_info.value.status_code == 502 - - -def test_download_uses_decoded_size_when_backend_size_is_invalid() -> None: - raw = b"abc" - - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={ - "path": "b.bin", - "size": "unknown", - "truncated": True, - "content_base64": base64.b64encode(raw).decode(), - }, - ) - - result = _client(handler).download("abc1234", "b.bin") - - assert result.size == len(raw) - assert result.truncated is True - - -def test_non_object_body_is_502() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, content=json.dumps([1, 2, 3]), headers={"content-type": "application/json"}) - - with pytest.raises(AgentBackendHTTPError) as exc_info: - _client(handler).list_files("abc1234", ".") - - assert exc_info.value.status_code == 502 diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py new file mode 100644 index 00000000000..b1e473ec6d7 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from inspect import unwrap +from types import SimpleNamespace + +import pytest +from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError +from dify_agent.protocol import SandboxListResponse, SandboxReadResponse, SandboxUploadResponse + +from controllers.console import agent_app_sandbox as module +from models.model import App, AppMode, IconType +from services.agent_app_sandbox_service import AgentSandboxInspectorError + + +class _AgentAppService: + def __init__(self) -> None: + self.calls: list[tuple[str, str, str, str, str]] = [] + + def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> SandboxListResponse: + self.calls.append(("list", tenant_id, app_id, conversation_id, path)) + return SandboxListResponse(path=path, entries=[], truncated=False) + + def read_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> SandboxReadResponse: + self.calls.append(("read", tenant_id, app_id, conversation_id, path)) + return SandboxReadResponse(path=path, size=5, truncated=False, binary=False, text="hello") + + def upload_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> SandboxUploadResponse: + self.calls.append(("upload", tenant_id, app_id, conversation_id, path)) + return SandboxUploadResponse( + path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"} + ) + + +class _WorkflowService: + def __init__(self) -> None: + self.calls: list[tuple[str, str, str, str, str, str | None, str]] = [] + + def list_files( + self, + *, + tenant_id: str, + app_id: str, + workflow_run_id: str, + node_id: str, + node_execution_id: str | None, + path: str, + ) -> SandboxListResponse: + self.calls.append(("list", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path)) + return SandboxListResponse(path=path, entries=[], truncated=False) + + def read_file( + self, + *, + tenant_id: str, + app_id: str, + workflow_run_id: str, + node_id: str, + node_execution_id: str | None, + path: str, + ) -> SandboxReadResponse: + self.calls.append(("read", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path)) + return SandboxReadResponse(path=path, size=5, truncated=False, binary=False, text="hello") + + def upload_file( + self, + *, + tenant_id: str, + app_id: str, + workflow_run_id: str, + node_id: str, + node_execution_id: str | None, + path: str, + ) -> SandboxUploadResponse: + self.calls.append(("upload", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path)) + return SandboxUploadResponse( + path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"} + ) + + +def _app_model(app_id: str = "app-1") -> App: + return App( + id=app_id, + tenant_id="tenant-1", + name="App", + mode=AppMode.AGENT, + icon_type=IconType.EMOJI, + icon="bot", + icon_background="#fff", + enable_site=False, + enable_api=False, + ) + + +def test_handle_maps_sandbox_and_agent_backend_errors() -> None: + assert module._handle(AgentSandboxInspectorError("no_sandbox", "no sandbox", status_code=404)) == ( + {"code": "no_sandbox", "message": "no sandbox"}, + 404, + ) + assert module._handle(DifyAgentHTTPError(404, {"code": "sandbox_path_not_found", "message": "missing"})) == ( + {"code": "sandbox_path_not_found", "message": "missing"}, + 404, + ) + assert module._handle(DifyAgentHTTPError(500, "backend exploded")) == ( + {"code": "agent_backend_error", "message": "backend exploded"}, + 500, + ) + assert module._handle(DifyAgentTimeoutError("connection refused")) == ( + {"code": "agent_backend_unreachable", "message": "connection refused"}, + 502, + ) + assert module._handle(DifyAgentClientError("transport failed")) == ( + {"code": "agent_backend_unreachable", "message": "transport failed"}, + 502, + ) + with pytest.raises(RuntimeError): + module._handle(RuntimeError("boom")) + + +def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None: + service = _AgentAppService() + monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service) + monkeypatch.setattr( + module, + "query_params_from_request", + lambda model: SimpleNamespace(conversation_id="conv-1", path="sub/report.txt"), + ) + monkeypatch.setattr( + module, + "request", + SimpleNamespace(get_json=lambda silent=True: {"conversation_id": "conv-1", "path": "report.txt"}), + ) + app_model = _app_model() + + listing = unwrap(module.AgentAppSandboxListResource.get)(object(), "tenant-1", app_model) + preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), "tenant-1", app_model) + upload = unwrap(module.AgentAppSandboxUploadResource.post)(object(), "tenant-1", app_model) + + assert listing["path"] == "sub/report.txt" + assert preview["text"] == "hello" + assert upload["file"]["reference"] == "dify-file-ref:file-1" + assert service.calls == [ + ("list", "tenant-1", "app-1", "conv-1", "sub/report.txt"), + ("read", "tenant-1", "app-1", "conv-1", "sub/report.txt"), + ("upload", "tenant-1", "app-1", "conv-1", "report.txt"), + ] + + +def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytest.MonkeyPatch) -> None: + class FailingService: + def list_files(self, **kwargs): + raise AgentSandboxInspectorError("no_active_session", "no active session", status_code=404) + + monkeypatch.setattr(module, "AgentAppSandboxService", FailingService) + monkeypatch.setattr( + module, "query_params_from_request", lambda model: SimpleNamespace(conversation_id="conv-1", path=".") + ) + + assert unwrap(module.AgentAppSandboxListResource.get)(object(), "tenant-1", _app_model()) == ( + {"code": "no_active_session", "message": "no active session"}, + 404, + ) + + +def test_workflow_agent_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None: + service = _WorkflowService() + monkeypatch.setattr(module, "WorkflowAgentSandboxService", lambda: service) + monkeypatch.setattr( + module, + "query_params_from_request", + lambda model: SimpleNamespace(node_execution_id="exec-1", path="out.txt"), + ) + monkeypatch.setattr( + module, + "request", + SimpleNamespace(get_json=lambda silent=True: {"node_execution_id": "exec-1", "path": "upload.txt"}), + ) + app_model = _app_model() + + listing = unwrap(module.WorkflowAgentSandboxListResource.get)( + object(), "tenant-1", app_model, "run-1", "agent-node" + ) + preview = unwrap(module.WorkflowAgentSandboxReadResource.get)( + object(), "tenant-1", app_model, "run-1", "agent-node" + ) + upload = unwrap(module.WorkflowAgentSandboxUploadResource.post)( + object(), "tenant-1", app_model, "run-1", "agent-node" + ) + + assert listing["path"] == "out.txt" + assert preview["text"] == "hello" + assert upload["file"]["reference"] == "dify-file-ref:file-1" + assert service.calls == [ + ("list", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"), + ("read", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"), + ("upload", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "upload.txt"), + ] diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_app_workspace.py b/api/tests/unit_tests/controllers/console/app/test_agent_app_workspace.py deleted file mode 100644 index e31beb100bf..00000000000 --- a/api/tests/unit_tests/controllers/console/app/test_agent_app_workspace.py +++ /dev/null @@ -1,216 +0,0 @@ -from __future__ import annotations - -from inspect import unwrap -from types import SimpleNamespace -from typing import cast - -import pytest -from flask import Response - -from clients.agent_backend.errors import AgentBackendHTTPError, AgentBackendTransportError -from clients.agent_backend.workspace_files_client import ( - WorkspaceDownloadResult, - WorkspaceFileEntry, - WorkspaceListResult, - WorkspacePreviewResult, -) -from controllers.console import agent_app_workspace as module -from models.model import App, AppMode, IconType -from services.agent_app_workspace_service import AgentWorkspaceInspectorError - - -class _AgentAppService: - def __init__(self) -> None: - self.calls: list[tuple[str, str, str, str, str]] = [] - - def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspaceListResult: - self.calls.append(("list", tenant_id, app_id, conversation_id, path)) - return WorkspaceListResult( - path=path, - entries=[WorkspaceFileEntry(name="a.txt", type="file", size=3, mtime=10)], - truncated=False, - ) - - def preview(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspacePreviewResult: - self.calls.append(("preview", tenant_id, app_id, conversation_id, path)) - return WorkspacePreviewResult(path=path, size=5, truncated=False, binary=False, text="hello") - - def download(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspaceDownloadResult: - self.calls.append(("download", tenant_id, app_id, conversation_id, path)) - return WorkspaceDownloadResult(path=path, size=3, truncated=False, content=b"abc") - - -class _WorkflowService: - def __init__(self) -> None: - self.calls: list[tuple[str, str, str, str, str, str | None, str]] = [] - - def list_files( - self, - *, - tenant_id: str, - app_id: str, - workflow_run_id: str, - node_id: str, - node_execution_id: str | None, - path: str, - ) -> WorkspaceListResult: - self.calls.append(("list", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path)) - return WorkspaceListResult(path=path, entries=[], truncated=False) - - def preview( - self, - *, - tenant_id: str, - app_id: str, - workflow_run_id: str, - node_id: str, - node_execution_id: str | None, - path: str, - ) -> WorkspacePreviewResult: - self.calls.append(("preview", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path)) - return WorkspacePreviewResult(path=path, size=5, truncated=False, binary=False, text="hello") - - def download( - self, - *, - tenant_id: str, - app_id: str, - workflow_run_id: str, - node_id: str, - node_execution_id: str | None, - path: str, - ) -> WorkspaceDownloadResult: - self.calls.append(("download", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path)) - return WorkspaceDownloadResult(path=path, size=3, truncated=False, content=b"abc") - - -def _app_model(app_id: str = "app-1") -> App: - return App( - id=app_id, - tenant_id="tenant-1", - name="App", - mode=AppMode.AGENT, - icon_type=IconType.EMOJI, - icon="bot", - icon_background="#fff", - enable_site=False, - enable_api=False, - ) - - -def test_handle_maps_workspace_and_agent_backend_errors() -> None: - assert module._handle(AgentWorkspaceInspectorError("no_sandbox", "no sandbox", status_code=404)) == ( - {"code": "no_sandbox", "message": "no sandbox"}, - 404, - ) - assert module._handle( - AgentBackendHTTPError("not found", status_code=404, detail={"code": "not_found", "message": "missing"}) - ) == ({"code": "not_found", "message": "missing"}, 404) - assert module._handle(AgentBackendHTTPError("bad", status_code=500, detail="backend exploded")) == ( - {"code": "agent_backend_error", "message": "backend exploded"}, - 500, - ) - assert module._handle(AgentBackendTransportError("connection refused")) == ( - {"code": "agent_backend_unreachable", "message": "connection refused"}, - 502, - ) - with pytest.raises(RuntimeError): - module._handle(RuntimeError("boom")) - - -def test_download_response_returns_binary_or_too_large_error() -> None: - response = cast( - Response, - module._download_response( - WorkspaceDownloadResult(path="dir/report.txt", size=3, truncated=False, content=b"abc") - ), - ) - - assert response.status_code == 200 - assert response.data == b"abc" - assert response.headers["Content-Disposition"] == 'attachment; filename="report.txt"' - assert response.headers["Content-Length"] == "3" - assert response.headers["X-Workspace-File-Size"] == "3" - - assert module._download_response(WorkspaceDownloadResult(path="", size=10, truncated=True, content=b"")) == ( - { - "code": "workspace_file_too_large", - "message": ( - "file exceeds the workspace download limit; use preview for partial text or download a smaller file" - ), - "size": 10, - }, - 413, - ) - - -def test_agent_app_workspace_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None: - service = _AgentAppService() - monkeypatch.setattr(module, "AgentAppWorkspaceService", lambda: service) - monkeypatch.setattr( - module, - "query_params_from_request", - lambda model: SimpleNamespace(conversation_id="conv-1", path="sub/report.txt"), - ) - app_model = _app_model() - - listing = unwrap(module.AgentAppWorkspaceListResource.get)(object(), "tenant-1", app_model) - preview = unwrap(module.AgentAppWorkspacePreviewResource.get)(object(), "tenant-1", app_model) - download = unwrap(module.AgentAppWorkspaceDownloadResource.get)(object(), "tenant-1", app_model) - - assert listing["entries"][0]["name"] == "a.txt" - assert preview["text"] == "hello" - assert download.data == b"abc" - assert service.calls == [ - ("list", "tenant-1", "app-1", "conv-1", "sub/report.txt"), - ("preview", "tenant-1", "app-1", "conv-1", "sub/report.txt"), - ("download", "tenant-1", "app-1", "conv-1", "sub/report.txt"), - ] - - -def test_agent_app_workspace_resource_returns_normalized_errors(monkeypatch: pytest.MonkeyPatch) -> None: - class FailingService: - def list_files(self, **kwargs): - raise AgentWorkspaceInspectorError("no_active_session", "no active session", status_code=404) - - monkeypatch.setattr(module, "AgentAppWorkspaceService", FailingService) - monkeypatch.setattr( - module, - "query_params_from_request", - lambda model: SimpleNamespace(conversation_id="conv-1", path="."), - ) - - assert unwrap(module.AgentAppWorkspaceListResource.get)(object(), "tenant-1", _app_model()) == ( - {"code": "no_active_session", "message": "no active session"}, - 404, - ) - - -def test_workflow_agent_workspace_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None: - service = _WorkflowService() - monkeypatch.setattr(module, "WorkflowAgentWorkspaceService", lambda: service) - monkeypatch.setattr( - module, - "query_params_from_request", - lambda model: SimpleNamespace(node_execution_id="exec-1", path="out.txt"), - ) - app_model = _app_model() - - listing = unwrap(module.WorkflowAgentWorkspaceListResource.get)( - object(), "tenant-1", app_model, "run-1", "agent-node" - ) - preview = unwrap(module.WorkflowAgentWorkspacePreviewResource.get)( - object(), "tenant-1", app_model, "run-1", "agent-node" - ) - download = unwrap(module.WorkflowAgentWorkspaceDownloadResource.get)( - object(), "tenant-1", app_model, "run-1", "agent-node" - ) - - assert listing["path"] == "out.txt" - assert preview["text"] == "hello" - assert download.data == b"abc" - assert service.calls == [ - ("list", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"), - ("preview", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"), - ("download", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"), - ] diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py index e6b587bdde7..b41e7b6ab09 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py @@ -9,7 +9,7 @@ from typing import Any, override import pytest from agenton.compositor import CompositorSessionSnapshot -from dify_agent.protocol import CancelRunRequest, CancelRunResponse +from dify_agent.protocol import CancelRunRequest, CancelRunResponse, RuntimeLayerSpec from clients.agent_backend import ( AgentBackendError, @@ -67,13 +67,15 @@ class _RecordingFakeAgentBackendRunClient(FakeAgentBackendRunClient): class _FakeSessionStore: def __init__(self, loaded: CompositorSessionSnapshot | None = None) -> None: self.loaded = loaded - self.saved: list[tuple[AgentAppSessionScope, str, CompositorSessionSnapshot | None]] = [] + self.saved: list[ + tuple[AgentAppSessionScope, str, CompositorSessionSnapshot | None, list[RuntimeLayerSpec]] + ] = [] def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None: return self.loaded - def save_active_snapshot(self, *, scope, backend_run_id, snapshot) -> None: - self.saved.append((scope, backend_run_id, snapshot)) + def save_active_snapshot(self, *, scope, backend_run_id, snapshot, runtime_layer_specs) -> None: + self.saved.append((scope, backend_run_id, snapshot, list(runtime_layer_specs))) def _soul() -> AgentSoulConfig: @@ -142,11 +144,17 @@ def test_successful_turn_publishes_chunk_and_message_end_and_saves_session(): assert end_events[0].llm_result.model == "gpt-4o-mini" # The conversation session snapshot is persisted for multi-turn continuity. assert store.saved - saved_scope, saved_run_id, saved_snapshot = store.saved[0] + saved_scope, saved_run_id, saved_snapshot, saved_specs = store.saved[0] assert saved_scope.conversation_id == "conv-1" assert saved_scope.agent_config_snapshot_id == "snap-1" assert saved_run_id == "fake-run-1" assert saved_snapshot is not None + assert [spec.name for spec in saved_specs] == [ + "agent_soul_prompt", + "agent_app_user_prompt", + "execution_context", + "history", + ] def test_prior_session_snapshot_is_threaded_into_request(): diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py b/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py index 03247087ec5..65a7e0bc8ec 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py @@ -13,6 +13,7 @@ import pytest from agenton.compositor import CompositorSessionSnapshot from agenton.compositor.schemas import LayerSessionSnapshot from agenton.layers.base import LifecycleState +from dify_agent.protocol import RuntimeLayerSpec from sqlalchemy import delete from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore, AgentAppSessionScope @@ -44,6 +45,13 @@ def _snapshot(messages: int = 1) -> CompositorSessionSnapshot: ) +def _runtime_layer_specs() -> list[RuntimeLayerSpec]: + return [ + RuntimeLayerSpec(name="execution_context", type="dify.execution_context", config={"tenant_id": "tenant-1"}), + RuntimeLayerSpec(name="history", type="pydantic_ai.history"), + ] + + @pytest.fixture(autouse=True) def _create_table() -> Generator[None, None, None]: engine = session_factory.get_session_maker().kw["bind"] @@ -61,7 +69,12 @@ def test_load_returns_none_when_no_row(): def test_save_creates_conversation_owned_row_and_round_trips(): store = AgentAppRuntimeSessionStore() - store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=2)) + store.save_active_snapshot( + scope=_scope(), + backend_run_id="run-1", + snapshot=_snapshot(messages=2), + runtime_layer_specs=_runtime_layer_specs(), + ) loaded = store.load_active_snapshot(_scope()) assert loaded is not None @@ -76,19 +89,36 @@ def test_save_creates_conversation_owned_row_and_round_trips(): assert row.agent_config_snapshot_id == "snap-1" assert row.workflow_run_id is None # conversation owner leaves workflow cols NULL assert row.backend_run_id == "run-1" + assert "execution_context" in row.composition_layer_specs + assert "history" in row.composition_layer_specs def test_save_is_noop_when_snapshot_missing(): store = AgentAppRuntimeSessionStore() - store.save_active_snapshot(scope=_scope(), backend_run_id="run-x", snapshot=None) + store.save_active_snapshot( + scope=_scope(), + backend_run_id="run-x", + snapshot=None, + runtime_layer_specs=_runtime_layer_specs(), + ) with session_factory.create_session() as session: assert session.query(AgentRuntimeSession).count() == 0 def test_second_turn_updates_same_conversation_row(): store = AgentAppRuntimeSessionStore() - store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=1)) - store.save_active_snapshot(scope=_scope(), backend_run_id="run-2", snapshot=_snapshot(messages=3)) + store.save_active_snapshot( + scope=_scope(), + backend_run_id="run-1", + snapshot=_snapshot(messages=1), + runtime_layer_specs=_runtime_layer_specs(), + ) + store.save_active_snapshot( + scope=_scope(), + backend_run_id="run-2", + snapshot=_snapshot(messages=3), + runtime_layer_specs=_runtime_layer_specs(), + ) with session_factory.create_session() as session: rows = session.query(AgentRuntimeSession).all() assert len(rows) == 1 @@ -97,11 +127,21 @@ def test_second_turn_updates_same_conversation_row(): def test_mark_cleaned_then_load_returns_none_and_save_resurrects(): store = AgentAppRuntimeSessionStore() - store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot()) + store.save_active_snapshot( + scope=_scope(), + backend_run_id="run-1", + snapshot=_snapshot(), + runtime_layer_specs=_runtime_layer_specs(), + ) store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1") assert store.load_active_snapshot(_scope()) is None # Re-entry revives the row. - store.save_active_snapshot(scope=_scope(), backend_run_id="run-2", snapshot=_snapshot(messages=2)) + store.save_active_snapshot( + scope=_scope(), + backend_run_id="run-2", + snapshot=_snapshot(messages=2), + runtime_layer_specs=_runtime_layer_specs(), + ) with session_factory.create_session() as session: row = session.query(AgentRuntimeSession).one() assert row.status == AgentRuntimeSessionStatus.ACTIVE @@ -111,8 +151,18 @@ def test_mark_cleaned_then_load_returns_none_and_save_resurrects(): def test_distinct_conversations_do_not_collide(): store = AgentAppRuntimeSessionStore() - store.save_active_snapshot(scope=_scope(conversation_id="conv-A"), backend_run_id="a", snapshot=_snapshot()) - store.save_active_snapshot(scope=_scope(conversation_id="conv-B"), backend_run_id="b", snapshot=_snapshot()) + store.save_active_snapshot( + scope=_scope(conversation_id="conv-A"), + backend_run_id="a", + snapshot=_snapshot(), + runtime_layer_specs=_runtime_layer_specs(), + ) + store.save_active_snapshot( + scope=_scope(conversation_id="conv-B"), + backend_run_id="b", + snapshot=_snapshot(), + runtime_layer_specs=_runtime_layer_specs(), + ) assert store.load_active_snapshot(_scope(conversation_id="conv-A")) is not None assert store.load_active_snapshot(_scope(conversation_id="conv-B")) is not None with session_factory.create_session() as session: @@ -125,9 +175,13 @@ def test_distinct_agent_config_snapshots_keep_only_latest_active_session(): scope=_scope(agent_config_snapshot_id="snap-1"), backend_run_id="a", snapshot=_snapshot(), + runtime_layer_specs=_runtime_layer_specs(), ) store.save_active_snapshot( - scope=_scope(agent_config_snapshot_id="snap-2"), backend_run_id="b", snapshot=_snapshot(messages=2) + scope=_scope(agent_config_snapshot_id="snap-2"), + backend_run_id="b", + snapshot=_snapshot(messages=2), + runtime_layer_specs=_runtime_layer_specs(), ) assert store.load_active_snapshot(_scope(agent_config_snapshot_id="snap-1")) is None @@ -139,62 +193,83 @@ def test_distinct_agent_config_snapshots_keep_only_latest_active_session(): assert [row.status for row in rows] == [AgentRuntimeSessionStatus.CLEANED, AgentRuntimeSessionStatus.ACTIVE] -def test_load_for_conversation_resolves_without_agent_or_config_scope(): +def test_load_active_session_for_conversation_resolves_without_agent_or_config_scope(): store = AgentAppRuntimeSessionStore() - store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=2)) + store.save_active_snapshot( + scope=_scope(), + backend_run_id="run-1", + snapshot=_snapshot(messages=2), + runtime_layer_specs=_runtime_layer_specs(), + ) - # The inspector only knows tenant/app/conversation, not the agent config version. - loaded = store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") + loaded = store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") assert loaded is not None - assert loaded.layers[0].runtime_state["messages"] == [ + assert loaded.session_snapshot.layers[0].runtime_state["messages"] == [ {"role": "user", "content": "m0"}, {"role": "user", "content": "m1"}, ] + assert [spec.name for spec in loaded.runtime_layer_specs] == ["execution_context", "history"] -def test_load_for_conversation_uses_latest_active_snapshot_after_config_change(): +def test_load_active_session_for_conversation_uses_latest_active_snapshot_after_config_change(): store = AgentAppRuntimeSessionStore() store.save_active_snapshot( - scope=_scope(agent_config_snapshot_id="snap-1"), backend_run_id="a", snapshot=_snapshot() + scope=_scope(agent_config_snapshot_id="snap-1"), + backend_run_id="a", + snapshot=_snapshot(), + runtime_layer_specs=_runtime_layer_specs(), ) store.save_active_snapshot( - scope=_scope(agent_config_snapshot_id="snap-2"), backend_run_id="b", snapshot=_snapshot(messages=3) + scope=_scope(agent_config_snapshot_id="snap-2"), + backend_run_id="b", + snapshot=_snapshot(messages=3), + runtime_layer_specs=_runtime_layer_specs(), ) - loaded = store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") + loaded = store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") assert loaded is not None - assert loaded.layers[0].runtime_state["messages"] == [ + assert loaded.session_snapshot.layers[0].runtime_state["messages"] == [ {"role": "user", "content": "m0"}, {"role": "user", "content": "m1"}, {"role": "user", "content": "m2"}, ] -def test_load_for_conversation_returns_none_when_cleaned_or_absent(): +def test_load_active_session_for_conversation_returns_none_when_cleaned_or_absent(): store = AgentAppRuntimeSessionStore() assert ( - store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") + store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") is None ) - store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot()) + store.save_active_snapshot( + scope=_scope(), + backend_run_id="run-1", + snapshot=_snapshot(), + runtime_layer_specs=_runtime_layer_specs(), + ) store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1") assert ( - store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") + store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") is None ) -def test_load_for_conversation_isolates_other_conversations(): +def test_load_active_session_for_conversation_isolates_other_conversations(): store = AgentAppRuntimeSessionStore() - store.save_active_snapshot(scope=_scope(conversation_id="conv-A"), backend_run_id="a", snapshot=_snapshot()) + store.save_active_snapshot( + scope=_scope(conversation_id="conv-A"), + backend_run_id="a", + snapshot=_snapshot(), + runtime_layer_specs=_runtime_layer_specs(), + ) assert ( - store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-B") + store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-B") is None ) assert ( - store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-A") + store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-A") is not None ) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py index 81ca22dbb9e..471e17468c2 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py @@ -8,9 +8,9 @@ from dify_agent.protocol import RunStartedEvent, RunSucceededEvent, RunSucceeded from clients.agent_backend import ( AgentBackendRunEventAdapter, AgentBackendStreamInternalEvent, - CleanupLayerSpec, FakeAgentBackendRunClient, FakeAgentBackendScenario, + RuntimeLayerSpec, ) from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom from core.workflow.file_reference import build_file_reference @@ -118,7 +118,7 @@ class FakeSessionStore: WorkflowAgentSessionScope, str, CompositorSessionSnapshot | None, - list[CleanupLayerSpec], + list[RuntimeLayerSpec], ] ] = [] self.cleaned: list[tuple[WorkflowAgentSessionScope, str | None]] = [] @@ -132,9 +132,9 @@ class FakeSessionStore: scope: WorkflowAgentSessionScope, backend_run_id: str, snapshot: CompositorSessionSnapshot | None, - composition_layer_specs: list[CleanupLayerSpec], + runtime_layer_specs: list[RuntimeLayerSpec], ) -> None: - self.saved.append((scope, backend_run_id, snapshot, list(composition_layer_specs))) + self.saved.append((scope, backend_run_id, snapshot, list(runtime_layer_specs))) def mark_cleaned( self, diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_cleanup_layer.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_cleanup_layer.py index 11e77f43caa..bb7947ce656 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_cleanup_layer.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_cleanup_layer.py @@ -7,7 +7,7 @@ from agenton.compositor.schemas import LayerSessionSnapshot from agenton.layers.base import LifecycleState from dify_agent.protocol import CancelRunRequest, RunEvent, RunStatusResponse -from clients.agent_backend import AgentBackendRunRequestBuilder, CleanupLayerSpec, FakeAgentBackendRunClient +from clients.agent_backend import AgentBackendRunRequestBuilder, FakeAgentBackendRunClient, RuntimeLayerSpec from clients.agent_backend.errors import AgentBackendHTTPError from core.workflow.nodes.agent_v2.session_cleanup_layer import WorkflowAgentSessionCleanupLayer from core.workflow.nodes.agent_v2.session_store import ( @@ -40,7 +40,7 @@ def _layer_snapshot(name: str) -> LayerSessionSnapshot: def _stored_session(scope: WorkflowAgentSessionScope, *, index: int = 1) -> StoredWorkflowAgentSession: """A typical stored session with prompt + execution_context + history + llm specs. - The LLM layer is *not* in ``composition_layer_specs`` because the cleanup + The LLM layer is *not* in ``runtime_layer_specs`` because the cleanup contract excludes credential-bearing plugin layers, but it *is* present in the saved snapshot so the layer's filter logic gets exercised. """ @@ -55,10 +55,10 @@ def _stored_session(scope: WorkflowAgentSessionScope, *, index: int = 1) -> Stor ] ), backend_run_id=f"agent-run-{index}", - composition_layer_specs=[ - CleanupLayerSpec(name="workflow_node_job_prompt", type="plain.prompt", config={"prefix": "ok"}), - CleanupLayerSpec(name="execution_context", type="dify.execution_context", config={"tenant_id": "t"}), - CleanupLayerSpec(name="history", type="pydantic_ai.history"), + runtime_layer_specs=[ + RuntimeLayerSpec(name="workflow_node_job_prompt", type="plain.prompt", config={"prefix": "ok"}), + RuntimeLayerSpec(name="execution_context", type="dify.execution_context", config={"tenant_id": "t"}), + RuntimeLayerSpec(name="history", type="pydantic_ai.history"), ], ) @@ -268,7 +268,7 @@ def test_cleanup_layer_marks_cleaned_locally_when_http_cleanup_disabled(): def test_cleanup_layer_skips_sessions_without_persisted_specs(): """Backwards-compatible safety net: a row written before A.1 landed has - no composition_layer_specs, so cleanup would unavoidably hit the snapshot- + no runtime_layer_specs, so cleanup would unavoidably hit the snapshot- validation trap. The layer must skip such rows instead of issuing a doomed request.""" scope = _default_scope() @@ -276,7 +276,7 @@ def test_cleanup_layer_skips_sessions_without_persisted_specs(): scope=scope, session_snapshot=CompositorSessionSnapshot(layers=[_layer_snapshot("history")]), backend_run_id="legacy-run", - composition_layer_specs=[], + runtime_layer_specs=[], ) session_store = FakeSessionStore(stored=[legacy_session]) agent_backend_client = _WaitableFakeAgentBackendRunClient() diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_store.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_store.py index 1c2d0d13019..a57b2adc168 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_store.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_store.py @@ -14,9 +14,9 @@ import pytest from agenton.compositor import CompositorSessionSnapshot from agenton.compositor.schemas import LayerSessionSnapshot from agenton.layers.base import LifecycleState +from dify_agent.protocol import RuntimeLayerSpec from sqlalchemy import delete -from clients.agent_backend.request_builder import CleanupLayerSpec from core.db.session_factory import session_factory from core.workflow.nodes.agent_v2.session_store import ( StoredWorkflowAgentSession, @@ -52,10 +52,10 @@ def _snapshot(messages: int = 1) -> CompositorSessionSnapshot: ) -def _specs() -> list[CleanupLayerSpec]: +def _specs() -> list[RuntimeLayerSpec]: return [ - CleanupLayerSpec(name="workflow_node_job_prompt", type="plain.prompt", config={"prefix": "ok"}), - CleanupLayerSpec(name="history", type="pydantic_ai.history"), + RuntimeLayerSpec(name="workflow_node_job_prompt", type="plain.prompt", config={"prefix": "ok"}), + RuntimeLayerSpec(name="history", type="pydantic_ai.history"), ] @@ -85,15 +85,17 @@ def test_load_active_snapshot_returns_none_when_no_row_matches(): def test_save_active_snapshot_creates_row_and_load_round_trips(): store = WorkflowAgentRuntimeSessionStore() snapshot = _snapshot(messages=2) - store.save_active_snapshot( - scope=_scope(), backend_run_id="run-1", snapshot=snapshot, composition_layer_specs=_specs() - ) + store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=snapshot, runtime_layer_specs=_specs()) loaded = store.load_active_snapshot(_scope()) assert loaded is not None assert len(loaded.layers) == 1 assert loaded.layers[0].name == "history" assert loaded.layers[0].runtime_state["messages"] == snapshot.layers[0].runtime_state["messages"] + with session_factory.create_session() as session: + row = session.query(WorkflowAgentRuntimeSession).one() + assert "workflow_node_job_prompt" in row.composition_layer_specs + assert "history" in row.composition_layer_specs def test_save_active_snapshot_skips_when_workflow_run_id_missing(): @@ -103,7 +105,7 @@ def test_save_active_snapshot_skips_when_workflow_run_id_missing(): scope=_scope(workflow_run_id=None), backend_run_id="run-skipped", snapshot=_snapshot(), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) with session_factory.create_session() as session: assert session.query(WorkflowAgentRuntimeSession).count() == 0 @@ -116,7 +118,7 @@ def test_save_active_snapshot_skips_when_snapshot_missing(): scope=_scope(), backend_run_id="run-empty", snapshot=None, - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) with session_factory.create_session() as session: assert session.query(WorkflowAgentRuntimeSession).count() == 0 @@ -129,14 +131,14 @@ def test_save_active_snapshot_updates_existing_row_on_re_entry(): scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=1), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) # Second call with new snapshot + backend_run_id. store.save_active_snapshot( scope=_scope(), backend_run_id="run-2", snapshot=_snapshot(messages=2), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) with session_factory.create_session() as session: @@ -154,7 +156,7 @@ def test_save_active_snapshot_resurrects_cleaned_row(): scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1") # Save again — the existing row was CLEANED; should be revived. @@ -162,7 +164,7 @@ def test_save_active_snapshot_resurrects_cleaned_row(): scope=_scope(), backend_run_id="run-2", snapshot=_snapshot(messages=3), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) with session_factory.create_session() as session: @@ -179,13 +181,13 @@ def test_list_active_sessions_returns_specs_and_snapshot(): scope=_scope(binding_id="binding-A"), backend_run_id="run-A", snapshot=_snapshot(), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) store.save_active_snapshot( scope=_scope(binding_id="binding-B"), backend_run_id="run-B", snapshot=_snapshot(messages=2), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) listed = store.list_active_sessions(workflow_run_id="wfr-1") @@ -193,8 +195,8 @@ def test_list_active_sessions_returns_specs_and_snapshot(): by_run = {s.backend_run_id: s for s in listed} assert isinstance(by_run["run-A"], StoredWorkflowAgentSession) # Specs round-trip through pydantic TypeAdapter — ensure deserialize works. - assert by_run["run-A"].composition_layer_specs[0].name == "workflow_node_job_prompt" - assert by_run["run-A"].composition_layer_specs[1].type == "pydantic_ai.history" + assert by_run["run-A"].runtime_layer_specs[0].name == "workflow_node_job_prompt" + assert by_run["run-A"].runtime_layer_specs[1].type == "pydantic_ai.history" # node_execution_id default-replaces NULL with "" when the DB column is None. assert by_run["run-A"].scope.node_execution_id == "node-exec-1" @@ -205,13 +207,13 @@ def test_list_active_sessions_skips_cleaned_rows(): scope=_scope(binding_id="binding-A"), backend_run_id="run-A", snapshot=_snapshot(), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) store.save_active_snapshot( scope=_scope(binding_id="binding-B"), backend_run_id="run-B", snapshot=_snapshot(), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) store.mark_cleaned(scope=_scope(binding_id="binding-A"), backend_run_id="cleanup-A") @@ -220,7 +222,7 @@ def test_list_active_sessions_skips_cleaned_rows(): def test_list_active_sessions_handles_legacy_rows_without_specs(): - """Rows persisted before composition_layer_specs landed have an empty string.""" + """Rows persisted before runtime_layer_specs landed have an empty string.""" # Insert a legacy-shape row directly: empty specs payload simulates a row # written before the spec persistence feature landed in A.1. store = WorkflowAgentRuntimeSessionStore() @@ -228,11 +230,11 @@ def test_list_active_sessions_handles_legacy_rows_without_specs(): scope=_scope(), backend_run_id="run-legacy", snapshot=_snapshot(), - composition_layer_specs=[], + runtime_layer_specs=[], ) listed = store.list_active_sessions(workflow_run_id="wfr-1") assert len(listed) == 1 - assert listed[0].composition_layer_specs == [] + assert listed[0].runtime_layer_specs == [] def test_mark_cleaned_sets_status_and_cleaned_at_with_backend_run_id(): @@ -241,7 +243,7 @@ def test_mark_cleaned_sets_status_and_cleaned_at_with_backend_run_id(): scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1") @@ -259,7 +261,7 @@ def test_mark_cleaned_preserves_existing_backend_run_id_when_none_given(): scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(), - composition_layer_specs=_specs(), + runtime_layer_specs=_specs(), ) store.mark_cleaned(scope=_scope(), backend_run_id=None) diff --git a/api/tests/unit_tests/services/test_agent_app_sandbox_service.py b/api/tests/unit_tests/services/test_agent_app_sandbox_service.py new file mode 100644 index 00000000000..c1ec5cd1ffe --- /dev/null +++ b/api/tests/unit_tests/services/test_agent_app_sandbox_service.py @@ -0,0 +1,313 @@ +"""Unit tests for the Agent App / workflow sandbox services.""" + +from __future__ import annotations + +from collections.abc import Generator +from datetime import datetime + +import pytest +from agenton.compositor import CompositorSessionSnapshot +from agenton.compositor.schemas import LayerSessionSnapshot +from agenton.layers.base import LifecycleState +from dify_agent.protocol import RuntimeLayerSpec, SandboxListResponse, SandboxReadResponse, SandboxUploadResponse +from sqlalchemy import delete + +from core.app.apps.agent_app.session_store import AgentAppSessionScope, StoredAgentAppSession +from core.db.session_factory import session_factory +from models.agent import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus +from services.agent_app_sandbox_service import ( + AgentAppSandboxService, + AgentSandboxInspectorError, + WorkflowAgentSandboxService, + _default_client_factory, +) + + +def _snapshot(*, session_id: str = "abc1234") -> CompositorSessionSnapshot: + return CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot(name="execution_context", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), + LayerSessionSnapshot( + name="shell", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={"session_id": session_id, "workspace_cwd": f"~/workspace/{session_id}"}, + ), + ] + ) + + +def _runtime_layer_specs() -> list[RuntimeLayerSpec]: + return [ + RuntimeLayerSpec(name="execution_context", type="dify.execution_context", config={"tenant_id": "tenant-1"}), + RuntimeLayerSpec(name="shell", type="dify.shell", deps={"execution_context": "execution_context"}, config={}), + ] + + +class FakeStore: + def __init__(self, session: StoredAgentAppSession | None) -> None: + self.session = session + self.scope: tuple[str, str, str] | None = None + + def load_active_session_for_conversation(self, *, tenant_id: str, app_id: str, conversation_id: str): + self.scope = (tenant_id, app_id, conversation_id) + return self.session + + +class FakeClient: + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + self.locators: list[object] = [] + + def list_sandbox_files_sync(self, locator, path: str) -> SandboxListResponse: + self.locators.append(locator) + self.calls.append(("list", path)) + return SandboxListResponse(path=path, entries=[], truncated=False) + + def read_sandbox_file_sync(self, locator, path: str, max_bytes: int = 262144) -> SandboxReadResponse: + del max_bytes + self.locators.append(locator) + self.calls.append(("read", path)) + return SandboxReadResponse(path=path, size=5, truncated=False, binary=False, text="hello") + + def upload_sandbox_file_sync(self, locator, path: str) -> SandboxUploadResponse: + self.locators.append(locator) + self.calls.append(("upload", path)) + return SandboxUploadResponse( + path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"} + ) + + +def _stored_session() -> StoredAgentAppSession: + return StoredAgentAppSession( + scope=AgentAppSessionScope( + tenant_id="tenant-1", + app_id="app-1", + conversation_id="conv-1", + agent_id="agent-1", + agent_config_snapshot_id="snapshot-1", + ), + session_snapshot=_snapshot(), + backend_run_id="run-1", + runtime_layer_specs=_runtime_layer_specs(), + ) + + +def test_agent_app_sandbox_service_builds_locator_and_proxies() -> None: + store = FakeStore(_stored_session()) + client = FakeClient() + service = AgentAppSandboxService(session_store=store, client_factory=lambda: client) # type: ignore[arg-type] + + result = service.list_files(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1", path=".") + + assert result.path == "." + assert client.calls == [("list", ".")] + assert store.scope == ("tenant-1", "app-1", "conv-1") + + +def test_agent_app_sandbox_service_raises_when_no_active_session() -> None: + service = AgentAppSandboxService(session_store=FakeStore(None), client_factory=lambda: FakeClient()) # type: ignore[arg-type] + + with pytest.raises(AgentSandboxInspectorError) as exc_info: + service.read_file(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1", path="note.txt") + + assert exc_info.value.code == "no_active_session" + assert exc_info.value.status_code == 404 + + +def test_agent_app_sandbox_service_raises_when_runtime_specs_cannot_build_locator() -> None: + broken_session = StoredAgentAppSession( + scope=AgentAppSessionScope( + tenant_id="tenant-1", + app_id="app-1", + conversation_id="conv-1", + agent_id="agent-1", + agent_config_snapshot_id="snapshot-1", + ), + session_snapshot=_snapshot(), + backend_run_id="run-1", + runtime_layer_specs=[], + ) + service = AgentAppSandboxService(session_store=FakeStore(broken_session), client_factory=lambda: FakeClient()) # type: ignore[arg-type] + + with pytest.raises(AgentSandboxInspectorError) as exc_info: + service.list_files(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1", path=".") + + assert exc_info.value.code == "no_sandbox" + + +def test_default_client_factory_requires_agent_backend_base_url(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("services.agent_app_sandbox_service.dify_config.AGENT_BACKEND_BASE_URL", "") + + with pytest.raises(AgentSandboxInspectorError) as exc_info: + _default_client_factory() + + assert exc_info.value.code == "inspector_unavailable" + assert exc_info.value.status_code == 503 + + +@pytest.fixture +def _runtime_session_table() -> Generator[None, None, None]: + engine = session_factory.get_session_maker().kw["bind"] + AgentRuntimeSession.__table__.create(bind=engine, checkfirst=True) + yield + with session_factory.create_session() as session: + session.execute(delete(AgentRuntimeSession)) + session.commit() + AgentRuntimeSession.__table__.drop(bind=engine, checkfirst=True) + + +def _insert_workflow_session( + *, + runtime_layer_specs: str | None = None, + workflow_run_id: str = "run-1", + node_id: str = "node-1", + node_execution_id: str = "node-exec-1", + binding_id: str = "binding-1", + backend_run_id: str = "backend-run-1", + updated_at: datetime | None = None, + session_id: str = "abc1234", +) -> None: + default_runtime_layer_specs = ( + '[{"name":"execution_context","type":"dify.execution_context","config":{"tenant_id":"tenant-1"}},' + '{"name":"shell","type":"dify.shell","deps":{"execution_context":"execution_context"},"config":{}}]' + ) + with session_factory.create_session() as session: + row = AgentRuntimeSession( + tenant_id="tenant-1", + app_id="app-1", + owner_type=AgentRuntimeSessionOwnerType.WORKFLOW_RUN, + workflow_id="workflow-1", + workflow_run_id=workflow_run_id, + node_id=node_id, + node_execution_id=node_execution_id, + binding_id=binding_id, + agent_id="agent-1", + agent_config_snapshot_id="snapshot-1", + backend_run_id=backend_run_id, + session_snapshot=_snapshot(session_id=session_id).model_dump_json(), + composition_layer_specs=runtime_layer_specs or default_runtime_layer_specs, + status=AgentRuntimeSessionStatus.ACTIVE, + ) + if updated_at is not None: + row.updated_at = updated_at + session.add(row) + session.commit() + + +@pytest.mark.usefixtures("_runtime_session_table") +def test_workflow_sandbox_service_resolves_locator_and_proxies() -> None: + _insert_workflow_session() + client = FakeClient() + service = WorkflowAgentSandboxService(client_factory=lambda: client) # type: ignore[arg-type] + + result = service.upload_file( + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="run-1", + node_id="node-1", + node_execution_id="node-exec-1", + path="report.txt", + ) + + assert result.file.reference == "dify-file-ref:file-1" + assert client.calls == [("upload", "report.txt")] + + +@pytest.mark.usefixtures("_runtime_session_table") +def test_workflow_sandbox_service_filters_by_node_execution_id() -> None: + _insert_workflow_session( + node_execution_id="node-exec-1", + binding_id="binding-1", + backend_run_id="run-a", + session_id="abc1234", + ) + _insert_workflow_session( + node_execution_id="node-exec-2", + binding_id="binding-2", + backend_run_id="run-b", + session_id="def5678", + ) + client = FakeClient() + service = WorkflowAgentSandboxService(client_factory=lambda: client) # type: ignore[arg-type] + + result = service.read_file( + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="run-1", + node_id="node-1", + node_execution_id="node-exec-2", + path="out.txt", + ) + + assert result.text == "hello" + assert client.calls == [("read", "out.txt")] + assert client.locators[0].session_snapshot.layers[1].runtime_state["session_id"] == "def5678" + + +@pytest.mark.usefixtures("_runtime_session_table") +def test_workflow_sandbox_service_uses_latest_active_session_when_execution_id_omitted() -> None: + _insert_workflow_session( + node_execution_id="node-exec-1", + binding_id="binding-1", + backend_run_id="run-older", + updated_at=datetime(2026, 1, 1, 0, 0, 0), + session_id="abc1234", + ) + _insert_workflow_session( + node_execution_id="node-exec-2", + binding_id="binding-2", + backend_run_id="run-newer", + updated_at=datetime(2026, 1, 1, 0, 0, 1), + session_id="def5678", + ) + client = FakeClient() + service = WorkflowAgentSandboxService(client_factory=lambda: client) # type: ignore[arg-type] + + result = service.list_files( + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="run-1", + node_id="node-1", + node_execution_id=None, + path=".", + ) + + assert result.path == "." + assert client.calls == [("list", ".")] + assert client.locators[0].session_snapshot.layers[1].runtime_state["session_id"] == "def5678" + + +@pytest.mark.usefixtures("_runtime_session_table") +def test_workflow_sandbox_service_raises_when_no_active_session() -> None: + service = WorkflowAgentSandboxService(client_factory=lambda: FakeClient()) # type: ignore[arg-type] + + with pytest.raises(AgentSandboxInspectorError) as exc_info: + service.list_files( + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="run-1", + node_id="node-1", + node_execution_id=None, + path=".", + ) + + assert exc_info.value.code == "no_active_session" + assert exc_info.value.status_code == 404 + + +@pytest.mark.usefixtures("_runtime_session_table") +def test_workflow_sandbox_service_raises_when_runtime_specs_missing() -> None: + _insert_workflow_session(runtime_layer_specs="[]") + service = WorkflowAgentSandboxService(client_factory=lambda: FakeClient()) # type: ignore[arg-type] + + with pytest.raises(AgentSandboxInspectorError) as exc_info: + service.list_files( + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="run-1", + node_id="node-1", + node_execution_id=None, + path=".", + ) + + assert exc_info.value.code == "no_sandbox" diff --git a/api/tests/unit_tests/services/test_agent_app_workspace_service.py b/api/tests/unit_tests/services/test_agent_app_workspace_service.py deleted file mode 100644 index dd8d69ba88e..00000000000 --- a/api/tests/unit_tests/services/test_agent_app_workspace_service.py +++ /dev/null @@ -1,284 +0,0 @@ -"""Unit tests for the Agent App sandbox workspace inspector service. - -These cover session-id resolution from the conversation snapshot and proxying to -the backend client, with fakes for the session store and client (no DB / no HTTP). -""" - -from __future__ import annotations - -from collections.abc import Generator - -import pytest -from agenton.compositor import CompositorSessionSnapshot -from agenton.compositor.schemas import LayerSessionSnapshot -from agenton.layers.base import LifecycleState -from sqlalchemy import delete - -from clients.agent_backend.workspace_files_client import ( - WorkspaceDownloadResult, - WorkspaceFileEntry, - WorkspaceListResult, - WorkspacePreviewResult, -) -from core.db.session_factory import session_factory -from models.agent import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus -from services.agent_app_workspace_service import ( - AgentAppWorkspaceService, - AgentWorkspaceInspectorError, - WorkflowAgentWorkspaceService, - _default_client_factory, -) - - -def _snapshot(*, shell: bool = True, session_id: str | None = "abc1234") -> CompositorSessionSnapshot: - layers = [LayerSessionSnapshot(name="history", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={})] - if shell and session_id is not None: - layers.append( - LayerSessionSnapshot( - name="shell", - lifecycle_state=LifecycleState.SUSPENDED, - runtime_state={"session_id": session_id, "workspace_cwd": f"~/workspace/{session_id}"}, - ) - ) - elif shell: - layers.append(LayerSessionSnapshot(name="shell", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={})) - return CompositorSessionSnapshot(layers=layers) - - -class FakeStore: - def __init__(self, snapshot: CompositorSessionSnapshot | None) -> None: - self._snapshot = snapshot - self.scope: tuple[str, str, str] | None = None - - def load_active_snapshot_for_conversation( - self, *, tenant_id: str, app_id: str, conversation_id: str - ) -> CompositorSessionSnapshot | None: - self.scope = (tenant_id, app_id, conversation_id) - return self._snapshot - - -class FakeClient: - def __init__(self) -> None: - self.calls: list[tuple[str, str, str]] = [] - - def list_files(self, session_id: str, path: str) -> WorkspaceListResult: - self.calls.append(("list", session_id, path)) - return WorkspaceListResult( - path=path, entries=[WorkspaceFileEntry(name="a.txt", type="file", size=1, mtime=1)], truncated=False - ) - - def preview(self, session_id: str, path: str) -> WorkspacePreviewResult: - self.calls.append(("preview", session_id, path)) - return WorkspacePreviewResult(path=path, size=5, truncated=False, binary=False, text="hello") - - def download(self, session_id: str, path: str) -> WorkspaceDownloadResult: - self.calls.append(("download", session_id, path)) - return WorkspaceDownloadResult(path=path, size=3, truncated=False, content=b"abc") - - -def _service( - snapshot: CompositorSessionSnapshot | None, -) -> tuple[AgentAppWorkspaceService, FakeClient, FakeStore]: - store = FakeStore(snapshot) - client = FakeClient() - service = AgentAppWorkspaceService(session_store=store, client_factory=lambda: client) # type: ignore[arg-type] - return service, client, store - - -def test_list_resolves_session_id_and_proxies() -> None: - service, client, store = _service(_snapshot(session_id="abc1234")) - - result = service.list_files(tenant_id="t1", app_id="app1", conversation_id="conv1", path="sub") - - assert result.entries[0].name == "a.txt" - assert client.calls == [("list", "abc1234", "sub")] - assert store.scope == ("t1", "app1", "conv1") - - -def test_preview_and_download_use_resolved_session() -> None: - service, client, _ = _service(_snapshot(session_id="abc1234")) - - preview = service.preview(tenant_id="t", app_id="a", conversation_id="c", path="n.txt") - download = service.download(tenant_id="t", app_id="a", conversation_id="c", path="b.bin") - - assert preview.text == "hello" - assert download.content == b"abc" - assert client.calls == [("preview", "abc1234", "n.txt"), ("download", "abc1234", "b.bin")] - - -def test_no_active_session_raises_404() -> None: - service, client, _ = _service(None) - - with pytest.raises(AgentWorkspaceInspectorError) as exc_info: - service.list_files(tenant_id="t", app_id="a", conversation_id="c", path=".") - - assert exc_info.value.code == "no_active_session" - assert exc_info.value.status_code == 404 - assert client.calls == [] - - -def test_snapshot_without_shell_layer_raises_no_sandbox() -> None: - service, _, _ = _service(_snapshot(shell=False)) - - with pytest.raises(AgentWorkspaceInspectorError) as exc_info: - service.list_files(tenant_id="t", app_id="a", conversation_id="c", path=".") - - assert exc_info.value.code == "no_sandbox" - assert exc_info.value.status_code == 404 - - -def test_shell_layer_without_session_id_raises_no_sandbox() -> None: - service, _, _ = _service(_snapshot(session_id=None)) - - with pytest.raises(AgentWorkspaceInspectorError) as exc_info: - service.preview(tenant_id="t", app_id="a", conversation_id="c", path="n.txt") - - assert exc_info.value.code == "no_sandbox" - - -def test_default_client_factory_requires_agent_backend_base_url(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("services.agent_app_workspace_service.dify_config.AGENT_BACKEND_BASE_URL", "") - - with pytest.raises(AgentWorkspaceInspectorError) as exc_info: - _default_client_factory() - - assert exc_info.value.code == "inspector_unavailable" - assert exc_info.value.status_code == 503 - - -@pytest.fixture -def _runtime_session_table() -> Generator[None, None, None]: - engine = session_factory.get_session_maker().kw["bind"] - AgentRuntimeSession.__table__.create(bind=engine, checkfirst=True) - yield - with session_factory.create_session() as session: - session.execute(delete(AgentRuntimeSession)) - session.commit() - AgentRuntimeSession.__table__.drop(bind=engine, checkfirst=True) - - -def _insert_workflow_session( - *, - workflow_run_id: str = "run-1", - node_id: str = "node-1", - node_execution_id: str = "node-exec-1", - binding_id: str = "binding-1", - session_id: str = "abc1234", -) -> None: - with session_factory.create_session() as session: - session.add( - AgentRuntimeSession( - tenant_id="tenant-1", - app_id="app-1", - owner_type=AgentRuntimeSessionOwnerType.WORKFLOW_RUN, - workflow_id="workflow-1", - workflow_run_id=workflow_run_id, - node_id=node_id, - node_execution_id=node_execution_id, - binding_id=binding_id, - agent_id="agent-1", - agent_config_snapshot_id="snapshot-1", - backend_run_id="backend-run-1", - session_snapshot=_snapshot(session_id=session_id).model_dump_json(), - composition_layer_specs="[]", - status=AgentRuntimeSessionStatus.ACTIVE, - ) - ) - session.commit() - - -@pytest.mark.usefixtures("_runtime_session_table") -def test_workflow_workspace_service_resolves_run_node_session_and_proxies() -> None: - _insert_workflow_session(session_id="def5678") - client = FakeClient() - service = WorkflowAgentWorkspaceService(client_factory=lambda: client) # type: ignore[arg-type] - - result = service.list_files( - tenant_id="tenant-1", - app_id="app-1", - workflow_run_id="run-1", - node_id="node-1", - node_execution_id="node-exec-1", - path=".", - ) - - assert result.entries[0].name == "a.txt" - assert client.calls == [("list", "def5678", ".")] - - -@pytest.mark.usefixtures("_runtime_session_table") -def test_workflow_workspace_service_filters_by_node_execution_id() -> None: - _insert_workflow_session(node_execution_id="node-exec-1", session_id="abc1234") - _insert_workflow_session(node_execution_id="node-exec-2", binding_id="binding-2", session_id="def5678") - client = FakeClient() - service = WorkflowAgentWorkspaceService(client_factory=lambda: client) # type: ignore[arg-type] - - _ = service.preview( - tenant_id="tenant-1", - app_id="app-1", - workflow_run_id="run-1", - node_id="node-1", - node_execution_id="node-exec-2", - path="out.txt", - ) - - assert client.calls == [("preview", "def5678", "out.txt")] - - -@pytest.mark.usefixtures("_runtime_session_table") -def test_workflow_workspace_service_download_uses_latest_active_session_when_execution_id_is_omitted() -> None: - _insert_workflow_session(node_execution_id="node-exec-1", session_id="abc1234") - client = FakeClient() - service = WorkflowAgentWorkspaceService(client_factory=lambda: client) # type: ignore[arg-type] - - result = service.download( - tenant_id="tenant-1", - app_id="app-1", - workflow_run_id="run-1", - node_id="node-1", - node_execution_id=None, - path="out.bin", - ) - - assert result.content == b"abc" - assert client.calls == [("download", "abc1234", "out.bin")] - - -@pytest.mark.usefixtures("_runtime_session_table") -def test_workflow_workspace_service_raises_when_no_active_session_exists() -> None: - client = FakeClient() - service = WorkflowAgentWorkspaceService(client_factory=lambda: client) # type: ignore[arg-type] - - with pytest.raises(AgentWorkspaceInspectorError) as exc_info: - service.list_files( - tenant_id="tenant-1", - app_id="app-1", - workflow_run_id="run-1", - node_id="node-1", - node_execution_id=None, - path=".", - ) - - assert exc_info.value.code == "no_active_session" - assert exc_info.value.status_code == 404 - assert client.calls == [] - - -@pytest.mark.usefixtures("_runtime_session_table") -def test_workflow_workspace_service_raises_when_snapshot_has_no_shell_session() -> None: - _insert_workflow_session(session_id="") - client = FakeClient() - service = WorkflowAgentWorkspaceService(client_factory=lambda: client) # type: ignore[arg-type] - - with pytest.raises(AgentWorkspaceInspectorError) as exc_info: - service.preview( - tenant_id="tenant-1", - app_id="app-1", - workflow_run_id="run-1", - node_id="node-1", - node_execution_id=None, - path="out.txt", - ) - - assert exc_info.value.code == "no_sandbox" - assert client.calls == [] diff --git a/dify-agent/src/dify_agent/client/__init__.py b/dify-agent/src/dify_agent/client/__init__.py index ff0027b2912..d7bd4204ddd 100644 --- a/dify-agent/src/dify_agent/client/__init__.py +++ b/dify-agent/src/dify_agent/client/__init__.py @@ -1,4 +1,4 @@ -"""Unified sync and async Python client for the Dify Agent run API.""" +"""Unified sync and async Python client for the Dify Agent HTTP API.""" from ._client import ( Client, diff --git a/dify-agent/src/dify_agent/client/_client.py b/dify-agent/src/dify_agent/client/_client.py index 8de3af7c391..0ab8dc0dcf3 100644 --- a/dify-agent/src/dify_agent/client/_client.py +++ b/dify-agent/src/dify_agent/client/_client.py @@ -1,13 +1,12 @@ -"""HTTPX-based client for Dify Agent runs. +"""HTTPX-based client for the Dify Agent HTTP API. -The client uses the public DTOs from ``dify_agent.protocol.schemas`` for all -normal request and response parsing. It intentionally does not retry -``POST /runs`` because create-run is not idempotent, and create helpers require a -``CreateRunRequest`` instance rather than accepting raw payload dicts. SSE -streams are the only operation with reconnect logic: transient stream, connect, -or read failures, stream timeouts, and HTTP 5xx stream responses reconnect with -the latest observed event id, while HTTP 4xx responses, DTO validation failures, -and malformed SSE frames fail immediately. +The client uses the public DTOs from ``dify_agent.protocol`` for request and +response parsing across both run-management and sandbox-file endpoints. It +intentionally does not retry non-idempotent ``POST`` requests such as +``/runs``. SSE streams are the only operation with reconnect logic: transient +stream, connect, or read failures, stream timeouts, and HTTP 5xx stream +responses reconnect with the latest observed event id, while HTTP 4xx +responses, DTO validation failures, and malformed SSE frames fail immediately. """ from __future__ import annotations @@ -26,7 +25,7 @@ import httpx from pydantic import BaseModel, ValidationError from pydantic_ai.messages import FunctionToolResultEvent -from dify_agent.protocol.schemas import ( +from dify_agent.protocol import ( CancelRunRequest, CancelRunResponse, CreateRunRequest, @@ -35,6 +34,13 @@ from dify_agent.protocol.schemas import ( RunEvent, RunEventsResponse, RunStatusResponse, + SandboxListRequest, + SandboxListResponse, + SandboxLocator, + SandboxReadRequest, + SandboxReadResponse, + SandboxUploadRequest, + SandboxUploadResponse, ) _ResponseModelT = TypeVar("_ResponseModelT", bound=BaseModel) @@ -60,7 +66,7 @@ class DifyAgentHTTPError(DifyAgentClientError): class DifyAgentNotFoundError(DifyAgentHTTPError): - """Raised when the server returns ``404`` for a run resource.""" + """Raised when the server returns ``404`` for a requested Dify Agent resource.""" class DifyAgentValidationError(DifyAgentHTTPError): @@ -201,13 +207,15 @@ def _normalize_run_event_payload_for_local_pydantic_ai(payload: Any) -> Any: class Client: - """Unified synchronous and asynchronous client for Dify Agent runs. + """Unified synchronous and asynchronous client for the Dify Agent HTTP API. The instance is intentionally small and stateful: it stores base URL, default headers, timeout settings, optional external HTTPX clients, and lazy-owned - clients for whichever sync/async side is used. External clients are never - closed by this wrapper. Owned sync clients close via ``close_sync`` or the - sync context manager; owned async clients close via ``aclose`` or the async + clients for whichever sync/async side is used. It is the shared transport + boundary for both run-management endpoints (create/status/events/cancel) and + sandbox-file endpoints (list/read/upload). External clients are never closed + by this wrapper. Owned sync clients close via ``close_sync`` or the sync + context manager; owned async clients close via ``aclose`` or the async context manager. """ @@ -419,6 +427,62 @@ class Client: raise DifyAgentClientError(f"get_events_sync request failed: {exc}") from exc return _parse_model_response(response, RunEventsResponse) + async def list_sandbox_files(self, locator: SandboxLocator, path: str) -> SandboxListResponse: + """List a sandbox directory through ``POST /sandbox/files/list``.""" + request_model = _build_request_model(SandboxListRequest, locator=locator, path=path) + response = await self._post_async_json("list_sandbox_files", "/sandbox/files/list", request_model) + return _parse_model_response(response, SandboxListResponse) + + def list_sandbox_files_sync(self, locator: SandboxLocator, path: str) -> SandboxListResponse: + """Synchronous variant of ``list_sandbox_files``.""" + request_model = _build_request_model(SandboxListRequest, locator=locator, path=path) + response = self._post_sync_json("list_sandbox_files_sync", "/sandbox/files/list", request_model) + return _parse_model_response(response, SandboxListResponse) + + async def read_sandbox_file( + self, + locator: SandboxLocator, + path: str, + max_bytes: int = 262144, + ) -> SandboxReadResponse: + """Read a sandbox file preview through ``POST /sandbox/files/read``.""" + request_model = _build_request_model( + SandboxReadRequest, + locator=locator, + path=path, + max_bytes=max_bytes, + ) + response = await self._post_async_json("read_sandbox_file", "/sandbox/files/read", request_model) + return _parse_model_response(response, SandboxReadResponse) + + def read_sandbox_file_sync( + self, + locator: SandboxLocator, + path: str, + max_bytes: int = 262144, + ) -> SandboxReadResponse: + """Synchronous variant of ``read_sandbox_file``.""" + request_model = _build_request_model( + SandboxReadRequest, + locator=locator, + path=path, + max_bytes=max_bytes, + ) + response = self._post_sync_json("read_sandbox_file_sync", "/sandbox/files/read", request_model) + return _parse_model_response(response, SandboxReadResponse) + + async def upload_sandbox_file(self, locator: SandboxLocator, path: str) -> SandboxUploadResponse: + """Upload a sandbox file mapping through ``POST /sandbox/files/upload``.""" + request_model = _build_request_model(SandboxUploadRequest, locator=locator, path=path) + response = await self._post_async_json("upload_sandbox_file", "/sandbox/files/upload", request_model) + return _parse_model_response(response, SandboxUploadResponse) + + def upload_sandbox_file_sync(self, locator: SandboxLocator, path: str) -> SandboxUploadResponse: + """Synchronous variant of ``upload_sandbox_file``.""" + request_model = _build_request_model(SandboxUploadRequest, locator=locator, path=path) + response = self._post_sync_json("upload_sandbox_file_sync", "/sandbox/files/upload", request_model) + return _parse_model_response(response, SandboxUploadResponse) + async def stream_events( self, run_id: str, @@ -631,6 +695,32 @@ class Client: headers.update(extra) return headers + async def _post_async_json(self, operation: str, path: str, request_model: BaseModel) -> httpx.Response: + try: + return await self._get_async_http_client().post( + self._url(path), + content=request_model.model_dump_json(), + headers=self._merged_headers({"Content-Type": "application/json"}), + timeout=self._timeout, + ) + except httpx.TimeoutException as exc: + raise DifyAgentTimeoutError(f"{operation} timed out") from exc + except httpx.RequestError as exc: + raise DifyAgentClientError(f"{operation} request failed: {exc}") from exc + + def _post_sync_json(self, operation: str, path: str, request_model: BaseModel) -> httpx.Response: + try: + return self._get_sync_http_client().post( + self._url(path), + content=request_model.model_dump_json(), + headers=self._merged_headers({"Content-Type": "application/json"}), + timeout=self._timeout, + ) + except httpx.TimeoutException as exc: + raise DifyAgentTimeoutError(f"{operation} timed out") from exc + except httpx.RequestError as exc: + raise DifyAgentClientError(f"{operation} request failed: {exc}") from exc + def _validate_create_run_request(request: CreateRunRequest) -> CreateRunRequest: """Reject raw payloads so create-run uses the public request DTO boundary.""" @@ -639,6 +729,16 @@ def _validate_create_run_request(request: CreateRunRequest) -> CreateRunRequest: raise DifyAgentValidationError(detail="request must be a CreateRunRequest") +def _build_request_model[_RequestModelT: BaseModel]( + model_type: type[_RequestModelT], /, **payload: object +) -> _RequestModelT: + """Validate one request DTO built from method parameters.""" + try: + return model_type.model_validate(payload) + except ValidationError as exc: + raise DifyAgentValidationError(detail=exc.errors(include_url=False)) from exc + + def _parse_model_response(response: httpx.Response, model_type: type[_ResponseModelT]) -> _ResponseModelT: """Map HTTP errors and parse a Pydantic response DTO.""" _raise_for_status(response) diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py index 31391ce2c24..52824e20050 100644 --- a/dify-agent/src/dify_agent/layers/shell/layer.py +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -19,12 +19,13 @@ Agenton still exits ``resource_context()`` but never transitions the layer to ``ACTIVE``. In that failed-enter path, normal suspend/delete hooks do not run, so the enter hook itself must perform best-effort business compensation before re-raising the failure. Agent Stub env injection uses shellctl's native per-run -``env`` argument only for user-visible ``shell.run``. +``env`` argument for user-visible ``shell.run`` and for trusted server-owned +fixed scripts executed through ``run_remote_script()``. """ from __future__ import annotations -from collections.abc import AsyncGenerator, Callable, Mapping, Sequence +from collections.abc import AsyncGenerator, Callable, Sequence from contextlib import asynccontextmanager import json import logging @@ -60,6 +61,7 @@ _SESSION_TIME_HEX_MASK = 0xFFFFF _SESSION_RANDOM_HEX_LENGTH = 2 _SESSION_ID_ATTEMPT_LIMIT = 256 _SESSION_ID_PATTERN = re.compile(r"^[0-9a-f]{7}$") +_REMOTE_COMMAND_MAX_OUTPUT_WINDOWS = 64 _SHELL_LAYER_PREFIX_PROMPT = """You have access to a shell layer. It provides four tools: 1. shell_run @@ -179,7 +181,7 @@ class ShellctlClientProtocol(Protocol): script: str, *, cwd: str | None = None, - env: Mapping[str, str] | None = None, + env: dict[str, str] | None = None, timeout: float = DEFAULT_TIMEOUT_SECONDS, ) -> JobResult: ... @@ -275,6 +277,20 @@ class DifyShellRuntimeState(BaseModel): return self +@dataclass(frozen=True, slots=True) +class RemoteCommandResult: + """Completed remote sandbox command returned to server-owned callers.""" + + job_id: str + status: str + done: bool + exit_code: int | None + output: str + offset: int + truncated: bool + output_path: str + + @dataclass(slots=True) class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerConfig, DifyShellRuntimeState]): """Shell tool layer backed by a live shellctl client while active. @@ -500,6 +516,35 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC except (RuntimeError, ValueError, ShellctlClientError) as exc: return _tool_error(str(exc), job_id=job_id) + async def run_remote_script( + self, + script: str, + *, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + inject_agent_stub_env: bool = False, + ) -> RemoteCommandResult: + """Run one trusted server-side script inside the sandbox workspace. + + The sandbox file service uses this boundary for fixed list/read/upload + helpers. The layer owns output paging, transient shellctl job cleanup, + and optional Agent Stub env injection. + + Unlike model-visible ``shell.run``, this server-owned boundary does not + source ``.dify/env.sh``. That file is user-controlled shell config, so + sourcing it here would let sandbox code clobber trusted Agent Stub env + values before ``dify-agent file upload`` executes. + """ + env = None + if inject_agent_stub_env: + env = self._build_user_shell_run_env() + if env is None: + raise RuntimeError("Agent Stub environment injection is not available for this shell session.") + return await self._run_remote_job_to_completion( + script, + timeout=timeout, + env=env, + ) + async def _allocate_workspace(self) -> tuple[str, str]: """Allocate a unique ``~/workspace/`` directory by mkdir collision checks.""" for _attempt in range(_SESSION_ID_ATTEMPT_LIMIT): @@ -564,6 +609,44 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC self._track_job_result(result) return _job_result_observation(result) + async def _run_remote_job_to_completion( + self, + script: str, + *, + timeout: float, + env: dict[str, str] | None, + ) -> RemoteCommandResult: + """Run a workspace-scoped script to completion and delete its job state.""" + client = self._require_client() + job_id: str | None = None + try: + result = await client.run(script, cwd=self._require_workspace_cwd(), env=env, timeout=timeout) + job_id = result.job_id + self._track_job_result(result) + output_parts = [result.output] + truncated = result.truncated + windows = 1 + while (result.truncated or not result.done) and windows < _REMOTE_COMMAND_MAX_OUTPUT_WINDOWS: + result = await client.wait(result.job_id, offset=self._tracked_offset(result.job_id), timeout=timeout) + self._track_job_result(result) + output_parts.append(result.output) + truncated = truncated or result.truncated + windows += 1 + return RemoteCommandResult( + job_id=result.job_id, + status=result.status.value, + done=result.done, + exit_code=result.exit_code, + output="".join(output_parts), + offset=result.offset, + truncated=truncated, + output_path=result.output_path, + ) + finally: + if job_id is not None: + await self._delete_job_best_effort(job_id) + self._forget_tracked_job(job_id) + def _require_client(self) -> ShellctlClientProtocol: """Return the live client or reject tool/lifecycle use without one.""" if self._shellctl_client is None: @@ -635,31 +718,44 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC async def _delete_tracked_jobs_best_effort(self, job_ids: Sequence[str]) -> None: """Force-delete tracked shellctl jobs, ignoring already-missing ones.""" - client = self._require_client() for job_id in _deduplicate_preserving_order(job_ids): - try: - _ = await client.delete(job_id, force=True) - except ShellctlClientError as exc: - if exc.code == "job_not_found": - continue - logger.warning( - "Failed to delete shellctl job %s for session %s: %s", - job_id, - self.runtime_state.session_id, - exc, - ) - except RuntimeError as exc: - logger.warning( - "Failed to delete shellctl job %s for session %s: %s", - job_id, - self.runtime_state.session_id, - exc, - ) + await self._delete_job_best_effort(job_id) def _clear_tracked_jobs(self) -> None: self.runtime_state.job_offsets = {} self.runtime_state.job_ids = [] + async def _delete_job_best_effort(self, job_id: str) -> None: + client = self._require_client() + try: + _ = await client.delete(job_id, force=True) + except ShellctlClientError as exc: + if exc.code == "job_not_found": + return + logger.warning( + "Failed to delete shellctl job %s for session %s: %s", + job_id, + self.runtime_state.session_id, + exc, + ) + except RuntimeError as exc: + logger.warning( + "Failed to delete shellctl job %s for session %s: %s", + job_id, + self.runtime_state.session_id, + exc, + ) + + def _forget_tracked_job(self, job_id: str) -> None: + if job_id not in self.runtime_state.job_ids and job_id not in self.runtime_state.job_offsets: + return + job_offsets = dict(self.runtime_state.job_offsets) + _ = job_offsets.pop(job_id, None) + self.runtime_state.job_offsets = job_offsets + self.runtime_state.job_ids = [ + tracked_job_id for tracked_job_id in self.runtime_state.job_ids if tracked_job_id != job_id + ] + def _build_user_shell_run_env(self) -> dict[str, str] | None: """Build per-command Agent Stub env only for user-visible ``shell.run``.""" execution_context_layer = self.deps.execution_context @@ -834,6 +930,7 @@ __all__ = [ "DifyShellLayerDeps", "DifyShellLayer", "DifyShellRuntimeState", + "RemoteCommandResult", "ShellctlClientFactory", "ShellctlClientProtocol", "create_shellctl_client_factory", diff --git a/dify-agent/src/dify_agent/protocol/__init__.py b/dify-agent/src/dify_agent/protocol/__init__.py index e179d96793b..46dc9c0ea10 100644 --- a/dify-agent/src/dify_agent/protocol/__init__.py +++ b/dify-agent/src/dify_agent/protocol/__init__.py @@ -37,6 +37,21 @@ from .schemas import ( normalize_composition, utc_now, ) +from .sandbox import ( + RuntimeLayerSpec, + SandboxFileEntry, + SandboxListRequest, + SandboxListResponse, + SandboxLocator, + SandboxReadRequest, + SandboxReadResponse, + SandboxUploadRequest, + SandboxUploadResponse, + SandboxUploadedFile, + build_sandbox_locator_from_layer_specs, + build_sandbox_locator_from_run_request, + extract_runtime_layer_specs, +) __all__ = [ "BaseRunEvent", @@ -68,6 +83,19 @@ __all__ = [ "RunStatusResponse", "RunSucceededEvent", "RunSucceededEventData", + "RuntimeLayerSpec", + "SandboxFileEntry", + "SandboxListRequest", + "SandboxListResponse", + "SandboxLocator", + "SandboxReadRequest", + "SandboxReadResponse", + "SandboxUploadRequest", + "SandboxUploadResponse", + "SandboxUploadedFile", + "build_sandbox_locator_from_layer_specs", + "build_sandbox_locator_from_run_request", + "extract_runtime_layer_specs", "normalize_composition", "utc_now", ] diff --git a/dify-agent/src/dify_agent/protocol/sandbox.py b/dify-agent/src/dify_agent/protocol/sandbox.py new file mode 100644 index 00000000000..3d7b9aad129 --- /dev/null +++ b/dify-agent/src/dify_agent/protocol/sandbox.py @@ -0,0 +1,246 @@ +"""Public sandbox DTOs shared by the API and Dify Agent backends. + +The sandbox file APIs must rebuild only the minimum runtime needed to re-enter a +prior shell session: ``dify.execution_context`` for Dify-owned identity and +``dify.shell`` for the sandbox workspace itself. ``SandboxLocator`` therefore +contains a safe composition subset plus the matching filtered session snapshot. +Credential-bearing plugin layers are intentionally excluded from persisted +runtime specs and from sandbox locators. +""" + +from __future__ import annotations + +from typing import ClassVar, Literal, cast + +from agenton.compositor import CompositorSessionSnapshot +from agenton.compositor.schemas import LayerSessionSnapshot +from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID +from pydantic import BaseModel, ConfigDict, Field, JsonValue + +from .schemas import CreateRunRequest, RunComposition, RunLayerSpec + +_SENSITIVE_LAYER_TYPES = frozenset({DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID}) + + +class RuntimeLayerSpec(BaseModel): + """Persistable non-sensitive layer spec derived from a run composition. + + API-side runtime-session rows store these specs so later cleanup or sandbox + requests can rebuild the minimal layer graph without persisting model or + tool credentials. + """ + + name: str + type: str + deps: dict[str, str] = Field(default_factory=dict) + metadata: dict[str, JsonValue] = Field(default_factory=dict) + config: JsonValue = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxLocator(BaseModel): + """Safe subset of one prior run request needed to re-enter a sandbox shell.""" + + composition: RunComposition + session_snapshot: CompositorSessionSnapshot + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxFileEntry(BaseModel): + """One directory entry returned by ``/sandbox/files/list``.""" + + name: str + type: Literal["file", "dir", "symlink", "other"] + size: int | None = None + mtime: int | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxListRequest(BaseModel): + """Request body for listing a sandbox directory.""" + + locator: SandboxLocator + path: str = "." + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxListResponse(BaseModel): + """Structured sandbox directory listing.""" + + path: str + entries: list[SandboxFileEntry] + truncated: bool + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxReadRequest(BaseModel): + """Request body for reading a sandbox file preview.""" + + locator: SandboxLocator + path: str + max_bytes: int = 262144 + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxReadResponse(BaseModel): + """Text preview returned by ``/sandbox/files/read``.""" + + path: str + size: int | None = None + truncated: bool + binary: bool + text: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxUploadedFile(BaseModel): + """Canonical ToolFile mapping returned after sandbox upload.""" + + transfer_method: Literal["tool_file"] = "tool_file" + reference: str + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxUploadRequest(BaseModel): + """Request body for uploading one sandbox file through the Agent Stub.""" + + locator: SandboxLocator + path: str + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class SandboxUploadResponse(BaseModel): + """Result returned after sandbox upload creates a ToolFile mapping.""" + + path: str + file: SandboxUploadedFile + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +def extract_runtime_layer_specs(composition: RunComposition) -> list[RuntimeLayerSpec]: + """Project a run composition into the persistable non-sensitive layer list.""" + specs: list[RuntimeLayerSpec] = [] + for layer in composition.layers: + if layer.type in _SENSITIVE_LAYER_TYPES: + continue + config_value: JsonValue = None + if isinstance(layer.config, BaseModel): + config_value = layer.config.model_dump(mode="json", warnings=False) + else: + config_value = cast(JsonValue, layer.config) + specs.append( + RuntimeLayerSpec( + name=layer.name, + type=layer.type, + deps=dict(layer.deps), + metadata=dict(layer.metadata), + config=config_value, + ) + ) + return specs + + +def build_sandbox_locator_from_run_request(request: CreateRunRequest) -> SandboxLocator: + """Build a safe sandbox locator from a full create-run request. + + Raises: + ValueError: if the request has no resumable session snapshot or lacks the + execution-context/shell layers needed for sandbox access. + """ + if request.session_snapshot is None: + raise ValueError("Sandbox locator requires a non-empty session_snapshot.") + return build_sandbox_locator_from_layer_specs( + layer_specs=extract_runtime_layer_specs(request.composition), + session_snapshot=request.session_snapshot, + ) + + +def build_sandbox_locator_from_layer_specs( + *, + layer_specs: list[RuntimeLayerSpec], + session_snapshot: CompositorSessionSnapshot, +) -> SandboxLocator: + """Build a sandbox locator from persisted runtime specs plus a saved snapshot.""" + if not layer_specs: + raise ValueError("Sandbox locator requires persisted runtime layer specs.") + + for spec in layer_specs: + if spec.type in _SENSITIVE_LAYER_TYPES: + raise ValueError(f"Sandbox locator runtime specs must not include sensitive layer type {spec.type!r}.") + + execution_context_index = next( + (index for index, spec in enumerate(layer_specs) if spec.name == "execution_context"), + None, + ) + shell_index = next((index for index, spec in enumerate(layer_specs) if spec.name == "shell"), None) + if execution_context_index is None: + raise ValueError("Sandbox locator requires an 'execution_context' runtime layer spec.") + if shell_index is None: + raise ValueError("Sandbox locator requires a 'shell' runtime layer spec.") + if execution_context_index > shell_index: + raise ValueError("Sandbox locator requires 'execution_context' to appear before 'shell'.") + + execution_context_spec = layer_specs[execution_context_index] + shell_spec = layer_specs[shell_index] + if shell_spec.deps.get("execution_context") != execution_context_spec.name: + raise ValueError("Sandbox shell layer must depend on the execution_context layer.") + + kept_specs = [execution_context_spec, shell_spec] + kept_names = [spec.name for spec in kept_specs] + snapshot_layers = [layer for layer in session_snapshot.layers if layer.name in set(kept_names)] + if [layer.name for layer in snapshot_layers] != kept_names: + raise ValueError("Sandbox locator session_snapshot must contain execution_context and shell layers in order.") + + return SandboxLocator( + composition=RunComposition( + schema_version=1, + layers=[ + RunLayerSpec( + name=spec.name, + type=spec.type, + deps=dict(spec.deps), + metadata=dict(spec.metadata), + config=spec.config, + ) + for spec in kept_specs + ], + ), + session_snapshot=CompositorSessionSnapshot( + schema_version=session_snapshot.schema_version, + layers=[ + LayerSessionSnapshot( + name=layer.name, + lifecycle_state=layer.lifecycle_state, + runtime_state=dict(layer.runtime_state), + ) + for layer in snapshot_layers + ], + ), + ) + + +__all__ = [ + "RuntimeLayerSpec", + "SandboxFileEntry", + "SandboxListRequest", + "SandboxListResponse", + "SandboxLocator", + "SandboxReadRequest", + "SandboxReadResponse", + "SandboxUploadRequest", + "SandboxUploadResponse", + "SandboxUploadedFile", + "build_sandbox_locator_from_layer_specs", + "build_sandbox_locator_from_run_request", + "extract_runtime_layer_specs", +] diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py index 90e422b6432..f4eab601a2e 100644 --- a/dify-agent/src/dify_agent/server/app.py +++ b/dify-agent/src/dify_agent/server/app.py @@ -25,9 +25,9 @@ from dify_agent.agent_stub.server.router import create_agent_stub_router from dify_agent.runtime.compositor_factory import create_default_layer_providers from dify_agent.runtime.run_scheduler import RunScheduler from dify_agent.server.routes.runs import create_runs_router -from dify_agent.server.routes.workspace_files import create_workspace_files_router +from dify_agent.server.routes.sandbox_files import create_sandbox_files_router +from dify_agent.server.sandbox_files import SandboxFileService from dify_agent.server.settings import ServerSettings -from dify_agent.server.workspace_files import WorkspaceFileService from dify_agent.storage.redis_run_store import RedisRunStore @@ -44,13 +44,8 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: agent_stub_url=resolved_settings.agent_stub_url, agent_stub_token_codec=agent_stub_token_codec, ) - workspace_file_service = ( - WorkspaceFileService( - shellctl_entrypoint=resolved_settings.shellctl_entrypoint, - shellctl_auth_token=resolved_settings.shellctl_auth_token, - ) - if resolved_settings.shellctl_entrypoint - else None + sandbox_file_service = ( + SandboxFileService(layer_providers=layer_providers) if resolved_settings.shellctl_entrypoint else None ) state: dict[str, object] = {} @@ -100,8 +95,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: return state["scheduler"] # pyright: ignore[reportReturnType] app.include_router(create_runs_router(get_store, get_scheduler)) - # TODO: refactor - app.include_router(create_workspace_files_router(lambda: workspace_file_service)) + app.include_router(create_sandbox_files_router(lambda: sandbox_file_service)) app.include_router( create_agent_stub_router( token_codec=agent_stub_token_codec, diff --git a/dify-agent/src/dify_agent/server/routes/sandbox_files.py b/dify-agent/src/dify_agent/server/routes/sandbox_files.py new file mode 100644 index 00000000000..10429620cc9 --- /dev/null +++ b/dify-agent/src/dify_agent/server/routes/sandbox_files.py @@ -0,0 +1,73 @@ +"""FastAPI routes for sandbox file operations. + +The agent backend receives a structured ``SandboxLocator`` rather than a raw +shell session id. Routes stay private-network only like ``/runs`` and forward +all sandbox work to ``SandboxFileService``. +""" + +from collections.abc import Callable +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from dify_agent.protocol import ( + SandboxListRequest, + SandboxListResponse, + SandboxReadRequest, + SandboxReadResponse, + SandboxUploadRequest, + SandboxUploadResponse, +) +from dify_agent.server.sandbox_files import SandboxFileError, SandboxFileService + + +def create_sandbox_files_router(get_service: Callable[[], SandboxFileService | None]) -> APIRouter: + """Create sandbox file routes bound to the app's service provider.""" + router = APIRouter(prefix="/sandbox", tags=["sandbox"]) + + def service_dep() -> SandboxFileService: + service = get_service() + if service is None: + raise HTTPException( + status_code=503, + detail={"code": "sandbox_backend_unavailable", "message": "sandbox service is not configured"}, + ) + return service + + def raise_http(exc: SandboxFileError) -> HTTPException: + return HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) + + @router.post("/files/list", response_model=SandboxListResponse) + async def list_files( + request: SandboxListRequest, + service: Annotated[SandboxFileService, Depends(service_dep)], + ) -> SandboxListResponse: + try: + return await service.list_files(request) + except SandboxFileError as exc: + raise raise_http(exc) from exc + + @router.post("/files/read", response_model=SandboxReadResponse) + async def read_file( + request: SandboxReadRequest, + service: Annotated[SandboxFileService, Depends(service_dep)], + ) -> SandboxReadResponse: + try: + return await service.read_file(request) + except SandboxFileError as exc: + raise raise_http(exc) from exc + + @router.post("/files/upload", response_model=SandboxUploadResponse) + async def upload_file( + request: SandboxUploadRequest, + service: Annotated[SandboxFileService, Depends(service_dep)], + ) -> SandboxUploadResponse: + try: + return await service.upload_file(request) + except SandboxFileError as exc: + raise raise_http(exc) from exc + + return router + + +__all__ = ["create_sandbox_files_router"] diff --git a/dify-agent/src/dify_agent/server/routes/workspace_files.py b/dify-agent/src/dify_agent/server/routes/workspace_files.py deleted file mode 100644 index 22e4003f863..00000000000 --- a/dify-agent/src/dify_agent/server/routes/workspace_files.py +++ /dev/null @@ -1,78 +0,0 @@ -"""FastAPI routes for read-only inspection of shell-layer workspaces. - -These endpoints back the Dify "sandbox file system" inspector. They are -read-only and scoped to a single ``~/workspace/`` directory; the -heavy lifting (path containment, PTY-safe transport) lives in -``WorkspaceFileService``. Like the runs router, they rely on network isolation -rather than per-request auth. -""" - -from collections.abc import Callable -from typing import Annotated - -from fastapi import APIRouter, Depends, HTTPException, Query - -from dify_agent.server.workspace_files import ( - WorkspaceDownloadResponse, - WorkspaceFileError, - WorkspaceFileService, - WorkspaceListResponse, - WorkspacePreviewResponse, -) - - -def create_workspace_files_router( - get_service: Callable[[], WorkspaceFileService | None], -) -> APIRouter: - """Create read-only workspace file routes bound to the app's service provider.""" - router = APIRouter(prefix="/workspaces", tags=["workspaces"]) - - def service_dep() -> WorkspaceFileService: - service = get_service() - if service is None: - raise HTTPException( - status_code=503, - detail="workspace inspector is not configured (no shellctl entrypoint)", - ) - return service - - def _raise_http(exc: WorkspaceFileError) -> HTTPException: - return HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) - - @router.get("/{session_id}/files", response_model=WorkspaceListResponse) - async def list_files( - session_id: str, - service: Annotated[WorkspaceFileService, Depends(service_dep)], - path: str = Query(default="."), - ) -> WorkspaceListResponse: - try: - return await service.list_dir(session_id, path) - except WorkspaceFileError as exc: - raise _raise_http(exc) from exc - - @router.get("/{session_id}/files/preview", response_model=WorkspacePreviewResponse) - async def preview_file( - session_id: str, - service: Annotated[WorkspaceFileService, Depends(service_dep)], - path: str = Query(...), - ) -> WorkspacePreviewResponse: - try: - return await service.preview(session_id, path) - except WorkspaceFileError as exc: - raise _raise_http(exc) from exc - - @router.get("/{session_id}/files/download", response_model=WorkspaceDownloadResponse) - async def download_file( - session_id: str, - service: Annotated[WorkspaceFileService, Depends(service_dep)], - path: str = Query(...), - ) -> WorkspaceDownloadResponse: - try: - return await service.download(session_id, path) - except WorkspaceFileError as exc: - raise _raise_http(exc) from exc - - return router - - -__all__ = ["create_workspace_files_router"] diff --git a/dify-agent/src/dify_agent/server/sandbox_files.py b/dify-agent/src/dify_agent/server/sandbox_files.py new file mode 100644 index 00000000000..034fcf1d06b --- /dev/null +++ b/dify-agent/src/dify_agent/server/sandbox_files.py @@ -0,0 +1,399 @@ +"""Sandbox file service that re-enters prior shell sessions through the shell layer. + +Unlike the removed workspace inspector, this service never talks to shellctl +directly and never reads sandbox files outside the shell layer. It rebuilds a +minimal compositor from ``SandboxLocator``, enters the saved +``execution_context`` + ``shell`` layers, and executes fixed scripts through +``DifyShellLayer.run_remote_script()``. + +The scripts still frame their structured payloads with a PTY-safe +base64-between-sentinels envelope. shellctl jobs are tmux-backed, so raw JSON can +be wrapped or surrounded by prompt noise; the framing keeps list/read/upload +responses parseable without falling back to direct shellctl file access. +""" + +from __future__ import annotations + +import json +import base64 +import binascii +import shlex +import textwrap +from dataclasses import dataclass +from typing import TypeVar, cast + +from dify_agent.layers.shell.layer import DifyShellLayer, RemoteCommandResult +from dify_agent.protocol import ( + SandboxListRequest, + SandboxListResponse, + SandboxLocator, + SandboxReadRequest, + SandboxReadResponse, + SandboxUploadRequest, + SandboxUploadResponse, + normalize_composition, +) +from pydantic import BaseModel, ValidationError +from dify_agent.runtime.compositor_factory import DifyAgentLayerProvider, build_pydantic_ai_compositor + +_LIST_MAX_ENTRIES = 1000 +_LIST_TIMEOUT_SECONDS = 10.0 +_READ_TIMEOUT_SECONDS = 15.0 +_UPLOAD_TIMEOUT_SECONDS = 30.0 +_OUTPUT_BEGIN = "<<>>" +_OUTPUT_END = "<<>>" +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel) + +_LIST_SCRIPT = """ +import base64 +import json +import stat +import sys +from pathlib import Path + + +BEGIN = "<<>>" +END = "<<>>" + + +def emit(payload): + blob = base64.b64encode(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("ascii") + print(BEGIN + blob + END) + + +def resolve_target(root: Path, raw_path: str) -> tuple[Path | None, str]: + target = (root / raw_path).resolve() + if target != root and root not in target.parents: + emit({"error": "invalid_sandbox_path", "message": "path escapes the sandbox workspace"}) + return None, raw_path + return target, raw_path + + +root = Path.cwd().resolve() +raw_path = sys.argv[1] +limit = int(sys.argv[2]) +target_info = resolve_target(root, raw_path) +if target_info[0] is None: + sys.exit(0) +target, normalized_path = target_info + +if not target.exists(): + emit({"error": "sandbox_path_not_found", "message": "path not found in sandbox"}) + sys.exit(0) +if not target.is_dir(): + emit({"error": "sandbox_path_not_readable", "message": "path is not a directory"}) + sys.exit(0) + +entries = [] +for child in sorted(target.iterdir(), key=lambda item: item.name)[:limit]: + child_stat = child.lstat() + mode = child_stat.st_mode + if stat.S_ISLNK(mode): + entry_type = "symlink" + elif stat.S_ISDIR(mode): + entry_type = "dir" + elif stat.S_ISREG(mode): + entry_type = "file" + else: + entry_type = "other" + entries.append( + { + "name": child.name, + "type": entry_type, + "size": int(child_stat.st_size), + "mtime": int(child_stat.st_mtime), + } + ) + +emit( + { + "path": normalized_path, + "entries": entries, + "truncated": len(list(target.iterdir())) > limit, + } +) +""" + +_READ_SCRIPT = """ +import base64 +import json +import sys +from pathlib import Path + + +BEGIN = "<<>>" +END = "<<>>" + + +def emit(payload): + blob = base64.b64encode(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("ascii") + print(BEGIN + blob + END) + + +root = Path.cwd().resolve() +raw_path = sys.argv[1] +max_bytes = int(sys.argv[2]) +target = (root / raw_path).resolve() +if target != root and root not in target.parents: + emit({"error": "invalid_sandbox_path", "message": "path escapes the sandbox workspace"}) + sys.exit(0) +if not target.exists(): + emit({"error": "sandbox_path_not_found", "message": "path not found in sandbox"}) + sys.exit(0) +if not target.is_file(): + emit({"error": "sandbox_path_not_readable", "message": "path is not a readable file"}) + sys.exit(0) + +size = int(target.stat().st_size) +with target.open("rb") as file_obj: + data = file_obj.read(max_bytes + 1) + +truncated = len(data) > max_bytes +data = data[:max_bytes] +try: + text = data.decode("utf-8") +except UnicodeDecodeError: + emit( + { + "path": raw_path, + "size": size, + "truncated": truncated, + "binary": True, + "text": None, + } + ) + sys.exit(0) + +emit( + { + "path": raw_path, + "size": size, + "truncated": truncated, + "binary": False, + "text": text, + } +) +""" + +_UPLOAD_SCRIPT = """ +import base64 +import json +import subprocess +import sys +from pathlib import Path + + +BEGIN = "<<>>" +END = "<<>>" + + +def emit(payload): + blob = base64.b64encode(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("ascii") + print(BEGIN + blob + END) + + +root = Path.cwd().resolve() +raw_path = sys.argv[1] +target = (root / raw_path).resolve() +if target != root and root not in target.parents: + emit({"error": "invalid_sandbox_path", "message": "path escapes the sandbox workspace"}) + sys.exit(0) +if not target.exists(): + emit({"error": "sandbox_path_not_found", "message": "path not found in sandbox"}) + sys.exit(0) +if not target.is_file(): + emit({"error": "sandbox_path_not_readable", "message": "path is not a readable file"}) + sys.exit(0) + +command = ["dify-agent", "file", "upload", raw_path] +completed = subprocess.run(command, capture_output=True, text=True, check=False) +if completed.returncode != 0: + emit( + { + "error": "agent_stub_upload_failed", + "message": (completed.stderr or completed.stdout or f"upload exited with code {completed.returncode}").strip(), + } + ) + sys.exit(0) + +try: + file_mapping = json.loads(completed.stdout) +except ValueError as exc: + emit({"error": "agent_stub_upload_failed", "message": f"upload returned invalid JSON: {exc}"}) + sys.exit(0) + +emit({"path": raw_path, "file": file_mapping}) +""" + + +class SandboxFileError(Exception): + """Sandbox file failure mapped to HTTP by the FastAPI route layer.""" + + 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 + + +@dataclass(slots=True) +class SandboxFileService: + """Execute fixed sandbox file operations through the saved shell session.""" + + layer_providers: tuple[DifyAgentLayerProvider, ...] + + async def list_files(self, request: SandboxListRequest) -> SandboxListResponse: + normalized_path = _normalize_sandbox_path(request.path, allow_current_directory=True) + payload = await self._run_locator_script( + request.locator, + script_source=_LIST_SCRIPT, + args=[normalized_path, str(_LIST_MAX_ENTRIES)], + timeout=_LIST_TIMEOUT_SECONDS, + inject_agent_stub_env=False, + ) + return _validate_response_model(SandboxListResponse, payload) + + async def read_file(self, request: SandboxReadRequest) -> SandboxReadResponse: + normalized_path = _normalize_sandbox_path(request.path, allow_current_directory=False) + payload = await self._run_locator_script( + request.locator, + script_source=_READ_SCRIPT, + args=[normalized_path, str(request.max_bytes)], + timeout=_READ_TIMEOUT_SECONDS, + inject_agent_stub_env=False, + ) + return _validate_response_model(SandboxReadResponse, payload) + + async def upload_file(self, request: SandboxUploadRequest) -> SandboxUploadResponse: + normalized_path = _normalize_sandbox_path(request.path, allow_current_directory=False) + payload = await self._run_locator_script( + request.locator, + script_source=_UPLOAD_SCRIPT, + args=[normalized_path], + timeout=_UPLOAD_TIMEOUT_SECONDS, + inject_agent_stub_env=True, + ) + return _validate_response_model(SandboxUploadResponse, payload) + + async def _run_locator_script( + self, + locator: SandboxLocator, + *, + script_source: str, + args: list[str], + timeout: float, + inject_agent_stub_env: bool, + ) -> dict[str, object]: + try: + graph_config, layer_configs = normalize_composition(locator.composition) + compositor = build_pydantic_ai_compositor(graph_config, providers=self.layer_providers) + async with compositor.enter(configs=layer_configs, session_snapshot=locator.session_snapshot) as run: + run.suspend_on_exit() + shell_layer = run.get_layer("shell", DifyShellLayer) + result = await shell_layer.run_remote_script( + _build_python_script_command(script_source=script_source, args=args), + timeout=timeout, + inject_agent_stub_env=inject_agent_stub_env, + ) + except (KeyError, TypeError, ValueError) as exc: + raise SandboxFileError("invalid_sandbox_locator", str(exc), status_code=400) from exc + except RuntimeError as exc: + raise SandboxFileError("sandbox_command_failed", str(exc), status_code=502) from exc + + return _decode_sandbox_payload(result) + + +def _normalize_sandbox_path(path: str, *, allow_current_directory: bool) -> str: + normalized = (path or "").strip() + if normalized in {"", ".", "./"}: + if allow_current_directory: + return "." + raise SandboxFileError("invalid_sandbox_path", "path must not be blank", status_code=400) + if normalized.startswith("/") or normalized.startswith("~"): + raise SandboxFileError( + "invalid_sandbox_path", "path must be relative to the sandbox workspace", status_code=400 + ) + if "\x00" in normalized or any(ord(ch) < 0x20 for ch in normalized): + raise SandboxFileError("invalid_sandbox_path", "path contains unsupported control characters", status_code=400) + return normalized + + +def _build_python_script_command(*, script_source: str, args: list[str]) -> str: + quoted_args = " ".join(shlex.quote(value) for value in args) + script = textwrap.dedent(script_source).strip() + return f"python3 - {quoted_args} <<'PY'\n{script}\nPY" + + +def _decode_sandbox_payload(result: RemoteCommandResult) -> dict[str, object]: + if result.exit_code not in (0, None): + raise SandboxFileError( + "sandbox_command_failed", + f"sandbox command exited with code {result.exit_code}: {_output_tail(result.output)!r}", + status_code=502, + ) + begin = result.output.find(_OUTPUT_BEGIN) + end = result.output.find(_OUTPUT_END, begin + len(_OUTPUT_BEGIN)) if begin != -1 else -1 + if begin == -1 or end == -1: + raise SandboxFileError( + "sandbox_command_failed", + "sandbox command returned no framed payload", + status_code=502, + ) + blob = result.output[begin + len(_OUTPUT_BEGIN) : end] + compact = "".join(blob.split()) + try: + decoded = base64.b64decode(compact, validate=True) + loaded = cast(object, json.loads(decoded.decode("utf-8"))) + except (binascii.Error, ValueError) as exc: + raise SandboxFileError( + "sandbox_command_failed", + f"sandbox command returned invalid framed payload: {exc}", + status_code=502, + ) from exc + if not isinstance(loaded, dict): + raise SandboxFileError( + "sandbox_command_failed", "sandbox command returned a non-object payload", status_code=502 + ) + payload = cast(dict[str, object], loaded) + error = payload.get("error") + if isinstance(error, str): + status_code = ( + 404 + if error in {"sandbox_not_found", "sandbox_path_not_found"} + else 502 + if error == "agent_stub_upload_failed" + else 400 + ) + if error in {"sandbox_command_failed", "agent_stub_upload_failed"}: + status_code = 502 + message = payload.get("message") + raise SandboxFileError( + error, + str(message) if isinstance(message, str) and message else error, + status_code=status_code, + ) + return payload + + +def _output_tail(output: str) -> str: + stripped = output.strip() + return stripped[-200:] + + +def _validate_response_model( + model_type: type[ResponseModelT], + payload: dict[str, object], +) -> ResponseModelT: + try: + return model_type.model_validate(payload) + except ValidationError as exc: + raise SandboxFileError( + "sandbox_command_failed", f"sandbox command returned invalid payload: {exc}", status_code=502 + ) from exc + + +__all__ = ["SandboxFileError", "SandboxFileService"] diff --git a/dify-agent/src/dify_agent/server/workspace_files.py b/dify-agent/src/dify_agent/server/workspace_files.py deleted file mode 100644 index 3991a9df4a1..00000000000 --- a/dify-agent/src/dify_agent/server/workspace_files.py +++ /dev/null @@ -1,418 +0,0 @@ -"""Read-only inspector for a shell-layer workspace (``~/workspace/``). - -The ``dify.shell`` layer runs the agent's bash in a per-session workspace that -lives on the shellctl host. shellctl exposes only job control (run/wait/...), so -there is no native file API: the only way to read those files is to run a -read-only command inside the workspace and capture its output. - -This service does exactly that, safely: - -* It runs a fixed Python reader (no shell parsing of user input) via - ``ShellctlClient.run``. The reader is delivered base64-encoded and all - user-controlled values (workspace root, relative path, op, size caps) are - passed through the environment, never interpolated into the command. -* Path containment is enforced inside the reader with ``realpath`` against the - workspace root, so ``..`` and symlink escapes are rejected. -* The reader emits its result as a single base64 blob between sentinels. base64 - tolerates the newlines a PTY inserts when wrapping long lines, so the payload - survives tmux capture intact; we strip whitespace before decoding. - -Only listing, text/binary preview, and download are supported; everything is -read-only and scoped to the workspace. -""" - -from __future__ import annotations - -import base64 -import binascii -import json -import logging -import re -from collections.abc import Callable -from dataclasses import dataclass -from typing import Literal, Protocol, cast - -from pydantic import BaseModel, Field -from shell_session_manager.shellctl.client import ShellctlClient, ShellctlClientError -from shell_session_manager.shellctl.shared import MAX_OUTPUT_LIMIT_BYTES, JobResult, TerminalSize - -logger = logging.getLogger(__name__) - -# Mirrors the dify.shell layer's workspace session-id contract (5+2 lowercase -# hex). Kept local so this read-only inspector does not depend on the layer's -# private helpers; the layer remains the source of truth for the format. -_SESSION_ID_PATTERN = re.compile(r"^[0-9a-f]{7}$") - -# Result sentinels emitted by the reader; chosen to be PTY/shell-noise resistant. -_BEGIN = "<<>>" -_END = "<<>>" - -# Conservative read caps (tunable). The download cap leaves headroom under the -# 1 MiB shellctl output window after base64 + JSON overhead, paged when needed. -PREVIEW_MAX_BYTES = 256 * 1024 -DOWNLOAD_MAX_BYTES = 8 * 1024 * 1024 -LIST_MAX_ENTRIES = 1000 -_READ_TIMEOUT_SECONDS = 20.0 -# Upper bound on output windows paged per request (backstop against a runaway -# job); DOWNLOAD_MAX_BYTES of base64 fits comfortably within this many 1 MiB windows. -_MAX_OUTPUT_WINDOWS = 64 - -# Fixed Python reader. Receives all inputs via the environment so no user value -# is ever interpolated into a shell command. Emits one base64 blob of JSON -# between the sentinels. -_READER_SOURCE = """ -import base64, json, os, stat, sys - -BEGIN = "<<>>" -END = "<<>>" - - -def emit(obj): - blob = base64.b64encode(json.dumps(obj).encode("utf-8")).decode("ascii") - sys.stdout.write(BEGIN + blob + END + "\\n") - sys.stdout.flush() - - -op = os.environ.get("DIFY_FS_OP", "") -root = os.path.realpath(os.path.expanduser(os.environ.get("DIFY_FS_ROOT", ""))) -rel = os.environ.get("DIFY_FS_REL", "") -max_bytes = int(os.environ.get("DIFY_FS_MAX", "0") or "0") -list_limit = int(os.environ.get("DIFY_FS_LIST_LIMIT", "1000") or "1000") - -if not os.path.isdir(root): - emit({"error": "workspace_not_found"}) - sys.exit(0) - -target = os.path.realpath(os.path.join(root, rel)) -if target != root and not target.startswith(root + os.sep): - emit({"error": "path_escape"}) - sys.exit(0) -if not os.path.exists(target): - emit({"error": "not_found"}) - sys.exit(0) - - -def entry_for(name, p): - st = os.lstat(p) - mode = st.st_mode - if stat.S_ISLNK(mode): - etype = "symlink" - elif stat.S_ISDIR(mode): - etype = "dir" - else: - etype = "file" - return {"name": name, "type": etype, "size": int(st.st_size), "mtime": int(st.st_mtime)} - - -if op == "list": - if not os.path.isdir(target): - emit({"error": "not_a_directory"}) - sys.exit(0) - names = sorted(os.listdir(target)) - truncated = len(names) > list_limit - entries = [entry_for(n, os.path.join(target, n)) for n in names[:list_limit]] - emit({"entries": entries, "truncated": truncated}) -elif op in ("preview", "download"): - if os.path.isdir(target): - emit({"error": "is_a_directory"}) - sys.exit(0) - size = int(os.path.getsize(target)) - with open(target, "rb") as f: - data = f.read(max_bytes + 1) - truncated = len(data) > max_bytes - data = data[:max_bytes] - content_b64 = base64.b64encode(data).decode("ascii") - payload = {"size": size, "truncated": truncated, "content_base64": content_b64} - if op == "preview": - try: - data.decode("utf-8") - payload["binary"] = False - except UnicodeDecodeError: - payload["binary"] = True - emit(payload) -else: - emit({"error": "bad_op"}) - sys.exit(0) -""" - -_READER_B64 = base64.b64encode(_READER_SOURCE.encode("utf-8")).decode("ascii") - - -class WorkspaceFileError(Exception): - """Read failure mapped to an HTTP status by the route layer.""" - - 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 - - -# error code emitted by the reader -> (http status, client message) -_READER_ERROR_HTTP: dict[str, tuple[int, str]] = { - "workspace_not_found": (404, "workspace does not exist"), - "not_found": (404, "path not found in workspace"), - "path_escape": (400, "path escapes the workspace"), - "not_a_directory": (400, "path is not a directory"), - "is_a_directory": (400, "path is a directory"), - "bad_op": (400, "unsupported operation"), -} - - -class WorkspaceFileEntry(BaseModel): - """One entry in a workspace directory listing.""" - - name: str - type: Literal["file", "dir", "symlink"] - size: int - mtime: int - - -class WorkspaceListResponse(BaseModel): - """Directory listing of a workspace path.""" - - path: str - entries: list[WorkspaceFileEntry] - truncated: bool = Field(description="True when the directory had more than LIST_MAX_ENTRIES entries.") - - -class WorkspacePreviewResponse(BaseModel): - """Inline preview of a workspace file.""" - - path: str - size: int - truncated: bool - binary: bool - # text is omitted for binary files - text: str | None = None - - -class WorkspaceDownloadResponse(BaseModel): - """Raw bytes (base64) of a workspace file for download.""" - - path: str - size: int - truncated: bool - content_base64: str - - -class ShellctlReadClient(Protocol): - """The shellctl job-control surface this read-only inspector relies on.""" - - async def run(self, script: str, *, timeout: float = ..., terminal: TerminalSize | None = ...) -> JobResult: ... - - async def wait(self, job_id: str, *, offset: int, timeout: float = ...) -> JobResult: ... - - async def delete(self, job_id: str, *, force: bool = ...) -> object: ... - - async def close(self) -> None: ... - - -ShellctlReadClientFactory = Callable[[], ShellctlReadClient] - - -@dataclass(slots=True) -class WorkspaceFileService: - """Run read-only workspace inspection commands through shellctl.""" - - shellctl_entrypoint: str - shellctl_auth_token: str | None = None - client_factory: ShellctlReadClientFactory | None = None - - def _client(self) -> ShellctlReadClient: - if self.client_factory is not None: - return self.client_factory() - return ShellctlClient( - self.shellctl_entrypoint, - token=self.shellctl_auth_token, - output_limit=MAX_OUTPUT_LIMIT_BYTES, - ) - - async def list_dir(self, session_id: str, path: str) -> WorkspaceListResponse: - data = await self._read(session_id, op="list", path=path) - raw_entries = data.get("entries", []) - entries_in = cast(list[object], raw_entries) if isinstance(raw_entries, list) else [] - entries = [WorkspaceFileEntry.model_validate(e) for e in entries_in] - return WorkspaceListResponse( - path=_normalize_path(path), entries=entries, truncated=_payload_bool(data.get("truncated")) - ) - - async def preview(self, session_id: str, path: str) -> WorkspacePreviewResponse: - data = await self._read(session_id, op="preview", path=path, max_bytes=PREVIEW_MAX_BYTES) - binary = _payload_bool(data.get("binary")) - text: str | None = None - if not binary: - text = base64.b64decode(_payload_str(data.get("content_base64"))).decode("utf-8", errors="replace") - return WorkspacePreviewResponse( - path=_normalize_path(path), - size=_payload_int(data.get("size")), - truncated=_payload_bool(data.get("truncated")), - binary=binary, - text=text, - ) - - async def download(self, session_id: str, path: str) -> WorkspaceDownloadResponse: - data = await self._read(session_id, op="download", path=path, max_bytes=DOWNLOAD_MAX_BYTES) - return WorkspaceDownloadResponse( - path=_normalize_path(path), - size=_payload_int(data.get("size")), - truncated=_payload_bool(data.get("truncated")), - content_base64=_payload_str(data.get("content_base64")), - ) - - async def _read(self, session_id: str, *, op: str, path: str, max_bytes: int = 0) -> dict[str, object]: - safe_session_id = self._validate_session_id(session_id) - rel = _validate_rel_path(path) - script = _build_reader_command(session_id=safe_session_id, op=op, rel=rel, max_bytes=max_bytes) - - client = self._client() - job_id: str | None = None - try: - result = await client.run( - script, - timeout=_READ_TIMEOUT_SECONDS, - terminal=TerminalSize(cols=4096, rows=200), - ) - job_id = result.job_id - output = result.output - offset = result.offset - windows = 1 - while _END not in output and (result.truncated or not result.done) and windows < _MAX_OUTPUT_WINDOWS: - result = await client.wait(job_id, offset=offset, timeout=_READ_TIMEOUT_SECONDS) - output += result.output - offset = result.offset - windows += 1 - return _decode_blob(output) - except ShellctlClientError as exc: - raise WorkspaceFileError("shellctl_error", exc.message, status_code=502) from exc - finally: - if job_id is not None: - try: - _ = await client.delete(job_id, force=True) - except ShellctlClientError as exc: - if exc.code != "job_not_found": - logger.warning("failed to delete workspace read job %s: %s", job_id, exc) - await client.close() - - @staticmethod - def _validate_session_id(session_id: str) -> str: - if not _SESSION_ID_PATTERN.fullmatch(session_id): - raise WorkspaceFileError( - "invalid_session_id", - "session_id must match the 5+2 lowercase hex format '<5 hex><2 hex>'.", - status_code=400, - ) - return session_id - - -def _decode_blob(output: str) -> dict[str, object]: - start = output.find(_BEGIN) - end = output.find(_END, start + len(_BEGIN)) if start != -1 else -1 - if start == -1 or end == -1: - snippet = output[-200:].strip() - raise WorkspaceFileError( - "reader_failed", - f"workspace reader produced no result (output tail: {snippet!r})", - status_code=502, - ) - blob = output[start + len(_BEGIN) : end] - compact = "".join(blob.split()) # strip PTY-injected whitespace/newlines - try: - decoded = base64.b64decode(compact, validate=True) - loaded = cast(object, json.loads(decoded.decode("utf-8"))) - except (binascii.Error, ValueError) as exc: - raise WorkspaceFileError( - "reader_failed", f"could not decode workspace reader output: {exc}", status_code=502 - ) from exc - if not isinstance(loaded, dict): - raise WorkspaceFileError("reader_failed", "workspace reader returned a non-object payload", status_code=502) - data = cast(dict[str, object], loaded) - error = data.get("error") - if isinstance(error, str): - status, message = _READER_ERROR_HTTP.get(error, (400, error)) - raise WorkspaceFileError(error, message, status_code=status) - return data - - -def _payload_int(value: object) -> int: - if isinstance(value, bool): - return int(value) - if isinstance(value, (int, float)): - return int(value) - if isinstance(value, str): - try: - return int(value) - except ValueError as exc: - raise WorkspaceFileError( - "reader_failed", "workspace reader returned a non-integer field", status_code=502 - ) from exc - raise WorkspaceFileError("reader_failed", "workspace reader returned a non-integer field", status_code=502) - - -def _payload_str(value: object) -> str: - if isinstance(value, str): - return value - raise WorkspaceFileError("reader_failed", "workspace reader returned a non-string field", status_code=502) - - -def _payload_bool(value: object) -> bool: - return bool(value) - - -def _build_reader_command(*, session_id: str, op: str, rel: str, max_bytes: int) -> str: - """Build the shell command: fixed base64 reader + user data via the environment.""" - # session_id is validated lowercase hex, so the workspace root literal is injection-safe. - root = f"~/workspace/{session_id}" - env = ( - f"DIFY_FS_OP={_shquote(op)} " - f"DIFY_FS_ROOT={_shquote(root)} " - f"DIFY_FS_REL={_shquote(rel)} " - f"DIFY_FS_MAX={int(max_bytes)} " - f"DIFY_FS_LIST_LIMIT={LIST_MAX_ENTRIES}" - ) - return f"{env} python3 -c 'import base64;exec(base64.b64decode(\"{_READER_B64}\"))'" - - -def _shquote(value: str) -> str: - """Single-quote a value for POSIX shells, escaping embedded single quotes.""" - return "'" + value.replace("'", "'\\''") + "'" - - -def _normalize_path(path: str) -> str: - return path.strip().lstrip("/") or "." - - -def _validate_rel_path(path: str) -> str: - """Reject absolute paths, parent traversal, and control characters early. - - Containment is also enforced inside the reader via realpath; this is a cheap - first gate and keeps obviously-bad input from reaching the workspace at all. - """ - rel = (path or "").strip() - if rel in ("", ".", "./"): - return "." - if rel.startswith("/") or rel.startswith("~"): - raise WorkspaceFileError("invalid_path", "path must be relative to the workspace", status_code=400) - if "\x00" in rel or any(ord(ch) < 0x20 for ch in rel): - raise WorkspaceFileError("invalid_path", "path contains control characters", status_code=400) - segments = rel.split("/") - if any(seg == ".." for seg in segments): - raise WorkspaceFileError("invalid_path", "path must not traverse outside the workspace", status_code=400) - return rel - - -__all__ = [ - "DOWNLOAD_MAX_BYTES", - "LIST_MAX_ENTRIES", - "PREVIEW_MAX_BYTES", - "WorkspaceDownloadResponse", - "WorkspaceFileEntry", - "WorkspaceFileError", - "WorkspaceFileService", - "WorkspaceListResponse", - "WorkspacePreviewResponse", -] diff --git a/dify-agent/tests/local/dify_agent/client/test_client.py b/dify-agent/tests/local/dify_agent/client/test_client.py index b66bd805b1e..d1b06ad5fc9 100644 --- a/dify-agent/tests/local/dify_agent/client/test_client.py +++ b/dify-agent/tests/local/dify_agent/client/test_client.py @@ -20,7 +20,7 @@ from dify_agent.client import ( DifyAgentTimeoutError, DifyAgentValidationError, ) -from dify_agent.protocol.schemas import ( +from dify_agent.protocol import ( CancelRunRequest, CancelRunResponse, CreateRunRequest, @@ -31,6 +31,10 @@ from dify_agent.protocol.schemas import ( RunStartedEvent, RunSucceededEvent, RunSucceededEventData, + SandboxListResponse, + SandboxLocator, + SandboxReadResponse, + SandboxUploadResponse, ) @@ -65,6 +69,44 @@ def _run_status_json(status: str) -> dict[str, object]: return {"run_id": "run-1", "status": status, "created_at": now, "updated_at": now, "error": None} +def _sandbox_locator() -> SandboxLocator: + return SandboxLocator.model_validate( + { + "composition": { + "schema_version": 1, + "layers": [ + { + "name": "execution_context", + "type": "dify.execution_context", + "config": { + "tenant_id": "tenant-1", + "user_from": "account", + "agent_mode": "agent_app", + "invoke_from": "service-api", + }, + }, + { + "name": "shell", + "type": "dify.shell", + "deps": {"execution_context": "execution_context"}, + "config": {}, + }, + ], + }, + "session_snapshot": { + "layers": [ + {"name": "execution_context", "lifecycle_state": "suspended", "runtime_state": {}}, + { + "name": "shell", + "lifecycle_state": "suspended", + "runtime_state": {"session_id": "abc12ff", "workspace_cwd": "~/workspace/abc12ff"}, + }, + ] + }, + } + ) + + def _function_tool_result_payload(key: str) -> dict[str, object]: return { "type": "pydantic_ai_event", @@ -195,6 +237,99 @@ def test_async_methods_and_wait_run_parse_protocol_dtos() -> None: asyncio.run(scenario()) +def test_sync_sandbox_methods_post_dtos_and_parse_responses() -> None: + locator = _sandbox_locator() + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/sandbox/files/list": + payload = cast(dict[str, object], json.loads(request.content)) + assert payload["path"] == "." + return httpx.Response(200, json={"path": ".", "entries": [], "truncated": False}) + if request.url.path == "/sandbox/files/read": + payload = cast(dict[str, object], json.loads(request.content)) + assert payload["path"] == "note.txt" + assert payload["max_bytes"] == 128 + return httpx.Response( + 200, json={"path": "note.txt", "size": 5, "truncated": False, "binary": False, "text": "hello"} + ) + if request.url.path == "/sandbox/files/upload": + payload = cast(dict[str, object], json.loads(request.content)) + assert payload["path"] == "report.txt" + return httpx.Response( + 200, + json={ + "path": "report.txt", + "file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + }, + ) + raise AssertionError(f"unexpected request: {request.method} {request.url}") + + client = Client(base_url="http://testserver", sync_http_client=httpx.Client(transport=httpx.MockTransport(handler))) + + listing = client.list_sandbox_files_sync(locator, ".") + preview = client.read_sandbox_file_sync(locator, "note.txt", max_bytes=128) + uploaded = client.upload_sandbox_file_sync(locator, "report.txt") + + assert isinstance(listing, SandboxListResponse) + assert listing.path == "." + assert isinstance(preview, SandboxReadResponse) + assert preview.text == "hello" + assert isinstance(uploaded, SandboxUploadResponse) + assert uploaded.file.reference == "dify-file-ref:file-1" + + +def test_async_sandbox_methods_post_dtos_and_parse_responses() -> None: + locator = _sandbox_locator() + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/sandbox/files/list": + return httpx.Response(200, json={"path": ".", "entries": [], "truncated": False}) + if request.url.path == "/sandbox/files/read": + return httpx.Response( + 200, json={"path": "note.txt", "size": 5, "truncated": False, "binary": False, "text": "hello"} + ) + if request.url.path == "/sandbox/files/upload": + return httpx.Response( + 200, + json={ + "path": "report.txt", + "file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + }, + ) + raise AssertionError(f"unexpected request: {request.method} {request.url}") + + async def scenario() -> None: + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = Client(base_url="http://testserver", async_http_client=http_client) + + listing = await client.list_sandbox_files(locator, ".") + preview = await client.read_sandbox_file(locator, "note.txt") + uploaded = await client.upload_sandbox_file(locator, "report.txt") + + assert listing.path == "." + assert preview.text == "hello" + assert uploaded.file.reference == "dify-file-ref:file-1" + await http_client.aclose() + + asyncio.run(scenario()) + + +def test_sync_sandbox_methods_map_invalid_json_to_validation_error() -> None: + responses = iter([httpx.Response(200, text="not-json"), httpx.Response(404, json={"detail": "missing"})]) + + def handler(_request: httpx.Request) -> httpx.Response: + return next(responses) + + client = Client(base_url="http://testserver", sync_http_client=httpx.Client(transport=httpx.MockTransport(handler))) + + with pytest.raises(DifyAgentValidationError): + _ = client.list_sandbox_files_sync(_sandbox_locator(), ".") + + with pytest.raises(DifyAgentHTTPError) as http_error: + _ = client.read_sandbox_file_sync(_sandbox_locator(), "missing.txt") + assert http_error.value.status_code == 404 + + def test_error_mapping_and_create_run_input_validation() -> None: responses = iter( [ diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py index ff8a857ae2e..61368295fad 100644 --- a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py @@ -636,6 +636,148 @@ def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() -> assert all(call.env is None for call in internal_run_calls) +def test_run_remote_script_uses_workspace_cwd_accumulates_output_and_deletes_job() -> None: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: + assert '. ".dify/env.sh"' not in script + assert script == "printf 'hello world'" + assert cwd == "~/workspace/abc12ff" + assert env is None + assert timeout == 7.5 + return _job_result( + "remote-job", + status=JobStatusName.RUNNING, + done=False, + output="hello ", + offset=6, + truncated=True, + ) + + def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult: + assert job_id == "remote-job" + assert offset == 6 + assert timeout == 7.5 + return _job_result( + "remote-job", + status=JobStatusName.EXITED, + done=True, + exit_code=0, + output="world", + offset=11, + ) + + client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler) + layer = _shell_layer(client_factory=lambda _entrypoint: client) + + async def scenario() -> None: + async with layer.resource_context(): + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + result = await layer.run_remote_script("printf 'hello world'", timeout=7.5) + assert result.output == "hello world" + assert result.exit_code == 0 + + asyncio.run(scenario()) + + assert [call.job_id for call in client.delete_calls] == ["remote-job"] + assert layer.runtime_state.job_ids == [] + assert layer.runtime_state.job_offsets == {} + + +def test_run_remote_script_deletes_job_even_when_command_exits_non_zero() -> None: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: + assert script == "exit 17" + assert cwd == "~/workspace/abc12ff" + assert env is None + assert timeout == 3.0 + return _job_result( + "remote-failed-job", + status=JobStatusName.EXITED, + done=True, + exit_code=17, + output="failed\n", + offset=7, + ) + + client = FakeShellctlClient(run_handler=run_handler) + layer = _shell_layer(client_factory=lambda _entrypoint: client) + + async def scenario() -> None: + async with layer.resource_context(): + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + result = await layer.run_remote_script("exit 17", timeout=3.0) + assert result.exit_code == 17 + assert result.output == "failed\n" + + asyncio.run(scenario()) + + assert [call.job_id for call in client.delete_calls] == ["remote-failed-job"] + assert layer.runtime_state.job_ids == [] + assert layer.runtime_state.job_offsets == {} + + +def test_run_remote_script_can_inject_agent_stub_env_for_server_owned_uploads() -> None: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: + assert script == "dify-agent file upload report.txt" + assert '. ".dify/env.sh"' not in script + del timeout + assert cwd == "~/workspace/abc12ff" + assert env == { + AGENT_STUB_URL_ENV_VAR: "https://agent.example.com/agent-stub", + AGENT_STUB_AUTH_JWE_ENV_VAR: "token-for:tenant-1:abc12ff", + } + return _job_result("remote-upload", status=JobStatusName.EXITED, done=True, exit_code=0, output="{}") + + client = FakeShellctlClient(run_handler=run_handler) + layer = DifyShellLayer.from_config_with_settings( + DifyShellLayerConfig(), + shellctl_entrypoint="http://shellctl", + shellctl_client_factory=lambda _entrypoint: client, + agent_stub_url="https://agent.example.com/agent-stub", + agent_stub_token_factory=lambda execution_context, *, session_id: ( + f"token-for:{execution_context.tenant_id}:{session_id}" + ), + ) + layer.deps = layer.deps_type(execution_context=_execution_context_layer()) + + async def scenario() -> None: + async with layer.resource_context(): + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + _ = await layer.run_remote_script("dify-agent file upload report.txt", inject_agent_stub_env=True) + + asyncio.run(scenario()) + + assert [call.job_id for call in client.delete_calls] == ["remote-upload"] + + +def test_run_remote_script_raises_when_agent_stub_env_is_unavailable() -> None: + client = FakeShellctlClient( + run_handler=lambda _script, _cwd, _env, _timeout: _job_result( + "unexpected-run", + status=JobStatusName.EXITED, + done=True, + exit_code=0, + ) + ) + layer = DifyShellLayer.from_config_with_settings( + DifyShellLayerConfig(), + shellctl_entrypoint="http://shellctl", + shellctl_client_factory=lambda _entrypoint: client, + agent_stub_url="https://agent.example.com/agent-stub", + agent_stub_token_factory=lambda execution_context, *, session_id: ( + f"token-for:{execution_context.tenant_id}:{session_id}" + ), + ) + + async def scenario() -> None: + async with layer.resource_context(): + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + with pytest.raises(RuntimeError, match="Agent Stub environment injection is not available"): + await layer.run_remote_script("dify-agent file upload report.txt", inject_agent_stub_env=True) + + asyncio.run(scenario()) + + assert client.run_calls == [] + + def test_shell_layer_skips_agent_stub_env_without_execution_context_dependency() -> None: client = FakeShellctlClient( run_handler=lambda _script, _cwd, _env, _timeout: _job_result( diff --git a/dify-agent/tests/local/dify_agent/protocol/test_sandbox_locator.py b/dify-agent/tests/local/dify_agent/protocol/test_sandbox_locator.py new file mode 100644 index 00000000000..a717c6b899b --- /dev/null +++ b/dify-agent/tests/local/dify_agent/protocol/test_sandbox_locator.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import pytest +from agenton.compositor import CompositorSessionSnapshot +from agenton.compositor.schemas import LayerSessionSnapshot +from agenton.layers.base import LifecycleState +from agenton_collections.layers.plain import PromptLayerConfig +from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID +from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig +from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig +from dify_agent.protocol import ( + CreateRunRequest, + RunComposition, + RunLayerSpec, + RuntimeLayerSpec, + build_sandbox_locator_from_layer_specs, + build_sandbox_locator_from_run_request, + extract_runtime_layer_specs, +) + + +def _request() -> CreateRunRequest: + composition = RunComposition( + layers=[ + RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(prefix="hi")), + RunLayerSpec( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), + ), + RunLayerSpec(name="llm", type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID), + RunLayerSpec( + name="shell", + type=DIFY_SHELL_LAYER_TYPE_ID, + deps={"execution_context": "execution_context"}, + config=DifyShellLayerConfig(), + ), + ] + ) + snapshot = CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot(name="prompt", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), + LayerSessionSnapshot( + name="execution_context", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={}, + ), + LayerSessionSnapshot(name="llm", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), + LayerSessionSnapshot( + name="shell", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={"session_id": "abc12ff", "workspace_cwd": "~/workspace/abc12ff"}, + ), + ] + ) + return CreateRunRequest(composition=composition, session_snapshot=snapshot) + + +def test_build_sandbox_locator_from_run_request_filters_to_execution_context_and_shell() -> None: + locator = build_sandbox_locator_from_run_request(_request()) + + assert [layer.name for layer in locator.composition.layers] == ["execution_context", "shell"] + assert [layer.type for layer in locator.composition.layers] == [ + DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + DIFY_SHELL_LAYER_TYPE_ID, + ] + assert [layer.name for layer in locator.session_snapshot.layers] == ["execution_context", "shell"] + + +def test_build_sandbox_locator_from_run_request_rejects_missing_session_snapshot() -> None: + request = _request() + request.session_snapshot = None + + with pytest.raises(ValueError, match="session_snapshot"): + build_sandbox_locator_from_run_request(request) + + +def test_extract_runtime_layer_specs_drops_sensitive_plugin_layers() -> None: + specs = extract_runtime_layer_specs(_request().composition) + + assert [spec.name for spec in specs] == ["prompt", "execution_context", "shell"] + + +def test_build_sandbox_locator_from_layer_specs_rejects_missing_shell() -> None: + with pytest.raises(ValueError, match="shell"): + build_sandbox_locator_from_layer_specs( + layer_specs=[RuntimeLayerSpec(name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID)], + session_snapshot=CompositorSessionSnapshot(layers=[]), + ) + + +def test_build_sandbox_locator_from_layer_specs_rejects_missing_snapshot_layer() -> None: + specs = [ + RuntimeLayerSpec(name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID), + RuntimeLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, deps={"execution_context": "execution_context"}), + ] + + with pytest.raises(ValueError, match="session_snapshot"): + build_sandbox_locator_from_layer_specs( + layer_specs=specs, + session_snapshot=CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot( + name="execution_context", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={} + ) + ] + ), + ) + + +def test_build_sandbox_locator_from_layer_specs_rejects_shell_dep_mismatch() -> None: + specs = [ + RuntimeLayerSpec(name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID), + RuntimeLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, deps={"execution_context": "wrong-layer"}), + ] + + with pytest.raises(ValueError, match="depend on the execution_context"): + build_sandbox_locator_from_layer_specs( + layer_specs=specs, + session_snapshot=CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot( + name="execution_context", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={}, + ), + LayerSessionSnapshot( + name="shell", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={}, + ), + ] + ), + ) + + +def test_build_sandbox_locator_from_layer_specs_rejects_order_mismatch() -> None: + specs = [ + RuntimeLayerSpec(name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID), + RuntimeLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, deps={"execution_context": "execution_context"}), + ] + + with pytest.raises(ValueError, match="in order"): + build_sandbox_locator_from_layer_specs( + layer_specs=specs, + session_snapshot=CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot(name="shell", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), + LayerSessionSnapshot( + name="execution_context", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={}, + ), + ] + ), + ) + + +def test_build_sandbox_locator_from_layer_specs_rejects_sensitive_runtime_specs() -> None: + with pytest.raises(ValueError, match="sensitive"): + build_sandbox_locator_from_layer_specs( + layer_specs=[RuntimeLayerSpec(name="llm", type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID)], + session_snapshot=CompositorSessionSnapshot(layers=[]), + ) diff --git a/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py b/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py new file mode 100644 index 00000000000..35e83667d17 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py @@ -0,0 +1,446 @@ +from __future__ import annotations + +import asyncio +import base64 +import json +from collections.abc import Callable, Mapping +from dataclasses import dataclass + +import pytest +from agenton.compositor import CompositorSessionSnapshot, LayerProvider +from agenton.compositor.schemas import LayerSessionSnapshot +from agenton.layers.base import LifecycleState +from dify_agent.agent_stub.server.shell_agent_stub_env import AGENT_STUB_AUTH_JWE_ENV_VAR, AGENT_STUB_URL_ENV_VAR +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig +from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer +from dify_agent.layers.shell import DifyShellLayerConfig +from dify_agent.layers.shell.layer import DifyShellLayer +from dify_agent.protocol import ( + CreateRunRequest, + RunComposition, + RunLayerSpec, + SandboxLocator, + SandboxListRequest, + SandboxReadRequest, + SandboxUploadRequest, + build_sandbox_locator_from_run_request, +) +from dify_agent.server.routes.sandbox_files import create_sandbox_files_router +from dify_agent.server.sandbox_files import ( + SandboxFileError, + SandboxFileService, + _OUTPUT_BEGIN, + _OUTPUT_END, +) +from fastapi import FastAPI +from fastapi.testclient import TestClient +from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName + + +@dataclass(slots=True) +class RunCall: + script: str + cwd: str | None + env: Mapping[str, str] | None + timeout: float + + +class FakeShellctlClient: + def __init__( + self, + *, + run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], JobResult], + ) -> None: + self.run_handler = run_handler + self.run_calls: list[RunCall] = [] + self.delete_calls: list[str] = [] + + async def run( + self, + script: str, + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + timeout: float = 10.0, + ) -> JobResult: + self.run_calls.append(RunCall(script=script, cwd=cwd, env=env, timeout=timeout)) + return self.run_handler(script, cwd, env, timeout) + + async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> JobResult: + raise AssertionError(f"Unexpected wait() call for {job_id} offset={offset} timeout={timeout}") + + async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0) -> JobResult: + raise AssertionError(f"Unexpected input() call for {job_id} text={text!r}") + + async def terminate(self, job_id: str, grace_seconds: float = 2.0): + raise AssertionError(f"Unexpected terminate() call for {job_id} grace={grace_seconds}") + + async def delete( + self, + job_id: str, + *, + force: bool = False, + grace_seconds: float | None = None, + ) -> DeleteJobResponse: + del force, grace_seconds + self.delete_calls.append(job_id) + return DeleteJobResponse(job_id=job_id) + + async def close(self) -> None: + return None + + +def _wrap(payload: dict[str, object], *, pty_wrap: int = 0, noise: bool = False) -> str: + blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + if pty_wrap: + blob = "\n".join(blob[index : index + pty_wrap] for index in range(0, len(blob), pty_wrap)) + framed = f"{_OUTPUT_BEGIN}{blob}{_OUTPUT_END}\n" + if noise: + framed = f"user@host:~/workspace/abc12ff$ python3 - ...\r\n{framed}user@host:~/workspace/abc12ff$ \r\n" + return framed + + +def _job_result(*, output: dict[str, object] | str, job_id: str = "sandbox-job") -> JobResult: + return JobResult( + job_id=job_id, + status=JobStatusName.EXITED, + done=True, + exit_code=0, + output=_wrap(output) if isinstance(output, dict) else output, + offset=0, + truncated=False, + output_path="/tmp/sandbox-job.out", + ) + + +def _failed_job_result(*, output: str, exit_code: int, job_id: str = "sandbox-job") -> JobResult: + return JobResult( + job_id=job_id, + status=JobStatusName.EXITED, + done=True, + exit_code=exit_code, + output=output, + offset=0, + truncated=False, + output_path="/tmp/sandbox-job.out", + ) + + +def _execution_context() -> DifyExecutionContextLayerConfig: + return DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_id="user-1", + user_from="account", + app_id="app-1", + conversation_id="conv-1", + agent_id="agent-1", + agent_config_version_id="snapshot-1", + agent_mode="agent_app", + invoke_from="service-api", + ) + + +def _locator() -> SandboxLocator: + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec(name="execution_context", type="dify.execution_context", config=_execution_context()), + RunLayerSpec( + name="shell", + type="dify.shell", + deps={"execution_context": "execution_context"}, + config=DifyShellLayerConfig(), + ), + ] + ), + session_snapshot=CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot( + name="execution_context", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={}, + ), + LayerSessionSnapshot( + name="shell", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={"session_id": "abc12ff", "workspace_cwd": "~/workspace/abc12ff"}, + ), + ] + ), + ) + return build_sandbox_locator_from_run_request(request) + + +def _service( + run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], JobResult], +) -> tuple[SandboxFileService, FakeShellctlClient]: + client = FakeShellctlClient(run_handler=run_handler) + execution_context_provider = LayerProvider.from_factory( + layer_type=DifyExecutionContextLayer, + create=lambda config: DifyExecutionContextLayer.from_config_with_settings( + DifyExecutionContextLayerConfig.model_validate(config), + daemon_url="http://plugin-daemon", + daemon_api_key="daemon-secret", + ), + ) + shell_provider = LayerProvider.from_factory( + layer_type=DifyShellLayer, + create=lambda config: DifyShellLayer.from_config_with_settings( + DifyShellLayerConfig.model_validate(config), + shellctl_entrypoint="http://shellctl", + shellctl_client_factory=lambda _entrypoint: client, + agent_stub_url="https://agent.example.com/agent-stub", + agent_stub_token_factory=lambda execution_context, *, session_id: ( + f"token-for:{execution_context.tenant_id}:{session_id}" + ), + ), + ) + return SandboxFileService(layer_providers=(execution_context_provider, shell_provider)), client + + +def test_list_files_runs_fixed_script_and_parses_response() -> None: + service, client = _service( + lambda script, cwd, env, timeout: _job_result( + output={ + "path": ".", + "entries": [{"name": "notes.txt", "type": "file", "size": 5, "mtime": 1}], + "truncated": False, + } + ) + ) + + result = asyncio.run(service.list_files(SandboxListRequest(locator=_locator(), path="."))) + + assert result.entries[0].name == "notes.txt" + assert client.run_calls[0].cwd == "~/workspace/abc12ff" + assert client.run_calls[0].env is None + assert "python3 - . 1000 <<'PY'" in client.run_calls[0].script + assert client.delete_calls == ["sandbox-job"] + + +@pytest.mark.parametrize("bad_path", ["/etc/passwd", "~/secret-dir", "bad\x00path"]) +def test_list_files_rejects_invalid_paths_before_shell_execution(bad_path: str) -> None: + service, client = _service( + lambda script, cwd, env, timeout: _job_result( + output={"path": ".", "entries": [], "truncated": False}, + ) + ) + + with pytest.raises(SandboxFileError) as exc_info: + asyncio.run(service.list_files(SandboxListRequest(locator=_locator(), path=bad_path))) + + assert exc_info.value.code == "invalid_sandbox_path" + assert client.run_calls == [] + + +def test_decode_tolerates_pty_wrapped_base64_and_shell_noise() -> None: + service, _client = _service( + lambda script, cwd, env, timeout: _job_result( + output=_wrap( + { + "path": "note.txt", + "size": 40, + "truncated": False, + "binary": False, + "text": "hello from sandbox\n" * 4, + }, + pty_wrap=12, + noise=True, + ) + ) + ) + + result = asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="note.txt"))) + + assert result.text == "hello from sandbox\n" * 4 + + +def test_read_file_maps_script_error_codes() -> None: + service, _client = _service( + lambda script, cwd, env, timeout: _job_result( + output={"error": "sandbox_path_not_found", "message": "path not found in sandbox"} + ) + ) + + with pytest.raises(SandboxFileError) as exc_info: + asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="missing.txt"))) + + assert exc_info.value.code == "sandbox_path_not_found" + assert exc_info.value.status_code == 404 + + +@pytest.mark.parametrize("bad_path", ["", "/etc/passwd", "~/secret.txt", "bad\x00path"]) +def test_read_file_rejects_invalid_paths_before_shell_execution(bad_path: str) -> None: + service, client = _service( + lambda script, cwd, env, timeout: _job_result( + output={ + "path": "should-not-run", + "size": 1, + "truncated": False, + "binary": False, + "text": "x", + } + ) + ) + + with pytest.raises(SandboxFileError) as exc_info: + asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path=bad_path))) + + assert exc_info.value.code == "invalid_sandbox_path" + assert client.run_calls == [] + + +def test_read_file_returns_binary_payload_without_text() -> None: + service, _client = _service( + lambda script, cwd, env, timeout: _job_result( + output={ + "path": "blob.bin", + "size": 64, + "truncated": False, + "binary": True, + "text": None, + } + ) + ) + + result = asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="blob.bin"))) + + assert result.binary is True + assert result.text is None + assert result.truncated is False + + +def test_read_file_preserves_truncated_flag_for_large_text() -> None: + service, _client = _service( + lambda script, cwd, env, timeout: _job_result( + output={ + "path": "large.txt", + "size": 1024, + "truncated": True, + "binary": False, + "text": "partial preview", + } + ) + ) + + result = asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="large.txt"))) + + assert result.binary is False + assert result.text == "partial preview" + assert result.truncated is True + + +def test_upload_file_injects_agent_stub_env_and_returns_mapping() -> None: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: + assert cwd == "~/workspace/abc12ff" + assert timeout == 30.0 + assert env == { + AGENT_STUB_URL_ENV_VAR: "https://agent.example.com/agent-stub", + AGENT_STUB_AUTH_JWE_ENV_VAR: "token-for:tenant-1:abc12ff", + } + assert 'dify-agent", "file", "upload"' in script + return _job_result( + output={ + "path": "report.txt", + "file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + }, + job_id="upload-job", + ) + + service, client = _service(run_handler) + + result = asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path="report.txt"))) + + assert result.file.reference == "dify-file-ref:file-1" + assert client.delete_calls == ["upload-job"] + + +def test_upload_file_maps_agent_stub_upload_failed_payload() -> None: + service, _client = _service( + lambda script, cwd, env, timeout: _job_result( + output={ + "error": "agent_stub_upload_failed", + "message": "upload returned invalid JSON", + }, + job_id="upload-failed-job", + ) + ) + + with pytest.raises(SandboxFileError) as exc_info: + asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path="report.txt"))) + + assert exc_info.value.code == "agent_stub_upload_failed" + assert exc_info.value.status_code == 502 + + +def test_read_file_maps_non_zero_command_exit_to_sandbox_command_failed() -> None: + service, _client = _service( + lambda script, cwd, env, timeout: _failed_job_result( + output="python traceback or stderr tail", + exit_code=17, + ) + ) + + with pytest.raises(SandboxFileError) as exc_info: + asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="note.txt"))) + + assert exc_info.value.code == "sandbox_command_failed" + assert exc_info.value.status_code == 502 + + +def test_upload_file_maps_missing_framed_payload_to_sandbox_command_failed() -> None: + service, _client = _service( + lambda script, cwd, env, timeout: _job_result(output="plain output without sentinel framing") + ) + + with pytest.raises(SandboxFileError) as exc_info: + asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path="report.txt"))) + + assert exc_info.value.code == "sandbox_command_failed" + assert exc_info.value.status_code == 502 + + +@pytest.mark.parametrize("bad_path", ["", "/etc/passwd", "~/secret.txt", "bad\x00path"]) +def test_upload_file_rejects_invalid_paths_before_shell_execution(bad_path: str) -> None: + service, client = _service( + lambda script, cwd, env, timeout: _job_result( + output={ + "path": "should-not-run", + "file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + } + ) + ) + + with pytest.raises(SandboxFileError) as exc_info: + asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path=bad_path))) + + assert exc_info.value.code == "invalid_sandbox_path" + assert client.run_calls == [] + + +def _client(service: SandboxFileService | None) -> TestClient: + app = FastAPI() + app.include_router(create_sandbox_files_router(lambda: service)) + return TestClient(app) + + +def test_router_list_ok() -> None: + service, _client_instance = _service( + lambda script, cwd, env, timeout: _job_result(output={"path": ".", "entries": [], "truncated": False}) + ) + + response = _client(service).post( + "/sandbox/files/list", json={"locator": _locator().model_dump(mode="json"), "path": "."} + ) + + assert response.status_code == 200 + assert response.json()["path"] == "." + + +def test_router_returns_503_when_service_unconfigured() -> None: + response = _client(None).post( + "/sandbox/files/list", json={"locator": _locator().model_dump(mode="json"), "path": "."} + ) + + assert response.status_code == 503 + assert response.json()["detail"]["code"] == "sandbox_backend_unavailable" diff --git a/dify-agent/tests/local/dify_agent/server/test_workspace_files.py b/dify-agent/tests/local/dify_agent/server/test_workspace_files.py deleted file mode 100644 index 0e38ed99d1c..00000000000 --- a/dify-agent/tests/local/dify_agent/server/test_workspace_files.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Unit tests for the read-only workspace file inspector (agent-backend side). - -A fake shellctl client returns reader-style output (base64-of-JSON between -sentinels) so the tests cover decode/error-mapping/paging and PTY-newline -tolerance without a live shellctl. -""" - -from __future__ import annotations - -import asyncio -import base64 -import json - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient -from shell_session_manager.shellctl.shared import JobResult, JobStatusName - -from dify_agent.server.routes.workspace_files import create_workspace_files_router -from dify_agent.server.workspace_files import ( - _BEGIN, - _END, - WorkspaceFileError, - WorkspaceFileService, - _validate_rel_path, -) - -SID = "abc1234" # valid 5+2 lowercase hex - - -def _wrap(payload: dict[str, object], *, pty_wrap: int = 0, noise: bool = True) -> str: - """Render a reader result the way the in-workspace Python reader would.""" - blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") - if pty_wrap: - blob = "\n".join(blob[i : i + pty_wrap] for i in range(0, len(blob), pty_wrap)) - body = f"{_BEGIN}{blob}{_END}\n" - if noise: - body = f"user@host:~/workspace/{SID}$ python3 -c ...\r\n" + body + f"user@host:~/workspace/{SID}$ \r\n" - return body - - -class FakeShellctlClient: - """Returns queued output windows; records cleanup calls.""" - - def __init__(self, windows: list[str]) -> None: - self.windows = windows - self._cursor = 0 - self.run_scripts: list[str] = [] - self.deleted: list[str] = [] - self.closed = False - - def _result(self, chunk: str, *, last: bool) -> JobResult: - return JobResult( - job_id="job-1", - done=last, - status=JobStatusName.EXITED, - exit_code=0, - output_path="/tmp/job-1.out", - output=chunk, - offset=64 * (self._cursor + 1), - truncated=not last, - ) - - async def run(self, script: str, *, timeout: float = 30.0, terminal: object | None = None) -> JobResult: - del timeout, terminal - self.run_scripts.append(script) - self._cursor = 0 - return self._result(self.windows[0], last=len(self.windows) == 1) - - async def wait(self, job_id: str, *, offset: int, timeout: float = 30.0) -> JobResult: - del job_id, offset, timeout - self._cursor += 1 - chunk = self.windows[self._cursor] - return self._result(chunk, last=self._cursor == len(self.windows) - 1) - - async def delete(self, job_id: str, *, force: bool = False) -> object: - del force - self.deleted.append(job_id) - return {"deleted": True} - - async def close(self) -> None: - self.closed = True - - -def _service(windows: list[str]) -> tuple[WorkspaceFileService, FakeShellctlClient]: - fake = FakeShellctlClient(windows) - service = WorkspaceFileService(shellctl_entrypoint="http://shellctl", client_factory=lambda: fake) - return service, fake - - -# --- service: happy paths ------------------------------------------------------ - - -def test_list_dir_returns_entries_and_cleans_up() -> None: - payload = { - "entries": [ - {"name": "notes.txt", "type": "file", "size": 12, "mtime": 1700000000}, - {"name": "sub", "type": "dir", "size": 4096, "mtime": 1700000001}, - ], - "truncated": False, - } - service, fake = _service([_wrap(payload)]) - - result = asyncio.run(service.list_dir(SID, ".")) - - assert [e.name for e in result.entries] == ["notes.txt", "sub"] - assert result.entries[1].type == "dir" - assert result.truncated is False - # cleanup: read job deleted and client closed - assert fake.deleted == ["job-1"] - assert fake.closed is True - - -def test_preview_text_decodes_content() -> None: - content = "hello ZEBRA\nsecond line\n" - payload = { - "size": len(content), - "truncated": False, - "binary": False, - "content_base64": base64.b64encode(content.encode()).decode(), - } - service, _ = _service([_wrap(payload)]) - - result = asyncio.run(service.preview(SID, "notes.txt")) - - assert result.binary is False - assert result.text == content - assert result.size == len(content) - - -def test_preview_binary_has_no_text() -> None: - payload = {"size": 300, "truncated": True, "binary": True, "content_base64": base64.b64encode(b"\x00\x01").decode()} - service, _ = _service([_wrap(payload)]) - - result = asyncio.run(service.preview(SID, "blob.bin")) - - assert result.binary is True - assert result.text is None - assert result.truncated is True - - -def test_download_roundtrips_bytes() -> None: - raw = bytes(range(256)) - payload = {"size": len(raw), "truncated": False, "content_base64": base64.b64encode(raw).decode()} - service, _ = _service([_wrap(payload)]) - - result = asyncio.run(service.download(SID, "sub/data.bin")) - - assert base64.b64decode(result.content_base64) == raw - assert result.size == 256 - - -# --- service: PTY tolerance + paging ------------------------------------------ - - -def test_decode_tolerates_pty_inserted_newlines() -> None: - raw = bytes(range(200)) - payload = {"size": len(raw), "truncated": False, "content_base64": base64.b64encode(raw).decode()} - # wrap the base64 blob every 10 chars with newlines, as a narrow PTY would - service, _ = _service([_wrap(payload, pty_wrap=10)]) - - result = asyncio.run(service.download(SID, "blob.bin")) - - assert base64.b64decode(result.content_base64) == raw - - -def test_reads_across_multiple_output_windows() -> None: - raw = bytes(range(128)) - payload = {"size": len(raw), "truncated": False, "content_base64": base64.b64encode(raw).decode()} - full = _wrap(payload, noise=False) - third = len(full) // 3 - windows = [full[:third], full[third : 2 * third], full[2 * third :]] - service, fake = _service(windows) - - result = asyncio.run(service.download(SID, "blob.bin")) - - assert base64.b64decode(result.content_base64) == raw - assert fake._cursor == 2 # paged through all three windows - - -# --- service: error mapping ---------------------------------------------------- - - -@pytest.mark.parametrize( - ("error_code", "status"), - [ - ("workspace_not_found", 404), - ("not_found", 404), - ("path_escape", 400), - ("not_a_directory", 400), - ("is_a_directory", 400), - ], -) -def test_reader_error_maps_to_status(error_code: str, status: int) -> None: - service, _ = _service([_wrap({"error": error_code})]) - - with pytest.raises(WorkspaceFileError) as exc_info: - asyncio.run(service.list_dir(SID, ".")) - - assert exc_info.value.code == error_code - assert exc_info.value.status_code == status - - -def test_invalid_session_id_rejected_before_any_shell_call() -> None: - service, fake = _service([_wrap({"entries": [], "truncated": False})]) - - with pytest.raises(WorkspaceFileError) as exc_info: - asyncio.run(service.list_dir("NOTHEX", ".")) - - assert exc_info.value.code == "invalid_session_id" - assert exc_info.value.status_code == 400 - assert fake.run_scripts == [] # never reached shellctl - - -def test_missing_sentinel_is_reader_failure() -> None: - service, _ = _service(["command not found: python3\r\n"]) - - with pytest.raises(WorkspaceFileError) as exc_info: - asyncio.run(service.list_dir(SID, ".")) - - assert exc_info.value.code == "reader_failed" - assert exc_info.value.status_code == 502 - - -# --- path validation ----------------------------------------------------------- - - -@pytest.mark.parametrize("good", [".", "", "notes.txt", "sub/inner.txt", "a/b/c.json"]) -def test_validate_rel_path_accepts(good: str) -> None: - _validate_rel_path(good) - - -@pytest.mark.parametrize("bad", ["/etc/passwd", "../escape", "sub/../../etc", "~/secrets", "a/\x00b"]) -def test_validate_rel_path_rejects(bad: str) -> None: - with pytest.raises(WorkspaceFileError): - _validate_rel_path(bad) - - -# --- router -------------------------------------------------------------------- - - -def _client(service: WorkspaceFileService | None) -> TestClient: - app = FastAPI() - app.include_router(create_workspace_files_router(lambda: service)) - return TestClient(app) - - -def test_router_list_ok() -> None: - payload = {"entries": [{"name": "a.txt", "type": "file", "size": 1, "mtime": 1}], "truncated": False} - service, _ = _service([_wrap(payload)]) - - response = _client(service).get(f"/workspaces/{SID}/files", params={"path": "."}) - - assert response.status_code == 200 - assert response.json()["entries"][0]["name"] == "a.txt" - - -def test_router_maps_reader_error_to_status() -> None: - service, _ = _service([_wrap({"error": "not_found"})]) - - response = _client(service).get(f"/workspaces/{SID}/files/preview", params={"path": "missing.txt"}) - - assert response.status_code == 404 - assert response.json()["detail"]["code"] == "not_found" - - -def test_router_returns_503_when_inspector_unconfigured() -> None: - response = _client(None).get(f"/workspaces/{SID}/files", params={"path": "."}) - - assert response.status_code == 503 diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 2ce5e6efac1..8c66e870621 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -46,15 +46,12 @@ import { zGetAppsByAppIdAgentLogsResponse, zGetAppsByAppIdAgentReferencingWorkflowsPath, zGetAppsByAppIdAgentReferencingWorkflowsResponse, - zGetAppsByAppIdAgentWorkspaceFilesDownloadPath, - zGetAppsByAppIdAgentWorkspaceFilesDownloadQuery, - zGetAppsByAppIdAgentWorkspaceFilesDownloadResponse, - zGetAppsByAppIdAgentWorkspaceFilesPath, - zGetAppsByAppIdAgentWorkspaceFilesPreviewPath, - zGetAppsByAppIdAgentWorkspaceFilesPreviewQuery, - zGetAppsByAppIdAgentWorkspaceFilesPreviewResponse, - zGetAppsByAppIdAgentWorkspaceFilesQuery, - zGetAppsByAppIdAgentWorkspaceFilesResponse, + zGetAppsByAppIdAgentSandboxFilesPath, + zGetAppsByAppIdAgentSandboxFilesQuery, + zGetAppsByAppIdAgentSandboxFilesReadPath, + zGetAppsByAppIdAgentSandboxFilesReadQuery, + zGetAppsByAppIdAgentSandboxFilesReadResponse, + zGetAppsByAppIdAgentSandboxFilesResponse, zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdPath, zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse, zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdPath, @@ -153,15 +150,12 @@ import { zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse, zGetAppsByAppIdWorkflowRunsByRunIdPath, zGetAppsByAppIdWorkflowRunsByRunIdResponse, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadPath, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadQuery, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponse, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPath, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewPath, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewQuery, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponse, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesQuery, - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponse, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesPath, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesQuery, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadPath, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadQuery, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse, zGetAppsByAppIdWorkflowRunsCountPath, zGetAppsByAppIdWorkflowRunsCountQuery, zGetAppsByAppIdWorkflowRunsCountResponse, @@ -269,6 +263,9 @@ import { zPostAppsByAppIdAgentFeaturesBody, zPostAppsByAppIdAgentFeaturesPath, zPostAppsByAppIdAgentFeaturesResponse, + zPostAppsByAppIdAgentSandboxFilesUploadBody, + zPostAppsByAppIdAgentSandboxFilesUploadPath, + zPostAppsByAppIdAgentSandboxFilesUploadResponse, zPostAppsByAppIdAgentSkillsStandardizePath, zPostAppsByAppIdAgentSkillsStandardizeResponse, zPostAppsByAppIdAgentSkillsUploadPath, @@ -350,6 +347,9 @@ import { zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse, zPostAppsByAppIdWorkflowCommentsPath, zPostAppsByAppIdWorkflowCommentsResponse, + zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadBody, + zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadPath, + zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse, zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopPath, zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse, zPostAppsByAppIdWorkflowsByWorkflowIdRestorePath, @@ -903,80 +903,80 @@ export const agentReferencingWorkflows = { } /** - * Download a file from an Agent App conversation's sandbox workspace (read-only) + * Read a text/binary preview file in an Agent App conversation sandbox */ export const get7 = oc .route({ - description: 'Download a file from an Agent App conversation\'s sandbox workspace (read-only)', + description: 'Read a text/binary preview file in an Agent App conversation sandbox', inputStructure: 'detailed', method: 'GET', - operationId: 'getAppsByAppIdAgentWorkspaceFilesDownload', - path: '/apps/{app_id}/agent-workspace/files/download', + operationId: 'getAppsByAppIdAgentSandboxFilesRead', + path: '/apps/{app_id}/agent-sandbox/files/read', tags: ['console'], }) .input( z.object({ - params: zGetAppsByAppIdAgentWorkspaceFilesDownloadPath, - query: zGetAppsByAppIdAgentWorkspaceFilesDownloadQuery, + params: zGetAppsByAppIdAgentSandboxFilesReadPath, + query: zGetAppsByAppIdAgentSandboxFilesReadQuery, }), ) - .output(zGetAppsByAppIdAgentWorkspaceFilesDownloadResponse) + .output(zGetAppsByAppIdAgentSandboxFilesReadResponse) -export const download = { +export const read = { get: get7, } /** - * Preview a text/binary file in an Agent App conversation's sandbox workspace + * Upload one Agent App sandbox file as a Dify ToolFile mapping */ -export const get8 = oc +export const post11 = oc .route({ - description: 'Preview a text/binary file in an Agent App conversation\'s sandbox workspace', + description: 'Upload one Agent App sandbox file as a Dify ToolFile mapping', inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdAgentWorkspaceFilesPreview', - path: '/apps/{app_id}/agent-workspace/files/preview', + method: 'POST', + operationId: 'postAppsByAppIdAgentSandboxFilesUpload', + path: '/apps/{app_id}/agent-sandbox/files/upload', tags: ['console'], }) .input( z.object({ - params: zGetAppsByAppIdAgentWorkspaceFilesPreviewPath, - query: zGetAppsByAppIdAgentWorkspaceFilesPreviewQuery, + body: zPostAppsByAppIdAgentSandboxFilesUploadBody, + params: zPostAppsByAppIdAgentSandboxFilesUploadPath, }), ) - .output(zGetAppsByAppIdAgentWorkspaceFilesPreviewResponse) + .output(zPostAppsByAppIdAgentSandboxFilesUploadResponse) -export const preview2 = { - get: get8, +export const upload = { + post: post11, } /** - * List a directory in an Agent App conversation's sandbox workspace (read-only) + * List a directory in an Agent App conversation sandbox */ -export const get9 = oc +export const get8 = oc .route({ - description: 'List a directory in an Agent App conversation\'s sandbox workspace (read-only)', + description: 'List a directory in an Agent App conversation sandbox', inputStructure: 'detailed', method: 'GET', - operationId: 'getAppsByAppIdAgentWorkspaceFiles', - path: '/apps/{app_id}/agent-workspace/files', + operationId: 'getAppsByAppIdAgentSandboxFiles', + path: '/apps/{app_id}/agent-sandbox/files', tags: ['console'], }) .input( z.object({ - params: zGetAppsByAppIdAgentWorkspaceFilesPath, - query: zGetAppsByAppIdAgentWorkspaceFilesQuery, + params: zGetAppsByAppIdAgentSandboxFilesPath, + query: zGetAppsByAppIdAgentSandboxFilesQuery, }), ) - .output(zGetAppsByAppIdAgentWorkspaceFilesResponse) + .output(zGetAppsByAppIdAgentSandboxFilesResponse) export const files = { - get: get9, - download, - preview: preview2, + get: get8, + read, + upload, } -export const agentWorkspace = { +export const agentSandbox = { files, } @@ -989,7 +989,7 @@ export const agentWorkspace = { * * @deprecated */ -export const get10 = oc +export const get9 = oc .route({ deprecated: true, description: @@ -1005,7 +1005,7 @@ export const get10 = oc .output(zGetAppsByAppIdAgentLogsResponse) export const logs = { - get: get10, + get: get9, } /** @@ -1017,7 +1017,7 @@ export const logs = { * * @deprecated */ -export const post11 = oc +export const post12 = oc .route({ deprecated: true, description: @@ -1034,7 +1034,7 @@ export const post11 = oc .output(zPostAppsByAppIdAgentSkillsStandardizeResponse) export const standardize = { - post: post11, + post: post12, } /** @@ -1048,7 +1048,7 @@ export const standardize = { * * @deprecated */ -export const post12 = oc +export const post13 = oc .route({ deprecated: true, description: @@ -1064,13 +1064,13 @@ export const post12 = oc .input(z.object({ params: zPostAppsByAppIdAgentSkillsUploadPath })) .output(zPostAppsByAppIdAgentSkillsUploadResponse) -export const upload = { - post: post12, +export const upload2 = { + post: post13, } export const skills = { standardize, - upload, + upload: upload2, } export const agent = { @@ -1085,7 +1085,7 @@ export const agent = { * * @deprecated */ -export const get11 = oc +export const get10 = oc .route({ deprecated: true, description: @@ -1100,7 +1100,7 @@ export const get11 = oc .output(zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse) export const byJobId = { - get: get11, + get: get10, } export const status = { @@ -1114,7 +1114,7 @@ export const status = { * * @deprecated */ -export const post13 = oc +export const post14 = oc .route({ deprecated: true, description: @@ -1134,7 +1134,7 @@ export const post13 = oc .output(zPostAppsByAppIdAnnotationReplyByActionResponse) export const byAction = { - post: post13, + post: post14, status, } @@ -1149,7 +1149,7 @@ export const annotationReply = { * * @deprecated */ -export const get12 = oc +export const get11 = oc .route({ deprecated: true, description: @@ -1164,7 +1164,7 @@ export const get12 = oc .output(zGetAppsByAppIdAnnotationSettingResponse) export const annotationSetting = { - get: get12, + get: get11, } /** @@ -1174,7 +1174,7 @@ export const annotationSetting = { * * @deprecated */ -export const post14 = oc +export const post15 = oc .route({ deprecated: true, description: @@ -1194,7 +1194,7 @@ export const post14 = oc .output(zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse) export const byAnnotationSettingId = { - post: post14, + post: post15, } export const annotationSettings = { @@ -1208,7 +1208,7 @@ export const annotationSettings = { * * @deprecated */ -export const post15 = oc +export const post16 = oc .route({ deprecated: true, description: @@ -1223,7 +1223,7 @@ export const post15 = oc .output(zPostAppsByAppIdAnnotationsBatchImportResponse) export const batchImport = { - post: post15, + post: post16, } /** @@ -1233,7 +1233,7 @@ export const batchImport = { * * @deprecated */ -export const get13 = oc +export const get12 = oc .route({ deprecated: true, description: @@ -1248,7 +1248,7 @@ export const get13 = oc .output(zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse) export const byJobId2 = { - get: get13, + get: get12, } export const batchImportStatus = { @@ -1258,7 +1258,7 @@ export const batchImportStatus = { /** * Get count of message annotations for the app */ -export const get14 = oc +export const get13 = oc .route({ description: 'Get count of message annotations for the app', inputStructure: 'detailed', @@ -1271,13 +1271,13 @@ export const get14 = oc .output(zGetAppsByAppIdAnnotationsCountResponse) export const count2 = { - get: get14, + get: get13, } /** * Export all annotations for an app with CSV injection protection */ -export const get15 = oc +export const get14 = oc .route({ description: 'Export all annotations for an app with CSV injection protection', inputStructure: 'detailed', @@ -1290,13 +1290,13 @@ export const get15 = oc .output(zGetAppsByAppIdAnnotationsExportResponse) export const export_ = { - get: get15, + get: get14, } /** * Get hit histories for an annotation */ -export const get16 = oc +export const get15 = oc .route({ description: 'Get hit histories for an annotation', inputStructure: 'detailed', @@ -1314,7 +1314,7 @@ export const get16 = oc .output(zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse) export const hitHistories = { - get: get16, + get: get15, } /** @@ -1343,7 +1343,7 @@ export const delete_ = oc * * @deprecated */ -export const post16 = oc +export const post17 = oc .route({ deprecated: true, description: @@ -1364,7 +1364,7 @@ export const post16 = oc export const byAnnotationId = { delete: delete_, - post: post16, + post: post17, hitHistories, } @@ -1394,7 +1394,7 @@ export const delete2 = oc * * @deprecated */ -export const get17 = oc +export const get16 = oc .route({ deprecated: true, description: @@ -1420,7 +1420,7 @@ export const get17 = oc * * @deprecated */ -export const post17 = oc +export const post18 = oc .route({ deprecated: true, description: @@ -1439,8 +1439,8 @@ export const post17 = oc export const annotations = { delete: delete2, - get: get17, - post: post17, + get: get16, + post: post18, batchImport, batchImportStatus, count: count2, @@ -1455,7 +1455,7 @@ export const annotations = { * * @deprecated */ -export const post18 = oc +export const post19 = oc .route({ deprecated: true, description: @@ -1470,13 +1470,13 @@ export const post18 = oc .output(zPostAppsByAppIdApiEnableResponse) export const apiEnable = { - post: post18, + post: post19, } /** * Transcript audio to text for chat messages */ -export const post19 = oc +export const post20 = oc .route({ description: 'Transcript audio to text for chat messages', inputStructure: 'detailed', @@ -1489,7 +1489,7 @@ export const post19 = oc .output(zPostAppsByAppIdAudioToTextResponse) export const audioToText = { - post: post19, + post: post20, } /** @@ -1515,7 +1515,7 @@ export const delete3 = oc * * @deprecated */ -export const get18 = oc +export const get17 = oc .route({ deprecated: true, description: @@ -1531,13 +1531,13 @@ export const get18 = oc export const byConversationId = { delete: delete3, - get: get18, + get: get17, } /** * Get chat conversations with pagination, filtering and summary */ -export const get19 = oc +export const get18 = oc .route({ description: 'Get chat conversations with pagination, filtering and summary', inputStructure: 'detailed', @@ -1555,14 +1555,14 @@ export const get19 = oc .output(zGetAppsByAppIdChatConversationsResponse) export const chatConversations = { - get: get19, + get: get18, byConversationId, } /** * Get suggested questions for a message */ -export const get20 = oc +export const get19 = oc .route({ description: 'Get suggested questions for a message', inputStructure: 'detailed', @@ -1575,7 +1575,7 @@ export const get20 = oc .output(zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse) export const suggestedQuestions = { - get: get20, + get: get19, } export const byMessageId = { @@ -1585,7 +1585,7 @@ export const byMessageId = { /** * Stop a running chat message generation */ -export const post20 = oc +export const post21 = oc .route({ description: 'Stop a running chat message generation', inputStructure: 'detailed', @@ -1598,7 +1598,7 @@ export const post20 = oc .output(zPostAppsByAppIdChatMessagesByTaskIdStopResponse) export const stop = { - post: post20, + post: post21, } export const byTaskId = { @@ -1612,7 +1612,7 @@ export const byTaskId = { * * @deprecated */ -export const get21 = oc +export const get20 = oc .route({ deprecated: true, description: @@ -1629,7 +1629,7 @@ export const get21 = oc .output(zGetAppsByAppIdChatMessagesResponse) export const chatMessages = { - get: get21, + get: get20, byMessageId, byTaskId, } @@ -1657,7 +1657,7 @@ export const delete4 = oc * * @deprecated */ -export const get22 = oc +export const get21 = oc .route({ deprecated: true, description: @@ -1673,13 +1673,13 @@ export const get22 = oc export const byConversationId2 = { delete: delete4, - get: get22, + get: get21, } /** * Get completion conversations with pagination and filtering */ -export const get23 = oc +export const get22 = oc .route({ description: 'Get completion conversations with pagination and filtering', inputStructure: 'detailed', @@ -1697,14 +1697,14 @@ export const get23 = oc .output(zGetAppsByAppIdCompletionConversationsResponse) export const completionConversations = { - get: get23, + get: get22, byConversationId: byConversationId2, } /** * Stop a running completion message generation */ -export const post21 = oc +export const post22 = oc .route({ description: 'Stop a running completion message generation', inputStructure: 'detailed', @@ -1717,7 +1717,7 @@ export const post21 = oc .output(zPostAppsByAppIdCompletionMessagesByTaskIdStopResponse) export const stop2 = { - post: post21, + post: post22, } export const byTaskId2 = { @@ -1731,7 +1731,7 @@ export const byTaskId2 = { * * @deprecated */ -export const post22 = oc +export const post23 = oc .route({ deprecated: true, description: @@ -1751,14 +1751,14 @@ export const post22 = oc .output(zPostAppsByAppIdCompletionMessagesResponse) export const completionMessages = { - post: post22, + post: post23, byTaskId: byTaskId2, } /** * Get conversation variables for an application */ -export const get24 = oc +export const get23 = oc .route({ description: 'Get conversation variables for an application', inputStructure: 'detailed', @@ -1776,7 +1776,7 @@ export const get24 = oc .output(zGetAppsByAppIdConversationVariablesResponse) export const conversationVariables = { - get: get24, + get: get23, } /** @@ -1786,7 +1786,7 @@ export const conversationVariables = { * Convert expert mode of chatbot app to workflow mode * Convert Completion App to Workflow App */ -export const post23 = oc +export const post24 = oc .route({ description: 'Convert application to workflow mode\nConvert expert mode of chatbot app to workflow mode\nConvert Completion App to Workflow App', @@ -1806,7 +1806,7 @@ export const post23 = oc .output(zPostAppsByAppIdConvertToWorkflowResponse) export const convertToWorkflow = { - post: post23, + post: post24, } /** @@ -1818,7 +1818,7 @@ export const convertToWorkflow = { * * @deprecated */ -export const post24 = oc +export const post25 = oc .route({ deprecated: true, description: @@ -1835,7 +1835,7 @@ export const post24 = oc .output(zPostAppsByAppIdCopyResponse) export const copy = { - post: post24, + post: post25, } /** @@ -1843,7 +1843,7 @@ export const copy = { * * Export application configuration as DSL */ -export const get25 = oc +export const get24 = oc .route({ description: 'Export application configuration as DSL', inputStructure: 'detailed', @@ -1859,7 +1859,7 @@ export const get25 = oc .output(zGetAppsByAppIdExportResponse) export const export2 = { - get: get25, + get: get24, } /** @@ -1869,7 +1869,7 @@ export const export2 = { * * @deprecated */ -export const get26 = oc +export const get25 = oc .route({ deprecated: true, description: @@ -1889,13 +1889,13 @@ export const get26 = oc .output(zGetAppsByAppIdFeedbacksExportResponse) export const export3 = { - get: get26, + get: get25, } /** * Create or update message feedback (like/dislike) */ -export const post25 = oc +export const post26 = oc .route({ description: 'Create or update message feedback (like/dislike)', inputStructure: 'detailed', @@ -1908,7 +1908,7 @@ export const post25 = oc .output(zPostAppsByAppIdFeedbacksResponse) export const feedbacks = { - post: post25, + post: post26, export: export3, } @@ -1919,7 +1919,7 @@ export const feedbacks = { * * @deprecated */ -export const post26 = oc +export const post27 = oc .route({ deprecated: true, description: @@ -1934,7 +1934,7 @@ export const post26 = oc .output(zPostAppsByAppIdIconResponse) export const icon = { - post: post26, + post: post27, } /** @@ -1944,7 +1944,7 @@ export const icon = { * * @deprecated */ -export const get27 = oc +export const get26 = oc .route({ deprecated: true, description: @@ -1959,7 +1959,7 @@ export const get27 = oc .output(zGetAppsByAppIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get27, + get: get26, } export const messages = { @@ -1975,7 +1975,7 @@ export const messages = { * * @deprecated */ -export const post27 = oc +export const post28 = oc .route({ deprecated: true, description: @@ -1993,7 +1993,7 @@ export const post27 = oc .output(zPostAppsByAppIdModelConfigResponse) export const modelConfig = { - post: post27, + post: post28, } /** @@ -2003,7 +2003,7 @@ export const modelConfig = { * * @deprecated */ -export const post28 = oc +export const post29 = oc .route({ deprecated: true, description: @@ -2018,13 +2018,13 @@ export const post28 = oc .output(zPostAppsByAppIdNameResponse) export const name = { - post: post28, + post: post29, } /** * Publish app to Creators Platform */ -export const post29 = oc +export const post30 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2037,7 +2037,7 @@ export const post29 = oc .output(zPostAppsByAppIdPublishToCreatorsPlatformResponse) export const publishToCreatorsPlatform = { - post: post29, + post: post30, } /** @@ -2047,7 +2047,7 @@ export const publishToCreatorsPlatform = { * * @deprecated */ -export const get28 = oc +export const get27 = oc .route({ deprecated: true, description: @@ -2068,7 +2068,7 @@ export const get28 = oc * * @deprecated */ -export const post30 = oc +export const post31 = oc .route({ deprecated: true, description: @@ -2105,15 +2105,15 @@ export const put2 = oc .output(zPutAppsByAppIdServerResponse) export const server = { - get: get28, - post: post30, + get: get27, + post: post31, put: put2, } /** * Reset access token for application site */ -export const post31 = oc +export const post32 = oc .route({ description: 'Reset access token for application site', inputStructure: 'detailed', @@ -2126,13 +2126,13 @@ export const post31 = oc .output(zPostAppsByAppIdSiteAccessTokenResetResponse) export const accessTokenReset = { - post: post31, + post: post32, } /** * Update application site configuration */ -export const post32 = oc +export const post33 = oc .route({ description: 'Update application site configuration', inputStructure: 'detailed', @@ -2145,7 +2145,7 @@ export const post32 = oc .output(zPostAppsByAppIdSiteResponse) export const site = { - post: post32, + post: post33, accessTokenReset, } @@ -2156,7 +2156,7 @@ export const site = { * * @deprecated */ -export const post33 = oc +export const post34 = oc .route({ deprecated: true, description: @@ -2171,7 +2171,7 @@ export const post33 = oc .output(zPostAppsByAppIdSiteEnableResponse) export const siteEnable = { - post: post33, + post: post34, } /** @@ -2181,7 +2181,7 @@ export const siteEnable = { * * @deprecated */ -export const get29 = oc +export const get28 = oc .route({ deprecated: true, description: @@ -2201,7 +2201,7 @@ export const get29 = oc .output(zGetAppsByAppIdStatisticsAverageResponseTimeResponse) export const averageResponseTime = { - get: get29, + get: get28, } /** @@ -2211,7 +2211,7 @@ export const averageResponseTime = { * * @deprecated */ -export const get30 = oc +export const get29 = oc .route({ deprecated: true, description: @@ -2231,7 +2231,7 @@ export const get30 = oc .output(zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse) export const averageSessionInteractions = { - get: get30, + get: get29, } /** @@ -2241,7 +2241,7 @@ export const averageSessionInteractions = { * * @deprecated */ -export const get31 = oc +export const get30 = oc .route({ deprecated: true, description: @@ -2261,7 +2261,7 @@ export const get31 = oc .output(zGetAppsByAppIdStatisticsDailyConversationsResponse) export const dailyConversations = { - get: get31, + get: get30, } /** @@ -2271,7 +2271,7 @@ export const dailyConversations = { * * @deprecated */ -export const get32 = oc +export const get31 = oc .route({ deprecated: true, description: @@ -2291,7 +2291,7 @@ export const get32 = oc .output(zGetAppsByAppIdStatisticsDailyEndUsersResponse) export const dailyEndUsers = { - get: get32, + get: get31, } /** @@ -2301,7 +2301,7 @@ export const dailyEndUsers = { * * @deprecated */ -export const get33 = oc +export const get32 = oc .route({ deprecated: true, description: @@ -2321,7 +2321,7 @@ export const get33 = oc .output(zGetAppsByAppIdStatisticsDailyMessagesResponse) export const dailyMessages = { - get: get33, + get: get32, } /** @@ -2331,7 +2331,7 @@ export const dailyMessages = { * * @deprecated */ -export const get34 = oc +export const get33 = oc .route({ deprecated: true, description: @@ -2351,7 +2351,7 @@ export const get34 = oc .output(zGetAppsByAppIdStatisticsTokenCostsResponse) export const tokenCosts = { - get: get34, + get: get33, } /** @@ -2361,7 +2361,7 @@ export const tokenCosts = { * * @deprecated */ -export const get35 = oc +export const get34 = oc .route({ deprecated: true, description: @@ -2381,7 +2381,7 @@ export const get35 = oc .output(zGetAppsByAppIdStatisticsTokensPerSecondResponse) export const tokensPerSecond = { - get: get35, + get: get34, } /** @@ -2391,7 +2391,7 @@ export const tokensPerSecond = { * * @deprecated */ -export const get36 = oc +export const get35 = oc .route({ deprecated: true, description: @@ -2411,7 +2411,7 @@ export const get36 = oc .output(zGetAppsByAppIdStatisticsUserSatisfactionRateResponse) export const userSatisfactionRate = { - get: get36, + get: get35, } export const statistics = { @@ -2432,7 +2432,7 @@ export const statistics = { * * @deprecated */ -export const get37 = oc +export const get36 = oc .route({ deprecated: true, description: @@ -2452,7 +2452,7 @@ export const get37 = oc .output(zGetAppsByAppIdTextToAudioVoicesResponse) export const voices = { - get: get37, + get: get36, } /** @@ -2462,7 +2462,7 @@ export const voices = { * * @deprecated */ -export const post34 = oc +export const post35 = oc .route({ deprecated: true, description: @@ -2479,7 +2479,7 @@ export const post34 = oc .output(zPostAppsByAppIdTextToAudioResponse) export const textToAudio = { - post: post34, + post: post35, voices, } @@ -2492,7 +2492,7 @@ export const textToAudio = { * * @deprecated */ -export const get38 = oc +export const get37 = oc .route({ deprecated: true, description: @@ -2510,7 +2510,7 @@ export const get38 = oc /** * Update app tracing configuration */ -export const post35 = oc +export const post36 = oc .route({ description: 'Update app tracing configuration', inputStructure: 'detailed', @@ -2523,8 +2523,8 @@ export const post35 = oc .output(zPostAppsByAppIdTraceResponse) export const trace = { - get: get38, - post: post35, + get: get37, + post: post36, } /** @@ -2558,7 +2558,7 @@ export const delete5 = oc * * @deprecated */ -export const get39 = oc +export const get38 = oc .route({ deprecated: true, description: @@ -2609,7 +2609,7 @@ export const patch = oc * * @deprecated */ -export const post36 = oc +export const post37 = oc .route({ deprecated: true, description: @@ -2629,15 +2629,15 @@ export const post36 = oc export const traceConfig = { delete: delete5, - get: get39, + get: get38, patch, - post: post36, + post: post37, } /** * Update app trigger (enable/disable) */ -export const post37 = oc +export const post38 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2655,13 +2655,13 @@ export const post37 = oc .output(zPostAppsByAppIdTriggerEnableResponse) export const triggerEnable = { - post: post37, + post: post38, } /** * Get app triggers list */ -export const get40 = oc +export const get39 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2674,7 +2674,7 @@ export const get40 = oc .output(zGetAppsByAppIdTriggersResponse) export const triggers = { - get: get40, + get: get39, } /** @@ -2682,7 +2682,7 @@ export const triggers = { * * Get workflow application execution logs */ -export const get41 = oc +export const get40 = oc .route({ description: 'Get workflow application execution logs', inputStructure: 'detailed', @@ -2701,7 +2701,7 @@ export const get41 = oc .output(zGetAppsByAppIdWorkflowAppLogsResponse) export const workflowAppLogs = { - get: get41, + get: get40, } /** @@ -2709,7 +2709,7 @@ export const workflowAppLogs = { * * Get workflow archived execution logs */ -export const get42 = oc +export const get41 = oc .route({ description: 'Get workflow archived execution logs', inputStructure: 'detailed', @@ -2728,7 +2728,7 @@ export const get42 = oc .output(zGetAppsByAppIdWorkflowArchivedLogsResponse) export const workflowArchivedLogs = { - get: get42, + get: get41, } /** @@ -2736,7 +2736,7 @@ export const workflowArchivedLogs = { * * Get workflow runs count statistics */ -export const get43 = oc +export const get42 = oc .route({ description: 'Get workflow runs count statistics', inputStructure: 'detailed', @@ -2755,7 +2755,7 @@ export const get43 = oc .output(zGetAppsByAppIdWorkflowRunsCountResponse) export const count3 = { - get: get43, + get: get42, } /** @@ -2763,7 +2763,7 @@ export const count3 = { * * Stop running workflow task */ -export const post38 = oc +export const post39 = oc .route({ description: 'Stop running workflow task', inputStructure: 'detailed', @@ -2777,7 +2777,7 @@ export const post38 = oc .output(zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse) export const stop3 = { - post: post38, + post: post39, } export const byTaskId3 = { @@ -2791,7 +2791,7 @@ export const tasks = { /** * Generate a download URL for an archived workflow run. */ -export const get44 = oc +export const get43 = oc .route({ description: 'Generate a download URL for an archived workflow run.', inputStructure: 'detailed', @@ -2804,7 +2804,7 @@ export const get44 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdExportResponse) export const export4 = { - get: get44, + get: get43, } /** @@ -2812,7 +2812,7 @@ export const export4 = { * * Get workflow run node execution list */ -export const get45 = oc +export const get44 = oc .route({ description: 'Get workflow run node execution list', inputStructure: 'detailed', @@ -2826,7 +2826,7 @@ export const get45 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse) export const nodeExecutions = { - get: get45, + get: get44, } /** @@ -2834,7 +2834,7 @@ export const nodeExecutions = { * * Get workflow run detail */ -export const get46 = oc +export const get45 = oc .route({ description: 'Get workflow run detail', inputStructure: 'detailed', @@ -2848,97 +2848,92 @@ export const get46 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdResponse) export const byRunId = { - get: get46, + get: get45, export: export4, nodeExecutions, } /** - * Download a file from a Workflow Agent node's sandbox workspace (read-only) + * Read a text/binary preview file in a workflow Agent node sandbox + */ +export const get46 = oc + .route({ + description: 'Read a text/binary preview file in a workflow Agent node sandbox', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesRead', + path: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/read', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadPath, + query: zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadQuery, + }), + ) + .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse) + +export const read2 = { + get: get46, +} + +/** + * Upload one workflow Agent sandbox file as a Dify ToolFile mapping + */ +export const post40 = oc + .route({ + description: 'Upload one workflow Agent sandbox file as a Dify ToolFile mapping', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUpload', + path: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/upload', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadBody, + params: zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadPath, + }), + ) + .output(zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse) + +export const upload3 = { + post: post40, +} + +/** + * List a directory in a workflow Agent node sandbox */ export const get47 = oc .route({ - description: 'Download a file from a Workflow Agent node\'s sandbox workspace (read-only)', + description: 'List a directory in a workflow Agent node sandbox', inputStructure: 'detailed', method: 'GET', - operationId: - 'getAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownload', - path: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/download', + operationId: 'getAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFiles', + path: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files', tags: ['console'], }) .input( z.object({ - params: - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadPath, + params: zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesPath, query: - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadQuery, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesQuery.optional(), }), ) - .output( - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponse, - ) - -export const download2 = { - get: get47, -} - -/** - * Preview a text/binary file in a Workflow Agent node's sandbox workspace - */ -export const get48 = oc - .route({ - description: 'Preview a text/binary file in a Workflow Agent node\'s sandbox workspace', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreview', - path: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/preview', - tags: ['console'], - }) - .input( - z.object({ - params: zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewPath, - query: zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewQuery, - }), - ) - .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponse) - -export const preview3 = { - get: get48, -} - -/** - * List a directory in a Workflow Agent node's sandbox workspace (read-only) - */ -export const get49 = oc - .route({ - description: 'List a directory in a Workflow Agent node\'s sandbox workspace (read-only)', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFiles', - path: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files', - tags: ['console'], - }) - .input( - z.object({ - params: zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPath, - query: - zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesQuery.optional(), - }), - ) - .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponse) + .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse) export const files2 = { - get: get49, - download: download2, - preview: preview3, + get: get47, + read: read2, + upload: upload3, } -export const workspace = { +export const sandbox = { files: files2, } export const byNodeId4 = { - workspace, + sandbox, } export const agentNodes = { @@ -2954,7 +2949,7 @@ export const byWorkflowRunId = { * * Get workflow run list */ -export const get50 = oc +export const get48 = oc .route({ description: 'Get workflow run list', inputStructure: 'detailed', @@ -2973,7 +2968,7 @@ export const get50 = oc .output(zGetAppsByAppIdWorkflowRunsResponse) export const workflowRuns2 = { - get: get50, + get: get48, count: count3, tasks, byRunId, @@ -2985,7 +2980,7 @@ export const workflowRuns2 = { * * Get all users in current tenant for mentions */ -export const get51 = oc +export const get49 = oc .route({ description: 'Get all users in current tenant for mentions', inputStructure: 'detailed', @@ -2999,7 +2994,7 @@ export const get51 = oc .output(zGetAppsByAppIdWorkflowCommentsMentionUsersResponse) export const mentionUsers = { - get: get51, + get: get49, } /** @@ -3054,7 +3049,7 @@ export const byReplyId = { * * Add a reply to a workflow comment */ -export const post39 = oc +export const post41 = oc .route({ description: 'Add a reply to a workflow comment', inputStructure: 'detailed', @@ -3074,7 +3069,7 @@ export const post39 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse) export const replies = { - post: post39, + post: post41, byReplyId, } @@ -3083,7 +3078,7 @@ export const replies = { * * Resolve a workflow comment */ -export const post40 = oc +export const post42 = oc .route({ description: 'Resolve a workflow comment', inputStructure: 'detailed', @@ -3097,7 +3092,7 @@ export const post40 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse) export const resolve = { - post: post40, + post: post42, } /** @@ -3124,7 +3119,7 @@ export const delete7 = oc * * Get a specific workflow comment */ -export const get52 = oc +export const get50 = oc .route({ description: 'Get a specific workflow comment', inputStructure: 'detailed', @@ -3162,7 +3157,7 @@ export const put4 = oc export const byCommentId = { delete: delete7, - get: get52, + get: get50, put: put4, replies, resolve, @@ -3173,7 +3168,7 @@ export const byCommentId = { * * Get all comments for a workflow */ -export const get53 = oc +export const get51 = oc .route({ description: 'Get all comments for a workflow', inputStructure: 'detailed', @@ -3191,7 +3186,7 @@ export const get53 = oc * * Create a new workflow comment */ -export const post41 = oc +export const post43 = oc .route({ description: 'Create a new workflow comment', inputStructure: 'detailed', @@ -3211,8 +3206,8 @@ export const post41 = oc .output(zPostAppsByAppIdWorkflowCommentsResponse) export const comments = { - get: get53, - post: post41, + get: get51, + post: post43, mentionUsers, byCommentId, } @@ -3224,7 +3219,7 @@ export const comments = { * * @deprecated */ -export const get54 = oc +export const get52 = oc .route({ deprecated: true, description: @@ -3244,7 +3239,7 @@ export const get54 = oc .output(zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse) export const averageAppInteractions = { - get: get54, + get: get52, } /** @@ -3254,7 +3249,7 @@ export const averageAppInteractions = { * * @deprecated */ -export const get55 = oc +export const get53 = oc .route({ deprecated: true, description: @@ -3274,7 +3269,7 @@ export const get55 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse) export const dailyConversations2 = { - get: get55, + get: get53, } /** @@ -3284,7 +3279,7 @@ export const dailyConversations2 = { * * @deprecated */ -export const get56 = oc +export const get54 = oc .route({ deprecated: true, description: @@ -3304,7 +3299,7 @@ export const get56 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse) export const dailyTerminals = { - get: get56, + get: get54, } /** @@ -3314,7 +3309,7 @@ export const dailyTerminals = { * * @deprecated */ -export const get57 = oc +export const get55 = oc .route({ deprecated: true, description: @@ -3334,7 +3329,7 @@ export const get57 = oc .output(zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse) export const tokenCosts2 = { - get: get57, + get: get55, } export const statistics2 = { @@ -3358,7 +3353,7 @@ export const workflow = { * * @deprecated */ -export const get58 = oc +export const get56 = oc .route({ deprecated: true, description: @@ -3379,7 +3374,7 @@ export const get58 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse) export const byBlockType = { - get: get58, + get: get56, } /** @@ -3391,7 +3386,7 @@ export const byBlockType = { * * @deprecated */ -export const get59 = oc +export const get57 = oc .route({ deprecated: true, description: @@ -3407,7 +3402,7 @@ export const get59 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse) export const defaultWorkflowBlockConfigs = { - get: get59, + get: get57, byBlockType, } @@ -3418,7 +3413,7 @@ export const defaultWorkflowBlockConfigs = { * * @deprecated */ -export const get60 = oc +export const get58 = oc .route({ deprecated: true, description: @@ -3439,7 +3434,7 @@ export const get60 = oc * * @deprecated */ -export const post42 = oc +export const post44 = oc .route({ deprecated: true, description: @@ -3459,8 +3454,8 @@ export const post42 = oc .output(zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse) export const conversationVariables2 = { - get: get60, - post: post42, + get: get58, + post: post44, } /** @@ -3472,7 +3467,7 @@ export const conversationVariables2 = { * * @deprecated */ -export const get61 = oc +export const get59 = oc .route({ deprecated: true, description: @@ -3494,7 +3489,7 @@ export const get61 = oc * * @deprecated */ -export const post43 = oc +export const post45 = oc .route({ deprecated: true, description: @@ -3514,8 +3509,8 @@ export const post43 = oc .output(zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse) export const environmentVariables = { - get: get61, - post: post43, + get: get59, + post: post45, } /** @@ -3525,7 +3520,7 @@ export const environmentVariables = { * * @deprecated */ -export const post44 = oc +export const post46 = oc .route({ deprecated: true, description: @@ -3545,7 +3540,7 @@ export const post44 = oc .output(zPostAppsByAppIdWorkflowsDraftFeaturesResponse) export const features = { - post: post44, + post: post46, } /** @@ -3557,7 +3552,7 @@ export const features = { * * @deprecated */ -export const post45 = oc +export const post47 = oc .route({ deprecated: true, description: @@ -3578,7 +3573,7 @@ export const post45 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse) export const deliveryTest = { - post: post45, + post: post47, } /** @@ -3590,7 +3585,7 @@ export const deliveryTest = { * * @deprecated */ -export const post46 = oc +export const post48 = oc .route({ deprecated: true, description: @@ -3610,8 +3605,8 @@ export const post46 = oc ) .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse) -export const preview4 = { - post: post46, +export const preview2 = { + post: post48, } /** @@ -3623,7 +3618,7 @@ export const preview4 = { * * @deprecated */ -export const post47 = oc +export const post49 = oc .route({ deprecated: true, description: @@ -3644,11 +3639,11 @@ export const post47 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse) export const run5 = { - post: post47, + post: post49, } export const form2 = { - preview: preview4, + preview: preview2, run: run5, } @@ -3674,7 +3669,7 @@ export const humanInput2 = { * * @deprecated */ -export const post48 = oc +export const post50 = oc .route({ deprecated: true, description: @@ -3695,7 +3690,7 @@ export const post48 = oc .output(zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse) export const run6 = { - post: post48, + post: post50, } export const byNodeId6 = { @@ -3719,7 +3714,7 @@ export const iteration2 = { * * @deprecated */ -export const post49 = oc +export const post51 = oc .route({ deprecated: true, description: @@ -3740,7 +3735,7 @@ export const post49 = oc .output(zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse) export const run7 = { - post: post49, + post: post51, } export const byNodeId7 = { @@ -3755,7 +3750,7 @@ export const loop2 = { nodes: nodes6, } -export const get62 = oc +export const get60 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3769,10 +3764,10 @@ export const get62 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse) export const candidates2 = { - get: get62, + get: get60, } -export const post50 = oc +export const post52 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3789,10 +3784,10 @@ export const post50 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse) export const impact = { - post: post50, + post: post52, } -export const post51 = oc +export const post53 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3809,10 +3804,10 @@ export const post51 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse) export const saveToRoster = { - post: post51, + post: post53, } -export const post52 = oc +export const post54 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3829,10 +3824,10 @@ export const post52 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse) export const validate2 = { - post: post52, + post: post54, } -export const get63 = oc +export const get61 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3860,7 +3855,7 @@ export const put5 = oc .output(zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) export const agentComposer2 = { - get: get63, + get: get61, put: put5, candidates: candidates2, impact, @@ -3871,7 +3866,7 @@ export const agentComposer2 = { /** * Get last run result for draft workflow node */ -export const get64 = oc +export const get62 = oc .route({ description: 'Get last run result for draft workflow node', inputStructure: 'detailed', @@ -3884,7 +3879,7 @@ export const get64 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse) export const lastRun = { - get: get64, + get: get62, } /** @@ -3896,7 +3891,7 @@ export const lastRun = { * * @deprecated */ -export const post53 = oc +export const post55 = oc .route({ deprecated: true, description: @@ -3917,7 +3912,7 @@ export const post53 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse) export const run8 = { - post: post53, + post: post55, } /** @@ -3929,7 +3924,7 @@ export const run8 = { * * @deprecated */ -export const post54 = oc +export const post56 = oc .route({ deprecated: true, description: @@ -3945,7 +3940,7 @@ export const post54 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse) export const run9 = { - post: post54, + post: post56, } export const trigger = { @@ -3975,7 +3970,7 @@ export const delete8 = oc * * @deprecated */ -export const get65 = oc +export const get63 = oc .route({ deprecated: true, description: @@ -3991,7 +3986,7 @@ export const get65 = oc export const variables = { delete: delete8, - get: get65, + get: get63, } export const byNodeId8 = { @@ -4015,7 +4010,7 @@ export const nodes7 = { * * @deprecated */ -export const post55 = oc +export const post57 = oc .route({ deprecated: true, description: @@ -4036,7 +4031,7 @@ export const post55 = oc .output(zPostAppsByAppIdWorkflowsDraftRunResponse) export const run10 = { - post: post55, + post: post57, } /** @@ -4046,7 +4041,7 @@ export const run10 = { * * @deprecated */ -export const get66 = oc +export const get64 = oc .route({ deprecated: true, description: @@ -4061,13 +4056,13 @@ export const get66 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse) export const events = { - get: get66, + get: get64, } /** * Full value for one declared output, including signed download URL for files. */ -export const get67 = oc +export const get65 = oc .route({ description: 'Full value for one declared output, including signed download URL for files.', inputStructure: 'detailed', @@ -4083,18 +4078,18 @@ export const get67 = oc ) .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse) -export const preview5 = { - get: get67, +export const preview3 = { + get: get65, } export const byOutputName = { - preview: preview5, + preview: preview3, } /** * One node's declared outputs for a draft workflow run. */ -export const get68 = oc +export const get66 = oc .route({ description: 'One node\'s declared outputs for a draft workflow run.', inputStructure: 'detailed', @@ -4107,14 +4102,14 @@ export const get68 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId9 = { - get: get68, + get: get66, byOutputName, } /** * Snapshot of every node's declared outputs for a draft workflow run. */ -export const get69 = oc +export const get67 = oc .route({ description: 'Snapshot of every node\'s declared outputs for a draft workflow run.', inputStructure: 'detailed', @@ -4127,7 +4122,7 @@ export const get69 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponse) export const nodeOutputs = { - get: get69, + get: get67, events, byNodeId: byNodeId9, } @@ -4147,7 +4142,7 @@ export const runs = { * * @deprecated */ -export const get70 = oc +export const get68 = oc .route({ deprecated: true, description: @@ -4162,7 +4157,7 @@ export const get70 = oc .output(zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse) export const systemVariables = { - get: get70, + get: get68, } /** @@ -4174,7 +4169,7 @@ export const systemVariables = { * * @deprecated */ -export const post56 = oc +export const post58 = oc .route({ deprecated: true, description: @@ -4195,7 +4190,7 @@ export const post56 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse) export const run11 = { - post: post56, + post: post58, } /** @@ -4207,7 +4202,7 @@ export const run11 = { * * @deprecated */ -export const post57 = oc +export const post59 = oc .route({ deprecated: true, description: @@ -4228,7 +4223,7 @@ export const post57 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse) export const runAll = { - post: post57, + post: post59, } export const trigger2 = { @@ -4284,7 +4279,7 @@ export const delete9 = oc * * @deprecated */ -export const get71 = oc +export const get69 = oc .route({ deprecated: true, description: @@ -4326,7 +4321,7 @@ export const patch2 = oc export const byVariableId = { delete: delete9, - get: get71, + get: get69, patch: patch2, reset, } @@ -4356,7 +4351,7 @@ export const delete10 = oc * * @deprecated */ -export const get72 = oc +export const get70 = oc .route({ deprecated: true, description: @@ -4378,7 +4373,7 @@ export const get72 = oc export const variables2 = { delete: delete10, - get: get72, + get: get70, byVariableId, } @@ -4391,7 +4386,7 @@ export const variables2 = { * * @deprecated */ -export const get73 = oc +export const get71 = oc .route({ deprecated: true, description: @@ -4415,7 +4410,7 @@ export const get73 = oc * * @deprecated */ -export const post58 = oc +export const post60 = oc .route({ deprecated: true, description: @@ -4436,8 +4431,8 @@ export const post58 = oc .output(zPostAppsByAppIdWorkflowsDraftResponse) export const draft2 = { - get: get73, - post: post58, + get: get71, + post: post60, conversationVariables: conversationVariables2, environmentVariables, features, @@ -4461,7 +4456,7 @@ export const draft2 = { * * @deprecated */ -export const get74 = oc +export const get72 = oc .route({ deprecated: true, description: @@ -4483,7 +4478,7 @@ export const get74 = oc * * @deprecated */ -export const post59 = oc +export const post61 = oc .route({ deprecated: true, description: @@ -4504,8 +4499,8 @@ export const post59 = oc .output(zPostAppsByAppIdWorkflowsPublishResponse) export const publish = { - get: get74, - post: post59, + get: get72, + post: post61, } /** @@ -4515,7 +4510,7 @@ export const publish = { * * @deprecated */ -export const get75 = oc +export const get73 = oc .route({ deprecated: true, description: @@ -4530,13 +4525,13 @@ export const get75 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse) export const events2 = { - get: get75, + get: get73, } /** * Full value for one declared output of a published run. */ -export const get76 = oc +export const get74 = oc .route({ description: 'Full value for one declared output of a published run.', inputStructure: 'detailed', @@ -4556,18 +4551,18 @@ export const get76 = oc zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse, ) -export const preview6 = { - get: get76, +export const preview4 = { + get: get74, } export const byOutputName2 = { - preview: preview6, + preview: preview4, } /** * One node's declared outputs for a published workflow run. */ -export const get77 = oc +export const get75 = oc .route({ description: 'One node\'s declared outputs for a published workflow run.', inputStructure: 'detailed', @@ -4580,14 +4575,14 @@ export const get77 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId10 = { - get: get77, + get: get75, byOutputName: byOutputName2, } /** * Snapshot of every node's declared outputs for a published workflow run. */ -export const get78 = oc +export const get76 = oc .route({ description: 'Snapshot of every node\'s declared outputs for a published workflow run.', inputStructure: 'detailed', @@ -4600,7 +4595,7 @@ export const get78 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse) export const nodeOutputs2 = { - get: get78, + get: get76, events: events2, byNodeId: byNodeId10, } @@ -4624,7 +4619,7 @@ export const published = { * * @deprecated */ -export const get79 = oc +export const get77 = oc .route({ deprecated: true, description: @@ -4645,7 +4640,7 @@ export const get79 = oc .output(zGetAppsByAppIdWorkflowsTriggersWebhookResponse) export const webhook = { - get: get79, + get: get77, } export const triggers2 = { @@ -4659,7 +4654,7 @@ export const triggers2 = { * * @deprecated */ -export const post60 = oc +export const post62 = oc .route({ deprecated: true, description: @@ -4674,7 +4669,7 @@ export const post60 = oc .output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post60, + post: post62, } /** @@ -4743,7 +4738,7 @@ export const byWorkflowId = { * * @deprecated */ -export const get80 = oc +export const get78 = oc .route({ deprecated: true, description: @@ -4764,7 +4759,7 @@ export const get80 = oc .output(zGetAppsByAppIdWorkflowsResponse) export const workflows3 = { - get: get80, + get: get78, defaultWorkflowBlockConfigs, draft: draft2, publish, @@ -4801,7 +4796,7 @@ export const delete12 = oc * * @deprecated */ -export const get81 = oc +export const get79 = oc .route({ deprecated: true, description: @@ -4842,13 +4837,13 @@ export const put7 = oc export const byAppId2 = { delete: delete12, - get: get81, + get: get79, put: put7, advancedChat, agentComposer, agentFeatures, agentReferencingWorkflows, - agentWorkspace, + agentSandbox, agent, annotationReply, annotationSetting, @@ -4914,7 +4909,7 @@ export const byApiKeyId = { * * Get all API keys for an app */ -export const get82 = oc +export const get80 = oc .route({ description: 'Get all API keys for an app', inputStructure: 'detailed', @@ -4932,7 +4927,7 @@ export const get82 = oc * * Create a new API key for an app */ -export const post61 = oc +export const post63 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -4947,8 +4942,8 @@ export const post61 = oc .output(zPostAppsByResourceIdApiKeysResponse) export const apiKeys = { - get: get82, - post: post61, + get: get80, + post: post63, byApiKeyId, } @@ -4963,7 +4958,7 @@ export const byResourceId = { * * @deprecated */ -export const get83 = oc +export const get81 = oc .route({ deprecated: true, description: @@ -4978,7 +4973,7 @@ export const get83 = oc .output(zGetAppsByServerIdServerRefreshResponse) export const refresh = { - get: get83, + get: get81, } export const server2 = { @@ -4994,7 +4989,7 @@ export const byServerId = { * * Get list of applications with pagination and filtering */ -export const get84 = oc +export const get82 = oc .route({ description: 'Get list of applications with pagination and filtering', inputStructure: 'detailed', @@ -5016,7 +5011,7 @@ export const get84 = oc * * @deprecated */ -export const post62 = oc +export const post64 = oc .route({ deprecated: true, description: @@ -5033,8 +5028,8 @@ export const post62 = oc .output(zPostAppsResponse) export const apps = { - get: get84, - post: post62, + get: get82, + post: post64, imports, workflows, byAppId: byAppId2, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 7de8e67d776..73ef265f19a 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -223,20 +223,30 @@ export type AgentReferencingWorkflowsResponse = { data?: Array } -export type WorkspaceListResponse = { - entries?: Array +export type SandboxListResponse = { + entries?: Array path: string truncated?: boolean } -export type WorkspacePreviewResponse = { +export type SandboxReadResponse = { binary: boolean path: string - size: number + size?: number | null text?: string | null truncated: boolean } +export type AgentSandboxUploadPayload = { + conversation_id: string + path: string +} + +export type SandboxUploadResponse = { + file: SandboxToolFileResponse + path: string +} + export type AnnotationReplyPayload = { embedding_model_name: string embedding_provider_name: string @@ -638,6 +648,11 @@ export type WorkflowRunNodeExecutionListResponse = { data: Array } +export type WorkflowAgentSandboxUploadPayload = { + node_execution_id?: string | null + path: string +} + export type WorkflowCommentBasicList = { data: Array } @@ -1198,11 +1213,16 @@ export type AgentReferencingWorkflowResponse = { workflow_id: string } -export type WorkspaceFileEntryResponse = { - mtime: number +export type SandboxFileEntryResponse = { + mtime?: number | null name: string - size: number - type: 'dir' | 'file' | 'symlink' + size?: number | null + type: 'dir' | 'file' | 'other' | 'symlink' +} + +export type SandboxToolFileResponse = { + reference: string + transfer_method?: string } export type AnnotationHitHistory = { @@ -2655,7 +2675,7 @@ export type GetAppsByAppIdAgentReferencingWorkflowsResponses = { export type GetAppsByAppIdAgentReferencingWorkflowsResponse = GetAppsByAppIdAgentReferencingWorkflowsResponses[keyof GetAppsByAppIdAgentReferencingWorkflowsResponses] -export type GetAppsByAppIdAgentWorkspaceFilesData = { +export type GetAppsByAppIdAgentSandboxFilesData = { body?: never path: { app_id: string @@ -2664,17 +2684,17 @@ export type GetAppsByAppIdAgentWorkspaceFilesData = { conversation_id: string path?: string } - url: '/apps/{app_id}/agent-workspace/files' + url: '/apps/{app_id}/agent-sandbox/files' } -export type GetAppsByAppIdAgentWorkspaceFilesResponses = { - 200: WorkspaceListResponse +export type GetAppsByAppIdAgentSandboxFilesResponses = { + 200: SandboxListResponse } -export type GetAppsByAppIdAgentWorkspaceFilesResponse - = GetAppsByAppIdAgentWorkspaceFilesResponses[keyof GetAppsByAppIdAgentWorkspaceFilesResponses] +export type GetAppsByAppIdAgentSandboxFilesResponse + = GetAppsByAppIdAgentSandboxFilesResponses[keyof GetAppsByAppIdAgentSandboxFilesResponses] -export type GetAppsByAppIdAgentWorkspaceFilesDownloadData = { +export type GetAppsByAppIdAgentSandboxFilesReadData = { body?: never path: { app_id: string @@ -2683,43 +2703,31 @@ export type GetAppsByAppIdAgentWorkspaceFilesDownloadData = { conversation_id: string path: string } - url: '/apps/{app_id}/agent-workspace/files/download' + url: '/apps/{app_id}/agent-sandbox/files/read' } -export type GetAppsByAppIdAgentWorkspaceFilesDownloadErrors = { - 413: { - [key: string]: unknown - } +export type GetAppsByAppIdAgentSandboxFilesReadResponses = { + 200: SandboxReadResponse } -export type GetAppsByAppIdAgentWorkspaceFilesDownloadError - = GetAppsByAppIdAgentWorkspaceFilesDownloadErrors[keyof GetAppsByAppIdAgentWorkspaceFilesDownloadErrors] +export type GetAppsByAppIdAgentSandboxFilesReadResponse + = GetAppsByAppIdAgentSandboxFilesReadResponses[keyof GetAppsByAppIdAgentSandboxFilesReadResponses] -export type GetAppsByAppIdAgentWorkspaceFilesDownloadResponses = { - 200: Blob | File -} - -export type GetAppsByAppIdAgentWorkspaceFilesDownloadResponse - = GetAppsByAppIdAgentWorkspaceFilesDownloadResponses[keyof GetAppsByAppIdAgentWorkspaceFilesDownloadResponses] - -export type GetAppsByAppIdAgentWorkspaceFilesPreviewData = { - body?: never +export type PostAppsByAppIdAgentSandboxFilesUploadData = { + body: AgentSandboxUploadPayload path: { app_id: string } - query: { - conversation_id: string - path: string - } - url: '/apps/{app_id}/agent-workspace/files/preview' + query?: never + url: '/apps/{app_id}/agent-sandbox/files/upload' } -export type GetAppsByAppIdAgentWorkspaceFilesPreviewResponses = { - 200: WorkspacePreviewResponse +export type PostAppsByAppIdAgentSandboxFilesUploadResponses = { + 200: SandboxUploadResponse } -export type GetAppsByAppIdAgentWorkspaceFilesPreviewResponse - = GetAppsByAppIdAgentWorkspaceFilesPreviewResponses[keyof GetAppsByAppIdAgentWorkspaceFilesPreviewResponses] +export type PostAppsByAppIdAgentSandboxFilesUploadResponse + = PostAppsByAppIdAgentSandboxFilesUploadResponses[keyof PostAppsByAppIdAgentSandboxFilesUploadResponses] export type GetAppsByAppIdAgentLogsData = { body?: never @@ -4553,7 +4561,7 @@ export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses = { export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse = GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses[keyof GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses] -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesData = { +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesData = { body?: never path: { app_id: string @@ -4564,50 +4572,17 @@ export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspace node_execution_id?: string path?: string } - url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files' + url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files' } -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponses = { - 200: WorkspaceListResponse +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses = { + 200: SandboxListResponse } -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponse - = GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponses] +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse + = GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses] -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadData - = { - body?: never - path: { - app_id: string - node_id: string - workflow_run_id: string - } - query: { - node_execution_id?: string - path: string - } - url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/download' - } - -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadErrors - = { - 413: { - [key: string]: unknown - } - } - -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadError - = GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadErrors[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadErrors] - -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponses - = { - 200: Blob | File - } - -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponse - = GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponses] - -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewData = { +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadData = { body?: never path: { app_id: string @@ -4618,16 +4593,34 @@ export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspace node_execution_id?: string path: string } - url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/preview' + url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/read' } -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponses +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses = { + 200: SandboxReadResponse +} + +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse + = GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses] + +export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadData = { + body: WorkflowAgentSandboxUploadPayload + path: { + app_id: string + node_id: string + workflow_run_id: string + } + query?: never + url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/upload' +} + +export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses = { - 200: WorkspacePreviewResponse + 200: SandboxUploadResponse } -export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponse - = GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponses] +export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse + = PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses[keyof PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses] export type GetAppsByAppIdWorkflowCommentsData = { body?: never diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 67136f0b2ff..6f16228b794 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -85,16 +85,24 @@ export const zSimpleResultResponse = z.object({ }) /** - * WorkspacePreviewResponse + * SandboxReadResponse */ -export const zWorkspacePreviewResponse = z.object({ +export const zSandboxReadResponse = z.object({ binary: z.boolean(), path: z.string(), - size: z.int(), + size: z.int().nullish(), text: z.string().nullish(), truncated: z.boolean(), }) +/** + * AgentSandboxUploadPayload + */ +export const zAgentSandboxUploadPayload = z.object({ + conversation_id: z.string().min(1), + path: z.string().min(1), +}) + /** * AnnotationReplyPayload */ @@ -395,6 +403,14 @@ export const zWorkflowRunExportResponse = z.object({ status: z.string(), }) +/** + * WorkflowAgentSandboxUploadPayload + */ +export const zWorkflowAgentSandboxUploadPayload = z.object({ + node_execution_id: z.string().nullish(), + path: z.string().min(1), +}) + /** * WorkflowCommentCreatePayload */ @@ -867,24 +883,40 @@ export const zAgentReferencingWorkflowsResponse = z.object({ }) /** - * WorkspaceFileEntryResponse + * SandboxFileEntryResponse */ -export const zWorkspaceFileEntryResponse = z.object({ - mtime: z.int(), +export const zSandboxFileEntryResponse = z.object({ + mtime: z.int().nullish(), name: z.string(), - size: z.int(), - type: z.enum(['dir', 'file', 'symlink']), + size: z.int().nullish(), + type: z.enum(['dir', 'file', 'other', 'symlink']), }) /** - * WorkspaceListResponse + * SandboxListResponse */ -export const zWorkspaceListResponse = z.object({ - entries: z.array(zWorkspaceFileEntryResponse).optional(), +export const zSandboxListResponse = z.object({ + entries: z.array(zSandboxFileEntryResponse).optional(), path: z.string(), truncated: z.boolean().optional().default(false), }) +/** + * SandboxToolFileResponse + */ +export const zSandboxToolFileResponse = z.object({ + reference: z.string(), + transfer_method: z.string().optional().default('tool_file'), +}) + +/** + * SandboxUploadResponse + */ +export const zSandboxUploadResponse = z.object({ + file: zSandboxToolFileResponse, + path: z.string(), +}) + /** * AnnotationHitHistory */ @@ -3178,11 +3210,11 @@ export const zGetAppsByAppIdAgentReferencingWorkflowsPath = z.object({ */ export const zGetAppsByAppIdAgentReferencingWorkflowsResponse = zAgentReferencingWorkflowsResponse -export const zGetAppsByAppIdAgentWorkspaceFilesPath = z.object({ +export const zGetAppsByAppIdAgentSandboxFilesPath = z.object({ app_id: z.string(), }) -export const zGetAppsByAppIdAgentWorkspaceFilesQuery = z.object({ +export const zGetAppsByAppIdAgentSandboxFilesQuery = z.object({ conversation_id: z.string().min(1), path: z.string().optional().default('.'), }) @@ -3190,27 +3222,13 @@ export const zGetAppsByAppIdAgentWorkspaceFilesQuery = z.object({ /** * Listing returned */ -export const zGetAppsByAppIdAgentWorkspaceFilesResponse = zWorkspaceListResponse +export const zGetAppsByAppIdAgentSandboxFilesResponse = zSandboxListResponse -export const zGetAppsByAppIdAgentWorkspaceFilesDownloadPath = z.object({ +export const zGetAppsByAppIdAgentSandboxFilesReadPath = z.object({ app_id: z.string(), }) -export const zGetAppsByAppIdAgentWorkspaceFilesDownloadQuery = z.object({ - conversation_id: z.string().min(1), - path: z.string().min(1), -}) - -/** - * File bytes - */ -export const zGetAppsByAppIdAgentWorkspaceFilesDownloadResponse = z.custom() - -export const zGetAppsByAppIdAgentWorkspaceFilesPreviewPath = z.object({ - app_id: z.string(), -}) - -export const zGetAppsByAppIdAgentWorkspaceFilesPreviewQuery = z.object({ +export const zGetAppsByAppIdAgentSandboxFilesReadQuery = z.object({ conversation_id: z.string().min(1), path: z.string().min(1), }) @@ -3218,7 +3236,18 @@ export const zGetAppsByAppIdAgentWorkspaceFilesPreviewQuery = z.object({ /** * Preview returned */ -export const zGetAppsByAppIdAgentWorkspaceFilesPreviewResponse = zWorkspacePreviewResponse +export const zGetAppsByAppIdAgentSandboxFilesReadResponse = zSandboxReadResponse + +export const zPostAppsByAppIdAgentSandboxFilesUploadBody = zAgentSandboxUploadPayload + +export const zPostAppsByAppIdAgentSandboxFilesUploadPath = z.object({ + app_id: z.string(), +}) + +/** + * Uploaded + */ +export const zPostAppsByAppIdAgentSandboxFilesUploadResponse = zSandboxUploadResponse export const zGetAppsByAppIdAgentLogsPath = z.object({ app_id: z.string(), @@ -4124,14 +4153,14 @@ export const zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsPath = z.object({ export const zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse = zWorkflowRunNodeExecutionListResponse -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPath +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesPath = z.object({ app_id: z.string(), node_id: z.string(), workflow_run_id: z.string(), }) -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesQuery +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesQuery = z.object({ node_execution_id: z.string().optional(), path: z.string().optional().default('.'), @@ -4140,36 +4169,17 @@ export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspa /** * Listing returned */ -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponse - = zWorkspaceListResponse +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse + = zSandboxListResponse -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadPath +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadPath = z.object({ app_id: z.string(), node_id: z.string(), workflow_run_id: z.string(), }) -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadQuery - = z.object({ - node_execution_id: z.string().optional(), - path: z.string().min(1), - }) - -/** - * File bytes - */ -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponse - = z.custom() - -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewPath - = z.object({ - app_id: z.string(), - node_id: z.string(), - workflow_run_id: z.string(), - }) - -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewQuery +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadQuery = z.object({ node_execution_id: z.string().optional(), path: z.string().min(1), @@ -4178,8 +4188,24 @@ export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspa /** * Preview returned */ -export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponse - = zWorkspacePreviewResponse +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse + = zSandboxReadResponse + +export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadBody + = zWorkflowAgentSandboxUploadPayload + +export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadPath + = z.object({ + app_id: z.string(), + node_id: z.string(), + workflow_run_id: z.string(), + }) + +/** + * Uploaded + */ +export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse + = zSandboxUploadResponse export const zGetAppsByAppIdWorkflowCommentsPath = z.object({ app_id: z.string(), diff --git a/packages/contracts/sandbox-contract.smoke.test.ts b/packages/contracts/sandbox-contract.smoke.test.ts new file mode 100644 index 00000000000..84e721f14a7 --- /dev/null +++ b/packages/contracts/sandbox-contract.smoke.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' +import { dirname, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const thisDir = dirname(fileURLToPath(import.meta.url)) +const sourcePath = resolve(thisDir, './generated/api/console/apps/orpc.gen.ts') + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === './zod.gen' || specifier.endsWith('/zod.gen')) + return nextResolve(`${specifier}.ts`, context) + + return nextResolve(specifier, context) + }, +}) + +const { agentSandbox, sandbox } = await import(pathToFileURL(sourcePath).href) + +assert.ok(agentSandbox.files.get) +assert.ok(agentSandbox.files.read.get) +assert.ok(agentSandbox.files.upload.post) + +assert.ok(sandbox.files.get) +assert.ok(sandbox.files.read.get) +assert.ok(sandbox.files.upload.post)