mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:23:44 +08:00
feat(agent): Sandbox / CLI Agent (dify.shell) + read-only sandbox file inspector (#36984)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
d3058d63bd
commit
44725dde74
@ -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(
|
||||
|
||||
135
api/clients/agent_backend/workspace_files_client.py
Normal file
135
api/clients/agent_backend/workspace_files_client.py
Normal file
@ -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",
|
||||
]
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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",
|
||||
|
||||
319
api/controllers/console/app/agent_app_workspace.py
Normal file
319
api/controllers/console/app/agent_app_workspace.py
Normal file
@ -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/<uuid:app_id>/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/<uuid:app_id>/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/<uuid:app_id>/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/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/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/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/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/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/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)
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 |
|
||||
|
||||
220
api/services/agent_app_workspace_service.py
Normal file
220
api/services/agent_app_workspace_service.py
Normal file
@ -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"]
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
@ -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"),
|
||||
]
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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<file> → 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"]
|
||||
|
||||
@ -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"}]}
|
||||
|
||||
@ -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 == []
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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>'.")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
78
dify-agent/src/dify_agent/server/routes/workspace_files.py
Normal file
78
dify-agent/src/dify_agent/server/routes/workspace_files.py
Normal file
@ -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/<session_id>`` 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"]
|
||||
418
dify-agent/src/dify_agent/server/workspace_files.py
Normal file
418
dify-agent/src/dify_agent/server/workspace_files.py
Normal file
@ -0,0 +1,418 @@
|
||||
"""Read-only inspector for a shell-layer workspace (``~/workspace/<session_id>``).
|
||||
|
||||
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 = "<<<DIFY_FS_BEGIN>>>"
|
||||
_END = "<<<DIFY_FS_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 = "<<<DIFY_FS_BEGIN>>>"
|
||||
END = "<<<DIFY_FS_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",
|
||||
]
|
||||
@ -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":
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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]
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
270
dify-agent/tests/local/dify_agent/server/test_workspace_files.py
Normal file
270
dify-agent/tests/local/dify_agent/server/test_workspace_files.py
Normal file
@ -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
|
||||
@ -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']",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -219,6 +219,20 @@ export type AgentReferencingWorkflowsResponse = {
|
||||
data?: Array<AgentReferencingWorkflowResponse>
|
||||
}
|
||||
|
||||
export type WorkspaceListResponse = {
|
||||
entries?: Array<WorkspaceFileEntryResponse>
|
||||
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: {
|
||||
|
||||
@ -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<Blob | File>()
|
||||
|
||||
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<Blob | File>()
|
||||
|
||||
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(),
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user