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:
zyssyz123 2026-06-04 06:37:31 +08:00 committed by GitHub
parent d3058d63bd
commit 44725dde74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 3761 additions and 240 deletions

View File

@ -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(

View 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",
]

View File

@ -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,
)

View File

@ -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",

View 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)

View File

@ -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,

View File

@ -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:

View File

@ -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),

View File

@ -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

View File

@ -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(

View File

@ -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 |

View 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"]

View File

@ -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"

View File

@ -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

View File

@ -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"),
]

View File

@ -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,
}

View File

@ -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
)

View File

@ -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"]

View File

@ -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"}]}

View File

@ -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 == []

View File

@ -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.

View File

@ -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",
]

View File

@ -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",
]

View File

@ -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>'.")

View File

@ -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

View 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"]

View 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",
]

View File

@ -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":

View File

@ -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")

View File

@ -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]
)

View File

@ -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,

View 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

View File

@ -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

View File

@ -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: {

View File

@ -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(),
})