From 44725dde741e1021e73554a50421fb76d504f2e2 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Thu, 4 Jun 2026 06:37:31 +0800 Subject: [PATCH] feat(agent): Sandbox / CLI Agent (dify.shell) + read-only sandbox file inspector (#36984) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/clients/agent_backend/request_builder.py | 34 ++ .../agent_backend/workspace_files_client.py | 135 +++++ api/configs/extra/agent_backend_config.py | 10 + api/controllers/console/__init__.py | 2 + .../console/app/agent_app_workspace.py | 319 +++++++++++ .../apps/agent_app/runtime_request_builder.py | 13 +- api/core/app/apps/agent_app/session_store.py | 40 ++ .../agent_v2/runtime_feature_manifest.py | 11 +- .../nodes/agent_v2/runtime_request_builder.py | 93 +++- .../workflow/nodes/agent_v2/validators.py | 14 +- api/openapi/markdown/console-swagger.md | 161 ++++++ api/services/agent_app_workspace_service.py | 220 ++++++++ .../agent_backend/test_request_builder.py | 65 +++ .../test_workspace_files_client.py | 152 +++++ .../console/app/test_agent_app_workspace.py | 199 +++++++ .../agent_app/test_runtime_request_builder.py | 35 ++ .../app/apps/agent_app/test_session_store.py | 66 ++- .../agent_v2/test_runtime_request_builder.py | 89 ++- .../nodes/agent_v2/test_validators.py | 21 + .../test_agent_app_workspace_service.py | 284 ++++++++++ dify-agent/src/dify_agent/client/_client.py | 50 +- .../src/dify_agent/layers/shell/__init__.py | 18 +- .../src/dify_agent/layers/shell/configs.py | 85 ++- .../src/dify_agent/layers/shell/layer.py | 109 +++- dify-agent/src/dify_agent/server/app.py | 11 + .../server/routes/workspace_files.py | 78 +++ .../src/dify_agent/server/workspace_files.py | 418 ++++++++++++++ .../local/dify_agent/client/test_client.py | 44 ++ .../dify_agent/layers/shell/test_configs.py | 54 +- .../dify_agent/layers/shell/test_layer.py | 85 ++- .../local/dify_agent/runtime/test_runner.py | 4 +- .../dify_agent/server/test_workspace_files.py | 270 +++++++++ .../dify_agent/test_import_boundaries.py | 2 +- .../generated/api/console/apps/orpc.gen.ts | 518 ++++++++++++------ .../generated/api/console/apps/types.gen.ts | 163 ++++++ .../generated/api/console/apps/zod.gen.ts | 129 +++++ 36 files changed, 3761 insertions(+), 240 deletions(-) create mode 100644 api/clients/agent_backend/workspace_files_client.py create mode 100644 api/controllers/console/app/agent_app_workspace.py create mode 100644 api/services/agent_app_workspace_service.py create mode 100644 api/tests/unit_tests/clients/agent_backend/test_workspace_files_client.py create mode 100644 api/tests/unit_tests/controllers/console/app/test_agent_app_workspace.py create mode 100644 api/tests/unit_tests/services/test_agent_app_workspace_service.py create mode 100644 dify-agent/src/dify_agent/server/routes/workspace_files.py create mode 100644 dify-agent/src/dify_agent/server/workspace_files.py create mode 100644 dify-agent/tests/local/dify_agent/server/test_workspace_files.py diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index 176b8796f4..e315a98998 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -30,6 +30,7 @@ from dify_agent.layers.execution_context import ( DifyExecutionContextLayerConfig, ) from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig +from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig from dify_agent.protocol import ( DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, @@ -48,6 +49,7 @@ WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt" AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt" 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 @@ -167,6 +169,10 @@ class AgentBackendWorkflowNodeRunInput(BaseModel): idempotency_key: str | None = None output: AgentBackendOutputConfig | None = None tools: DifyPluginToolsLayerConfig | None = None + # Inject the sandboxed shell layer (dify.shell). Requires the agent backend + # to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED. + include_shell: bool = False + shell_config: DifyShellLayerConfig | None = None session_snapshot: CompositorSessionSnapshot | None = None include_history: bool = True suspend_on_exit: bool = True @@ -199,6 +205,10 @@ class AgentBackendAgentAppRunInput(BaseModel): idempotency_key: str | None = None output: AgentBackendOutputConfig | None = None tools: DifyPluginToolsLayerConfig | None = None + # Inject the sandboxed shell layer (dify.shell). Requires the agent backend + # to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED. + include_shell: bool = False + shell_config: DifyShellLayerConfig | None = None session_snapshot: CompositorSessionSnapshot | None = None include_history: bool = True suspend_on_exit: bool = True @@ -289,6 +299,18 @@ class AgentBackendRunRequestBuilder: ) ) + if run_input.include_shell: + # Sandboxed bash workspace (dify.shell). The layer declares NoLayerDeps, + # so the spec carries no deps; shellctl connection is server-injected. + layers.append( + RunLayerSpec( + name=DIFY_SHELL_LAYER_ID, + type=DIFY_SHELL_LAYER_TYPE_ID, + metadata=run_input.metadata, + config=run_input.shell_config or DifyShellLayerConfig(), + ) + ) + if run_input.output is not None: layers.append( RunLayerSpec( @@ -432,6 +454,18 @@ class AgentBackendRunRequestBuilder: ) ) + if run_input.include_shell: + # Sandboxed bash workspace (dify.shell). The layer declares NoLayerDeps, + # so the spec carries no deps; shellctl connection is server-injected. + layers.append( + RunLayerSpec( + name=DIFY_SHELL_LAYER_ID, + type=DIFY_SHELL_LAYER_TYPE_ID, + metadata=run_input.metadata, + config=run_input.shell_config or DifyShellLayerConfig(), + ) + ) + if run_input.output is not None: layers.append( RunLayerSpec( diff --git a/api/clients/agent_backend/workspace_files_client.py b/api/clients/agent_backend/workspace_files_client.py new file mode 100644 index 0000000000..bd41f457ba --- /dev/null +++ b/api/clients/agent_backend/workspace_files_client.py @@ -0,0 +1,135 @@ +"""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/configs/extra/agent_backend_config.py b/api/configs/extra/agent_backend_config.py index ae1dc2ed22..347302ceb3 100644 --- a/api/configs/extra/agent_backend_config.py +++ b/api/configs/extra/agent_backend_config.py @@ -21,3 +21,13 @@ class AgentBackendConfig(BaseSettings): description="Scenario used by the fake Agent backend client.", default="success", ) + + AGENT_SHELL_ENABLED: bool = Field( + description=( + "Inject the dify.shell layer (sandboxed bash workspace) into Agent runs. " + "Requires the agent backend to be wired with a shellctl entrypoint; keep it " + "off until shellctl is deployed, otherwise every agent run that includes the " + "shell layer will fail." + ), + default=False, + ) diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index ee4a369c13..5e88fc8496 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -53,6 +53,7 @@ from .app import ( agent, agent_app_access, agent_app_feature, + agent_app_workspace, annotation, app, audio, @@ -150,6 +151,7 @@ __all__ = [ "agent", "agent_app_access", "agent_app_feature", + "agent_app_workspace", "agent_composer", "agent_providers", "agent_roster", diff --git a/api/controllers/console/app/agent_app_workspace.py b/api/controllers/console/app/agent_app_workspace.py new file mode 100644 index 0000000000..888fa8d40c --- /dev/null +++ b/api/controllers/console/app/agent_app_workspace.py @@ -0,0 +1,319 @@ +"""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 +from fields.base import ResponseModel +from libs.login import current_account_with_tenant, 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]) + def get(self, app_model: App): + _, tenant_id = current_account_with_tenant() + 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]) + def get(self, app_model: App): + _, tenant_id = current_account_with_tenant() + 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]) + def get(self, app_model: App): + _, tenant_id = current_account_with_tenant() + 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]) + def get(self, app_model: App, workflow_run_id: UUID, node_id: str): + _, tenant_id = current_account_with_tenant() + 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]) + def get(self, app_model: App, workflow_run_id: UUID, node_id: str): + _, tenant_id = current_account_with_tenant() + 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]) + def get(self, app_model: App, workflow_run_id: UUID, node_id: str): + _, tenant_id = current_account_with_tenant() + 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/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py index a62728c9cf..ca269a9fe8 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -22,11 +22,13 @@ from clients.agent_backend import ( AgentBackendRunRequestBuilder, redact_for_agent_backend_log, ) +from configs import dify_config from core.app.entities.app_invoke_entities import DifyRunContext from core.workflow.nodes.agent_v2.plugin_tools_builder import ( WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError, ) +from core.workflow.nodes.agent_v2.runtime_request_builder import build_shell_layer_config from models.agent_config_entities import AgentSoulConfig from models.provider_ids import ModelProviderID @@ -96,10 +98,13 @@ class AgentAppRuntimeRequestBuilder: ) except WorkflowAgentPluginToolsBuildError as error: raise AgentAppRuntimeRequestBuildError(error.error_code, str(error)) from error - if tools_layer is not None: + if tools_layer is not None or agent_soul.tools.cli_tools: metadata["agent_tools"] = { - "dify_tool_count": len(tools_layer.tools), - "dify_tool_names": [tool.name or tool.tool_name for tool in tools_layer.tools], + "dify_tool_count": len(tools_layer.tools) if tools_layer is not None else 0, + "dify_tool_names": [tool.name or tool.tool_name for tool in tools_layer.tools] + if tools_layer is not None + else [], + "cli_tool_count": len(agent_soul.tools.cli_tools), } request = self._request_builder.build_for_agent_app( @@ -126,6 +131,8 @@ class AgentAppRuntimeRequestBuilder: agent_soul_prompt=agent_soul.prompt.system_prompt or None, user_prompt=context.user_query, tools=tools_layer, + include_shell=dify_config.AGENT_SHELL_ENABLED, + shell_config=build_shell_layer_config(agent_soul), session_snapshot=context.session_snapshot, idempotency_key=context.idempotency_key, metadata=metadata, diff --git a/api/core/app/apps/agent_app/session_store.py b/api/core/app/apps/agent_app/session_store.py index 183e7113a9..62c14c33b9 100644 --- a/api/core/app/apps/agent_app/session_store.py +++ b/api/core/app/apps/agent_app/session_store.py @@ -44,6 +44,32 @@ class AgentAppRuntimeSessionStore: return None return CompositorSessionSnapshot.model_validate_json(row.session_snapshot) + def load_active_snapshot_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. + + 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). + """ + stmt = ( + select(AgentRuntimeSession) + .where( + AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION, + AgentRuntimeSession.tenant_id == tenant_id, + AgentRuntimeSession.app_id == app_id, + AgentRuntimeSession.conversation_id == conversation_id, + AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE, + ) + .order_by(AgentRuntimeSession.updated_at.desc()) + ) + with session_factory.create_session() as session: + row = session.scalar(stmt) + if row is None: + return None + return CompositorSessionSnapshot.model_validate_json(row.session_snapshot) + def save_active_snapshot( self, *, @@ -75,6 +101,20 @@ class AgentAppRuntimeSessionStore: row.session_snapshot = snapshot_json row.status = AgentRuntimeSessionStatus.ACTIVE row.cleaned_at = None + session.flush() + other_rows = session.scalars( + select(AgentRuntimeSession).where( + AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION, + AgentRuntimeSession.tenant_id == scope.tenant_id, + AgentRuntimeSession.app_id == scope.app_id, + AgentRuntimeSession.conversation_id == scope.conversation_id, + AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE, + AgentRuntimeSession.id != row.id, + ) + ).all() + for other_row in other_rows: + other_row.status = AgentRuntimeSessionStatus.CLEANED + other_row.cleaned_at = naive_utc_now() session.commit() def mark_cleaned(self, *, scope: AgentAppSessionScope, backend_run_id: str | None = None) -> None: diff --git a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py index 63d67b1532..2859063242 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py +++ b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py @@ -12,24 +12,24 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset( "model", "structured_output", "tools.dify_tools", + "tools.cli_tools", + "env", + "sandbox", } ) RESERVED_AGENT_BACKEND_FEATURES = frozenset( { "skills_files", - "tools.cli_tools", "knowledge", "human", - "env", - "sandbox", "memory", } ) def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any]: - """Describe PRD capabilities that are persisted but not executed in phase 3.""" + """Describe PRD capabilities supported by or still reserved from Agent backend runtime.""" warnings: list[dict[str, str]] = [] soul_dump = agent_soul.model_dump(mode="json", exclude_none=True, exclude_defaults=True) for section in sorted(RESERVED_AGENT_BACKEND_FEATURES): @@ -48,6 +48,9 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed") reserved_status["tools.dify_tools"] = "supported_when_config_valid" + reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap" + reserved_status["env"] = "supported_by_shell_bootstrap" + reserved_status["sandbox"] = "forwarded_to_shell_layer_config" return { "supported": sorted(SUPPORTED_AGENT_BACKEND_FEATURES), diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index 1715e9e938..962efe9e0c 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -2,11 +2,19 @@ from __future__ import annotations from collections.abc import Mapping, Sequence from dataclasses import dataclass -from typing import Any, Literal, Protocol, cast +from typing import Any, Literal, Protocol, assert_never, cast from agenton.compositor import CompositorSessionSnapshot from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig +from dify_agent.layers.shell import ( + DifyShellCliToolConfig, + DifyShellEnvVarConfig, + DifyShellLayerConfig, + DifyShellSandboxConfig, + DifyShellSecretRefConfig, +) from dify_agent.protocol import CreateRunRequest +from pydantic import BaseModel from clients.agent_backend import ( AgentBackendModelConfig, @@ -15,6 +23,7 @@ from clients.agent_backend import ( AgentBackendWorkflowNodeRunInput, redact_for_agent_backend_log, ) +from configs import dify_config from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom from core.workflow.system_variables import SystemVariableKey, get_system_text from graphon.variables.segments import Segment @@ -123,10 +132,12 @@ class WorkflowAgentRuntimeRequestBuilder: ) except WorkflowAgentPluginToolsBuildError as error: raise WorkflowAgentRuntimeRequestBuildError(error.error_code, str(error)) from error - if tools_layer is not None: + if tools_layer is not None or agent_soul.tools.cli_tools: metadata["agent_tools"] = { - "dify_tool_count": len(tools_layer.tools), - "dify_tool_names": [tool.name or tool.tool_name for tool in tools_layer.tools], + "dify_tool_count": len(tools_layer.tools) if tools_layer is not None else 0, + "dify_tool_names": [tool.name or tool.tool_name for tool in tools_layer.tools] + if tools_layer is not None + else [], "cli_tool_count": len(agent_soul.tools.cli_tools), } @@ -165,6 +176,8 @@ class WorkflowAgentRuntimeRequestBuilder: user_prompt=user_prompt, output=self._build_output_config(node_job.declared_outputs), tools=tools_layer, + include_shell=dify_config.AGENT_SHELL_ENABLED, + shell_config=build_shell_layer_config(agent_soul), session_snapshot=context.session_snapshot, idempotency_key=self._idempotency_key(context), metadata=metadata, @@ -372,7 +385,9 @@ class WorkflowAgentRuntimeRequestBuilder: "mime_type": {"type": "string"}, "url": {"type": "string"}, }, + "required": ["file_id"], } + assert_never(output_type) @staticmethod def _normalize_credentials(credentials: Mapping[str, Any]) -> dict[str, str | int | float | bool | None]: @@ -383,3 +398,73 @@ class WorkflowAgentRuntimeRequestBuilder: else: normalized[key] = str(value) return normalized + + +def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfig: + """Map Agent Soul shell-adjacent fields into the Agent backend shell config.""" + sandbox_config = _plain_mapping(agent_soul.sandbox.config) + return DifyShellLayerConfig( + cli_tools=[tool for tool in (_shell_cli_tool(item) for item in agent_soul.tools.cli_tools) if tool is not None], + env=[env for env in (_shell_env_var(item) for item in agent_soul.env.variables) if env is not None], + secret_refs=[ + secret for secret in (_shell_secret_ref(item) for item in agent_soul.env.secret_refs) if secret is not None + ], + sandbox=DifyShellSandboxConfig( + provider=agent_soul.sandbox.provider, + config=sandbox_config, + ) + if agent_soul.sandbox.provider or sandbox_config + else None, + ) + + +def _shell_cli_tool(item: object) -> DifyShellCliToolConfig | None: + data = _plain_mapping(item) + commands: list[str] = [] + raw_commands = data.get("install_commands") + if isinstance(raw_commands, list): + commands.extend(str(command) for command in raw_commands if str(command).strip()) + for key in ("install_command", "install", "setup_command"): + raw_command = data.get(key) + if isinstance(raw_command, str) and raw_command.strip(): + commands.append(raw_command) + name = data.get("name") or data.get("tool_name") or data.get("label") + if not commands and not isinstance(name, str): + return None + return DifyShellCliToolConfig(name=name if isinstance(name, str) else None, install_commands=commands) + + +def _shell_env_var(item: object) -> DifyShellEnvVarConfig | None: + data = _plain_mapping(item) + name = _name_from_mapping(data) + if name is None: + return None + value = data.get("value", data.get("default", "")) + if not isinstance(value, str): + value = str(value) + return DifyShellEnvVarConfig(name=name, value=value) + + +def _shell_secret_ref(item: object) -> DifyShellSecretRefConfig | None: + data = _plain_mapping(item) + name = _name_from_mapping(data) + if name is None: + return None + ref = data.get("ref") or data.get("id") or data.get("credential_id") or data.get("provider_credential_id") + return DifyShellSecretRefConfig(name=name, ref=str(ref) if ref is not None else None) + + +def _plain_mapping(item: object) -> dict[str, Any]: + if isinstance(item, BaseModel): + return item.model_dump(mode="python", exclude_none=True, exclude_defaults=True) + if isinstance(item, Mapping): + return dict(item) + return {} + + +def _name_from_mapping(item: Mapping[str, Any]) -> str | None: + for key in ("name", "key", "env_name", "variable"): + value = item.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None diff --git a/api/core/workflow/nodes/agent_v2/validators.py b/api/core/workflow/nodes/agent_v2/validators.py index 964ed24c2e..fe57d6065c 100644 --- a/api/core/workflow/nodes/agent_v2/validators.py +++ b/api/core/workflow/nodes/agent_v2/validators.py @@ -303,8 +303,18 @@ class WorkflowAgentNodeValidator: f"Workflow Agent node {binding.node_id} has duplicate Dify Plugin Tool name {exposed_name}." ) exposed_names.add(exposed_name) - # CLI tools remain saved-but-not-executed. They are allowed at publish - # time so existing Agent Soul drafts are not blocked by a reserved field. + + cli_tool_names: set[str] = set() + for cli_tool in agent_soul.tools.cli_tools: + name = cli_tool.get("name") or cli_tool.get("tool_name") or cli_tool.get("label") + if not isinstance(name, str) or not name.strip(): + continue + normalized_name = name.strip() + if normalized_name in cli_tool_names: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} has duplicate CLI Tool name {normalized_name}." + ) + cli_tool_names.add(normalized_name) @staticmethod def _validate_file_ref( diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index 04e00459af..5324ab2a14 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -1103,6 +1103,70 @@ 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 + +#### GET +##### Description + +List a directory in an Agent App conversation's sandbox workspace (read-only) + +##### 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 | Directory path relative to the sandbox workspace | No | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Listing returned | [WorkspaceListResponse](#workspacelistresponse) | + +### /apps/{app_id}/agent-workspace/files/download + +#### GET +##### Description + +Download a file from an Agent App conversation's sandbox workspace (read-only) + +##### 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 | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | File bytes | binary | +| 413 | File exceeds the workspace download limit | | + +### /apps/{app_id}/agent-workspace/files/preview + +#### GET +##### Description + +Preview a text/binary file in an Agent App conversation's sandbox workspace + +##### 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 | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Preview returned | [WorkspacePreviewResponse](#workspacepreviewresponse) | + ### /apps/{app_id}/agent/logs #### GET @@ -2623,6 +2687,76 @@ 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 + +#### GET +##### Description + +List a directory in a Workflow Agent node's sandbox workspace (read-only) + +##### 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 | Directory path relative to the sandbox workspace | No | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Listing returned | [WorkspaceListResponse](#workspacelistresponse) | + +### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/download + +#### GET +##### Description + +Download a file from a Workflow Agent node's sandbox workspace (read-only) + +##### 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 | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | File bytes | binary | +| 413 | File exceeds the workspace download limit | | + +### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/preview + +#### GET +##### Description + +Preview a text/binary file in a Workflow Agent node's sandbox workspace + +##### 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 | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Preview returned | [WorkspacePreviewResponse](#workspacepreviewresponse) | + ### /apps/{app_id}/workflow/comments #### GET @@ -16864,6 +16998,15 @@ 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 | @@ -16877,6 +17020,14 @@ 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 | @@ -16885,6 +17036,16 @@ 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_b1954337d565 | Name | Type | Description | Required | diff --git a/api/services/agent_app_workspace_service.py b/api/services/agent_app_workspace_service.py new file mode 100644 index 0000000000..d16d5e4d45 --- /dev/null +++ b/api/services/agent_app_workspace_service.py @@ -0,0 +1,220 @@ +"""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_request_builder.py b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py index e8dabddc03..de1b5c7190 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 @@ -16,6 +16,7 @@ from dify_agent.layers.dify_plugin import ( ) from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID +from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellEnvVarConfig, DifyShellLayerConfig from dify_agent.protocol import ( DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, @@ -30,6 +31,7 @@ from clients.agent_backend import ( DIFY_PLUGIN_TOOLS_LAYER_ID, WORKFLOW_NODE_JOB_PROMPT_LAYER_ID, WORKFLOW_USER_PROMPT_LAYER_ID, + AgentBackendAgentAppRunInput, AgentBackendModelConfig, AgentBackendOutputConfig, AgentBackendRunRequestBuilder, @@ -37,6 +39,7 @@ from clients.agent_backend import ( CleanupLayerSpec, redact_for_agent_backend_log, ) +from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID def _run_input() -> AgentBackendWorkflowNodeRunInput: @@ -249,3 +252,65 @@ def test_redact_for_agent_backend_log_hides_credentials(): redacted = cast(dict[str, Any], redact_for_agent_backend_log(request)) assert redacted["composition"]["layers"][5]["config"]["credentials"] == "[REDACTED]" + + +def _agent_app_input(*, include_shell: bool = False) -> AgentBackendAgentAppRunInput: + return AgentBackendAgentAppRunInput( + model=AgentBackendModelConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + credentials={"api_key": "secret-key"}, + ), + execution_context=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_id="user-1", + conversation_id="conv-1", + invoke_from="agent_app", + ), + agent_soul_prompt="You are Iris.", + user_prompt="List files.", + include_shell=include_shell, + metadata={"conversation_id": "conv-1"}, + ) + + +def test_workflow_request_builder_omits_shell_layer_by_default(): + request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input()) + assert DIFY_SHELL_LAYER_ID not in {layer.name for layer in request.composition.layers} + + +def test_workflow_request_builder_adds_shell_layer_when_include_shell(): + run_input = _run_input() + run_input.include_shell = True + run_input.shell_config = DifyShellLayerConfig(env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo")]) + + request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input) + layers = {layer.name: layer for layer in request.composition.layers} + + assert DIFY_SHELL_LAYER_ID in layers + shell = layers[DIFY_SHELL_LAYER_ID] + assert shell.type == DIFY_SHELL_LAYER_TYPE_ID + # The shell layer declares NoLayerDeps, so the spec must carry no deps. + assert not shell.deps + shell_config = cast(DifyShellLayerConfig, shell.config) + assert shell_config.env[0].name == "PROJECT_NAME" + + +def test_agent_app_request_builder_omits_shell_layer_by_default(): + request = AgentBackendRunRequestBuilder().build_for_agent_app(_agent_app_input()) + assert DIFY_SHELL_LAYER_ID not in {layer.name for layer in request.composition.layers} + + +def test_agent_app_request_builder_adds_shell_layer_when_include_shell(): + run_input = _agent_app_input(include_shell=True) + run_input.shell_config = DifyShellLayerConfig(env=[DifyShellEnvVarConfig(name="APP_ENV", value="enabled")]) + + request = AgentBackendRunRequestBuilder().build_for_agent_app(run_input) + layers = {layer.name: layer for layer in request.composition.layers} + + assert DIFY_SHELL_LAYER_ID in layers + assert layers[DIFY_SHELL_LAYER_ID].type == DIFY_SHELL_LAYER_TYPE_ID + assert not layers[DIFY_SHELL_LAYER_ID].deps + shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config) + assert shell_config.env[0].name == "APP_ENV" 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 new file mode 100644 index 0000000000..65e8ae1645 --- /dev/null +++ b/api/tests/unit_tests/clients/agent_backend/test_workspace_files_client.py @@ -0,0 +1,152 @@ +"""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_workspace.py b/api/tests/unit_tests/controllers/console/app/test_agent_app_workspace.py new file mode 100644 index 0000000000..352001710b --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_agent_app_workspace.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +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 services.agent_app_workspace_service import AgentWorkspaceInspectorError + + +def _unwrapped_get(resource_cls): + func = resource_cls.get + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + return func + + +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 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 = 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, "current_account_with_tenant", lambda: (None, "tenant-1")) + monkeypatch.setattr( + module, + "query_params_from_request", + lambda model: SimpleNamespace(conversation_id="conv-1", path="sub/report.txt"), + ) + app_model = SimpleNamespace(id="app-1") + + listing = _unwrapped_get(module.AgentAppWorkspaceListResource)(object(), app_model) + preview = _unwrapped_get(module.AgentAppWorkspacePreviewResource)(object(), app_model) + download = _unwrapped_get(module.AgentAppWorkspaceDownloadResource)(object(), 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, "current_account_with_tenant", lambda: (None, "tenant-1")) + monkeypatch.setattr( + module, + "query_params_from_request", + lambda model: SimpleNamespace(conversation_id="conv-1", path="."), + ) + + assert _unwrapped_get(module.AgentAppWorkspaceListResource)(object(), SimpleNamespace(id="app-1")) == ( + {"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, "current_account_with_tenant", lambda: (None, "tenant-1")) + monkeypatch.setattr( + module, + "query_params_from_request", + lambda model: SimpleNamespace(node_execution_id="exec-1", path="out.txt"), + ) + app_model = SimpleNamespace(id="app-1") + + listing = _unwrapped_get(module.WorkflowAgentWorkspaceListResource)(object(), app_model, "run-1", "agent-node") + preview = _unwrapped_get(module.WorkflowAgentWorkspacePreviewResource)(object(), app_model, "run-1", "agent-node") + download = _unwrapped_get(module.WorkflowAgentWorkspaceDownloadResource)(object(), 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_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py index 9f453bab38..3c1bff1b22 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -14,6 +14,7 @@ from clients.agent_backend import ( AgentBackendModelConfig, AgentBackendRunRequestBuilder, ) +from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID from core.app.apps.agent_app.runtime_request_builder import ( AgentAppRuntimeBuildContext, AgentAppRuntimeRequestBuilder, @@ -142,3 +143,37 @@ class TestAgentAppRuntimeRequestBuilder: with pytest.raises(AgentAppRuntimeRequestBuildError) as exc: builder.build(_ctx(AgentSoulConfig())) assert exc.value.error_code == "agent_model_not_configured" + + def test_build_maps_agent_soul_shell_settings_to_shell_layer(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) + soul = AgentSoulConfig.model_validate( + { + "model": { + "plugin_id": "langgenius/openai", + "model_provider": "langgenius/openai/openai", + "model": "gpt-4o-mini", + }, + "tools": {"cli_tools": [{"name": "ripgrep", "install_command": "apt-get install -y ripgrep"}]}, + "env": {"variables": [{"name": "PROJECT_NAME", "value": "demo"}]}, + "sandbox": {"provider": "independent", "config": {"cpu": 2}}, + } + ) + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + + result = builder.build(_ctx(soul)) + + dumped = result.request.model_dump(mode="json") + shell_config = {layer["name"]: layer for layer in dumped["composition"]["layers"]}[DIFY_SHELL_LAYER_ID][ + "config" + ] + assert shell_config["cli_tools"][0]["install_commands"] == ["apt-get install -y ripgrep"] + assert shell_config["env"][0] == {"name": "PROJECT_NAME", "value": "demo"} + assert shell_config["sandbox"] == {"provider": "independent", "config": {"cpu": 2}} + assert result.metadata["agent_tools"] == { + "dify_tool_count": 0, + "dify_tool_names": [], + "cli_tool_count": 1, + } 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 a806130517..03247087ec 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 @@ -119,7 +119,7 @@ def test_distinct_conversations_do_not_collide(): assert session.query(AgentRuntimeSession).count() == 2 -def test_distinct_agent_config_snapshots_do_not_reuse_prior_session(): +def test_distinct_agent_config_snapshots_keep_only_latest_active_session(): store = AgentAppRuntimeSessionStore() store.save_active_snapshot( scope=_scope(agent_config_snapshot_id="snap-1"), @@ -130,9 +130,71 @@ def test_distinct_agent_config_snapshots_do_not_reuse_prior_session(): scope=_scope(agent_config_snapshot_id="snap-2"), backend_run_id="b", snapshot=_snapshot(messages=2) ) - assert store.load_active_snapshot(_scope(agent_config_snapshot_id="snap-1")) is not None + assert store.load_active_snapshot(_scope(agent_config_snapshot_id="snap-1")) is None assert store.load_active_snapshot(_scope(agent_config_snapshot_id="snap-2")) is not None with session_factory.create_session() as session: rows = session.query(AgentRuntimeSession).order_by(AgentRuntimeSession.backend_run_id).all() assert len(rows) == 2 assert [row.agent_config_snapshot_id for row in rows] == ["snap-1", "snap-2"] + assert [row.status for row in rows] == [AgentRuntimeSessionStatus.CLEANED, AgentRuntimeSessionStatus.ACTIVE] + + +def test_load_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)) + + # 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") + assert loaded is not None + assert loaded.layers[0].runtime_state["messages"] == [ + {"role": "user", "content": "m0"}, + {"role": "user", "content": "m1"}, + ] + + +def test_load_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() + ) + store.save_active_snapshot( + scope=_scope(agent_config_snapshot_id="snap-2"), backend_run_id="b", snapshot=_snapshot(messages=3) + ) + + loaded = store.load_active_snapshot_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"] == [ + {"role": "user", "content": "m0"}, + {"role": "user", "content": "m1"}, + {"role": "user", "content": "m2"}, + ] + + +def test_load_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") + is None + ) + + store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot()) + 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") + is None + ) + + +def test_load_for_conversation_isolates_other_conversations(): + store = AgentAppRuntimeSessionStore() + store.save_active_snapshot(scope=_scope(conversation_id="conv-A"), backend_run_id="a", snapshot=_snapshot()) + + assert ( + store.load_active_snapshot_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") + is not None + ) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index b2f9e99c1b..bc26206be1 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -7,12 +7,14 @@ from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsL from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID from clients.agent_backend import DIFY_EXECUTION_CONTEXT_LAYER_ID, DIFY_PLUGIN_TOOLS_LAYER_ID +from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom from core.workflow.nodes.agent_v2.plugin_tools_builder import WorkflowAgentPluginToolsBuilder from core.workflow.nodes.agent_v2.runtime_request_builder import ( WorkflowAgentRuntimeBuildContext, WorkflowAgentRuntimeRequestBuilder, WorkflowAgentRuntimeRequestBuildError, + build_shell_layer_config, ) from graphon.variables.segments import StringSegment from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding @@ -224,13 +226,93 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada assert dumped["idempotency_key"] == "node-exec-1" output_schema = dumped["composition"]["layers"][-1]["config"]["json_schema"] assert output_schema["properties"]["report"]["properties"]["file_id"]["type"] == "string" + assert output_schema["properties"]["report"]["required"] == ["file_id"] assert output_schema["properties"]["confidence"]["type"] == "number" assert output_schema["required"] == ["report"] assert dumped["composition"]["layers"][5]["config"]["model_settings"] == {"temperature": 0.2} assert result.metadata["runtime_support"]["reserved_status"]["tools.dify_tools"] == "supported_when_config_valid" - assert result.metadata["runtime_support"]["reserved_status"]["tools.cli_tools"] == "reserved_not_executed" - warnings = result.metadata["runtime_support"]["unsupported_runtime_warnings"] - assert warnings[0]["section"] == "agent_soul.tools.cli_tools" + assert result.metadata["runtime_support"]["reserved_status"]["tools.cli_tools"] == "supported_by_shell_bootstrap" + assert result.metadata["runtime_support"]["unsupported_runtime_warnings"] == [] + + +def test_build_maps_agent_soul_shell_settings_to_shell_layer(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) + context = _context() + snapshot = AgentConfigSnapshot( + id="snapshot-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=AgentSoulConfig( + prompt={"system_prompt": "You are careful."}, + model=AgentSoulModelConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + ), + tools={"cli_tools": [{"name": "ripgrep", "install_commands": ["apt-get install -y ripgrep"]}]}, + env={"variables": [{"name": "PROJECT_NAME", "value": "demo"}]}, + sandbox={"provider": "independent", "config": {"cpu": 2}}, + ), + ) + context = replace(context, snapshot=snapshot) + + result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + dumped = result.request.model_dump(mode="json") + shell_config = {layer["name"]: layer for layer in dumped["composition"]["layers"]}[DIFY_SHELL_LAYER_ID]["config"] + assert shell_config["cli_tools"][0]["install_commands"] == ["apt-get install -y ripgrep"] + assert shell_config["env"][0] == {"name": "PROJECT_NAME", "value": "demo"} + assert shell_config["sandbox"] == {"provider": "independent", "config": {"cpu": 2}} + assert result.metadata["agent_tools"] == { + "dify_tool_count": 0, + "dify_tool_names": [], + "cli_tool_count": 1, + } + + +def test_build_shell_layer_config_accepts_legacy_fallback_keys(): + agent_soul = AgentSoulConfig.model_validate( + { + "tools": { + "cli_tools": [ + {"label": "node", "install_command": "apt-get install -y nodejs"}, + {"tool_name": "python", "setup_command": "pip install pytest"}, + {"install": "apk add git"}, + {"ignored": True}, + ] + }, + "env": { + "variables": [ + {"key": "PROJECT_NAME", "default": "demo"}, + {"env_name": "RETRY_COUNT", "value": 3}, + {"value": "missing-name"}, + ], + "secret_refs": [ + {"variable": "TOKEN", "credential_id": "credential-1"}, + {"name": "API_KEY", "provider_credential_id": "credential-2"}, + {"ref": "missing-name"}, + ], + }, + } + ) + + config = build_shell_layer_config(agent_soul).model_dump(mode="json") + + assert config["cli_tools"] == [ + {"name": "node", "install_commands": ["apt-get install -y nodejs"]}, + {"name": "python", "install_commands": ["pip install pytest"]}, + {"name": None, "install_commands": ["apk add git"]}, + ] + assert config["env"] == [ + {"name": "PROJECT_NAME", "value": "demo"}, + {"name": "RETRY_COUNT", "value": "3"}, + ] + assert config["secret_refs"] == [ + {"name": "TOKEN", "ref": "credential-1"}, + {"name": "API_KEY", "ref": "credential-2"}, + ] + assert config["sandbox"] is None def test_builds_workflow_run_request_with_dify_plugin_tools_layer(): @@ -381,6 +463,7 @@ def test_empty_declared_outputs_injects_prd_defaults_text_files_json(): assert properties["files"]["type"] == "array" # `files` defaults to array → items is a file ref object. assert properties["files"]["items"]["properties"]["file_id"]["type"] == "string" + assert properties["files"]["items"]["required"] == ["file_id"] assert properties["json"]["type"] == "object" # Defaults are all required=False so no `required:` key on the schema. assert "required" not in output_layer["json_schema"] diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py index a202bc9ce1..d8a14a8daf 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py @@ -167,6 +167,27 @@ def test_publish_validation_rejects_missing_agent_soul_model(): ) +def test_publish_validation_rejects_duplicate_cli_tool_names(): + node_job = WorkflowNodeJobConfig.model_validate({}) + snapshot = _snapshot() + snapshot.config_snapshot = AgentSoulConfig( + model=AgentSoulModelConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="gpt-test", + ), + tools={"cli_tools": [{"name": "pytest"}, {"tool_name": "pytest"}]}, + ) + session = Mock() + session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + + with pytest.raises(WorkflowAgentNodeValidationError, match="duplicate CLI Tool name pytest"): + WorkflowAgentNodeValidator.validate_published_workflow( + session=session, + workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + ) + + def test_publish_validation_rejects_missing_previous_node(): node_job = WorkflowNodeJobConfig.model_validate( {"previous_node_output_refs": [{"node_id": "missing-node", "output": "text"}]} 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 new file mode 100644 index 0000000000..dd8d69ba88 --- /dev/null +++ b/api/tests/unit_tests/services/test_agent_app_workspace_service.py @@ -0,0 +1,284 @@ +"""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/_client.py b/dify-agent/src/dify_agent/client/_client.py index 1a27381bc0..8de3af7c39 100644 --- a/dify-agent/src/dify_agent/client/_client.py +++ b/dify-agent/src/dify_agent/client/_client.py @@ -13,14 +13,18 @@ and malformed SSE frames fail immediately. from __future__ import annotations import asyncio +import inspect +import json import time from collections.abc import AsyncIterator, Iterator +from json import JSONDecodeError from types import TracebackType -from typing import Self, TypeVar, cast +from typing import Any, Self, TypeVar, cast from urllib.parse import quote import httpx from pydantic import BaseModel, ValidationError +from pydantic_ai.messages import FunctionToolResultEvent from dify_agent.protocol.schemas import ( CancelRunRequest, @@ -36,6 +40,7 @@ from dify_agent.protocol.schemas import ( _ResponseModelT = TypeVar("_ResponseModelT", bound=BaseModel) _TERMINAL_EVENT_TYPES = {"run_succeeded", "run_failed", "run_cancelled"} _TERMINAL_RUN_STATUSES = {"succeeded", "failed", "cancelled"} +_FUNCTION_TOOL_RESULT_PAYLOAD_KEY: str | None = None class DifyAgentClientError(RuntimeError): @@ -138,8 +143,9 @@ class _SSEDecoder: self._reset() try: - event = RUN_EVENT_ADAPTER.validate_json(data) - except ValidationError as exc: + payload = _normalize_run_event_payload_for_local_pydantic_ai(json.loads(data)) + event = RUN_EVENT_ADAPTER.validate_python(payload) + except (JSONDecodeError, ValidationError) as exc: raise DifyAgentStreamError("malformed SSE data frame") from exc if frame_event_type is not None and frame_event_type != event.type: raise DifyAgentStreamError( @@ -156,6 +162,44 @@ class _SSEDecoder: self._data_lines = [] +def _function_tool_result_payload_key() -> str: + """Return the local pydantic-ai wire key for function tool results. + + ``pydantic-ai`` renamed the field from ``part`` to ``result`` across + versions. Dify Agent server and API may temporarily run different versions + during local development or rolling deploys, so the client normalizes the + remote frame into the local schema before Pydantic validation. + """ + global _FUNCTION_TOOL_RESULT_PAYLOAD_KEY + if _FUNCTION_TOOL_RESULT_PAYLOAD_KEY is not None: + return _FUNCTION_TOOL_RESULT_PAYLOAD_KEY + + parameters = list(inspect.signature(FunctionToolResultEvent).parameters) + _FUNCTION_TOOL_RESULT_PAYLOAD_KEY = "part" if parameters and parameters[0] == "part" else "result" + return _FUNCTION_TOOL_RESULT_PAYLOAD_KEY + + +def _normalize_run_event_payload_for_local_pydantic_ai(payload: Any) -> Any: + """Normalize known pydantic-ai event field renames in one SSE frame.""" + if not isinstance(payload, dict) or payload.get("type") != "pydantic_ai_event": + return payload + + data = payload.get("data") + if not isinstance(data, dict) or data.get("event_kind") != "function_tool_result": + return payload + + target_key = _function_tool_result_payload_key() + source_key = "result" if target_key == "part" else "part" + if target_key not in data and source_key in data: + normalized_payload = dict(payload) + normalized_data = dict(data) + normalized_data[target_key] = normalized_data.pop(source_key) + normalized_payload["data"] = normalized_data + return normalized_payload + + return payload + + class Client: """Unified synchronous and asynchronous client for Dify Agent runs. diff --git a/dify-agent/src/dify_agent/layers/shell/__init__.py b/dify-agent/src/dify_agent/layers/shell/__init__.py index fd579570ea..69af3150c7 100644 --- a/dify-agent/src/dify_agent/layers/shell/__init__.py +++ b/dify-agent/src/dify_agent/layers/shell/__init__.py @@ -5,6 +5,20 @@ client code plus server-side lifecycle behavior. Keep this package root import-safe for client code that only needs to build run requests. """ -from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig +from dify_agent.layers.shell.configs import ( + DIFY_SHELL_LAYER_TYPE_ID, + DifyShellCliToolConfig, + DifyShellEnvVarConfig, + DifyShellLayerConfig, + DifyShellSandboxConfig, + DifyShellSecretRefConfig, +) -__all__ = ["DIFY_SHELL_LAYER_TYPE_ID", "DifyShellLayerConfig"] +__all__ = [ + "DIFY_SHELL_LAYER_TYPE_ID", + "DifyShellCliToolConfig", + "DifyShellEnvVarConfig", + "DifyShellLayerConfig", + "DifyShellSandboxConfig", + "DifyShellSecretRefConfig", +] diff --git a/dify-agent/src/dify_agent/layers/shell/configs.py b/dify-agent/src/dify_agent/layers/shell/configs.py index 18f607ded2..b2255bb9fd 100644 --- a/dify-agent/src/dify_agent/layers/shell/configs.py +++ b/dify-agent/src/dify_agent/layers/shell/configs.py @@ -1,25 +1,94 @@ """Client-safe DTOs for the Dify shell Agenton layer. -This first shell layer version intentionally has no public configuration beyond -its stable type id. Server-only shellctl connection settings are injected by the -runtime provider factory so client code cannot accidentally depend on process -environment or transport details. +Server-only shellctl connection settings are injected by the runtime provider +factory. Public config carries product-level Agent Soul settings that must affect +the sandbox workspace itself: CLI tool bootstrap commands, normal environment +variables, secret environment variable names, and sandbox-provider metadata. """ +import re from typing import ClassVar, Final -from pydantic import ConfigDict +from pydantic import BaseModel, ConfigDict, Field, field_validator from agenton.layers import LayerConfig DIFY_SHELL_LAYER_TYPE_ID: Final[str] = "dify.shell" +_ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") -class DifyShellLayerConfig(LayerConfig): - """Empty public config for the shellctl-backed Dify shell layer.""" +class DifyShellCliToolConfig(BaseModel): + """One CLI tool declaration that can bootstrap itself in the sandbox.""" model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + name: str | None = Field(default=None, max_length=255) + install_commands: list[str] = Field(default_factory=list) -__all__ = ["DIFY_SHELL_LAYER_TYPE_ID", "DifyShellLayerConfig"] + @field_validator("install_commands") + @classmethod + def _reject_blank_install_commands(cls, value: list[str]) -> list[str]: + return [command for command in (item.strip() for item in value) if command] + + +class DifyShellEnvVarConfig(BaseModel): + """One shell environment variable exported for every sandbox command.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + value: str = "" + + @field_validator("name") + @classmethod + def _validate_name(cls, value: str) -> str: + if not _ENV_NAME_PATTERN.fullmatch(value): + raise ValueError("env var name must be a valid shell identifier") + return value + + +class DifyShellSecretRefConfig(BaseModel): + """Name of a secret env var expected to be supplied by the sandbox host.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + ref: str | None = Field(default=None, max_length=255) + + @field_validator("name") + @classmethod + def _validate_name(cls, value: str) -> str: + if not _ENV_NAME_PATTERN.fullmatch(value): + raise ValueError("secret env var name must be a valid shell identifier") + return value + + +class DifyShellSandboxConfig(BaseModel): + """Sandbox provider selection persisted in Agent Soul.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + provider: str | None = Field(default=None, max_length=255) + config: dict[str, object] = Field(default_factory=dict) + + +class DifyShellLayerConfig(LayerConfig): + """Public config for the shellctl-backed Dify shell layer.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + cli_tools: list[DifyShellCliToolConfig] = Field(default_factory=list) + env: list[DifyShellEnvVarConfig] = Field(default_factory=list) + secret_refs: list[DifyShellSecretRefConfig] = Field(default_factory=list) + sandbox: DifyShellSandboxConfig | None = None + + +__all__ = [ + "DIFY_SHELL_LAYER_TYPE_ID", + "DifyShellCliToolConfig", + "DifyShellEnvVarConfig", + "DifyShellLayerConfig", + "DifyShellSandboxConfig", + "DifyShellSecretRefConfig", +] diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py index 5e31c0fb39..35aa69d4b3 100644 --- a/dify-agent/src/dify_agent/layers/shell/layer.py +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -1,7 +1,7 @@ """Shellctl-backed Dify shell layer. ``DifyShellLayer`` is a stateful pydantic-ai tool layer that exposes exactly -``shell.run``, ``shell.wait``, ``shell.input``, and ``shell.interrupt``. The +``shell_run``, ``shell_wait``, ``shell_input``, and ``shell_interrupt``. The layer persists only JSON-safe shell session state in ``runtime_state`` and keeps its live shellctl HTTP client on the layer instance only while ``resource_context()`` is active. Agenton enters that resource scope before @@ -25,6 +25,7 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Callable, Sequence from contextlib import asynccontextmanager +import json import logging import re import secrets @@ -58,51 +59,51 @@ _SESSION_ID_ATTEMPT_LIMIT = 256 _SESSION_ID_PATTERN = re.compile(r"^[0-9a-f]{7}$") _SHELL_LAYER_PREFIX_PROMPT = """You have access to a shell layer. It provides four tools: -1. shell.run +1. shell_run Start a new shell job in the current isolated workspace. Use it to execute commands or scripts. -2. shell.wait +2. shell_wait Wait for more output or completion from an existing shell job. - Use it when shell.run returns done=false. + Use it when shell_run returns done=false. -3. shell.input +3. shell_input Send stdin text to a running shell job, then wait for new output. Use it for interactive commands that are waiting for input. -4. shell.interrupt +4. shell_interrupt Interrupt a running shell job. Use it to stop a long-running, stuck, or no-longer-needed command. Common arguments: - script: - The command or script to execute. Used by shell.run. + The command or script to execute. Used by shell_run. - job_id: - The id of a shell job returned by shell.run. - Use it with shell.wait, shell.input, and shell.interrupt. + The id of a shell job returned by shell_run. + Use it with shell_wait, shell_input, and shell_interrupt. Never invent a job_id. - timeout: Maximum time, in seconds, to wait for output or completion for this tool call. - A timeout does not necessarily mean the job has stopped; if done=false, use shell.wait again. + A timeout does not necessarily mean the job has stopped; if done=false, use shell_wait again. - text: - Text to send to the running process stdin. Used by shell.input. + Text to send to the running process stdin. Used by shell_input. Include "\\n" if the process expects Enter. - grace_seconds: - Time to wait after interrupting before forceful cleanup. Used by shell.interrupt. + Time to wait after interrupting before forceful cleanup. Used by shell_interrupt. Usage rules: -- Start with shell.run. -- If shell.run returns done=false, call shell.wait with the returned job_id. -- Use shell.input only when the job is running and waiting for stdin. -- Use shell.interrupt when a job is stuck or should be stopped. +- Start with shell_run. +- If shell_run returns done=false, call shell_wait with the returned job_id. +- Use shell_input only when the job is running and waiting for stdin. +- Use shell_interrupt when a job is stuck or should be stopped. -The script argument of shell.run can be a normal shell script, or a shebang script. +The script argument of shell_run can be a normal shell script, or a shebang script. If the first line is a shebang, the shell layer executes the script directly. Tips: @@ -271,7 +272,7 @@ class DifyShellLayer(PydanticAILayer[NoLayerDeps, object, DifyShellLayerConfig, The mutable serializable state lives in ``runtime_state``; the live client is intentionally kept off-snapshot in ``_shellctl_client``. Tool methods update tracked job ids and output offsets after every successful shellctl response so - later ``shell.wait``/``shell.input`` calls can resume from the last known + later ``shell_wait``/``shell_input`` calls can resume from the last known offset without exposing offsets as model-controlled inputs. """ @@ -318,10 +319,10 @@ class DifyShellLayer(PydanticAILayer[NoLayerDeps, object, DifyShellLayerConfig, @override def tools(self) -> Sequence[PydanticAITool[object]]: return [ - Tool(self._tool_run, name="shell.run"), - Tool(self._tool_wait, name="shell.wait"), - Tool(self._tool_input, name="shell.input"), - Tool(self._tool_interrupt, name="shell.interrupt"), + Tool(self._tool_run, name="shell_run"), + Tool(self._tool_wait, name="shell_wait"), + Tool(self._tool_input, name="shell_input"), + Tool(self._tool_interrupt, name="shell_interrupt"), ] @override @@ -357,6 +358,7 @@ class DifyShellLayer(PydanticAILayer[NoLayerDeps, object, DifyShellLayerConfig, try: _ = self._require_client() session_id, workspace_cwd = await self._allocate_workspace() + await self._bootstrap_workspace(workspace_cwd) except BaseException: await self._cleanup_create_failure() raise @@ -432,7 +434,7 @@ class DifyShellLayer(PydanticAILayer[NoLayerDeps, object, DifyShellLayerConfig, """Start a new shell job inside the session workspace.""" try: client = self._require_client() - result = await client.run(script, cwd=self._require_workspace_cwd(), timeout=timeout) + result = await client.run(_wrap_user_script(script), cwd=self._require_workspace_cwd(), timeout=timeout) self._track_job_result(result) return _job_result_observation(result) except (RuntimeError, ValueError, ShellctlClientError) as exc: @@ -492,6 +494,17 @@ class DifyShellLayer(PydanticAILayer[NoLayerDeps, object, DifyShellLayerConfig, return session_id, _workspace_cwd(session_id) raise RuntimeError("Failed to allocate a unique shell workspace session id after 256 attempts.") + async def _bootstrap_workspace(self, workspace_cwd: str) -> None: + """Apply Agent Soul shell config to the freshly-created workspace.""" + bootstrap_script = _workspace_bootstrap_script(self.config) + if not bootstrap_script: + return + result = await self._run_internal_job_to_completion(bootstrap_script, cwd=workspace_cwd) + if result["exit_code"] != 0: + raise RuntimeError( + f"Failed to bootstrap shell workspace {workspace_cwd}: {result['status']} exit_code={result['exit_code']}" + ) + async def _cleanup_create_failure(self) -> None: """Best-effort shellctl job cleanup for create failures before ACTIVE state. @@ -681,6 +694,51 @@ def _workspace_cwd(session_id: str) -> str: return f"{_WORKSPACE_ROOT}/{_validated_session_id(session_id)}" +def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str: + """Return the workspace bootstrap script for env + CLI tool declarations.""" + lines: list[str] = [ + "set -eu", + 'mkdir -p ".dify"', + "cat > \".dify/env.sh\" <<'DIFY_ENV_EOF'", + ] + for env_var in config.env: + lines.append(f"export {env_var.name}={_shquote(env_var.value)}") + for secret_ref in config.secret_refs: + # Secret refs are resolved outside this public DTO. Preserve the env var + # name without inventing a value so host-provided env can flow through. + lines.append(f'export {secret_ref.name}="${{{secret_ref.name}:-}}"') + if config.sandbox is not None: + if config.sandbox.provider: + lines.append(f"export DIFY_SANDBOX_PROVIDER={_shquote(config.sandbox.provider)}") + if config.sandbox.config: + sandbox_config = json.dumps(config.sandbox.config, ensure_ascii=True, sort_keys=True) + lines.append(f"export DIFY_SANDBOX_CONFIG_JSON={_shquote(sandbox_config)}") + lines.extend( + [ + "DIFY_ENV_EOF", + 'chmod 600 ".dify/env.sh"', + ] + ) + for tool in config.cli_tools: + for command in tool.install_commands: + lines.append(command) + return "\n".join(lines) if len(lines) > 5 or config.cli_tools else "" + + +def _wrap_user_script(script: str) -> str: + """Source Agent Soul env before executing a model-requested shell command.""" + return "\n".join( + [ + 'if [ -f ".dify/env.sh" ]; then', + " set -a", + ' . ".dify/env.sh"', + " set +a", + "fi", + script, + ] + ) + + def _workspace_mkdir_script(*, session_id: str) -> str: """Return the internal mkdir command used for proposal-defined collision checks. @@ -705,6 +763,11 @@ def _workspace_cleanup_script(*, session_id: str) -> str: return f'rm -rf -- "$HOME/workspace/{_validated_session_id(session_id)}"' +def _shquote(value: str) -> str: + """Single-quote a value for POSIX shells, escaping embedded single quotes.""" + return "'" + value.replace("'", "'\\''") + "'" + + def _validated_session_id(session_id: str) -> str: if not _SESSION_ID_PATTERN.fullmatch(session_id): raise ValueError("session_id must match the 5+2 lowercase hex format '<5 hex><2 hex>'.") diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py index 99cc9fa8a2..1c59d575ea 100644 --- a/dify-agent/src/dify_agent/server/app.py +++ b/dify-agent/src/dify_agent/server/app.py @@ -20,7 +20,9 @@ from redis.asyncio import Redis 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.settings import ServerSettings +from dify_agent.server.workspace_files import WorkspaceFileService from dify_agent.storage.redis_run_store import RedisRunStore @@ -33,6 +35,14 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: shellctl_entrypoint=resolved_settings.shellctl_entrypoint, shellctl_auth_token=resolved_settings.shellctl_auth_token, ) + 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 + ) state: dict[str, object] = {} @asynccontextmanager @@ -68,6 +78,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)) + app.include_router(create_workspace_files_router(lambda: workspace_file_service)) return app diff --git a/dify-agent/src/dify_agent/server/routes/workspace_files.py b/dify-agent/src/dify_agent/server/routes/workspace_files.py new file mode 100644 index 0000000000..22e4003f86 --- /dev/null +++ b/dify-agent/src/dify_agent/server/routes/workspace_files.py @@ -0,0 +1,78 @@ +"""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/workspace_files.py b/dify-agent/src/dify_agent/server/workspace_files.py new file mode 100644 index 0000000000..3991a9df4a --- /dev/null +++ b/dify-agent/src/dify_agent/server/workspace_files.py @@ -0,0 +1,418 @@ +"""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 db2d2e2386..b66bd805b1 100644 --- a/dify-agent/tests/local/dify_agent/client/test_client.py +++ b/dify-agent/tests/local/dify_agent/client/test_client.py @@ -11,6 +11,7 @@ import pytest from agenton.compositor import CompositorSessionSnapshot from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID +from dify_agent.client import _client as client_module from dify_agent.client import ( Client, DifyAgentHTTPError, @@ -64,6 +65,23 @@ 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 _function_tool_result_payload(key: str) -> dict[str, object]: + return { + "type": "pydantic_ai_event", + "run_id": "run-1", + "created_at": datetime(2026, 5, 11, tzinfo=UTC).isoformat(), + "data": { + "event_kind": "function_tool_result", + key: { + "tool_name": "shell_run", + "content": "ok", + "tool_call_id": "call-1", + "part_kind": "tool-return", + }, + }, + } + + class DisconnectingSyncStream(httpx.SyncByteStream): chunks: list[bytes] @@ -76,6 +94,32 @@ class DisconnectingSyncStream(httpx.SyncByteStream): raise httpx.ReadError("stream disconnected") +def test_sse_decoder_accepts_function_tool_result_part_alias(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(client_module, "_FUNCTION_TOOL_RESULT_PAYLOAD_KEY", "result") + decoder = client_module._SSEDecoder() + payload = _function_tool_result_payload("part") + + assert decoder.feed_line(f"data: {json.dumps(payload)}") is None + event = decoder.feed_line("") + + assert event is not None + assert event.type == "pydantic_ai_event" + assert event.data.event_kind == "function_tool_result" + assert event.data.result.tool_name == "shell_run" + + +def test_function_tool_result_payload_normalization_supports_old_part_schema( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(client_module, "_FUNCTION_TOOL_RESULT_PAYLOAD_KEY", "part") + payload = _function_tool_result_payload("result") + + normalized = client_module._normalize_run_event_payload_for_local_pydantic_ai(payload) + + assert normalized["data"]["part"]["tool_name"] == "shell_run" + assert "result" not in normalized["data"] + + def test_sync_methods_parse_protocol_dtos_and_send_create_request_dto() -> None: def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/runs": diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py b/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py index ec1810c636..10ada98149 100644 --- a/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py @@ -2,19 +2,65 @@ import pytest from pydantic import ValidationError import dify_agent.layers.shell as shell_exports -from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig +from dify_agent.layers.shell import ( + DIFY_SHELL_LAYER_TYPE_ID, + DifyShellCliToolConfig, + DifyShellEnvVarConfig, + DifyShellLayerConfig, + DifyShellSandboxConfig, + DifyShellSecretRefConfig, +) def test_shell_package_exports_client_safe_config_symbols_only() -> None: - assert shell_exports.__all__ == ["DIFY_SHELL_LAYER_TYPE_ID", "DifyShellLayerConfig"] + assert shell_exports.__all__ == [ + "DIFY_SHELL_LAYER_TYPE_ID", + "DifyShellCliToolConfig", + "DifyShellEnvVarConfig", + "DifyShellLayerConfig", + "DifyShellSandboxConfig", + "DifyShellSecretRefConfig", + ] assert DIFY_SHELL_LAYER_TYPE_ID == "dify.shell" assert not hasattr(shell_exports, "DifyShellLayer") -def test_shell_layer_config_is_empty_and_forbids_unknown_fields() -> None: +def test_shell_layer_config_defaults_and_forbids_unknown_fields() -> None: config = DifyShellLayerConfig() - assert config.model_dump() == {} + assert config.model_dump() == { + "cli_tools": [], + "env": [], + "secret_refs": [], + "sandbox": None, + } with pytest.raises(ValidationError): _ = DifyShellLayerConfig.model_validate({"entrypoint": "http://shellctl"}) + + +def test_shell_layer_config_accepts_agent_soul_shell_settings() -> None: + config = DifyShellLayerConfig( + cli_tools=[ + DifyShellCliToolConfig( + name="ripgrep", install_commands=[" apt-get update ", "", "apt-get install -y ripgrep"] + ) + ], + env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo")], + secret_refs=[DifyShellSecretRefConfig(name="OPENAI_API_KEY", ref="credential-1")], + sandbox=DifyShellSandboxConfig(provider="independent", config={"cpu": 2}), + ) + + assert config.cli_tools[0].install_commands == ["apt-get update", "apt-get install -y ripgrep"] + assert config.env[0].name == "PROJECT_NAME" + assert config.secret_refs[0].ref == "credential-1" + assert config.sandbox is not None + assert config.sandbox.config == {"cpu": 2} + + +def test_shell_layer_config_rejects_invalid_env_names() -> None: + with pytest.raises(ValidationError): + _ = DifyShellEnvVarConfig(name="1_BAD", value="x") + + with pytest.raises(ValidationError): + _ = DifyShellSecretRefConfig(name="BAD-NAME", ref="secret") 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 3d4ae3221e..638da96ddc 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 @@ -8,7 +8,14 @@ import pytest from agenton.compositor import Compositor, LayerNode, LayerProvider from agenton.layers import LifecycleState -from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig +from dify_agent.layers.shell import ( + DIFY_SHELL_LAYER_TYPE_ID, + DifyShellCliToolConfig, + DifyShellEnvVarConfig, + DifyShellLayerConfig, + DifyShellSandboxConfig, + DifyShellSecretRefConfig, +) from dify_agent.layers.shell.layer import DifyShellLayer, DifyShellRuntimeState, ShellctlClientFactory from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName, JobStatusView @@ -180,9 +187,11 @@ class FakeShellctlClient: self.events.append(("close", "client")) -def _shell_layer(*, client_factory: ShellctlClientFactory) -> DifyShellLayer: +def _shell_layer( + *, client_factory: ShellctlClientFactory, config: DifyShellLayerConfig | None = None +) -> DifyShellLayer: return DifyShellLayer.from_config_with_settings( - DifyShellLayerConfig(), + config or DifyShellLayerConfig(), shellctl_entrypoint="http://shellctl", shellctl_client_factory=client_factory, ) @@ -378,9 +387,51 @@ def test_shell_layer_create_failure_force_deletes_internal_jobs_before_reraising assert client.closed is True +def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(time, "time", lambda: 0xABC12) + monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "ff") + + def run_handler(script: str, cwd: str | None, timeout: float) -> JobResult: + if cwd is None: + assert timeout == 30.0 + return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0) + assert cwd == "~/workspace/abc12ff" + assert "export PROJECT_NAME='demo project'" in script + assert "export QUOTED='it'\\''s ok'" in script + assert 'export OPENAI_API_KEY="${OPENAI_API_KEY:-}"' in script + assert "export DIFY_SANDBOX_PROVIDER='independent'" in script + assert "export DIFY_SANDBOX_CONFIG_JSON='{\"cpu\": 2}'" in script + assert "apt-get install -y ripgrep" in script + return _job_result("bootstrap-job", status=JobStatusName.EXITED, done=True, exit_code=0) + + client = FakeShellctlClient(run_handler=run_handler) + layer = _shell_layer( + client_factory=lambda _entrypoint: client, + config=DifyShellLayerConfig( + cli_tools=[DifyShellCliToolConfig(name="ripgrep", install_commands=["apt-get install -y ripgrep"])], + env=[ + DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo project"), + DifyShellEnvVarConfig(name="QUOTED", value="it's ok"), + ], + secret_refs=[DifyShellSecretRefConfig(name="OPENAI_API_KEY", ref="secret-1")], + sandbox=DifyShellSandboxConfig(provider="independent", config={"cpu": 2}), + ), + ) + + async def scenario() -> None: + async with layer.resource_context(): + await layer.on_context_create() + + asyncio.run(scenario()) + + assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ff"] + assert layer.runtime_state.job_ids == ["mkdir-job", "bootstrap-job"] + + def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -> None: def run_handler(script: str, cwd: str | None, timeout: float) -> JobResult: - assert script == "pwd" + assert script.endswith("\npwd") + assert '. ".dify/env.sh"' in script assert cwd == "~/workspace/abc12ff" assert timeout == 2.5 return _job_result( @@ -441,24 +492,24 @@ def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() - async with layer.resource_context(): layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") - run_tool_def = await tools["shell.run"].prepare_tool_def(None) # pyright: ignore[reportArgumentType] - wait_tool_def = await tools["shell.wait"].prepare_tool_def(None) # pyright: ignore[reportArgumentType] - input_tool_def = await tools["shell.input"].prepare_tool_def(None) # pyright: ignore[reportArgumentType] - interrupt_tool_def = await tools["shell.interrupt"].prepare_tool_def(None) # pyright: ignore[reportArgumentType] + run_tool_def = await tools["shell_run"].prepare_tool_def(None) # pyright: ignore[reportArgumentType] + wait_tool_def = await tools["shell_wait"].prepare_tool_def(None) # pyright: ignore[reportArgumentType] + input_tool_def = await tools["shell_input"].prepare_tool_def(None) # pyright: ignore[reportArgumentType] + interrupt_tool_def = await tools["shell_interrupt"].prepare_tool_def(None) # pyright: ignore[reportArgumentType] - run_result = await tools["shell.run"].function_schema.call( + run_result = await tools["shell_run"].function_schema.call( {"script": "pwd", "timeout": 2.5}, None, # pyright: ignore[reportArgumentType] ) - wait_result = await tools["shell.wait"].function_schema.call( + wait_result = await tools["shell_wait"].function_schema.call( {"job_id": "user-job", "timeout": 4.0}, None, # pyright: ignore[reportArgumentType] ) - input_result = await tools["shell.input"].function_schema.call( + input_result = await tools["shell_input"].function_schema.call( {"job_id": "user-job", "text": "ls\n", "timeout": 5.0}, None, # pyright: ignore[reportArgumentType] ) - interrupt_result = await tools["shell.interrupt"].function_schema.call( + interrupt_result = await tools["shell_interrupt"].function_schema.call( {"job_id": "user-job", "grace_seconds": 1.5}, None, # pyright: ignore[reportArgumentType] ) @@ -471,7 +522,7 @@ def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() - assert "offset" not in wait_tool_def.parameters_json_schema.get("properties", {}) assert "offset" not in input_tool_def.parameters_json_schema.get("properties", {}) assert "offset" not in interrupt_tool_def.parameters_json_schema.get("properties", {}) - assert set(tools) == {"shell.run", "shell.wait", "shell.input", "shell.interrupt"} + assert set(tools) == {"shell_run", "shell_wait", "shell_input", "shell_interrupt"} assert run_result["job_id"] == "user-job" assert run_result["offset"] == 10 assert wait_result["offset"] == 18 @@ -501,15 +552,15 @@ def test_shell_layer_tools_reject_untracked_job_ids_without_shellctl_calls() -> async with layer.resource_context(): layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") - wait_result = await tools["shell.wait"].function_schema.call( + wait_result = await tools["shell_wait"].function_schema.call( {"job_id": "missing-job"}, None, # pyright: ignore[reportArgumentType] ) - input_result = await tools["shell.input"].function_schema.call( + input_result = await tools["shell_input"].function_schema.call( {"job_id": "missing-job", "text": "hello"}, None, # pyright: ignore[reportArgumentType] ) - interrupt_result = await tools["shell.interrupt"].function_schema.call( + interrupt_result = await tools["shell_interrupt"].function_schema.call( {"job_id": "missing-job"}, None, # pyright: ignore[reportArgumentType] ) @@ -535,7 +586,7 @@ def test_shell_layer_hooks_and_tools_fail_clearly_outside_active_resource_contex with pytest.raises(RuntimeError, match="resource_context"): await layer.on_context_suspend() - run_result = await tools["shell.run"].function_schema.call( + run_result = await tools["shell_run"].function_schema.call( {"script": "pwd"}, None, # pyright: ignore[reportArgumentType] ) diff --git a/dify-agent/tests/local/dify_agent/runtime/test_runner.py b/dify-agent/tests/local/dify_agent/runtime/test_runner.py index ec2acae15c..c826a76522 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_runner.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_runner.py @@ -663,7 +663,7 @@ def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers( async def duplicate_shell_run() -> str: return "tool" - return [Tool(duplicate_shell_run, name="shell.run")] + return [Tool(duplicate_shell_run, name="shell_run")] def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> object: del model, tools, output_type @@ -740,7 +740,7 @@ def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers( async with httpx.AsyncClient() as client: with pytest.raises( AgentRunValidationError, - match="unique tool names across all layers, got duplicates: shell.run", + match="unique tool names across all layers, got duplicates: shell_run", ): await AgentRunRunner( sink=sink, 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 new file mode 100644 index 0000000000..0e38ed99d1 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/server/test_workspace_files.py @@ -0,0 +1,270 @@ +"""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/dify-agent/tests/local/dify_agent/test_import_boundaries.py b/dify-agent/tests/local/dify_agent/test_import_boundaries.py index 5f18f738b2..db7b683b3e 100644 --- a/dify-agent/tests/local/dify_agent/test_import_boundaries.py +++ b/dify-agent/tests/local/dify_agent/test_import_boundaries.py @@ -107,7 +107,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> "assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig']", "assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']", "assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']", - "assert dify_agent_layers_shell.__all__ == ['DIFY_SHELL_LAYER_TYPE_ID', 'DifyShellLayerConfig']", + "assert dify_agent_layers_shell.__all__ == ['DIFY_SHELL_LAYER_TYPE_ID', 'DifyShellCliToolConfig', 'DifyShellEnvVarConfig', 'DifyShellLayerConfig', 'DifyShellSandboxConfig', 'DifyShellSecretRefConfig']", ], ) diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 6fca124513..09feff2e93 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -46,6 +46,15 @@ import { zGetAppsByAppIdAgentLogsResponse, zGetAppsByAppIdAgentReferencingWorkflowsPath, zGetAppsByAppIdAgentReferencingWorkflowsResponse, + zGetAppsByAppIdAgentWorkspaceFilesDownloadPath, + zGetAppsByAppIdAgentWorkspaceFilesDownloadQuery, + zGetAppsByAppIdAgentWorkspaceFilesDownloadResponse, + zGetAppsByAppIdAgentWorkspaceFilesPath, + zGetAppsByAppIdAgentWorkspaceFilesPreviewPath, + zGetAppsByAppIdAgentWorkspaceFilesPreviewQuery, + zGetAppsByAppIdAgentWorkspaceFilesPreviewResponse, + zGetAppsByAppIdAgentWorkspaceFilesQuery, + zGetAppsByAppIdAgentWorkspaceFilesResponse, zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdPath, zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse, zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdPath, @@ -144,6 +153,15 @@ import { zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse, zGetAppsByAppIdWorkflowRunsByRunIdPath, zGetAppsByAppIdWorkflowRunsByRunIdResponse, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadPath, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadQuery, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponse, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPath, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewPath, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewQuery, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponse, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesQuery, + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponse, zGetAppsByAppIdWorkflowRunsCountPath, zGetAppsByAppIdWorkflowRunsCountQuery, zGetAppsByAppIdWorkflowRunsCountResponse, @@ -880,6 +898,84 @@ export const agentReferencingWorkflows = { get: get6, } +/** + * Download a file from an Agent App conversation's sandbox workspace (read-only) + */ +export const get7 = oc + .route({ + description: 'Download a file from an Agent App conversation\'s sandbox workspace (read-only)', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentWorkspaceFilesDownload', + path: '/apps/{app_id}/agent-workspace/files/download', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentWorkspaceFilesDownloadPath, + query: zGetAppsByAppIdAgentWorkspaceFilesDownloadQuery, + }), + ) + .output(zGetAppsByAppIdAgentWorkspaceFilesDownloadResponse) + +export const download = { + get: get7, +} + +/** + * Preview a text/binary file in an Agent App conversation's sandbox workspace + */ +export const get8 = oc + .route({ + description: 'Preview a text/binary file in an Agent App conversation\'s sandbox workspace', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentWorkspaceFilesPreview', + path: '/apps/{app_id}/agent-workspace/files/preview', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentWorkspaceFilesPreviewPath, + query: zGetAppsByAppIdAgentWorkspaceFilesPreviewQuery, + }), + ) + .output(zGetAppsByAppIdAgentWorkspaceFilesPreviewResponse) + +export const preview2 = { + get: get8, +} + +/** + * List a directory in an Agent App conversation's sandbox workspace (read-only) + */ +export const get9 = oc + .route({ + description: 'List a directory in an Agent App conversation\'s sandbox workspace (read-only)', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentWorkspaceFiles', + path: '/apps/{app_id}/agent-workspace/files', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentWorkspaceFilesPath, + query: zGetAppsByAppIdAgentWorkspaceFilesQuery, + }), + ) + .output(zGetAppsByAppIdAgentWorkspaceFilesResponse) + +export const files = { + get: get9, + download, + preview: preview2, +} + +export const agentWorkspace = { + files, +} + /** * Get agent logs * @@ -889,7 +985,7 @@ export const agentReferencingWorkflows = { * * @deprecated */ -export const get7 = oc +export const get10 = oc .route({ deprecated: true, description: @@ -905,7 +1001,7 @@ export const get7 = oc .output(zGetAppsByAppIdAgentLogsResponse) export const logs = { - get: get7, + get: get10, } export const agent = { @@ -919,7 +1015,7 @@ export const agent = { * * @deprecated */ -export const get8 = oc +export const get11 = oc .route({ deprecated: true, description: @@ -934,7 +1030,7 @@ export const get8 = oc .output(zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse) export const byJobId = { - get: get8, + get: get11, } export const status = { @@ -983,7 +1079,7 @@ export const annotationReply = { * * @deprecated */ -export const get9 = oc +export const get12 = oc .route({ deprecated: true, description: @@ -998,7 +1094,7 @@ export const get9 = oc .output(zGetAppsByAppIdAnnotationSettingResponse) export const annotationSetting = { - get: get9, + get: get12, } /** @@ -1067,7 +1163,7 @@ export const batchImport = { * * @deprecated */ -export const get10 = oc +export const get13 = oc .route({ deprecated: true, description: @@ -1082,7 +1178,7 @@ export const get10 = oc .output(zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse) export const byJobId2 = { - get: get10, + get: get13, } export const batchImportStatus = { @@ -1092,7 +1188,7 @@ export const batchImportStatus = { /** * Get count of message annotations for the app */ -export const get11 = oc +export const get14 = oc .route({ description: 'Get count of message annotations for the app', inputStructure: 'detailed', @@ -1105,13 +1201,13 @@ export const get11 = oc .output(zGetAppsByAppIdAnnotationsCountResponse) export const count2 = { - get: get11, + get: get14, } /** * Export all annotations for an app with CSV injection protection */ -export const get12 = oc +export const get15 = oc .route({ description: 'Export all annotations for an app with CSV injection protection', inputStructure: 'detailed', @@ -1124,13 +1220,13 @@ export const get12 = oc .output(zGetAppsByAppIdAnnotationsExportResponse) export const export_ = { - get: get12, + get: get15, } /** * Get hit histories for an annotation */ -export const get13 = oc +export const get16 = oc .route({ description: 'Get hit histories for an annotation', inputStructure: 'detailed', @@ -1148,7 +1244,7 @@ export const get13 = oc .output(zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse) export const hitHistories = { - get: get13, + get: get16, } /** @@ -1228,7 +1324,7 @@ export const delete2 = oc * * @deprecated */ -export const get14 = oc +export const get17 = oc .route({ deprecated: true, description: @@ -1273,7 +1369,7 @@ export const post15 = oc export const annotations = { delete: delete2, - get: get14, + get: get17, post: post15, batchImport, batchImportStatus, @@ -1349,7 +1445,7 @@ export const delete3 = oc * * @deprecated */ -export const get15 = oc +export const get18 = oc .route({ deprecated: true, description: @@ -1365,13 +1461,13 @@ export const get15 = oc export const byConversationId = { delete: delete3, - get: get15, + get: get18, } /** * Get chat conversations with pagination, filtering and summary */ -export const get16 = oc +export const get19 = oc .route({ description: 'Get chat conversations with pagination, filtering and summary', inputStructure: 'detailed', @@ -1389,14 +1485,14 @@ export const get16 = oc .output(zGetAppsByAppIdChatConversationsResponse) export const chatConversations = { - get: get16, + get: get19, byConversationId, } /** * Get suggested questions for a message */ -export const get17 = oc +export const get20 = oc .route({ description: 'Get suggested questions for a message', inputStructure: 'detailed', @@ -1409,7 +1505,7 @@ export const get17 = oc .output(zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse) export const suggestedQuestions = { - get: get17, + get: get20, } export const byMessageId = { @@ -1446,7 +1542,7 @@ export const byTaskId = { * * @deprecated */ -export const get18 = oc +export const get21 = oc .route({ deprecated: true, description: @@ -1463,7 +1559,7 @@ export const get18 = oc .output(zGetAppsByAppIdChatMessagesResponse) export const chatMessages = { - get: get18, + get: get21, byMessageId, byTaskId, } @@ -1491,7 +1587,7 @@ export const delete4 = oc * * @deprecated */ -export const get19 = oc +export const get22 = oc .route({ deprecated: true, description: @@ -1507,13 +1603,13 @@ export const get19 = oc export const byConversationId2 = { delete: delete4, - get: get19, + get: get22, } /** * Get completion conversations with pagination and filtering */ -export const get20 = oc +export const get23 = oc .route({ description: 'Get completion conversations with pagination and filtering', inputStructure: 'detailed', @@ -1531,7 +1627,7 @@ export const get20 = oc .output(zGetAppsByAppIdCompletionConversationsResponse) export const completionConversations = { - get: get20, + get: get23, byConversationId: byConversationId2, } @@ -1592,7 +1688,7 @@ export const completionMessages = { /** * Get conversation variables for an application */ -export const get21 = oc +export const get24 = oc .route({ description: 'Get conversation variables for an application', inputStructure: 'detailed', @@ -1610,7 +1706,7 @@ export const get21 = oc .output(zGetAppsByAppIdConversationVariablesResponse) export const conversationVariables = { - get: get21, + get: get24, } /** @@ -1677,7 +1773,7 @@ export const copy = { * * Export application configuration as DSL */ -export const get22 = oc +export const get25 = oc .route({ description: 'Export application configuration as DSL', inputStructure: 'detailed', @@ -1693,7 +1789,7 @@ export const get22 = oc .output(zGetAppsByAppIdExportResponse) export const export2 = { - get: get22, + get: get25, } /** @@ -1703,7 +1799,7 @@ export const export2 = { * * @deprecated */ -export const get23 = oc +export const get26 = oc .route({ deprecated: true, description: @@ -1723,7 +1819,7 @@ export const get23 = oc .output(zGetAppsByAppIdFeedbacksExportResponse) export const export3 = { - get: get23, + get: get26, } /** @@ -1778,7 +1874,7 @@ export const icon = { * * @deprecated */ -export const get24 = oc +export const get27 = oc .route({ deprecated: true, description: @@ -1793,7 +1889,7 @@ export const get24 = oc .output(zGetAppsByAppIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get24, + get: get27, } export const messages = { @@ -1881,7 +1977,7 @@ export const publishToCreatorsPlatform = { * * @deprecated */ -export const get25 = oc +export const get28 = oc .route({ deprecated: true, description: @@ -1939,7 +2035,7 @@ export const put2 = oc .output(zPutAppsByAppIdServerResponse) export const server = { - get: get25, + get: get28, post: post28, put: put2, } @@ -2015,7 +2111,7 @@ export const siteEnable = { * * @deprecated */ -export const get26 = oc +export const get29 = oc .route({ deprecated: true, description: @@ -2035,7 +2131,7 @@ export const get26 = oc .output(zGetAppsByAppIdStatisticsAverageResponseTimeResponse) export const averageResponseTime = { - get: get26, + get: get29, } /** @@ -2045,7 +2141,7 @@ export const averageResponseTime = { * * @deprecated */ -export const get27 = oc +export const get30 = oc .route({ deprecated: true, description: @@ -2065,7 +2161,7 @@ export const get27 = oc .output(zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse) export const averageSessionInteractions = { - get: get27, + get: get30, } /** @@ -2075,7 +2171,7 @@ export const averageSessionInteractions = { * * @deprecated */ -export const get28 = oc +export const get31 = oc .route({ deprecated: true, description: @@ -2095,7 +2191,7 @@ export const get28 = oc .output(zGetAppsByAppIdStatisticsDailyConversationsResponse) export const dailyConversations = { - get: get28, + get: get31, } /** @@ -2105,7 +2201,7 @@ export const dailyConversations = { * * @deprecated */ -export const get29 = oc +export const get32 = oc .route({ deprecated: true, description: @@ -2125,7 +2221,7 @@ export const get29 = oc .output(zGetAppsByAppIdStatisticsDailyEndUsersResponse) export const dailyEndUsers = { - get: get29, + get: get32, } /** @@ -2135,7 +2231,7 @@ export const dailyEndUsers = { * * @deprecated */ -export const get30 = oc +export const get33 = oc .route({ deprecated: true, description: @@ -2155,7 +2251,7 @@ export const get30 = oc .output(zGetAppsByAppIdStatisticsDailyMessagesResponse) export const dailyMessages = { - get: get30, + get: get33, } /** @@ -2165,7 +2261,7 @@ export const dailyMessages = { * * @deprecated */ -export const get31 = oc +export const get34 = oc .route({ deprecated: true, description: @@ -2185,7 +2281,7 @@ export const get31 = oc .output(zGetAppsByAppIdStatisticsTokenCostsResponse) export const tokenCosts = { - get: get31, + get: get34, } /** @@ -2195,7 +2291,7 @@ export const tokenCosts = { * * @deprecated */ -export const get32 = oc +export const get35 = oc .route({ deprecated: true, description: @@ -2215,7 +2311,7 @@ export const get32 = oc .output(zGetAppsByAppIdStatisticsTokensPerSecondResponse) export const tokensPerSecond = { - get: get32, + get: get35, } /** @@ -2225,7 +2321,7 @@ export const tokensPerSecond = { * * @deprecated */ -export const get33 = oc +export const get36 = oc .route({ deprecated: true, description: @@ -2245,7 +2341,7 @@ export const get33 = oc .output(zGetAppsByAppIdStatisticsUserSatisfactionRateResponse) export const userSatisfactionRate = { - get: get33, + get: get36, } export const statistics = { @@ -2266,7 +2362,7 @@ export const statistics = { * * @deprecated */ -export const get34 = oc +export const get37 = oc .route({ deprecated: true, description: @@ -2286,7 +2382,7 @@ export const get34 = oc .output(zGetAppsByAppIdTextToAudioVoicesResponse) export const voices = { - get: get34, + get: get37, } /** @@ -2326,7 +2422,7 @@ export const textToAudio = { * * @deprecated */ -export const get35 = oc +export const get38 = oc .route({ deprecated: true, description: @@ -2357,7 +2453,7 @@ export const post33 = oc .output(zPostAppsByAppIdTraceResponse) export const trace = { - get: get35, + get: get38, post: post33, } @@ -2392,7 +2488,7 @@ export const delete5 = oc * * @deprecated */ -export const get36 = oc +export const get39 = oc .route({ deprecated: true, description: @@ -2463,7 +2559,7 @@ export const post34 = oc export const traceConfig = { delete: delete5, - get: get36, + get: get39, patch, post: post34, } @@ -2495,7 +2591,7 @@ export const triggerEnable = { /** * Get app triggers list */ -export const get37 = oc +export const get40 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2508,7 +2604,7 @@ export const get37 = oc .output(zGetAppsByAppIdTriggersResponse) export const triggers = { - get: get37, + get: get40, } /** @@ -2516,7 +2612,7 @@ export const triggers = { * * Get workflow application execution logs */ -export const get38 = oc +export const get41 = oc .route({ description: 'Get workflow application execution logs', inputStructure: 'detailed', @@ -2535,7 +2631,7 @@ export const get38 = oc .output(zGetAppsByAppIdWorkflowAppLogsResponse) export const workflowAppLogs = { - get: get38, + get: get41, } /** @@ -2543,7 +2639,7 @@ export const workflowAppLogs = { * * Get workflow archived execution logs */ -export const get39 = oc +export const get42 = oc .route({ description: 'Get workflow archived execution logs', inputStructure: 'detailed', @@ -2562,7 +2658,7 @@ export const get39 = oc .output(zGetAppsByAppIdWorkflowArchivedLogsResponse) export const workflowArchivedLogs = { - get: get39, + get: get42, } /** @@ -2570,7 +2666,7 @@ export const workflowArchivedLogs = { * * Get workflow runs count statistics */ -export const get40 = oc +export const get43 = oc .route({ description: 'Get workflow runs count statistics', inputStructure: 'detailed', @@ -2589,7 +2685,7 @@ export const get40 = oc .output(zGetAppsByAppIdWorkflowRunsCountResponse) export const count3 = { - get: get40, + get: get43, } /** @@ -2625,7 +2721,7 @@ export const tasks = { /** * Generate a download URL for an archived workflow run. */ -export const get41 = oc +export const get44 = oc .route({ description: 'Generate a download URL for an archived workflow run.', inputStructure: 'detailed', @@ -2638,7 +2734,7 @@ export const get41 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdExportResponse) export const export4 = { - get: get41, + get: get44, } /** @@ -2646,7 +2742,7 @@ export const export4 = { * * Get workflow run node execution list */ -export const get42 = oc +export const get45 = oc .route({ description: 'Get workflow run node execution list', inputStructure: 'detailed', @@ -2660,7 +2756,7 @@ export const get42 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse) export const nodeExecutions = { - get: get42, + get: get45, } /** @@ -2668,7 +2764,7 @@ export const nodeExecutions = { * * Get workflow run detail */ -export const get43 = oc +export const get46 = oc .route({ description: 'Get workflow run detail', inputStructure: 'detailed', @@ -2682,17 +2778,113 @@ export const get43 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdResponse) export const byRunId = { - get: get43, + get: get46, export: export4, nodeExecutions, } +/** + * Download a file from a Workflow Agent node's sandbox workspace (read-only) + */ +export const get47 = oc + .route({ + description: 'Download a file from a Workflow Agent node\'s sandbox workspace (read-only)', + inputStructure: 'detailed', + method: 'GET', + operationId: + 'getAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownload', + path: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/download', + tags: ['console'], + }) + .input( + z.object({ + params: + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadPath, + query: + zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadQuery, + }), + ) + .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) + +export const files2 = { + get: get49, + download: download2, + preview: preview3, +} + +export const workspace = { + files: files2, +} + +export const byNodeId4 = { + workspace, +} + +export const agentNodes = { + byNodeId: byNodeId4, +} + +export const byWorkflowRunId = { + agentNodes, +} + /** * Get workflow run list * * Get workflow run list */ -export const get44 = oc +export const get50 = oc .route({ description: 'Get workflow run list', inputStructure: 'detailed', @@ -2711,10 +2903,11 @@ export const get44 = oc .output(zGetAppsByAppIdWorkflowRunsResponse) export const workflowRuns2 = { - get: get44, + get: get50, count: count3, tasks, byRunId, + byWorkflowRunId, } /** @@ -2722,7 +2915,7 @@ export const workflowRuns2 = { * * Get all users in current tenant for mentions */ -export const get45 = oc +export const get51 = oc .route({ description: 'Get all users in current tenant for mentions', inputStructure: 'detailed', @@ -2736,7 +2929,7 @@ export const get45 = oc .output(zGetAppsByAppIdWorkflowCommentsMentionUsersResponse) export const mentionUsers = { - get: get45, + get: get51, } /** @@ -2861,7 +3054,7 @@ export const delete7 = oc * * Get a specific workflow comment */ -export const get46 = oc +export const get52 = oc .route({ description: 'Get a specific workflow comment', inputStructure: 'detailed', @@ -2899,7 +3092,7 @@ export const put4 = oc export const byCommentId = { delete: delete7, - get: get46, + get: get52, put: put4, replies, resolve, @@ -2910,7 +3103,7 @@ export const byCommentId = { * * Get all comments for a workflow */ -export const get47 = oc +export const get53 = oc .route({ description: 'Get all comments for a workflow', inputStructure: 'detailed', @@ -2948,7 +3141,7 @@ export const post39 = oc .output(zPostAppsByAppIdWorkflowCommentsResponse) export const comments = { - get: get47, + get: get53, post: post39, mentionUsers, byCommentId, @@ -2961,7 +3154,7 @@ export const comments = { * * @deprecated */ -export const get48 = oc +export const get54 = oc .route({ deprecated: true, description: @@ -2981,7 +3174,7 @@ export const get48 = oc .output(zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse) export const averageAppInteractions = { - get: get48, + get: get54, } /** @@ -2991,7 +3184,7 @@ export const averageAppInteractions = { * * @deprecated */ -export const get49 = oc +export const get55 = oc .route({ deprecated: true, description: @@ -3011,7 +3204,7 @@ export const get49 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse) export const dailyConversations2 = { - get: get49, + get: get55, } /** @@ -3021,7 +3214,7 @@ export const dailyConversations2 = { * * @deprecated */ -export const get50 = oc +export const get56 = oc .route({ deprecated: true, description: @@ -3041,7 +3234,7 @@ export const get50 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse) export const dailyTerminals = { - get: get50, + get: get56, } /** @@ -3051,7 +3244,7 @@ export const dailyTerminals = { * * @deprecated */ -export const get51 = oc +export const get57 = oc .route({ deprecated: true, description: @@ -3071,7 +3264,7 @@ export const get51 = oc .output(zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse) export const tokenCosts2 = { - get: get51, + get: get57, } export const statistics2 = { @@ -3095,7 +3288,7 @@ export const workflow = { * * @deprecated */ -export const get52 = oc +export const get58 = oc .route({ deprecated: true, description: @@ -3116,7 +3309,7 @@ export const get52 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse) export const byBlockType = { - get: get52, + get: get58, } /** @@ -3128,7 +3321,7 @@ export const byBlockType = { * * @deprecated */ -export const get53 = oc +export const get59 = oc .route({ deprecated: true, description: @@ -3144,7 +3337,7 @@ export const get53 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse) export const defaultWorkflowBlockConfigs = { - get: get53, + get: get59, byBlockType, } @@ -3155,7 +3348,7 @@ export const defaultWorkflowBlockConfigs = { * * @deprecated */ -export const get54 = oc +export const get60 = oc .route({ deprecated: true, description: @@ -3196,7 +3389,7 @@ export const post40 = oc .output(zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse) export const conversationVariables2 = { - get: get54, + get: get60, post: post40, } @@ -3209,7 +3402,7 @@ export const conversationVariables2 = { * * @deprecated */ -export const get55 = oc +export const get61 = oc .route({ deprecated: true, description: @@ -3251,7 +3444,7 @@ export const post41 = oc .output(zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse) export const environmentVariables = { - get: get55, + get: get61, post: post41, } @@ -3347,7 +3540,7 @@ export const post44 = oc ) .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse) -export const preview2 = { +export const preview4 = { post: post44, } @@ -3385,17 +3578,17 @@ export const run5 = { } export const form2 = { - preview: preview2, + preview: preview4, run: run5, } -export const byNodeId4 = { +export const byNodeId5 = { deliveryTest, form: form2, } export const nodes4 = { - byNodeId: byNodeId4, + byNodeId: byNodeId5, } export const humanInput2 = { @@ -3435,12 +3628,12 @@ export const run6 = { post: post46, } -export const byNodeId5 = { +export const byNodeId6 = { run: run6, } export const nodes5 = { - byNodeId: byNodeId5, + byNodeId: byNodeId6, } export const iteration2 = { @@ -3480,19 +3673,19 @@ export const run7 = { post: post47, } -export const byNodeId6 = { +export const byNodeId7 = { run: run7, } export const nodes6 = { - byNodeId: byNodeId6, + byNodeId: byNodeId7, } export const loop2 = { nodes: nodes6, } -export const get56 = oc +export const get62 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3506,7 +3699,7 @@ export const get56 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse) export const candidates2 = { - get: get56, + get: get62, } export const post48 = oc @@ -3569,7 +3762,7 @@ export const validate2 = { post: post50, } -export const get57 = oc +export const get63 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3597,7 +3790,7 @@ export const put5 = oc .output(zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) export const agentComposer2 = { - get: get57, + get: get63, put: put5, candidates: candidates2, impact, @@ -3608,7 +3801,7 @@ export const agentComposer2 = { /** * Get last run result for draft workflow node */ -export const get58 = oc +export const get64 = oc .route({ description: 'Get last run result for draft workflow node', inputStructure: 'detailed', @@ -3621,7 +3814,7 @@ export const get58 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse) export const lastRun = { - get: get58, + get: get64, } /** @@ -3712,7 +3905,7 @@ export const delete8 = oc * * @deprecated */ -export const get59 = oc +export const get65 = oc .route({ deprecated: true, description: @@ -3728,10 +3921,10 @@ export const get59 = oc export const variables = { delete: delete8, - get: get59, + get: get65, } -export const byNodeId7 = { +export const byNodeId8 = { agentComposer: agentComposer2, lastRun, run: run8, @@ -3740,7 +3933,7 @@ export const byNodeId7 = { } export const nodes7 = { - byNodeId: byNodeId7, + byNodeId: byNodeId8, } /** @@ -3783,7 +3976,7 @@ export const run10 = { * * @deprecated */ -export const get60 = oc +export const get66 = oc .route({ deprecated: true, description: @@ -3798,7 +3991,7 @@ export const get60 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse) export const events = { - get: get60, + get: get66, } /** @@ -3808,7 +4001,7 @@ export const events = { * * @deprecated */ -export const get61 = oc +export const get67 = oc .route({ deprecated: true, description: @@ -3826,12 +4019,12 @@ export const get61 = oc ) .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse) -export const preview3 = { - get: get61, +export const preview5 = { + get: get67, } export const byOutputName = { - preview: preview3, + preview: preview5, } /** @@ -3841,7 +4034,7 @@ export const byOutputName = { * * @deprecated */ -export const get62 = oc +export const get68 = oc .route({ deprecated: true, description: @@ -3855,8 +4048,8 @@ export const get62 = oc .input(z.object({ params: zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdPath })) .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse) -export const byNodeId8 = { - get: get62, +export const byNodeId9 = { + get: get68, byOutputName, } @@ -3867,7 +4060,7 @@ export const byNodeId8 = { * * @deprecated */ -export const get63 = oc +export const get69 = oc .route({ deprecated: true, description: @@ -3882,9 +4075,9 @@ export const get63 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponse) export const nodeOutputs = { - get: get63, + get: get69, events, - byNodeId: byNodeId8, + byNodeId: byNodeId9, } export const byRunId2 = { @@ -3902,7 +4095,7 @@ export const runs = { * * @deprecated */ -export const get64 = oc +export const get70 = oc .route({ deprecated: true, description: @@ -3917,7 +4110,7 @@ export const get64 = oc .output(zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse) export const systemVariables = { - get: get64, + get: get70, } /** @@ -4039,7 +4232,7 @@ export const delete9 = oc * * @deprecated */ -export const get65 = oc +export const get71 = oc .route({ deprecated: true, description: @@ -4081,7 +4274,7 @@ export const patch2 = oc export const byVariableId = { delete: delete9, - get: get65, + get: get71, patch: patch2, reset, } @@ -4111,7 +4304,7 @@ export const delete10 = oc * * @deprecated */ -export const get66 = oc +export const get72 = oc .route({ deprecated: true, description: @@ -4133,7 +4326,7 @@ export const get66 = oc export const variables2 = { delete: delete10, - get: get66, + get: get72, byVariableId, } @@ -4146,7 +4339,7 @@ export const variables2 = { * * @deprecated */ -export const get67 = oc +export const get73 = oc .route({ deprecated: true, description: @@ -4191,7 +4384,7 @@ export const post56 = oc .output(zPostAppsByAppIdWorkflowsDraftResponse) export const draft2 = { - get: get67, + get: get73, post: post56, conversationVariables: conversationVariables2, environmentVariables, @@ -4216,7 +4409,7 @@ export const draft2 = { * * @deprecated */ -export const get68 = oc +export const get74 = oc .route({ deprecated: true, description: @@ -4259,7 +4452,7 @@ export const post57 = oc .output(zPostAppsByAppIdWorkflowsPublishResponse) export const publish = { - get: get68, + get: get74, post: post57, } @@ -4270,7 +4463,7 @@ export const publish = { * * @deprecated */ -export const get69 = oc +export const get75 = oc .route({ deprecated: true, description: @@ -4285,7 +4478,7 @@ export const get69 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse) export const events2 = { - get: get69, + get: get75, } /** @@ -4295,7 +4488,7 @@ export const events2 = { * * @deprecated */ -export const get70 = oc +export const get76 = oc .route({ deprecated: true, description: @@ -4317,12 +4510,12 @@ export const get70 = oc zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse, ) -export const preview4 = { - get: get70, +export const preview6 = { + get: get76, } export const byOutputName2 = { - preview: preview4, + preview: preview6, } /** @@ -4332,7 +4525,7 @@ export const byOutputName2 = { * * @deprecated */ -export const get71 = oc +export const get77 = oc .route({ deprecated: true, description: @@ -4346,8 +4539,8 @@ export const get71 = oc .input(z.object({ params: zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdPath })) .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse) -export const byNodeId9 = { - get: get71, +export const byNodeId10 = { + get: get77, byOutputName: byOutputName2, } @@ -4358,7 +4551,7 @@ export const byNodeId9 = { * * @deprecated */ -export const get72 = oc +export const get78 = oc .route({ deprecated: true, description: @@ -4373,9 +4566,9 @@ export const get72 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse) export const nodeOutputs2 = { - get: get72, + get: get78, events: events2, - byNodeId: byNodeId9, + byNodeId: byNodeId10, } export const byRunId3 = { @@ -4397,7 +4590,7 @@ export const published = { * * @deprecated */ -export const get73 = oc +export const get79 = oc .route({ deprecated: true, description: @@ -4418,7 +4611,7 @@ export const get73 = oc .output(zGetAppsByAppIdWorkflowsTriggersWebhookResponse) export const webhook = { - get: get73, + get: get79, } export const triggers2 = { @@ -4516,7 +4709,7 @@ export const byWorkflowId = { * * @deprecated */ -export const get74 = oc +export const get80 = oc .route({ deprecated: true, description: @@ -4537,7 +4730,7 @@ export const get74 = oc .output(zGetAppsByAppIdWorkflowsResponse) export const workflows3 = { - get: get74, + get: get80, defaultWorkflowBlockConfigs, draft: draft2, publish, @@ -4574,7 +4767,7 @@ export const delete12 = oc * * @deprecated */ -export const get75 = oc +export const get81 = oc .route({ deprecated: true, description: @@ -4615,12 +4808,13 @@ export const put7 = oc export const byAppId2 = { delete: delete12, - get: get75, + get: get81, put: put7, advancedChat, agentComposer, agentFeatures, agentReferencingWorkflows, + agentWorkspace, agent, annotationReply, annotationSetting, @@ -4686,7 +4880,7 @@ export const byApiKeyId = { * * Get all API keys for an app */ -export const get76 = oc +export const get82 = oc .route({ description: 'Get all API keys for an app', inputStructure: 'detailed', @@ -4719,7 +4913,7 @@ export const post59 = oc .output(zPostAppsByResourceIdApiKeysResponse) export const apiKeys = { - get: get76, + get: get82, post: post59, byApiKeyId, } @@ -4735,7 +4929,7 @@ export const byResourceId = { * * @deprecated */ -export const get77 = oc +export const get83 = oc .route({ deprecated: true, description: @@ -4750,7 +4944,7 @@ export const get77 = oc .output(zGetAppsByServerIdServerRefreshResponse) export const refresh = { - get: get77, + get: get83, } export const server2 = { @@ -4766,7 +4960,7 @@ export const byServerId = { * * Get list of applications with pagination and filtering */ -export const get78 = oc +export const get84 = oc .route({ description: 'Get list of applications with pagination and filtering', inputStructure: 'detailed', @@ -4805,7 +4999,7 @@ export const post60 = oc .output(zPostAppsResponse) export const apps = { - get: get78, + get: get84, post: post60, imports, workflows, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 989d71a662..c36a9ef7fc 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -219,6 +219,20 @@ export type AgentReferencingWorkflowsResponse = { data?: Array } +export type WorkspaceListResponse = { + entries?: Array + path: string + truncated?: boolean +} + +export type WorkspacePreviewResponse = { + binary: boolean + path: string + size: number + text?: string | null + truncated: boolean +} + export type AnnotationReplyPayload = { embedding_model_name: string embedding_provider_name: string @@ -1136,6 +1150,13 @@ export type AgentReferencingWorkflowResponse = { workflow_id: string } +export type WorkspaceFileEntryResponse = { + mtime: number + name: string + size: number + type: 'dir' | 'file' | 'symlink' +} + export type AnnotationHitHistory = { annotation_content?: string | null annotation_question?: string | null @@ -2450,6 +2471,72 @@ export type GetAppsByAppIdAgentReferencingWorkflowsResponses = { export type GetAppsByAppIdAgentReferencingWorkflowsResponse = GetAppsByAppIdAgentReferencingWorkflowsResponses[keyof GetAppsByAppIdAgentReferencingWorkflowsResponses] +export type GetAppsByAppIdAgentWorkspaceFilesData = { + body?: never + path: { + app_id: string + } + query: { + conversation_id: string + path?: string + } + url: '/apps/{app_id}/agent-workspace/files' +} + +export type GetAppsByAppIdAgentWorkspaceFilesResponses = { + 200: WorkspaceListResponse +} + +export type GetAppsByAppIdAgentWorkspaceFilesResponse + = GetAppsByAppIdAgentWorkspaceFilesResponses[keyof GetAppsByAppIdAgentWorkspaceFilesResponses] + +export type GetAppsByAppIdAgentWorkspaceFilesDownloadData = { + body?: never + path: { + app_id: string + } + query: { + conversation_id: string + path: string + } + url: '/apps/{app_id}/agent-workspace/files/download' +} + +export type GetAppsByAppIdAgentWorkspaceFilesDownloadErrors = { + 413: { + [key: string]: unknown + } +} + +export type GetAppsByAppIdAgentWorkspaceFilesDownloadError + = GetAppsByAppIdAgentWorkspaceFilesDownloadErrors[keyof GetAppsByAppIdAgentWorkspaceFilesDownloadErrors] + +export type GetAppsByAppIdAgentWorkspaceFilesDownloadResponses = { + 200: Blob | File +} + +export type GetAppsByAppIdAgentWorkspaceFilesDownloadResponse + = GetAppsByAppIdAgentWorkspaceFilesDownloadResponses[keyof GetAppsByAppIdAgentWorkspaceFilesDownloadResponses] + +export type GetAppsByAppIdAgentWorkspaceFilesPreviewData = { + body?: never + path: { + app_id: string + } + query: { + conversation_id: string + path: string + } + url: '/apps/{app_id}/agent-workspace/files/preview' +} + +export type GetAppsByAppIdAgentWorkspaceFilesPreviewResponses = { + 200: WorkspacePreviewResponse +} + +export type GetAppsByAppIdAgentWorkspaceFilesPreviewResponse + = GetAppsByAppIdAgentWorkspaceFilesPreviewResponses[keyof GetAppsByAppIdAgentWorkspaceFilesPreviewResponses] + export type GetAppsByAppIdAgentLogsData = { body?: never path: { @@ -4228,6 +4315,82 @@ export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses = { export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse = GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses[keyof GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses] +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesData = { + 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' +} + +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponses = { + 200: WorkspaceListResponse +} + +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponse + = GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponses] + +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 = { + 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/preview' +} + +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponses + = { + 200: WorkspacePreviewResponse + } + +export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponse + = GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponses] + export type GetAppsByAppIdWorkflowCommentsData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 2f79ac0900..73d37bd958 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -92,6 +92,17 @@ export const zSimpleResultResponse = z.object({ result: z.string(), }) +/** + * WorkspacePreviewResponse + */ +export const zWorkspacePreviewResponse = z.object({ + binary: z.boolean(), + path: z.string(), + size: z.int(), + text: z.string().nullish(), + truncated: z.boolean(), +}) + /** * AnnotationReplyPayload */ @@ -825,6 +836,25 @@ export const zAgentReferencingWorkflowsResponse = z.object({ data: z.array(zAgentReferencingWorkflowResponse).optional(), }) +/** + * WorkspaceFileEntryResponse + */ +export const zWorkspaceFileEntryResponse = z.object({ + mtime: z.int(), + name: z.string(), + size: z.int(), + type: z.enum(['dir', 'file', 'symlink']), +}) + +/** + * WorkspaceListResponse + */ +export const zWorkspaceListResponse = z.object({ + entries: z.array(zWorkspaceFileEntryResponse).optional(), + path: z.string(), + truncated: z.boolean().optional().default(false), +}) + /** * AnnotationHitHistory */ @@ -2902,6 +2932,48 @@ export const zGetAppsByAppIdAgentReferencingWorkflowsPath = z.object({ */ export const zGetAppsByAppIdAgentReferencingWorkflowsResponse = zAgentReferencingWorkflowsResponse +export const zGetAppsByAppIdAgentWorkspaceFilesPath = z.object({ + app_id: z.string(), +}) + +export const zGetAppsByAppIdAgentWorkspaceFilesQuery = z.object({ + conversation_id: z.string().min(1), + path: z.string().optional().default('.'), +}) + +/** + * Listing returned + */ +export const zGetAppsByAppIdAgentWorkspaceFilesResponse = zWorkspaceListResponse + +export const zGetAppsByAppIdAgentWorkspaceFilesDownloadPath = 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({ + conversation_id: z.string().min(1), + path: z.string().min(1), +}) + +/** + * Preview returned + */ +export const zGetAppsByAppIdAgentWorkspaceFilesPreviewResponse = zWorkspacePreviewResponse + export const zGetAppsByAppIdAgentLogsPath = z.object({ app_id: z.string(), }) @@ -3788,6 +3860,63 @@ export const zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsPath = z.object({ export const zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse = zWorkflowRunNodeExecutionListResponse +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPath + = z.object({ + app_id: z.string(), + node_id: z.string(), + workflow_run_id: z.string(), + }) + +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesQuery + = z.object({ + node_execution_id: z.string().optional(), + path: z.string().optional().default('.'), + }) + +/** + * Listing returned + */ +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponse + = zWorkspaceListResponse + +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadPath + = 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 + = z.object({ + node_execution_id: z.string().optional(), + path: z.string().min(1), + }) + +/** + * Preview returned + */ +export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponse + = zWorkspacePreviewResponse + export const zGetAppsByAppIdWorkflowCommentsPath = z.object({ app_id: z.string(), })