refactor(agent): replace workspace inspector with sandbox API (#37349)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
盐粒 Yanli 2026-06-12 11:46:31 +09:00 committed by GitHub
parent 09bb87d089
commit 92df792e4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 3838 additions and 2905 deletions

View File

@ -5,6 +5,8 @@ API adapters: request building from Dify product concepts, a thin client wrapper
event adaptation for future workflow integration, and deterministic fakes.
"""
from dify_agent.protocol import RuntimeLayerSpec, extract_runtime_layer_specs
from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient
from clients.agent_backend.errors import (
AgentBackendError,
@ -39,8 +41,6 @@ from clients.agent_backend.request_builder import (
AgentBackendOutputConfig,
AgentBackendRunRequestBuilder,
AgentBackendWorkflowNodeRunInput,
CleanupLayerSpec,
extract_cleanup_layer_specs,
redact_for_agent_backend_log,
)
@ -72,11 +72,11 @@ __all__ = [
"AgentBackendTransportError",
"AgentBackendValidationError",
"AgentBackendWorkflowNodeRunInput",
"CleanupLayerSpec",
"DifyAgentBackendRunClient",
"FakeAgentBackendRunClient",
"FakeAgentBackendScenario",
"RuntimeLayerSpec",
"create_agent_backend_run_client",
"extract_cleanup_layer_specs",
"extract_runtime_layer_specs",
"redact_for_agent_backend_log",
]

View File

@ -12,7 +12,7 @@ composition-driven.
from __future__ import annotations
from collections.abc import Mapping
from typing import ClassVar, cast
from typing import ClassVar
from agenton.compositor import CompositorSessionSnapshot
from agenton.compositor.schemas import LayerSessionSnapshot
@ -41,6 +41,7 @@ from dify_agent.protocol import (
RunComposition,
RunLayerSpec,
RunPurpose,
RuntimeLayerSpec,
)
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
@ -52,71 +53,10 @@ DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
DIFY_SHELL_LAYER_ID = "shell"
# Layer types that hold credentials in their per-run config. These are excluded
# from the cleanup-replay composition (and from the snapshot that is sent with
# the cleanup request) because we deliberately do not persist plaintext
# credentials between runs.
_CLEANUP_EXCLUDED_LAYER_TYPES: tuple[str, ...] = (
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
)
class CleanupLayerSpec(BaseModel):
"""One layer node replayed by an Agent backend cleanup-only run.
Cleanup composition cannot include credential-bearing plugin layers, so we
persist only the non-plugin layer specs together with the original config.
Storing the config (rather than just ``name``/``type``) means cleanup does
not depend on the original build-time inputs being re-derivable.
"""
name: str
type: str
deps: dict[str, str] = Field(default_factory=dict)
metadata: dict[str, JsonValue] = Field(default_factory=dict)
config: JsonValue = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
def extract_cleanup_layer_specs(composition: RunComposition) -> list[CleanupLayerSpec]:
"""Project the in-flight composition into the persistable cleanup spec list.
Plugin layers are intentionally dropped (their configs hold credentials and
the lifecycle contract says "do not include an LLM layer" during cleanup).
The filtered names must later drive snapshot filtering so the agenton
compositor's name-order check still passes for the cleanup run.
"""
excluded = set(_CLEANUP_EXCLUDED_LAYER_TYPES)
specs: list[CleanupLayerSpec] = []
for layer in composition.layers:
if layer.type in excluded:
continue
config_value: JsonValue = None
if isinstance(layer.config, BaseModel):
config_value = layer.config.model_dump(mode="json", warnings=False)
else:
# ``RunLayerSpec.config`` is typed as ``LayerConfigInput`` which
# includes ``Mapping[str, object] | bytes``. In the cleanup-replay
# pipeline our builder only emits BaseModel-derived configs or
# ``None``, so the wider input alias narrows safely here.
config_value = cast(JsonValue, layer.config)
specs.append(
CleanupLayerSpec(
name=layer.name,
type=layer.type,
deps=dict(layer.deps),
metadata=dict(layer.metadata),
config=config_value,
)
)
return specs
def _filter_snapshot_to_specs(
snapshot: CompositorSessionSnapshot,
specs: list[CleanupLayerSpec],
specs: list[RuntimeLayerSpec],
) -> CompositorSessionSnapshot:
"""Keep only snapshot layers whose names appear in the cleanup spec list.
@ -367,7 +307,7 @@ class AgentBackendRunRequestBuilder:
self,
*,
session_snapshot: CompositorSessionSnapshot,
composition_layer_specs: list[CleanupLayerSpec],
runtime_layer_specs: list[RuntimeLayerSpec],
idempotency_key: str | None = None,
metadata: dict[str, JsonValue] | None = None,
) -> CreateRunRequest:
@ -380,9 +320,9 @@ class AgentBackendRunRequestBuilder:
composition and the snapshot before submission because their configs
require credentials that are not persisted between runs.
"""
if not composition_layer_specs:
if not runtime_layer_specs:
raise ValueError(
"build_cleanup_request requires composition_layer_specs; an empty "
"build_cleanup_request requires runtime_layer_specs; an empty "
"composition would fail the agent backend's snapshot validation."
)
request_metadata = dict(metadata or {})
@ -395,9 +335,9 @@ class AgentBackendRunRequestBuilder:
metadata=dict(spec.metadata),
config=spec.config,
)
for spec in composition_layer_specs
for spec in runtime_layer_specs
]
filtered_snapshot = _filter_snapshot_to_specs(session_snapshot, composition_layer_specs)
filtered_snapshot = _filter_snapshot_to_specs(session_snapshot, runtime_layer_specs)
return CreateRunRequest(
composition=RunComposition(layers=layers),
purpose="workflow_node",

View File

@ -1,135 +0,0 @@
"""API-side client for the agent backend's read-only workspace file endpoints.
The agent backend exposes ``/workspaces/{session_id}/files{,/preview,/download}``
to inspect a shell-layer sandbox workspace. This thin synchronous client proxies
those reads for the console FS inspector and normalizes transport/HTTP failures
into the API backend's ``AgentBackendError`` boundary, preserving the backend's
status code and ``{code, message}`` detail so the controller can relay them.
"""
from __future__ import annotations
import base64
import binascii
from dataclasses import dataclass
from typing import Literal
import httpx
from pydantic import BaseModel
from clients.agent_backend.errors import AgentBackendHTTPError, AgentBackendTransportError
_DEFAULT_TIMEOUT_SECONDS = 30.0
class WorkspaceFileEntry(BaseModel):
"""One entry in a workspace directory listing."""
name: str
type: Literal["file", "dir", "symlink"]
size: int
mtime: int
class WorkspaceListResult(BaseModel):
"""Directory listing of a workspace path."""
path: str
entries: list[WorkspaceFileEntry]
truncated: bool
class WorkspacePreviewResult(BaseModel):
"""Inline preview of a workspace file."""
path: str
size: int
truncated: bool
binary: bool
text: str | None = None
@dataclass(frozen=True, slots=True)
class WorkspaceDownloadResult:
"""Decoded bytes of a workspace file for download."""
path: str
size: int
truncated: bool
content: bytes
class WorkspaceFilesBackendClient:
"""Synchronous proxy to the agent backend workspace file endpoints."""
def __init__(
self,
base_url: str,
*,
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
transport: httpx.BaseTransport | None = None,
) -> None:
self._base_url = base_url.rstrip("/")
self._timeout = timeout
self._transport = transport
def list_files(self, session_id: str, path: str) -> WorkspaceListResult:
data = self._get(f"/workspaces/{session_id}/files", params={"path": path})
return WorkspaceListResult.model_validate(data)
def preview(self, session_id: str, path: str) -> WorkspacePreviewResult:
data = self._get(f"/workspaces/{session_id}/files/preview", params={"path": path})
return WorkspacePreviewResult.model_validate(data)
def download(self, session_id: str, path: str) -> WorkspaceDownloadResult:
data = self._get(f"/workspaces/{session_id}/files/download", params={"path": path})
encoded = data.get("content_base64")
if not isinstance(encoded, str):
raise AgentBackendHTTPError("agent backend download response missing content", status_code=502, detail=data)
try:
content = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as exc:
raise AgentBackendHTTPError(
"agent backend returned undecodable download content", status_code=502, detail=str(exc)
) from exc
size = data.get("size")
return WorkspaceDownloadResult(
path=str(data.get("path", path)),
size=int(size) if isinstance(size, (int, float)) else len(content),
truncated=bool(data.get("truncated")),
content=content,
)
def _get(self, route: str, *, params: dict[str, str]) -> dict[str, object]:
url = f"{self._base_url}{route}"
try:
with httpx.Client(timeout=self._timeout, transport=self._transport, trust_env=False) as client:
response = client.get(url, params=params)
except httpx.HTTPError as exc:
raise AgentBackendTransportError(f"failed to reach agent backend workspace endpoint: {exc}") from exc
if response.status_code >= 400:
detail: object
try:
detail = response.json().get("detail", response.text)
except ValueError:
detail = response.text
raise AgentBackendHTTPError(
f"agent backend workspace request failed ({response.status_code})",
status_code=response.status_code,
detail=detail,
)
body = response.json()
if not isinstance(body, dict):
raise AgentBackendHTTPError(
"agent backend workspace response was not an object", status_code=502, detail=body
)
return body
__all__ = [
"WorkspaceDownloadResult",
"WorkspaceFileEntry",
"WorkspaceFilesBackendClient",
"WorkspaceListResult",
"WorkspacePreviewResult",
]

View File

@ -53,7 +53,7 @@ from .app import (
agent,
agent_app_access,
agent_app_feature,
agent_app_workspace,
agent_app_sandbox,
annotation,
app,
audio,
@ -153,7 +153,7 @@ __all__ = [
"agent",
"agent_app_access",
"agent_app_feature",
"agent_app_workspace",
"agent_app_sandbox",
"agent_composer",
"agent_providers",
"agent_roster",

View File

@ -0,0 +1,306 @@
"""Console routes for Agent App and workflow Agent sandbox file access.
The API keeps product-facing locators (conversation or workflow node identity)
on this public boundary and proxies list/read/upload to the agent backend's new
``/sandbox`` contract.
"""
from __future__ import annotations
from typing import Literal
from uuid import UUID
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from fields.base import ResponseModel
from libs.login import login_required
from models.model import App, AppMode
from services.agent_app_sandbox_service import (
AgentAppSandboxService,
AgentSandboxInspectorError,
WorkflowAgentSandboxService,
)
_NODE_EXECUTION_ID_DESCRIPTION = (
"Optional workflow node execution ID. When omitted, the latest active session for the node is used."
)
class AgentSandboxListQuery(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
class AgentSandboxFileQuery(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
class AgentSandboxUploadPayload(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
class WorkflowAgentSandboxListQuery(BaseModel):
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
node_execution_id: str | None = Field(
default=None,
description=_NODE_EXECUTION_ID_DESCRIPTION,
)
class WorkflowAgentSandboxFileQuery(BaseModel):
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
node_execution_id: str | None = Field(
default=None,
description=_NODE_EXECUTION_ID_DESCRIPTION,
)
class WorkflowAgentSandboxUploadPayload(BaseModel):
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
node_execution_id: str | None = Field(
default=None,
description=_NODE_EXECUTION_ID_DESCRIPTION,
)
class SandboxFileEntryResponse(ResponseModel):
name: str
type: Literal["file", "dir", "symlink", "other"]
size: int | None = None
mtime: int | None = None
class SandboxListResponse(ResponseModel):
path: str
entries: list[SandboxFileEntryResponse] = Field(default_factory=list)
truncated: bool = False
class SandboxReadResponse(ResponseModel):
path: str
size: int | None = None
truncated: bool
binary: bool
text: str | None = None
class SandboxToolFileResponse(ResponseModel):
transfer_method: Literal["tool_file"] = "tool_file"
reference: str
class SandboxUploadResponse(ResponseModel):
path: str
file: SandboxToolFileResponse
register_schema_models(
console_ns,
AgentSandboxUploadPayload,
WorkflowAgentSandboxUploadPayload,
)
register_response_schema_models(console_ns, SandboxListResponse, SandboxReadResponse, SandboxUploadResponse)
def _handle(exc: Exception) -> tuple[dict[str, object], int]:
if isinstance(exc, AgentSandboxInspectorError):
return {"code": exc.code, "message": exc.message}, exc.status_code
if isinstance(exc, DifyAgentHTTPError):
detail = exc.detail
if isinstance(detail, dict):
return {
"code": detail.get("code", "agent_backend_error"),
"message": detail.get("message", str(exc)),
}, exc.status_code
return {"code": "agent_backend_error", "message": str(detail)}, exc.status_code
if isinstance(exc, DifyAgentTimeoutError | DifyAgentClientError):
return {"code": "agent_backend_unreachable", "message": str(exc)}, 502
raise exc
@console_ns.route("/apps/<uuid:app_id>/agent-sandbox/files")
class AgentAppSandboxListResource(Resource):
@console_ns.doc("list_agent_app_sandbox_files")
@console_ns.doc(description="List a directory in an Agent App conversation sandbox")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentSandboxListQuery)})
@console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App):
query = query_params_from_request(AgentSandboxListQuery)
try:
result = AgentAppSandboxService().list_files(
tenant_id=tenant_id,
app_id=app_model.id,
conversation_id=query.conversation_id,
path=query.path,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
@console_ns.route("/apps/<uuid:app_id>/agent-sandbox/files/read")
class AgentAppSandboxReadResource(Resource):
@console_ns.doc("read_agent_app_sandbox_file")
@console_ns.doc(description="Read a text/binary preview file in an Agent App conversation sandbox")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentSandboxFileQuery)})
@console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App):
query = query_params_from_request(AgentSandboxFileQuery)
try:
result = AgentAppSandboxService().read_file(
tenant_id=tenant_id,
app_id=app_model.id,
conversation_id=query.conversation_id,
path=query.path,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
@console_ns.route("/apps/<uuid:app_id>/agent-sandbox/files/upload")
class AgentAppSandboxUploadResource(Resource):
@console_ns.doc("upload_agent_app_sandbox_file")
@console_ns.doc(description="Upload one Agent App sandbox file as a Dify ToolFile mapping")
@console_ns.expect(console_ns.models[AgentSandboxUploadPayload.__name__])
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def post(self, tenant_id: str, app_model: App):
payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {})
try:
result = AgentAppSandboxService().upload_file(
tenant_id=tenant_id,
app_id=app_model.id,
conversation_id=payload.conversation_id,
path=payload.path,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
@console_ns.route("/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files")
class WorkflowAgentSandboxListResource(Resource):
@console_ns.doc("list_workflow_agent_sandbox_files")
@console_ns.doc(description="List a directory in a workflow Agent node sandbox")
@console_ns.doc(
params={
"app_id": "Application ID",
"workflow_run_id": "Workflow run ID",
"node_id": "Workflow Agent node ID",
**query_params_from_model(WorkflowAgentSandboxListQuery),
}
)
@console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
query = query_params_from_request(WorkflowAgentSandboxListQuery)
try:
result = WorkflowAgentSandboxService().list_files(
tenant_id=tenant_id,
app_id=app_model.id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=query.node_execution_id,
path=query.path,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
@console_ns.route(
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files/read"
)
class WorkflowAgentSandboxReadResource(Resource):
@console_ns.doc("read_workflow_agent_sandbox_file")
@console_ns.doc(description="Read a text/binary preview file in a workflow Agent node sandbox")
@console_ns.doc(
params={
"app_id": "Application ID",
"workflow_run_id": "Workflow run ID",
"node_id": "Workflow Agent node ID",
**query_params_from_model(WorkflowAgentSandboxFileQuery),
}
)
@console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
query = query_params_from_request(WorkflowAgentSandboxFileQuery)
try:
result = WorkflowAgentSandboxService().read_file(
tenant_id=tenant_id,
app_id=app_model.id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=query.node_execution_id,
path=query.path,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()
@console_ns.route(
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files/upload"
)
class WorkflowAgentSandboxUploadResource(Resource):
@console_ns.doc("upload_workflow_agent_sandbox_file")
@console_ns.doc(description="Upload one workflow Agent sandbox file as a Dify ToolFile mapping")
@console_ns.expect(console_ns.models[WorkflowAgentSandboxUploadPayload.__name__])
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_tenant_id
def post(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
payload = WorkflowAgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {})
try:
result = WorkflowAgentSandboxService().upload_file(
tenant_id=tenant_id,
app_id=app_model.id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=payload.node_execution_id,
path=payload.path,
)
except Exception as exc:
return _handle(exc)
return result.model_dump()

View File

@ -1,319 +0,0 @@
"""Agent App sandbox file-system inspector (read-only).
Exposes the PRD "rc1-like sandbox file system, downloadable not editable" view
for an Agent App conversation: list a directory, preview a file, or download a
file from the conversation's shell-layer workspace. The API never touches
shellctl directly it resolves the conversation's sandbox ``session_id`` from
the stored session snapshot and proxies to the agent backend's read-only
workspace endpoints.
"""
from typing import Literal
from uuid import UUID
from flask import Response
from flask_restx import Resource, fields
from pydantic import BaseModel, Field
from clients.agent_backend.errors import AgentBackendHTTPError, AgentBackendTransportError
from clients.agent_backend.workspace_files_client import WorkspaceDownloadResult
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
)
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from fields.base import ResponseModel
from libs.login import login_required
from models.model import App, AppMode
from services.agent_app_workspace_service import (
AgentAppWorkspaceService,
AgentWorkspaceInspectorError,
WorkflowAgentWorkspaceService,
)
class _WorkspaceFileDownloadField(fields.Raw):
__schema_type__ = "string"
__schema_format__ = "binary"
class AgentWorkspaceListQuery(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
class AgentWorkspaceFileQuery(BaseModel):
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
class WorkflowAgentWorkspaceListQuery(BaseModel):
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
node_execution_id: str | None = Field(
default=None,
description=(
"Optional workflow node execution ID. When omitted, the latest active session for the node is used."
),
)
class WorkflowAgentWorkspaceFileQuery(BaseModel):
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
node_execution_id: str | None = Field(
default=None,
description=(
"Optional workflow node execution ID. When omitted, the latest active session for the node is used."
),
)
class WorkspaceFileEntryResponse(ResponseModel):
name: str
type: Literal["file", "dir", "symlink"]
size: int
mtime: int
class WorkspaceListResponse(ResponseModel):
path: str
entries: list[WorkspaceFileEntryResponse] = Field(default_factory=list)
truncated: bool = False
class WorkspacePreviewResponse(ResponseModel):
path: str
size: int
truncated: bool
binary: bool
text: str | None = None
register_response_schema_models(console_ns, WorkspaceListResponse)
register_response_schema_models(console_ns, WorkspacePreviewResponse)
def _handle(exc: Exception) -> tuple[dict[str, object], int]:
if isinstance(exc, AgentWorkspaceInspectorError):
return {"code": exc.code, "message": exc.message}, exc.status_code
if isinstance(exc, AgentBackendHTTPError):
detail = exc.detail
if isinstance(detail, dict):
return {
"code": detail.get("code", "agent_backend_error"),
"message": detail.get("message", str(exc)),
}, exc.status_code
return {"code": "agent_backend_error", "message": str(detail)}, exc.status_code
if isinstance(exc, AgentBackendTransportError):
return {"code": "agent_backend_unreachable", "message": str(exc)}, 502
raise exc
def _download_response(result: WorkspaceDownloadResult) -> Response | tuple[dict[str, object], int]:
if result.truncated:
return {
"code": "workspace_file_too_large",
"message": (
"file exceeds the workspace download limit; use preview for partial text or download a smaller file"
),
"size": result.size,
}, 413
filename = result.path.rsplit("/", 1)[-1] or "download"
return Response(
result.content,
mimetype="application/octet-stream",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(len(result.content)),
"X-Workspace-File-Size": str(result.size),
},
)
@console_ns.route("/apps/<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])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App):
query = query_params_from_request(AgentWorkspaceListQuery)
try:
result = AgentAppWorkspaceService().list_files(
tenant_id=tenant_id,
app_id=app_model.id,
conversation_id=query.conversation_id,
path=query.path,
)
except Exception as exc: # normalized to an HTTP response below
return _handle(exc)
return result.model_dump()
@console_ns.route("/apps/<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])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App):
query = query_params_from_request(AgentWorkspaceFileQuery)
try:
result = AgentAppWorkspaceService().preview(
tenant_id=tenant_id,
app_id=app_model.id,
conversation_id=query.conversation_id,
path=query.path,
)
except Exception as exc: # normalized to an HTTP response below
return _handle(exc)
return result.model_dump()
@console_ns.route("/apps/<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])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App):
query = query_params_from_request(AgentWorkspaceFileQuery)
try:
result = AgentAppWorkspaceService().download(
tenant_id=tenant_id,
app_id=app_model.id,
conversation_id=query.conversation_id,
path=query.path,
)
except Exception as exc: # normalized to an HTTP response below
return _handle(exc)
return _download_response(result)
@console_ns.route(
"/apps/<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])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
query = query_params_from_request(WorkflowAgentWorkspaceListQuery)
try:
result = WorkflowAgentWorkspaceService().list_files(
tenant_id=tenant_id,
app_id=app_model.id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=query.node_execution_id,
path=query.path,
)
except Exception as exc: # normalized to an HTTP response below
return _handle(exc)
return result.model_dump()
@console_ns.route(
"/apps/<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])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
query = query_params_from_request(WorkflowAgentWorkspaceFileQuery)
try:
result = WorkflowAgentWorkspaceService().preview(
tenant_id=tenant_id,
app_id=app_model.id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=query.node_execution_id,
path=query.path,
)
except Exception as exc: # normalized to an HTTP response below
return _handle(exc)
return result.model_dump()
@console_ns.route(
"/apps/<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])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
query = query_params_from_request(WorkflowAgentWorkspaceFileQuery)
try:
result = WorkflowAgentWorkspaceService().download(
tenant_id=tenant_id,
app_id=app_model.id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=query.node_execution_id,
path=query.path,
)
except Exception as exc: # normalized to an HTTP response below
return _handle(exc)
return _download_response(result)

View File

@ -23,6 +23,7 @@ from clients.agent_backend import (
AgentBackendRunEventAdapter,
AgentBackendRunSucceededInternalEvent,
AgentBackendStreamInternalEvent,
extract_runtime_layer_specs,
)
from core.app.apps.agent_app.runtime_request_builder import (
AgentAppRuntimeBuildContext,
@ -127,7 +128,12 @@ class AgentAppRunner:
answer = self._extract_answer(terminal.output)
self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer)
self._save_session(scope=scope, backend_run_id=terminal.run_id, snapshot=terminal.session_snapshot)
self._save_session(
scope=scope,
backend_run_id=terminal.run_id,
snapshot=terminal.session_snapshot,
runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition),
)
def _consume_stream(self, run_id: str, *, queue_manager: AppQueueManager):
terminal = None
@ -165,9 +171,21 @@ class AgentAppRunner:
# task pipeline streams the chunk over SSE and persists the message.
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=answer)
def _save_session(self, *, scope: AgentAppSessionScope, backend_run_id: str, snapshot: Any) -> None:
def _save_session(
self,
*,
scope: AgentAppSessionScope,
backend_run_id: str,
snapshot: Any,
runtime_layer_specs: Any,
) -> None:
try:
self._session_store.save_active_snapshot(scope=scope, backend_run_id=backend_run_id, snapshot=snapshot)
self._session_store.save_active_snapshot(
scope=scope,
backend_run_id=backend_run_id,
snapshot=snapshot,
runtime_layer_specs=runtime_layer_specs,
)
except Exception:
logger.warning(
"Failed to persist Agent App conversation session snapshot: "

View File

@ -9,9 +9,11 @@ phase-2 concern and not modeled here.
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol import RuntimeLayerSpec
from pydantic import TypeAdapter
from sqlalchemy import select
from core.db.session_factory import session_factory
@ -22,6 +24,18 @@ from models.agent import (
AgentRuntimeSessionStatus,
)
_RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec])
def _serialize_runtime_layer_specs(specs: list[RuntimeLayerSpec]) -> str:
return _RUNTIME_LAYER_SPECS_ADAPTER.dump_json(specs).decode()
def _deserialize_runtime_layer_specs(value: str | None) -> list[RuntimeLayerSpec]:
if not value:
return []
return _RUNTIME_LAYER_SPECS_ADAPTER.validate_json(value)
@dataclass(frozen=True, slots=True)
class AgentAppSessionScope:
@ -34,24 +48,47 @@ class AgentAppSessionScope:
agent_config_snapshot_id: str
@dataclass(frozen=True, slots=True)
class StoredAgentAppSession:
"""Persisted Agent App conversation session with reusable runtime specs."""
scope: AgentAppSessionScope
session_snapshot: CompositorSessionSnapshot
backend_run_id: str | None
runtime_layer_specs: list[RuntimeLayerSpec] = field(default_factory=list)
class AgentAppRuntimeSessionStore:
"""Persists Agent backend session snapshots for Agent App conversations."""
def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None:
stored = self.load_active_session(scope)
return stored.session_snapshot if stored is not None else None
def load_active_session(self, scope: AgentAppSessionScope) -> StoredAgentAppSession | None:
with session_factory.create_session() as session:
row = session.scalar(self._active_stmt(scope))
if row is None:
return None
return CompositorSessionSnapshot.model_validate_json(row.session_snapshot)
return StoredAgentAppSession(
scope=scope,
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
backend_run_id=row.backend_run_id,
runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs),
)
def load_active_snapshot_for_conversation(
def load_active_session_for_conversation(
self, *, tenant_id: str, app_id: str, conversation_id: str
) -> CompositorSessionSnapshot | None:
"""Load a conversation's active snapshot without the agent/config scope.
) -> StoredAgentAppSession | None:
"""Load the latest ACTIVE session for one conversation-level sandbox lookup.
One Agent App conversation maps to one active session, so the workspace
inspector can resolve it from the conversation alone (it does not know
which agent config version a past turn ran under).
Sandbox inspection only knows the product locator
``tenant_id + app_id + conversation_id``; it does not know which
``agent_id`` or Agent Soul snapshot produced the active shell session.
This method therefore resolves the newest ACTIVE conversation-owned row
for that conversation and returns both the resumable snapshot and the
persisted non-sensitive runtime layer specs needed to build a
``SandboxLocator``.
"""
stmt = (
select(AgentRuntimeSession)
@ -68,7 +105,18 @@ class AgentAppRuntimeSessionStore:
row = session.scalar(stmt)
if row is None:
return None
return CompositorSessionSnapshot.model_validate_json(row.session_snapshot)
return StoredAgentAppSession(
scope=AgentAppSessionScope(
tenant_id=row.tenant_id,
app_id=row.app_id,
conversation_id=row.conversation_id or "",
agent_id=row.agent_id,
agent_config_snapshot_id=row.agent_config_snapshot_id or "",
),
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
backend_run_id=row.backend_run_id,
runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs),
)
def save_active_snapshot(
self,
@ -76,10 +124,12 @@ class AgentAppRuntimeSessionStore:
scope: AgentAppSessionScope,
backend_run_id: str,
snapshot: CompositorSessionSnapshot | None,
runtime_layer_specs: list[RuntimeLayerSpec],
) -> None:
if snapshot is None:
return
snapshot_json = snapshot.model_dump_json()
runtime_layer_specs_json = _serialize_runtime_layer_specs(runtime_layer_specs)
with session_factory.create_session() as session:
row = session.scalar(self._scope_stmt(scope))
if row is None:
@ -92,13 +142,14 @@ class AgentAppRuntimeSessionStore:
conversation_id=scope.conversation_id,
backend_run_id=backend_run_id,
session_snapshot=snapshot_json,
composition_layer_specs="[]",
composition_layer_specs=runtime_layer_specs_json,
status=AgentRuntimeSessionStatus.ACTIVE,
)
session.add(row)
else:
row.backend_run_id = backend_run_id
row.session_snapshot = snapshot_json
row.composition_layer_specs = runtime_layer_specs_json
row.status = AgentRuntimeSessionStatus.ACTIVE
row.cleaned_at = None
session.flush()
@ -143,4 +194,4 @@ class AgentAppRuntimeSessionStore:
return cls._scope_stmt(scope).where(AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE)
__all__ = ["AgentAppRuntimeSessionStore", "AgentAppSessionScope"]
__all__ = ["AgentAppRuntimeSessionStore", "AgentAppSessionScope", "StoredAgentAppSession"]

View File

@ -20,8 +20,8 @@ from clients.agent_backend import (
AgentBackendStreamInternalEvent,
AgentBackendTransportError,
AgentBackendValidationError,
CleanupLayerSpec,
extract_cleanup_layer_specs,
RuntimeLayerSpec,
extract_runtime_layer_specs,
)
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
from core.workflow.system_variables import SystemVariableKey, get_system_text
@ -255,7 +255,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
session_scope=session_scope,
backend_run_id=terminal_event.run_id,
snapshot=terminal_event.session_snapshot,
composition_layer_specs=extract_cleanup_layer_specs(runtime_request.request.composition),
runtime_layer_specs=extract_runtime_layer_specs(runtime_request.request.composition),
metadata=metadata,
)
yield PauseRequestedEvent(
@ -293,7 +293,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
session_scope=session_scope,
backend_run_id=terminal_event.run_id,
snapshot=terminal_event.session_snapshot,
composition_layer_specs=extract_cleanup_layer_specs(runtime_request.request.composition),
runtime_layer_specs=extract_runtime_layer_specs(runtime_request.request.composition),
metadata=metadata,
)
@ -454,7 +454,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
session_scope: WorkflowAgentSessionScope,
backend_run_id: str,
snapshot: CompositorSessionSnapshot | None,
composition_layer_specs: list[CleanupLayerSpec],
runtime_layer_specs: list[RuntimeLayerSpec],
metadata: dict[str, Any],
) -> None:
if self._session_store is None:
@ -464,7 +464,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
scope=session_scope,
backend_run_id=backend_run_id,
snapshot=snapshot,
composition_layer_specs=composition_layer_specs,
runtime_layer_specs=runtime_layer_specs,
)
agent_backend = dict(metadata.get("agent_backend") or {})
agent_backend["session_snapshot_persisted"] = snapshot is not None

View File

@ -40,7 +40,7 @@ class WorkflowAgentSessionCleanupLayer(GraphEngineLayer):
the composition, the run fails asynchronously with ``run_failed`` but
the initial ``POST /runs`` already returned 202, so the API side has no
visibility of the failure unless it waits for terminal status. The
``composition_layer_specs`` persistence in A.1A.4 plus the
``runtime_layer_specs`` persistence in A.1A.4 plus the
``_filter_snapshot_to_specs`` shape in ``build_cleanup_request`` keeps
the two name lists in sync.
@ -144,13 +144,13 @@ class WorkflowAgentSessionCleanupLayer(GraphEngineLayer):
)
return
if not stored_session.composition_layer_specs:
if not stored_session.runtime_layer_specs:
# Sessions persisted before A.1 landed do not carry the spec list,
# so we cannot replay a valid cleanup composition. Leave the row
# ACTIVE and warn so the absence shows up in observability rather
# than being silently swallowed by a doomed cleanup run.
logger.warning(
"Skipping Agent backend cleanup: no composition_layer_specs persisted. "
"Skipping Agent backend cleanup: no runtime_layer_specs persisted. "
"workflow_run_id=%s node_id=%s agent_id=%s",
scope.workflow_run_id,
scope.node_id,
@ -160,7 +160,7 @@ class WorkflowAgentSessionCleanupLayer(GraphEngineLayer):
request = self._request_builder.build_cleanup_request(
session_snapshot=stored_session.session_snapshot,
composition_layer_specs=stored_session.composition_layer_specs,
runtime_layer_specs=stored_session.runtime_layer_specs,
idempotency_key=f"{scope.workflow_run_id}:{scope.node_id}:{scope.binding_id}:agent-session-cleanup",
metadata={
"tenant_id": scope.tenant_id,

View File

@ -3,10 +3,10 @@ from __future__ import annotations
from dataclasses import dataclass, field
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol import RuntimeLayerSpec
from pydantic import TypeAdapter
from sqlalchemy import select
from clients.agent_backend.request_builder import CleanupLayerSpec
from core.db.session_factory import session_factory
from libs.datetime_utils import naive_utc_now
from models.agent import (
@ -15,14 +15,14 @@ from models.agent import (
WorkflowAgentRuntimeSessionStatus,
)
_SPECS_ADAPTER: TypeAdapter[list[CleanupLayerSpec]] = TypeAdapter(list[CleanupLayerSpec])
_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec])
def _serialize_specs(specs: list[CleanupLayerSpec]) -> str:
def _serialize_specs(specs: list[RuntimeLayerSpec]) -> str:
return _SPECS_ADAPTER.dump_json(specs).decode()
def _deserialize_specs(value: str | None) -> list[CleanupLayerSpec]:
def _deserialize_specs(value: str | None) -> list[RuntimeLayerSpec]:
if not value:
return []
return _SPECS_ADAPTER.validate_json(value)
@ -46,7 +46,7 @@ class StoredWorkflowAgentSession:
scope: WorkflowAgentSessionScope
session_snapshot: CompositorSessionSnapshot
backend_run_id: str | None
composition_layer_specs: list[CleanupLayerSpec] = field(default_factory=list)
runtime_layer_specs: list[RuntimeLayerSpec] = field(default_factory=list)
class WorkflowAgentRuntimeSessionStore:
@ -97,7 +97,7 @@ class WorkflowAgentRuntimeSessionStore:
),
session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
backend_run_id=row.backend_run_id,
composition_layer_specs=_deserialize_specs(row.composition_layer_specs),
runtime_layer_specs=_deserialize_specs(row.composition_layer_specs),
)
for row in rows
]
@ -108,13 +108,13 @@ class WorkflowAgentRuntimeSessionStore:
scope: WorkflowAgentSessionScope,
backend_run_id: str,
snapshot: CompositorSessionSnapshot | None,
composition_layer_specs: list[CleanupLayerSpec],
runtime_layer_specs: list[RuntimeLayerSpec],
) -> None:
if scope.workflow_run_id is None or snapshot is None:
return
snapshot_json = snapshot.model_dump_json()
specs_json = _serialize_specs(composition_layer_specs)
specs_json = _serialize_specs(runtime_layer_specs)
with session_factory.create_session() as session:
row = session.scalar(
select(WorkflowAgentRuntimeSession).where(

View File

@ -372,10 +372,9 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base):
node_execution_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
binding_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
agent_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
# JSON-encoded list of cleanup layer specs ({name, type, deps, config}).
# Drives Agent backend cleanup-only runs: the agenton compositor rejects a
# session snapshot whose layer names do not match the cleanup composition,
# so we replay the same layer graph (minus credential-bearing plugin layers).
# JSON-encoded list of non-sensitive runtime layer specs ({name, type, deps,
# config}). The persisted schema keeps its original name because the sandbox
# refactor intentionally avoids a storage migration.
composition_layer_specs: Mapped[str] = mapped_column(LongText, nullable=False, server_default="[]")
# Conversation-owner column (NULL for workflow owner).
conversation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)

View File

@ -1103,12 +1103,12 @@ List workflow apps that reference this Agent App's bound Agent (read-only)
| 200 | Referencing workflows listed successfully | [AgentReferencingWorkflowsResponse](#agentreferencingworkflowsresponse) |
| 404 | App not found | |
### /apps/{app_id}/agent-workspace/files
### /apps/{app_id}/agent-sandbox/files
#### GET
##### Description
List a directory in an Agent App conversation's sandbox workspace (read-only)
List a directory in an Agent App conversation sandbox
##### Parameters
@ -1122,14 +1122,14 @@ List a directory in an Agent App conversation's sandbox workspace (read-only)
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Listing returned | [WorkspaceListResponse](#workspacelistresponse) |
| 200 | Listing returned | [SandboxListResponse](#sandboxlistresponse) |
### /apps/{app_id}/agent-workspace/files/download
### /apps/{app_id}/agent-sandbox/files/read
#### GET
##### Description
Download a file from an Agent App conversation's sandbox workspace (read-only)
Read a text/binary preview file in an Agent App conversation sandbox
##### Parameters
@ -1143,29 +1143,27 @@ Download a file from an Agent App conversation's sandbox workspace (read-only)
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | File bytes | binary |
| 413 | File exceeds the workspace download limit | |
| 200 | Preview returned | [SandboxReadResponse](#sandboxreadresponse) |
### /apps/{app_id}/agent-workspace/files/preview
### /apps/{app_id}/agent-sandbox/files/upload
#### GET
#### POST
##### Description
Preview a text/binary file in an Agent App conversation's sandbox workspace
Upload one Agent App sandbox file as a Dify ToolFile mapping
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string |
| conversation_id | query | Agent App conversation ID | Yes | string |
| path | query | File path relative to the sandbox workspace | Yes | string |
| app_id | path | | Yes | string |
| payload | body | | Yes | [AgentSandboxUploadPayload](#agentsandboxuploadpayload) |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Preview returned | [WorkspacePreviewResponse](#workspacepreviewresponse) |
| 200 | Uploaded | [SandboxUploadResponse](#sandboxuploadresponse) |
### /apps/{app_id}/agent/logs
@ -2737,12 +2735,12 @@ Get workflow run node execution list
| 200 | Node executions retrieved successfully | [WorkflowRunNodeExecutionListResponse](#workflowrunnodeexecutionlistresponse) |
| 404 | Workflow run not found | |
### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files
### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files
#### GET
##### Description
List a directory in a Workflow Agent node's sandbox workspace (read-only)
List a directory in a workflow Agent node sandbox
##### Parameters
@ -2758,14 +2756,14 @@ List a directory in a Workflow Agent node's sandbox workspace (read-only)
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Listing returned | [WorkspaceListResponse](#workspacelistresponse) |
| 200 | Listing returned | [SandboxListResponse](#sandboxlistresponse) |
### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/download
### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/read
#### GET
##### Description
Download a file from a Workflow Agent node's sandbox workspace (read-only)
Read a text/binary preview file in a workflow Agent node sandbox
##### Parameters
@ -2781,31 +2779,29 @@ Download a file from a Workflow Agent node's sandbox workspace (read-only)
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | File bytes | binary |
| 413 | File exceeds the workspace download limit | |
| 200 | Preview returned | [SandboxReadResponse](#sandboxreadresponse) |
### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/preview
### /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/upload
#### GET
#### POST
##### Description
Preview a text/binary file in a Workflow Agent node's sandbox workspace
Upload one workflow Agent sandbox file as a Dify ToolFile mapping
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string |
| node_id | path | Workflow Agent node ID | Yes | string |
| workflow_run_id | path | Workflow run ID | Yes | string |
| node_execution_id | query | Optional workflow node execution ID. When omitted, the latest active session for the node is used. | No | string |
| path | query | File path relative to the sandbox workspace | Yes | string |
| app_id | path | | Yes | string |
| node_id | path | | Yes | string |
| workflow_run_id | path | | Yes | string |
| payload | body | | Yes | [WorkflowAgentSandboxUploadPayload](#workflowagentsandboxuploadpayload) |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Preview returned | [WorkspacePreviewResponse](#workspacepreviewresponse) |
| 200 | Uploaded | [SandboxUploadResponse](#sandboxuploadresponse) |
### /apps/{app_id}/workflow/comments
@ -12320,6 +12316,13 @@ the current roster/workflow APIs scoped to Dify Agent.
| image | string | | No |
| working_dir | string | | No |
#### AgentSandboxUploadPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| conversation_id | string | Agent App conversation ID | Yes |
| path | string | File path relative to the sandbox workspace | Yes |
#### AgentScope
Visibility and lifecycle scope of an Agent record.
@ -16598,6 +16601,47 @@ Payload for publishing snippet workflow.
| instruction | string | Structured output generation instruction | Yes |
| model_config | [ModelConfig](#modelconfig) | Model configuration | Yes |
#### SandboxFileEntryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| mtime | integer | | No |
| name | string | | Yes |
| size | integer | | No |
| type | string | *Enum:* `"dir"`, `"file"`, `"other"`, `"symlink"` | Yes |
#### SandboxListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| entries | [ [SandboxFileEntryResponse](#sandboxfileentryresponse) ] | | No |
| path | string | | Yes |
| truncated | boolean | | No |
#### SandboxReadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| binary | boolean | | Yes |
| path | string | | Yes |
| size | integer | | No |
| text | string | | No |
| truncated | boolean | | Yes |
#### SandboxToolFileResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| reference | string | | Yes |
| transfer_method | string | | No |
#### SandboxUploadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| file | [SandboxToolFileResponse](#sandboxtoolfileresponse) | | Yes |
| path | string | | Yes |
#### SavedMessageCreatePayload
| Name | Type | Description | Required |
@ -17641,6 +17685,13 @@ How a workflow node is bound to an Agent.
| variant | string | | Yes |
| workflow_id | string | | No |
#### WorkflowAgentSandboxUploadPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| node_execution_id | string | Optional workflow node execution ID. When omitted, the latest active session for the node is used. | No |
| path | string | File path relative to the sandbox workspace | Yes |
#### WorkflowAppLogPaginationResponse
| Name | Type | Description | Required |
@ -18351,15 +18402,6 @@ Workflow tool configuration
| remove_webapp_brand | boolean | | No |
| replace_webapp_logo | string | | No |
#### WorkspaceFileEntryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| mtime | integer | | Yes |
| name | string | | Yes |
| size | integer | | Yes |
| type | string | *Enum:* `"dir"`, `"file"`, `"symlink"` | Yes |
#### WorkspaceInfoPayload
| Name | Type | Description | Required |
@ -18373,14 +18415,6 @@ Workflow tool configuration
| limit | integer | | No |
| page | integer | | No |
#### WorkspaceListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| entries | [ [WorkspaceFileEntryResponse](#workspacefileentryresponse) ] | | No |
| path | string | | Yes |
| truncated | boolean | | No |
#### WorkspacePermissionResponse
| Name | Type | Description | Required |
@ -18389,16 +18423,6 @@ Workflow tool configuration
| allow_owner_transfer | boolean | | Yes |
| workspace_id | string | | Yes |
#### WorkspacePreviewResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| binary | boolean | | Yes |
| path | string | | Yes |
| size | integer | | Yes |
| text | string | | No |
| truncated | boolean | | Yes |
#### _AnonymousInlineModel_7b67ac8a4db8
| Name | Type | Description | Required |

View File

@ -0,0 +1,225 @@
"""Resolve and proxy sandbox file access for Agent App and workflow Agent sessions.
These services keep product-facing locators (conversation, workflow run, node)
on the API boundary and translate them into the agent backend's
``SandboxLocator`` using persisted non-sensitive runtime layer specs plus the
saved Agenton session snapshot.
"""
from __future__ import annotations
from collections.abc import Callable
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.client import Client
from dify_agent.protocol import RuntimeLayerSpec, SandboxLocator, build_sandbox_locator_from_layer_specs
from pydantic import TypeAdapter
from sqlalchemy import select
from configs import dify_config
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
from core.db.session_factory import session_factory
from models.agent import AgentRuntimeSessionOwnerType, WorkflowAgentRuntimeSession, WorkflowAgentRuntimeSessionStatus
_RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec])
class AgentSandboxInspectorError(Exception):
"""A sandbox inspection failure mapped to an HTTP status by the controller."""
code: str
message: str
status_code: int
def __init__(self, code: str, message: str, *, status_code: int = 400) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
class AgentAppSandboxService:
"""List/read/upload files in an Agent App conversation sandbox."""
def __init__(
self,
*,
session_store: AgentAppRuntimeSessionStore | None = None,
client_factory: Callable[[], Client] | None = None,
) -> None:
self._session_store = session_store or AgentAppRuntimeSessionStore()
self._client_factory = client_factory or _default_client_factory
def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str):
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
return self._client_factory().list_sandbox_files_sync(locator, path)
def read_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str):
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
return self._client_factory().read_sandbox_file_sync(locator, path)
def upload_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str):
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
return self._client_factory().upload_sandbox_file_sync(locator, path)
def _resolve_locator(self, *, tenant_id: str, app_id: str, conversation_id: str) -> SandboxLocator:
stored = self._session_store.load_active_session_for_conversation(
tenant_id=tenant_id,
app_id=app_id,
conversation_id=conversation_id,
)
if stored is None:
raise AgentSandboxInspectorError(
"no_active_session",
"this conversation has no active sandbox session yet",
status_code=404,
)
return _build_locator_or_raise(
snapshot=stored.session_snapshot,
runtime_layer_specs=stored.runtime_layer_specs,
not_found_message="this conversation's agent has no sandbox workspace",
)
class WorkflowAgentSandboxService:
"""List/read/upload files in a workflow Agent node sandbox."""
def __init__(self, *, client_factory: Callable[[], Client] | None = None) -> None:
self._client_factory = client_factory or _default_client_factory
def list_files(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
path: str,
):
locator = self._resolve_locator(
tenant_id=tenant_id,
app_id=app_id,
workflow_run_id=workflow_run_id,
node_id=node_id,
node_execution_id=node_execution_id,
)
return self._client_factory().list_sandbox_files_sync(locator, path)
def read_file(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
path: str,
):
locator = self._resolve_locator(
tenant_id=tenant_id,
app_id=app_id,
workflow_run_id=workflow_run_id,
node_id=node_id,
node_execution_id=node_execution_id,
)
return self._client_factory().read_sandbox_file_sync(locator, path)
def upload_file(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
path: str,
):
locator = self._resolve_locator(
tenant_id=tenant_id,
app_id=app_id,
workflow_run_id=workflow_run_id,
node_id=node_id,
node_execution_id=node_execution_id,
)
return self._client_factory().upload_sandbox_file_sync(locator, path)
def _resolve_locator(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
) -> SandboxLocator:
"""Resolve one workflow Agent sandbox from product-facing identifiers.
Callers may target either a specific node execution or the current node
as a whole. When ``node_execution_id`` is provided, lookup narrows to
that execution's ACTIVE runtime-session row. When it is omitted, the
service falls back to the most recently updated ACTIVE session for the
same ``workflow_run_id + node_id`` pair so console sandbox inspection can
still work from the broader workflow/node locator.
"""
stmt = select(WorkflowAgentRuntimeSession).where(
WorkflowAgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.WORKFLOW_RUN,
WorkflowAgentRuntimeSession.tenant_id == tenant_id,
WorkflowAgentRuntimeSession.app_id == app_id,
WorkflowAgentRuntimeSession.workflow_run_id == workflow_run_id,
WorkflowAgentRuntimeSession.node_id == node_id,
WorkflowAgentRuntimeSession.status == WorkflowAgentRuntimeSessionStatus.ACTIVE,
)
if node_execution_id:
stmt = stmt.where(WorkflowAgentRuntimeSession.node_execution_id == node_execution_id)
stmt = stmt.order_by(WorkflowAgentRuntimeSession.updated_at.desc()).limit(1)
with session_factory.create_session() as session:
row = session.scalar(stmt)
if row is None:
raise AgentSandboxInspectorError(
"no_active_session",
"this workflow Agent node has no active sandbox session yet",
status_code=404,
)
return _build_locator_or_raise(
snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot),
runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs),
not_found_message="this workflow Agent node has no sandbox workspace",
)
def _build_locator_or_raise(
*,
snapshot: CompositorSessionSnapshot,
runtime_layer_specs: list[RuntimeLayerSpec],
not_found_message: str,
) -> SandboxLocator:
try:
return build_sandbox_locator_from_layer_specs(
layer_specs=runtime_layer_specs,
session_snapshot=snapshot,
)
except ValueError as exc:
raise AgentSandboxInspectorError("no_sandbox", not_found_message, status_code=404) from exc
def _deserialize_runtime_layer_specs(value: str | None) -> list[RuntimeLayerSpec]:
if not value:
return []
return _RUNTIME_LAYER_SPECS_ADAPTER.validate_json(value)
def _default_client_factory() -> Client:
base_url = dify_config.AGENT_BACKEND_BASE_URL
if not base_url:
raise AgentSandboxInspectorError(
"inspector_unavailable",
"the sandbox file inspector is not available (agent backend not configured)",
status_code=503,
)
return Client(base_url=base_url)
__all__ = ["AgentAppSandboxService", "AgentSandboxInspectorError", "WorkflowAgentSandboxService"]

View File

@ -1,220 +0,0 @@
"""Resolve and proxy read-only access to an Agent App conversation's sandbox.
The Agent App's shell layer runs bash in a per-conversation sandbox workspace on
the agent backend. The workspace identity (``session_id``) is generated inside
the shell layer and rides the conversation's ``session_snapshot``. This service
extracts that id and proxies list/preview/download to the agent backend's
read-only workspace endpoints, so the console can show a "sandbox file system"
inspector without the API ever touching shellctl directly.
"""
from __future__ import annotations
from collections.abc import Callable
from agenton.compositor import CompositorSessionSnapshot
from sqlalchemy import select
from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID
from clients.agent_backend.workspace_files_client import (
WorkspaceDownloadResult,
WorkspaceFilesBackendClient,
WorkspaceListResult,
WorkspacePreviewResult,
)
from configs import dify_config
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
from core.db.session_factory import session_factory
from models.agent import (
AgentRuntimeSessionOwnerType,
WorkflowAgentRuntimeSession,
WorkflowAgentRuntimeSessionStatus,
)
class AgentWorkspaceInspectorError(Exception):
"""A workspace inspection failure mapped to an HTTP status by the controller."""
code: str
message: str
status_code: int
def __init__(self, code: str, message: str, *, status_code: int = 400) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
class AgentAppWorkspaceService:
"""List/preview/download files in an Agent App conversation's sandbox workspace."""
def __init__(
self,
*,
session_store: AgentAppRuntimeSessionStore | None = None,
client_factory: Callable[[], WorkspaceFilesBackendClient] | None = None,
) -> None:
self._session_store = session_store or AgentAppRuntimeSessionStore()
self._client_factory = client_factory or _default_client_factory
def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspaceListResult:
session_id = self._resolve_session_id(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
return self._client_factory().list_files(session_id, path)
def preview(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspacePreviewResult:
session_id = self._resolve_session_id(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
return self._client_factory().preview(session_id, path)
def download(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspaceDownloadResult:
session_id = self._resolve_session_id(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
return self._client_factory().download(session_id, path)
def _resolve_session_id(self, *, tenant_id: str, app_id: str, conversation_id: str) -> str:
snapshot = self._session_store.load_active_snapshot_for_conversation(
tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id
)
if snapshot is None:
raise AgentWorkspaceInspectorError(
"no_active_session",
"this conversation has no active sandbox session yet",
status_code=404,
)
session_id = _shell_session_id(snapshot)
if not session_id:
raise AgentWorkspaceInspectorError(
"no_sandbox",
"this conversation's agent has no sandbox workspace",
status_code=404,
)
return session_id
class WorkflowAgentWorkspaceService:
"""List/preview/download files in a Workflow Agent node sandbox workspace."""
def __init__(
self,
*,
client_factory: Callable[[], WorkspaceFilesBackendClient] | None = None,
) -> None:
self._client_factory = client_factory or _default_client_factory
def list_files(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
path: str,
) -> WorkspaceListResult:
session_id = self._resolve_session_id(
tenant_id=tenant_id,
app_id=app_id,
workflow_run_id=workflow_run_id,
node_id=node_id,
node_execution_id=node_execution_id,
)
return self._client_factory().list_files(session_id, path)
def preview(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
path: str,
) -> WorkspacePreviewResult:
session_id = self._resolve_session_id(
tenant_id=tenant_id,
app_id=app_id,
workflow_run_id=workflow_run_id,
node_id=node_id,
node_execution_id=node_execution_id,
)
return self._client_factory().preview(session_id, path)
def download(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
path: str,
) -> WorkspaceDownloadResult:
session_id = self._resolve_session_id(
tenant_id=tenant_id,
app_id=app_id,
workflow_run_id=workflow_run_id,
node_id=node_id,
node_execution_id=node_execution_id,
)
return self._client_factory().download(session_id, path)
def _resolve_session_id(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
) -> str:
stmt = select(WorkflowAgentRuntimeSession).where(
WorkflowAgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.WORKFLOW_RUN,
WorkflowAgentRuntimeSession.tenant_id == tenant_id,
WorkflowAgentRuntimeSession.app_id == app_id,
WorkflowAgentRuntimeSession.workflow_run_id == workflow_run_id,
WorkflowAgentRuntimeSession.node_id == node_id,
WorkflowAgentRuntimeSession.status == WorkflowAgentRuntimeSessionStatus.ACTIVE,
)
if node_execution_id:
stmt = stmt.where(WorkflowAgentRuntimeSession.node_execution_id == node_execution_id)
stmt = stmt.order_by(WorkflowAgentRuntimeSession.updated_at.desc()).limit(1)
with session_factory.create_session() as session:
row = session.scalar(stmt)
if row is None:
raise AgentWorkspaceInspectorError(
"no_active_session",
"this workflow Agent node has no active sandbox session yet",
status_code=404,
)
snapshot = CompositorSessionSnapshot.model_validate_json(row.session_snapshot)
session_id = _shell_session_id(snapshot)
if not session_id:
raise AgentWorkspaceInspectorError(
"no_sandbox",
"this workflow Agent node has no sandbox workspace",
status_code=404,
)
return session_id
def _shell_session_id(snapshot: CompositorSessionSnapshot) -> str | None:
for layer in snapshot.layers:
if layer.name == DIFY_SHELL_LAYER_ID:
session_id = layer.runtime_state.get("session_id")
return session_id if isinstance(session_id, str) and session_id else None
return None
def _default_client_factory() -> WorkspaceFilesBackendClient:
base_url = dify_config.AGENT_BACKEND_BASE_URL
if not base_url:
raise AgentWorkspaceInspectorError(
"inspector_unavailable",
"the sandbox file inspector is not available (agent backend not configured)",
status_code=503,
)
return WorkspaceFilesBackendClient(base_url)
__all__ = ["AgentAppWorkspaceService", "AgentWorkspaceInspectorError", "WorkflowAgentWorkspaceService"]

View File

@ -20,7 +20,7 @@ from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID
from agenton_collections.layers.plain.basic import PromptLayer
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, PydanticAIHistoryLayer
from clients.agent_backend import AgentBackendRunRequestBuilder, CleanupLayerSpec
from clients.agent_backend import AgentBackendRunRequestBuilder, RuntimeLayerSpec
def test_cleanup_request_passes_agenton_snapshot_validation():
@ -35,12 +35,12 @@ def test_cleanup_request_passes_agenton_snapshot_validation():
# which is purely structural and does not depend on which non-plugin layer
# types appear.
persisted_specs = [
CleanupLayerSpec(
RuntimeLayerSpec(
name="workflow_node_job_prompt",
type=PLAIN_PROMPT_LAYER_TYPE_ID,
config={"prefix": "Do the cleanup."},
),
CleanupLayerSpec(name="history", type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID),
RuntimeLayerSpec(name="history", type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID),
]
# Saved snapshot still carries the LLM layer entry — cleanup's
# ``_filter_snapshot_to_specs`` must drop it so names match.
@ -66,7 +66,7 @@ def test_cleanup_request_passes_agenton_snapshot_validation():
cleanup_request = AgentBackendRunRequestBuilder().build_cleanup_request(
session_snapshot=full_snapshot,
composition_layer_specs=persisted_specs,
runtime_layer_specs=persisted_specs,
)
# Drive the real agenton compositor through ``from_config`` + ``_create_run``

View File

@ -36,7 +36,8 @@ from clients.agent_backend import (
AgentBackendOutputConfig,
AgentBackendRunRequestBuilder,
AgentBackendWorkflowNodeRunInput,
CleanupLayerSpec,
RuntimeLayerSpec,
extract_runtime_layer_specs,
redact_for_agent_backend_log,
)
from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID
@ -173,11 +174,11 @@ def test_request_builder_builds_cleanup_request_replays_persisted_layer_specs():
LayerSessionSnapshot(name="llm", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}),
]
)
specs = [CleanupLayerSpec(name="history", type="pydantic_ai.history")]
specs = [RuntimeLayerSpec(name="history", type="pydantic_ai.history")]
request = AgentBackendRunRequestBuilder().build_cleanup_request(
session_snapshot=session_snapshot,
composition_layer_specs=specs,
runtime_layer_specs=specs,
idempotency_key="run-1:node-1:binding-1:agent-session-cleanup",
metadata={"workflow_run_id": "run-1"},
)
@ -190,21 +191,19 @@ def test_request_builder_builds_cleanup_request_replays_persisted_layer_specs():
assert request.metadata["agent_backend_lifecycle"] == "session_cleanup"
def test_request_builder_rejects_empty_composition_layer_specs():
def test_request_builder_rejects_empty_runtime_layer_specs():
"""Empty specs would put us back in the original ``layers=[]`` trap that
fails on agenton's snapshot-vs-composition validation."""
with pytest.raises(ValueError, match="composition_layer_specs"):
with pytest.raises(ValueError, match="runtime_layer_specs"):
AgentBackendRunRequestBuilder().build_cleanup_request(
session_snapshot=CompositorSessionSnapshot(layers=[]),
composition_layer_specs=[],
runtime_layer_specs=[],
)
def test_extract_cleanup_layer_specs_drops_plugin_layers_keeps_configs():
def test_extract_runtime_layer_specs_drops_plugin_layers_keeps_configs():
from dify_agent.protocol import RunComposition, RunLayerSpec
from clients.agent_backend import extract_cleanup_layer_specs
composition = RunComposition(
layers=[
RunLayerSpec(
@ -228,7 +227,7 @@ def test_extract_cleanup_layer_specs_drops_plugin_layers_keeps_configs():
]
)
specs = extract_cleanup_layer_specs(composition)
specs = extract_runtime_layer_specs(composition)
assert [spec.name for spec in specs] == ["agent_soul_prompt", "history"]
# Non-plugin configs are dumped as JSON-compatible dicts so the persisted

View File

@ -1,152 +0,0 @@
"""Unit tests for the API-side workspace files backend client."""
from __future__ import annotations
import base64
import json
from collections.abc import Callable
import httpx
import pytest
from clients.agent_backend.errors import AgentBackendHTTPError, AgentBackendTransportError
from clients.agent_backend.workspace_files_client import WorkspaceFilesBackendClient
def _client(handler: Callable[[httpx.Request], httpx.Response]) -> WorkspaceFilesBackendClient:
return WorkspaceFilesBackendClient("http://backend", transport=httpx.MockTransport(handler))
def test_list_files_parses_entries() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/workspaces/abc1234/files"
assert request.url.params.get("path") == "sub"
return httpx.Response(
200,
json={
"path": "sub",
"entries": [{"name": "a.txt", "type": "file", "size": 3, "mtime": 10}],
"truncated": False,
},
)
result = _client(handler).list_files("abc1234", "sub")
assert result.path == "sub"
assert result.entries[0].name == "a.txt"
assert result.entries[0].type == "file"
assert result.truncated is False
def test_preview_parses_text() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/workspaces/abc1234/files/preview"
return httpx.Response(
200, json={"path": "n.txt", "size": 5, "truncated": False, "binary": False, "text": "hello"}
)
result = _client(handler).preview("abc1234", "n.txt")
assert result.binary is False
assert result.text == "hello"
def test_download_decodes_base64_to_bytes() -> None:
raw = bytes(range(64))
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/workspaces/abc1234/files/download"
return httpx.Response(
200,
json={
"path": "b.bin",
"size": len(raw),
"truncated": False,
"content_base64": base64.b64encode(raw).decode(),
},
)
result = _client(handler).download("abc1234", "b.bin")
assert result.content == raw
assert result.size == 64
def test_http_error_preserves_status_and_detail() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(404, json={"detail": {"code": "not_found", "message": "path not found in workspace"}})
with pytest.raises(AgentBackendHTTPError) as exc_info:
_client(handler).list_files("abc1234", "missing")
assert exc_info.value.status_code == 404
assert exc_info.value.detail == {"code": "not_found", "message": "path not found in workspace"}
def test_http_error_with_non_json_body_uses_response_text() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="backend exploded")
with pytest.raises(AgentBackendHTTPError) as exc_info:
_client(handler).preview("abc1234", "note.txt")
assert exc_info.value.status_code == 500
assert exc_info.value.detail == "backend exploded"
def test_transport_failure_becomes_transport_error() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("connection refused")
with pytest.raises(AgentBackendTransportError):
_client(handler).list_files("abc1234", ".")
def test_download_without_content_is_502() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"path": "b.bin", "size": 0, "truncated": False})
with pytest.raises(AgentBackendHTTPError) as exc_info:
_client(handler).download("abc1234", "b.bin")
assert exc_info.value.status_code == 502
def test_download_with_invalid_base64_is_502() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"path": "b.bin", "size": 3, "truncated": False, "content_base64": "not-@@@"})
with pytest.raises(AgentBackendHTTPError) as exc_info:
_client(handler).download("abc1234", "b.bin")
assert exc_info.value.status_code == 502
def test_download_uses_decoded_size_when_backend_size_is_invalid() -> None:
raw = b"abc"
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"path": "b.bin",
"size": "unknown",
"truncated": True,
"content_base64": base64.b64encode(raw).decode(),
},
)
result = _client(handler).download("abc1234", "b.bin")
assert result.size == len(raw)
assert result.truncated is True
def test_non_object_body_is_502() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=json.dumps([1, 2, 3]), headers={"content-type": "application/json"})
with pytest.raises(AgentBackendHTTPError) as exc_info:
_client(handler).list_files("abc1234", ".")
assert exc_info.value.status_code == 502

View File

@ -0,0 +1,196 @@
from __future__ import annotations
from inspect import unwrap
from types import SimpleNamespace
import pytest
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
from dify_agent.protocol import SandboxListResponse, SandboxReadResponse, SandboxUploadResponse
from controllers.console import agent_app_sandbox as module
from models.model import App, AppMode, IconType
from services.agent_app_sandbox_service import AgentSandboxInspectorError
class _AgentAppService:
def __init__(self) -> None:
self.calls: list[tuple[str, str, str, str, str]] = []
def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> SandboxListResponse:
self.calls.append(("list", tenant_id, app_id, conversation_id, path))
return SandboxListResponse(path=path, entries=[], truncated=False)
def read_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> SandboxReadResponse:
self.calls.append(("read", tenant_id, app_id, conversation_id, path))
return SandboxReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
def upload_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> SandboxUploadResponse:
self.calls.append(("upload", tenant_id, app_id, conversation_id, path))
return SandboxUploadResponse(
path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}
)
class _WorkflowService:
def __init__(self) -> None:
self.calls: list[tuple[str, str, str, str, str, str | None, str]] = []
def list_files(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
path: str,
) -> SandboxListResponse:
self.calls.append(("list", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path))
return SandboxListResponse(path=path, entries=[], truncated=False)
def read_file(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
path: str,
) -> SandboxReadResponse:
self.calls.append(("read", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path))
return SandboxReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
def upload_file(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
path: str,
) -> SandboxUploadResponse:
self.calls.append(("upload", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path))
return SandboxUploadResponse(
path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}
)
def _app_model(app_id: str = "app-1") -> App:
return App(
id=app_id,
tenant_id="tenant-1",
name="App",
mode=AppMode.AGENT,
icon_type=IconType.EMOJI,
icon="bot",
icon_background="#fff",
enable_site=False,
enable_api=False,
)
def test_handle_maps_sandbox_and_agent_backend_errors() -> None:
assert module._handle(AgentSandboxInspectorError("no_sandbox", "no sandbox", status_code=404)) == (
{"code": "no_sandbox", "message": "no sandbox"},
404,
)
assert module._handle(DifyAgentHTTPError(404, {"code": "sandbox_path_not_found", "message": "missing"})) == (
{"code": "sandbox_path_not_found", "message": "missing"},
404,
)
assert module._handle(DifyAgentHTTPError(500, "backend exploded")) == (
{"code": "agent_backend_error", "message": "backend exploded"},
500,
)
assert module._handle(DifyAgentTimeoutError("connection refused")) == (
{"code": "agent_backend_unreachable", "message": "connection refused"},
502,
)
assert module._handle(DifyAgentClientError("transport failed")) == (
{"code": "agent_backend_unreachable", "message": "transport failed"},
502,
)
with pytest.raises(RuntimeError):
module._handle(RuntimeError("boom"))
def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None:
service = _AgentAppService()
monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service)
monkeypatch.setattr(
module,
"query_params_from_request",
lambda model: SimpleNamespace(conversation_id="conv-1", path="sub/report.txt"),
)
monkeypatch.setattr(
module,
"request",
SimpleNamespace(get_json=lambda silent=True: {"conversation_id": "conv-1", "path": "report.txt"}),
)
app_model = _app_model()
listing = unwrap(module.AgentAppSandboxListResource.get)(object(), "tenant-1", app_model)
preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), "tenant-1", app_model)
upload = unwrap(module.AgentAppSandboxUploadResource.post)(object(), "tenant-1", app_model)
assert listing["path"] == "sub/report.txt"
assert preview["text"] == "hello"
assert upload["file"]["reference"] == "dify-file-ref:file-1"
assert service.calls == [
("list", "tenant-1", "app-1", "conv-1", "sub/report.txt"),
("read", "tenant-1", "app-1", "conv-1", "sub/report.txt"),
("upload", "tenant-1", "app-1", "conv-1", "report.txt"),
]
def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytest.MonkeyPatch) -> None:
class FailingService:
def list_files(self, **kwargs):
raise AgentSandboxInspectorError("no_active_session", "no active session", status_code=404)
monkeypatch.setattr(module, "AgentAppSandboxService", FailingService)
monkeypatch.setattr(
module, "query_params_from_request", lambda model: SimpleNamespace(conversation_id="conv-1", path=".")
)
assert unwrap(module.AgentAppSandboxListResource.get)(object(), "tenant-1", _app_model()) == (
{"code": "no_active_session", "message": "no active session"},
404,
)
def test_workflow_agent_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None:
service = _WorkflowService()
monkeypatch.setattr(module, "WorkflowAgentSandboxService", lambda: service)
monkeypatch.setattr(
module,
"query_params_from_request",
lambda model: SimpleNamespace(node_execution_id="exec-1", path="out.txt"),
)
monkeypatch.setattr(
module,
"request",
SimpleNamespace(get_json=lambda silent=True: {"node_execution_id": "exec-1", "path": "upload.txt"}),
)
app_model = _app_model()
listing = unwrap(module.WorkflowAgentSandboxListResource.get)(
object(), "tenant-1", app_model, "run-1", "agent-node"
)
preview = unwrap(module.WorkflowAgentSandboxReadResource.get)(
object(), "tenant-1", app_model, "run-1", "agent-node"
)
upload = unwrap(module.WorkflowAgentSandboxUploadResource.post)(
object(), "tenant-1", app_model, "run-1", "agent-node"
)
assert listing["path"] == "out.txt"
assert preview["text"] == "hello"
assert upload["file"]["reference"] == "dify-file-ref:file-1"
assert service.calls == [
("list", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"),
("read", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"),
("upload", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "upload.txt"),
]

View File

@ -1,216 +0,0 @@
from __future__ import annotations
from inspect import unwrap
from types import SimpleNamespace
from typing import cast
import pytest
from flask import Response
from clients.agent_backend.errors import AgentBackendHTTPError, AgentBackendTransportError
from clients.agent_backend.workspace_files_client import (
WorkspaceDownloadResult,
WorkspaceFileEntry,
WorkspaceListResult,
WorkspacePreviewResult,
)
from controllers.console import agent_app_workspace as module
from models.model import App, AppMode, IconType
from services.agent_app_workspace_service import AgentWorkspaceInspectorError
class _AgentAppService:
def __init__(self) -> None:
self.calls: list[tuple[str, str, str, str, str]] = []
def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspaceListResult:
self.calls.append(("list", tenant_id, app_id, conversation_id, path))
return WorkspaceListResult(
path=path,
entries=[WorkspaceFileEntry(name="a.txt", type="file", size=3, mtime=10)],
truncated=False,
)
def preview(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspacePreviewResult:
self.calls.append(("preview", tenant_id, app_id, conversation_id, path))
return WorkspacePreviewResult(path=path, size=5, truncated=False, binary=False, text="hello")
def download(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> WorkspaceDownloadResult:
self.calls.append(("download", tenant_id, app_id, conversation_id, path))
return WorkspaceDownloadResult(path=path, size=3, truncated=False, content=b"abc")
class _WorkflowService:
def __init__(self) -> None:
self.calls: list[tuple[str, str, str, str, str, str | None, str]] = []
def list_files(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
path: str,
) -> WorkspaceListResult:
self.calls.append(("list", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path))
return WorkspaceListResult(path=path, entries=[], truncated=False)
def preview(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
path: str,
) -> WorkspacePreviewResult:
self.calls.append(("preview", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path))
return WorkspacePreviewResult(path=path, size=5, truncated=False, binary=False, text="hello")
def download(
self,
*,
tenant_id: str,
app_id: str,
workflow_run_id: str,
node_id: str,
node_execution_id: str | None,
path: str,
) -> WorkspaceDownloadResult:
self.calls.append(("download", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path))
return WorkspaceDownloadResult(path=path, size=3, truncated=False, content=b"abc")
def _app_model(app_id: str = "app-1") -> App:
return App(
id=app_id,
tenant_id="tenant-1",
name="App",
mode=AppMode.AGENT,
icon_type=IconType.EMOJI,
icon="bot",
icon_background="#fff",
enable_site=False,
enable_api=False,
)
def test_handle_maps_workspace_and_agent_backend_errors() -> None:
assert module._handle(AgentWorkspaceInspectorError("no_sandbox", "no sandbox", status_code=404)) == (
{"code": "no_sandbox", "message": "no sandbox"},
404,
)
assert module._handle(
AgentBackendHTTPError("not found", status_code=404, detail={"code": "not_found", "message": "missing"})
) == ({"code": "not_found", "message": "missing"}, 404)
assert module._handle(AgentBackendHTTPError("bad", status_code=500, detail="backend exploded")) == (
{"code": "agent_backend_error", "message": "backend exploded"},
500,
)
assert module._handle(AgentBackendTransportError("connection refused")) == (
{"code": "agent_backend_unreachable", "message": "connection refused"},
502,
)
with pytest.raises(RuntimeError):
module._handle(RuntimeError("boom"))
def test_download_response_returns_binary_or_too_large_error() -> None:
response = cast(
Response,
module._download_response(
WorkspaceDownloadResult(path="dir/report.txt", size=3, truncated=False, content=b"abc")
),
)
assert response.status_code == 200
assert response.data == b"abc"
assert response.headers["Content-Disposition"] == 'attachment; filename="report.txt"'
assert response.headers["Content-Length"] == "3"
assert response.headers["X-Workspace-File-Size"] == "3"
assert module._download_response(WorkspaceDownloadResult(path="", size=10, truncated=True, content=b"")) == (
{
"code": "workspace_file_too_large",
"message": (
"file exceeds the workspace download limit; use preview for partial text or download a smaller file"
),
"size": 10,
},
413,
)
def test_agent_app_workspace_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None:
service = _AgentAppService()
monkeypatch.setattr(module, "AgentAppWorkspaceService", lambda: service)
monkeypatch.setattr(
module,
"query_params_from_request",
lambda model: SimpleNamespace(conversation_id="conv-1", path="sub/report.txt"),
)
app_model = _app_model()
listing = unwrap(module.AgentAppWorkspaceListResource.get)(object(), "tenant-1", app_model)
preview = unwrap(module.AgentAppWorkspacePreviewResource.get)(object(), "tenant-1", app_model)
download = unwrap(module.AgentAppWorkspaceDownloadResource.get)(object(), "tenant-1", app_model)
assert listing["entries"][0]["name"] == "a.txt"
assert preview["text"] == "hello"
assert download.data == b"abc"
assert service.calls == [
("list", "tenant-1", "app-1", "conv-1", "sub/report.txt"),
("preview", "tenant-1", "app-1", "conv-1", "sub/report.txt"),
("download", "tenant-1", "app-1", "conv-1", "sub/report.txt"),
]
def test_agent_app_workspace_resource_returns_normalized_errors(monkeypatch: pytest.MonkeyPatch) -> None:
class FailingService:
def list_files(self, **kwargs):
raise AgentWorkspaceInspectorError("no_active_session", "no active session", status_code=404)
monkeypatch.setattr(module, "AgentAppWorkspaceService", FailingService)
monkeypatch.setattr(
module,
"query_params_from_request",
lambda model: SimpleNamespace(conversation_id="conv-1", path="."),
)
assert unwrap(module.AgentAppWorkspaceListResource.get)(object(), "tenant-1", _app_model()) == (
{"code": "no_active_session", "message": "no active session"},
404,
)
def test_workflow_agent_workspace_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None:
service = _WorkflowService()
monkeypatch.setattr(module, "WorkflowAgentWorkspaceService", lambda: service)
monkeypatch.setattr(
module,
"query_params_from_request",
lambda model: SimpleNamespace(node_execution_id="exec-1", path="out.txt"),
)
app_model = _app_model()
listing = unwrap(module.WorkflowAgentWorkspaceListResource.get)(
object(), "tenant-1", app_model, "run-1", "agent-node"
)
preview = unwrap(module.WorkflowAgentWorkspacePreviewResource.get)(
object(), "tenant-1", app_model, "run-1", "agent-node"
)
download = unwrap(module.WorkflowAgentWorkspaceDownloadResource.get)(
object(), "tenant-1", app_model, "run-1", "agent-node"
)
assert listing["path"] == "out.txt"
assert preview["text"] == "hello"
assert download.data == b"abc"
assert service.calls == [
("list", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"),
("preview", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"),
("download", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"),
]

View File

@ -9,7 +9,7 @@ from typing import Any, override
import pytest
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol import CancelRunRequest, CancelRunResponse
from dify_agent.protocol import CancelRunRequest, CancelRunResponse, RuntimeLayerSpec
from clients.agent_backend import (
AgentBackendError,
@ -67,13 +67,15 @@ class _RecordingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
class _FakeSessionStore:
def __init__(self, loaded: CompositorSessionSnapshot | None = None) -> None:
self.loaded = loaded
self.saved: list[tuple[AgentAppSessionScope, str, CompositorSessionSnapshot | None]] = []
self.saved: list[
tuple[AgentAppSessionScope, str, CompositorSessionSnapshot | None, list[RuntimeLayerSpec]]
] = []
def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None:
return self.loaded
def save_active_snapshot(self, *, scope, backend_run_id, snapshot) -> None:
self.saved.append((scope, backend_run_id, snapshot))
def save_active_snapshot(self, *, scope, backend_run_id, snapshot, runtime_layer_specs) -> None:
self.saved.append((scope, backend_run_id, snapshot, list(runtime_layer_specs)))
def _soul() -> AgentSoulConfig:
@ -142,11 +144,17 @@ def test_successful_turn_publishes_chunk_and_message_end_and_saves_session():
assert end_events[0].llm_result.model == "gpt-4o-mini"
# The conversation session snapshot is persisted for multi-turn continuity.
assert store.saved
saved_scope, saved_run_id, saved_snapshot = store.saved[0]
saved_scope, saved_run_id, saved_snapshot, saved_specs = store.saved[0]
assert saved_scope.conversation_id == "conv-1"
assert saved_scope.agent_config_snapshot_id == "snap-1"
assert saved_run_id == "fake-run-1"
assert saved_snapshot is not None
assert [spec.name for spec in saved_specs] == [
"agent_soul_prompt",
"agent_app_user_prompt",
"execution_context",
"history",
]
def test_prior_session_snapshot_is_threaded_into_request():

View File

@ -13,6 +13,7 @@ import pytest
from agenton.compositor import CompositorSessionSnapshot
from agenton.compositor.schemas import LayerSessionSnapshot
from agenton.layers.base import LifecycleState
from dify_agent.protocol import RuntimeLayerSpec
from sqlalchemy import delete
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore, AgentAppSessionScope
@ -44,6 +45,13 @@ def _snapshot(messages: int = 1) -> CompositorSessionSnapshot:
)
def _runtime_layer_specs() -> list[RuntimeLayerSpec]:
return [
RuntimeLayerSpec(name="execution_context", type="dify.execution_context", config={"tenant_id": "tenant-1"}),
RuntimeLayerSpec(name="history", type="pydantic_ai.history"),
]
@pytest.fixture(autouse=True)
def _create_table() -> Generator[None, None, None]:
engine = session_factory.get_session_maker().kw["bind"]
@ -61,7 +69,12 @@ def test_load_returns_none_when_no_row():
def test_save_creates_conversation_owned_row_and_round_trips():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=2))
store.save_active_snapshot(
scope=_scope(),
backend_run_id="run-1",
snapshot=_snapshot(messages=2),
runtime_layer_specs=_runtime_layer_specs(),
)
loaded = store.load_active_snapshot(_scope())
assert loaded is not None
@ -76,19 +89,36 @@ def test_save_creates_conversation_owned_row_and_round_trips():
assert row.agent_config_snapshot_id == "snap-1"
assert row.workflow_run_id is None # conversation owner leaves workflow cols NULL
assert row.backend_run_id == "run-1"
assert "execution_context" in row.composition_layer_specs
assert "history" in row.composition_layer_specs
def test_save_is_noop_when_snapshot_missing():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(scope=_scope(), backend_run_id="run-x", snapshot=None)
store.save_active_snapshot(
scope=_scope(),
backend_run_id="run-x",
snapshot=None,
runtime_layer_specs=_runtime_layer_specs(),
)
with session_factory.create_session() as session:
assert session.query(AgentRuntimeSession).count() == 0
def test_second_turn_updates_same_conversation_row():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=1))
store.save_active_snapshot(scope=_scope(), backend_run_id="run-2", snapshot=_snapshot(messages=3))
store.save_active_snapshot(
scope=_scope(),
backend_run_id="run-1",
snapshot=_snapshot(messages=1),
runtime_layer_specs=_runtime_layer_specs(),
)
store.save_active_snapshot(
scope=_scope(),
backend_run_id="run-2",
snapshot=_snapshot(messages=3),
runtime_layer_specs=_runtime_layer_specs(),
)
with session_factory.create_session() as session:
rows = session.query(AgentRuntimeSession).all()
assert len(rows) == 1
@ -97,11 +127,21 @@ def test_second_turn_updates_same_conversation_row():
def test_mark_cleaned_then_load_returns_none_and_save_resurrects():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot())
store.save_active_snapshot(
scope=_scope(),
backend_run_id="run-1",
snapshot=_snapshot(),
runtime_layer_specs=_runtime_layer_specs(),
)
store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1")
assert store.load_active_snapshot(_scope()) is None
# Re-entry revives the row.
store.save_active_snapshot(scope=_scope(), backend_run_id="run-2", snapshot=_snapshot(messages=2))
store.save_active_snapshot(
scope=_scope(),
backend_run_id="run-2",
snapshot=_snapshot(messages=2),
runtime_layer_specs=_runtime_layer_specs(),
)
with session_factory.create_session() as session:
row = session.query(AgentRuntimeSession).one()
assert row.status == AgentRuntimeSessionStatus.ACTIVE
@ -111,8 +151,18 @@ def test_mark_cleaned_then_load_returns_none_and_save_resurrects():
def test_distinct_conversations_do_not_collide():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(scope=_scope(conversation_id="conv-A"), backend_run_id="a", snapshot=_snapshot())
store.save_active_snapshot(scope=_scope(conversation_id="conv-B"), backend_run_id="b", snapshot=_snapshot())
store.save_active_snapshot(
scope=_scope(conversation_id="conv-A"),
backend_run_id="a",
snapshot=_snapshot(),
runtime_layer_specs=_runtime_layer_specs(),
)
store.save_active_snapshot(
scope=_scope(conversation_id="conv-B"),
backend_run_id="b",
snapshot=_snapshot(),
runtime_layer_specs=_runtime_layer_specs(),
)
assert store.load_active_snapshot(_scope(conversation_id="conv-A")) is not None
assert store.load_active_snapshot(_scope(conversation_id="conv-B")) is not None
with session_factory.create_session() as session:
@ -125,9 +175,13 @@ def test_distinct_agent_config_snapshots_keep_only_latest_active_session():
scope=_scope(agent_config_snapshot_id="snap-1"),
backend_run_id="a",
snapshot=_snapshot(),
runtime_layer_specs=_runtime_layer_specs(),
)
store.save_active_snapshot(
scope=_scope(agent_config_snapshot_id="snap-2"), backend_run_id="b", snapshot=_snapshot(messages=2)
scope=_scope(agent_config_snapshot_id="snap-2"),
backend_run_id="b",
snapshot=_snapshot(messages=2),
runtime_layer_specs=_runtime_layer_specs(),
)
assert store.load_active_snapshot(_scope(agent_config_snapshot_id="snap-1")) is None
@ -139,62 +193,83 @@ def test_distinct_agent_config_snapshots_keep_only_latest_active_session():
assert [row.status for row in rows] == [AgentRuntimeSessionStatus.CLEANED, AgentRuntimeSessionStatus.ACTIVE]
def test_load_for_conversation_resolves_without_agent_or_config_scope():
def test_load_active_session_for_conversation_resolves_without_agent_or_config_scope():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=2))
store.save_active_snapshot(
scope=_scope(),
backend_run_id="run-1",
snapshot=_snapshot(messages=2),
runtime_layer_specs=_runtime_layer_specs(),
)
# The inspector only knows tenant/app/conversation, not the agent config version.
loaded = store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1")
loaded = store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1")
assert loaded is not None
assert loaded.layers[0].runtime_state["messages"] == [
assert loaded.session_snapshot.layers[0].runtime_state["messages"] == [
{"role": "user", "content": "m0"},
{"role": "user", "content": "m1"},
]
assert [spec.name for spec in loaded.runtime_layer_specs] == ["execution_context", "history"]
def test_load_for_conversation_uses_latest_active_snapshot_after_config_change():
def test_load_active_session_for_conversation_uses_latest_active_snapshot_after_config_change():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(
scope=_scope(agent_config_snapshot_id="snap-1"), backend_run_id="a", snapshot=_snapshot()
scope=_scope(agent_config_snapshot_id="snap-1"),
backend_run_id="a",
snapshot=_snapshot(),
runtime_layer_specs=_runtime_layer_specs(),
)
store.save_active_snapshot(
scope=_scope(agent_config_snapshot_id="snap-2"), backend_run_id="b", snapshot=_snapshot(messages=3)
scope=_scope(agent_config_snapshot_id="snap-2"),
backend_run_id="b",
snapshot=_snapshot(messages=3),
runtime_layer_specs=_runtime_layer_specs(),
)
loaded = store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1")
loaded = store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1")
assert loaded is not None
assert loaded.layers[0].runtime_state["messages"] == [
assert loaded.session_snapshot.layers[0].runtime_state["messages"] == [
{"role": "user", "content": "m0"},
{"role": "user", "content": "m1"},
{"role": "user", "content": "m2"},
]
def test_load_for_conversation_returns_none_when_cleaned_or_absent():
def test_load_active_session_for_conversation_returns_none_when_cleaned_or_absent():
store = AgentAppRuntimeSessionStore()
assert (
store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1")
store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1")
is None
)
store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot())
store.save_active_snapshot(
scope=_scope(),
backend_run_id="run-1",
snapshot=_snapshot(),
runtime_layer_specs=_runtime_layer_specs(),
)
store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1")
assert (
store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1")
store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1")
is None
)
def test_load_for_conversation_isolates_other_conversations():
def test_load_active_session_for_conversation_isolates_other_conversations():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(scope=_scope(conversation_id="conv-A"), backend_run_id="a", snapshot=_snapshot())
store.save_active_snapshot(
scope=_scope(conversation_id="conv-A"),
backend_run_id="a",
snapshot=_snapshot(),
runtime_layer_specs=_runtime_layer_specs(),
)
assert (
store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-B")
store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-B")
is None
)
assert (
store.load_active_snapshot_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-A")
store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-A")
is not None
)

View File

@ -8,9 +8,9 @@ from dify_agent.protocol import RunStartedEvent, RunSucceededEvent, RunSucceeded
from clients.agent_backend import (
AgentBackendRunEventAdapter,
AgentBackendStreamInternalEvent,
CleanupLayerSpec,
FakeAgentBackendRunClient,
FakeAgentBackendScenario,
RuntimeLayerSpec,
)
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom
from core.workflow.file_reference import build_file_reference
@ -118,7 +118,7 @@ class FakeSessionStore:
WorkflowAgentSessionScope,
str,
CompositorSessionSnapshot | None,
list[CleanupLayerSpec],
list[RuntimeLayerSpec],
]
] = []
self.cleaned: list[tuple[WorkflowAgentSessionScope, str | None]] = []
@ -132,9 +132,9 @@ class FakeSessionStore:
scope: WorkflowAgentSessionScope,
backend_run_id: str,
snapshot: CompositorSessionSnapshot | None,
composition_layer_specs: list[CleanupLayerSpec],
runtime_layer_specs: list[RuntimeLayerSpec],
) -> None:
self.saved.append((scope, backend_run_id, snapshot, list(composition_layer_specs)))
self.saved.append((scope, backend_run_id, snapshot, list(runtime_layer_specs)))
def mark_cleaned(
self,

View File

@ -7,7 +7,7 @@ from agenton.compositor.schemas import LayerSessionSnapshot
from agenton.layers.base import LifecycleState
from dify_agent.protocol import CancelRunRequest, RunEvent, RunStatusResponse
from clients.agent_backend import AgentBackendRunRequestBuilder, CleanupLayerSpec, FakeAgentBackendRunClient
from clients.agent_backend import AgentBackendRunRequestBuilder, FakeAgentBackendRunClient, RuntimeLayerSpec
from clients.agent_backend.errors import AgentBackendHTTPError
from core.workflow.nodes.agent_v2.session_cleanup_layer import WorkflowAgentSessionCleanupLayer
from core.workflow.nodes.agent_v2.session_store import (
@ -40,7 +40,7 @@ def _layer_snapshot(name: str) -> LayerSessionSnapshot:
def _stored_session(scope: WorkflowAgentSessionScope, *, index: int = 1) -> StoredWorkflowAgentSession:
"""A typical stored session with prompt + execution_context + history + llm specs.
The LLM layer is *not* in ``composition_layer_specs`` because the cleanup
The LLM layer is *not* in ``runtime_layer_specs`` because the cleanup
contract excludes credential-bearing plugin layers, but it *is* present in
the saved snapshot so the layer's filter logic gets exercised.
"""
@ -55,10 +55,10 @@ def _stored_session(scope: WorkflowAgentSessionScope, *, index: int = 1) -> Stor
]
),
backend_run_id=f"agent-run-{index}",
composition_layer_specs=[
CleanupLayerSpec(name="workflow_node_job_prompt", type="plain.prompt", config={"prefix": "ok"}),
CleanupLayerSpec(name="execution_context", type="dify.execution_context", config={"tenant_id": "t"}),
CleanupLayerSpec(name="history", type="pydantic_ai.history"),
runtime_layer_specs=[
RuntimeLayerSpec(name="workflow_node_job_prompt", type="plain.prompt", config={"prefix": "ok"}),
RuntimeLayerSpec(name="execution_context", type="dify.execution_context", config={"tenant_id": "t"}),
RuntimeLayerSpec(name="history", type="pydantic_ai.history"),
],
)
@ -268,7 +268,7 @@ def test_cleanup_layer_marks_cleaned_locally_when_http_cleanup_disabled():
def test_cleanup_layer_skips_sessions_without_persisted_specs():
"""Backwards-compatible safety net: a row written before A.1 landed has
no composition_layer_specs, so cleanup would unavoidably hit the snapshot-
no runtime_layer_specs, so cleanup would unavoidably hit the snapshot-
validation trap. The layer must skip such rows instead of issuing a
doomed request."""
scope = _default_scope()
@ -276,7 +276,7 @@ def test_cleanup_layer_skips_sessions_without_persisted_specs():
scope=scope,
session_snapshot=CompositorSessionSnapshot(layers=[_layer_snapshot("history")]),
backend_run_id="legacy-run",
composition_layer_specs=[],
runtime_layer_specs=[],
)
session_store = FakeSessionStore(stored=[legacy_session])
agent_backend_client = _WaitableFakeAgentBackendRunClient()

View File

@ -14,9 +14,9 @@ import pytest
from agenton.compositor import CompositorSessionSnapshot
from agenton.compositor.schemas import LayerSessionSnapshot
from agenton.layers.base import LifecycleState
from dify_agent.protocol import RuntimeLayerSpec
from sqlalchemy import delete
from clients.agent_backend.request_builder import CleanupLayerSpec
from core.db.session_factory import session_factory
from core.workflow.nodes.agent_v2.session_store import (
StoredWorkflowAgentSession,
@ -52,10 +52,10 @@ def _snapshot(messages: int = 1) -> CompositorSessionSnapshot:
)
def _specs() -> list[CleanupLayerSpec]:
def _specs() -> list[RuntimeLayerSpec]:
return [
CleanupLayerSpec(name="workflow_node_job_prompt", type="plain.prompt", config={"prefix": "ok"}),
CleanupLayerSpec(name="history", type="pydantic_ai.history"),
RuntimeLayerSpec(name="workflow_node_job_prompt", type="plain.prompt", config={"prefix": "ok"}),
RuntimeLayerSpec(name="history", type="pydantic_ai.history"),
]
@ -85,15 +85,17 @@ def test_load_active_snapshot_returns_none_when_no_row_matches():
def test_save_active_snapshot_creates_row_and_load_round_trips():
store = WorkflowAgentRuntimeSessionStore()
snapshot = _snapshot(messages=2)
store.save_active_snapshot(
scope=_scope(), backend_run_id="run-1", snapshot=snapshot, composition_layer_specs=_specs()
)
store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=snapshot, runtime_layer_specs=_specs())
loaded = store.load_active_snapshot(_scope())
assert loaded is not None
assert len(loaded.layers) == 1
assert loaded.layers[0].name == "history"
assert loaded.layers[0].runtime_state["messages"] == snapshot.layers[0].runtime_state["messages"]
with session_factory.create_session() as session:
row = session.query(WorkflowAgentRuntimeSession).one()
assert "workflow_node_job_prompt" in row.composition_layer_specs
assert "history" in row.composition_layer_specs
def test_save_active_snapshot_skips_when_workflow_run_id_missing():
@ -103,7 +105,7 @@ def test_save_active_snapshot_skips_when_workflow_run_id_missing():
scope=_scope(workflow_run_id=None),
backend_run_id="run-skipped",
snapshot=_snapshot(),
composition_layer_specs=_specs(),
runtime_layer_specs=_specs(),
)
with session_factory.create_session() as session:
assert session.query(WorkflowAgentRuntimeSession).count() == 0
@ -116,7 +118,7 @@ def test_save_active_snapshot_skips_when_snapshot_missing():
scope=_scope(),
backend_run_id="run-empty",
snapshot=None,
composition_layer_specs=_specs(),
runtime_layer_specs=_specs(),
)
with session_factory.create_session() as session:
assert session.query(WorkflowAgentRuntimeSession).count() == 0
@ -129,14 +131,14 @@ def test_save_active_snapshot_updates_existing_row_on_re_entry():
scope=_scope(),
backend_run_id="run-1",
snapshot=_snapshot(messages=1),
composition_layer_specs=_specs(),
runtime_layer_specs=_specs(),
)
# Second call with new snapshot + backend_run_id.
store.save_active_snapshot(
scope=_scope(),
backend_run_id="run-2",
snapshot=_snapshot(messages=2),
composition_layer_specs=_specs(),
runtime_layer_specs=_specs(),
)
with session_factory.create_session() as session:
@ -154,7 +156,7 @@ def test_save_active_snapshot_resurrects_cleaned_row():
scope=_scope(),
backend_run_id="run-1",
snapshot=_snapshot(),
composition_layer_specs=_specs(),
runtime_layer_specs=_specs(),
)
store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1")
# Save again — the existing row was CLEANED; should be revived.
@ -162,7 +164,7 @@ def test_save_active_snapshot_resurrects_cleaned_row():
scope=_scope(),
backend_run_id="run-2",
snapshot=_snapshot(messages=3),
composition_layer_specs=_specs(),
runtime_layer_specs=_specs(),
)
with session_factory.create_session() as session:
@ -179,13 +181,13 @@ def test_list_active_sessions_returns_specs_and_snapshot():
scope=_scope(binding_id="binding-A"),
backend_run_id="run-A",
snapshot=_snapshot(),
composition_layer_specs=_specs(),
runtime_layer_specs=_specs(),
)
store.save_active_snapshot(
scope=_scope(binding_id="binding-B"),
backend_run_id="run-B",
snapshot=_snapshot(messages=2),
composition_layer_specs=_specs(),
runtime_layer_specs=_specs(),
)
listed = store.list_active_sessions(workflow_run_id="wfr-1")
@ -193,8 +195,8 @@ def test_list_active_sessions_returns_specs_and_snapshot():
by_run = {s.backend_run_id: s for s in listed}
assert isinstance(by_run["run-A"], StoredWorkflowAgentSession)
# Specs round-trip through pydantic TypeAdapter — ensure deserialize works.
assert by_run["run-A"].composition_layer_specs[0].name == "workflow_node_job_prompt"
assert by_run["run-A"].composition_layer_specs[1].type == "pydantic_ai.history"
assert by_run["run-A"].runtime_layer_specs[0].name == "workflow_node_job_prompt"
assert by_run["run-A"].runtime_layer_specs[1].type == "pydantic_ai.history"
# node_execution_id default-replaces NULL with "" when the DB column is None.
assert by_run["run-A"].scope.node_execution_id == "node-exec-1"
@ -205,13 +207,13 @@ def test_list_active_sessions_skips_cleaned_rows():
scope=_scope(binding_id="binding-A"),
backend_run_id="run-A",
snapshot=_snapshot(),
composition_layer_specs=_specs(),
runtime_layer_specs=_specs(),
)
store.save_active_snapshot(
scope=_scope(binding_id="binding-B"),
backend_run_id="run-B",
snapshot=_snapshot(),
composition_layer_specs=_specs(),
runtime_layer_specs=_specs(),
)
store.mark_cleaned(scope=_scope(binding_id="binding-A"), backend_run_id="cleanup-A")
@ -220,7 +222,7 @@ def test_list_active_sessions_skips_cleaned_rows():
def test_list_active_sessions_handles_legacy_rows_without_specs():
"""Rows persisted before composition_layer_specs landed have an empty string."""
"""Rows persisted before runtime_layer_specs landed have an empty string."""
# Insert a legacy-shape row directly: empty specs payload simulates a row
# written before the spec persistence feature landed in A.1.
store = WorkflowAgentRuntimeSessionStore()
@ -228,11 +230,11 @@ def test_list_active_sessions_handles_legacy_rows_without_specs():
scope=_scope(),
backend_run_id="run-legacy",
snapshot=_snapshot(),
composition_layer_specs=[],
runtime_layer_specs=[],
)
listed = store.list_active_sessions(workflow_run_id="wfr-1")
assert len(listed) == 1
assert listed[0].composition_layer_specs == []
assert listed[0].runtime_layer_specs == []
def test_mark_cleaned_sets_status_and_cleaned_at_with_backend_run_id():
@ -241,7 +243,7 @@ def test_mark_cleaned_sets_status_and_cleaned_at_with_backend_run_id():
scope=_scope(),
backend_run_id="run-1",
snapshot=_snapshot(),
composition_layer_specs=_specs(),
runtime_layer_specs=_specs(),
)
store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1")
@ -259,7 +261,7 @@ def test_mark_cleaned_preserves_existing_backend_run_id_when_none_given():
scope=_scope(),
backend_run_id="run-1",
snapshot=_snapshot(),
composition_layer_specs=_specs(),
runtime_layer_specs=_specs(),
)
store.mark_cleaned(scope=_scope(), backend_run_id=None)

View File

@ -0,0 +1,313 @@
"""Unit tests for the Agent App / workflow sandbox services."""
from __future__ import annotations
from collections.abc import Generator
from datetime import datetime
import pytest
from agenton.compositor import CompositorSessionSnapshot
from agenton.compositor.schemas import LayerSessionSnapshot
from agenton.layers.base import LifecycleState
from dify_agent.protocol import RuntimeLayerSpec, SandboxListResponse, SandboxReadResponse, SandboxUploadResponse
from sqlalchemy import delete
from core.app.apps.agent_app.session_store import AgentAppSessionScope, StoredAgentAppSession
from core.db.session_factory import session_factory
from models.agent import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus
from services.agent_app_sandbox_service import (
AgentAppSandboxService,
AgentSandboxInspectorError,
WorkflowAgentSandboxService,
_default_client_factory,
)
def _snapshot(*, session_id: str = "abc1234") -> CompositorSessionSnapshot:
return CompositorSessionSnapshot(
layers=[
LayerSessionSnapshot(name="execution_context", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}),
LayerSessionSnapshot(
name="shell",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={"session_id": session_id, "workspace_cwd": f"~/workspace/{session_id}"},
),
]
)
def _runtime_layer_specs() -> list[RuntimeLayerSpec]:
return [
RuntimeLayerSpec(name="execution_context", type="dify.execution_context", config={"tenant_id": "tenant-1"}),
RuntimeLayerSpec(name="shell", type="dify.shell", deps={"execution_context": "execution_context"}, config={}),
]
class FakeStore:
def __init__(self, session: StoredAgentAppSession | None) -> None:
self.session = session
self.scope: tuple[str, str, str] | None = None
def load_active_session_for_conversation(self, *, tenant_id: str, app_id: str, conversation_id: str):
self.scope = (tenant_id, app_id, conversation_id)
return self.session
class FakeClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
self.locators: list[object] = []
def list_sandbox_files_sync(self, locator, path: str) -> SandboxListResponse:
self.locators.append(locator)
self.calls.append(("list", path))
return SandboxListResponse(path=path, entries=[], truncated=False)
def read_sandbox_file_sync(self, locator, path: str, max_bytes: int = 262144) -> SandboxReadResponse:
del max_bytes
self.locators.append(locator)
self.calls.append(("read", path))
return SandboxReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
def upload_sandbox_file_sync(self, locator, path: str) -> SandboxUploadResponse:
self.locators.append(locator)
self.calls.append(("upload", path))
return SandboxUploadResponse(
path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}
)
def _stored_session() -> StoredAgentAppSession:
return StoredAgentAppSession(
scope=AgentAppSessionScope(
tenant_id="tenant-1",
app_id="app-1",
conversation_id="conv-1",
agent_id="agent-1",
agent_config_snapshot_id="snapshot-1",
),
session_snapshot=_snapshot(),
backend_run_id="run-1",
runtime_layer_specs=_runtime_layer_specs(),
)
def test_agent_app_sandbox_service_builds_locator_and_proxies() -> None:
store = FakeStore(_stored_session())
client = FakeClient()
service = AgentAppSandboxService(session_store=store, client_factory=lambda: client) # type: ignore[arg-type]
result = service.list_files(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1", path=".")
assert result.path == "."
assert client.calls == [("list", ".")]
assert store.scope == ("tenant-1", "app-1", "conv-1")
def test_agent_app_sandbox_service_raises_when_no_active_session() -> None:
service = AgentAppSandboxService(session_store=FakeStore(None), client_factory=lambda: FakeClient()) # type: ignore[arg-type]
with pytest.raises(AgentSandboxInspectorError) as exc_info:
service.read_file(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1", path="note.txt")
assert exc_info.value.code == "no_active_session"
assert exc_info.value.status_code == 404
def test_agent_app_sandbox_service_raises_when_runtime_specs_cannot_build_locator() -> None:
broken_session = StoredAgentAppSession(
scope=AgentAppSessionScope(
tenant_id="tenant-1",
app_id="app-1",
conversation_id="conv-1",
agent_id="agent-1",
agent_config_snapshot_id="snapshot-1",
),
session_snapshot=_snapshot(),
backend_run_id="run-1",
runtime_layer_specs=[],
)
service = AgentAppSandboxService(session_store=FakeStore(broken_session), client_factory=lambda: FakeClient()) # type: ignore[arg-type]
with pytest.raises(AgentSandboxInspectorError) as exc_info:
service.list_files(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1", path=".")
assert exc_info.value.code == "no_sandbox"
def test_default_client_factory_requires_agent_backend_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("services.agent_app_sandbox_service.dify_config.AGENT_BACKEND_BASE_URL", "")
with pytest.raises(AgentSandboxInspectorError) as exc_info:
_default_client_factory()
assert exc_info.value.code == "inspector_unavailable"
assert exc_info.value.status_code == 503
@pytest.fixture
def _runtime_session_table() -> Generator[None, None, None]:
engine = session_factory.get_session_maker().kw["bind"]
AgentRuntimeSession.__table__.create(bind=engine, checkfirst=True)
yield
with session_factory.create_session() as session:
session.execute(delete(AgentRuntimeSession))
session.commit()
AgentRuntimeSession.__table__.drop(bind=engine, checkfirst=True)
def _insert_workflow_session(
*,
runtime_layer_specs: str | None = None,
workflow_run_id: str = "run-1",
node_id: str = "node-1",
node_execution_id: str = "node-exec-1",
binding_id: str = "binding-1",
backend_run_id: str = "backend-run-1",
updated_at: datetime | None = None,
session_id: str = "abc1234",
) -> None:
default_runtime_layer_specs = (
'[{"name":"execution_context","type":"dify.execution_context","config":{"tenant_id":"tenant-1"}},'
'{"name":"shell","type":"dify.shell","deps":{"execution_context":"execution_context"},"config":{}}]'
)
with session_factory.create_session() as session:
row = AgentRuntimeSession(
tenant_id="tenant-1",
app_id="app-1",
owner_type=AgentRuntimeSessionOwnerType.WORKFLOW_RUN,
workflow_id="workflow-1",
workflow_run_id=workflow_run_id,
node_id=node_id,
node_execution_id=node_execution_id,
binding_id=binding_id,
agent_id="agent-1",
agent_config_snapshot_id="snapshot-1",
backend_run_id=backend_run_id,
session_snapshot=_snapshot(session_id=session_id).model_dump_json(),
composition_layer_specs=runtime_layer_specs or default_runtime_layer_specs,
status=AgentRuntimeSessionStatus.ACTIVE,
)
if updated_at is not None:
row.updated_at = updated_at
session.add(row)
session.commit()
@pytest.mark.usefixtures("_runtime_session_table")
def test_workflow_sandbox_service_resolves_locator_and_proxies() -> None:
_insert_workflow_session()
client = FakeClient()
service = WorkflowAgentSandboxService(client_factory=lambda: client) # type: ignore[arg-type]
result = service.upload_file(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id="node-exec-1",
path="report.txt",
)
assert result.file.reference == "dify-file-ref:file-1"
assert client.calls == [("upload", "report.txt")]
@pytest.mark.usefixtures("_runtime_session_table")
def test_workflow_sandbox_service_filters_by_node_execution_id() -> None:
_insert_workflow_session(
node_execution_id="node-exec-1",
binding_id="binding-1",
backend_run_id="run-a",
session_id="abc1234",
)
_insert_workflow_session(
node_execution_id="node-exec-2",
binding_id="binding-2",
backend_run_id="run-b",
session_id="def5678",
)
client = FakeClient()
service = WorkflowAgentSandboxService(client_factory=lambda: client) # type: ignore[arg-type]
result = service.read_file(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id="node-exec-2",
path="out.txt",
)
assert result.text == "hello"
assert client.calls == [("read", "out.txt")]
assert client.locators[0].session_snapshot.layers[1].runtime_state["session_id"] == "def5678"
@pytest.mark.usefixtures("_runtime_session_table")
def test_workflow_sandbox_service_uses_latest_active_session_when_execution_id_omitted() -> None:
_insert_workflow_session(
node_execution_id="node-exec-1",
binding_id="binding-1",
backend_run_id="run-older",
updated_at=datetime(2026, 1, 1, 0, 0, 0),
session_id="abc1234",
)
_insert_workflow_session(
node_execution_id="node-exec-2",
binding_id="binding-2",
backend_run_id="run-newer",
updated_at=datetime(2026, 1, 1, 0, 0, 1),
session_id="def5678",
)
client = FakeClient()
service = WorkflowAgentSandboxService(client_factory=lambda: client) # type: ignore[arg-type]
result = service.list_files(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id=None,
path=".",
)
assert result.path == "."
assert client.calls == [("list", ".")]
assert client.locators[0].session_snapshot.layers[1].runtime_state["session_id"] == "def5678"
@pytest.mark.usefixtures("_runtime_session_table")
def test_workflow_sandbox_service_raises_when_no_active_session() -> None:
service = WorkflowAgentSandboxService(client_factory=lambda: FakeClient()) # type: ignore[arg-type]
with pytest.raises(AgentSandboxInspectorError) as exc_info:
service.list_files(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id=None,
path=".",
)
assert exc_info.value.code == "no_active_session"
assert exc_info.value.status_code == 404
@pytest.mark.usefixtures("_runtime_session_table")
def test_workflow_sandbox_service_raises_when_runtime_specs_missing() -> None:
_insert_workflow_session(runtime_layer_specs="[]")
service = WorkflowAgentSandboxService(client_factory=lambda: FakeClient()) # type: ignore[arg-type]
with pytest.raises(AgentSandboxInspectorError) as exc_info:
service.list_files(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id=None,
path=".",
)
assert exc_info.value.code == "no_sandbox"

View File

@ -1,284 +0,0 @@
"""Unit tests for the Agent App sandbox workspace inspector service.
These cover session-id resolution from the conversation snapshot and proxying to
the backend client, with fakes for the session store and client (no DB / no HTTP).
"""
from __future__ import annotations
from collections.abc import Generator
import pytest
from agenton.compositor import CompositorSessionSnapshot
from agenton.compositor.schemas import LayerSessionSnapshot
from agenton.layers.base import LifecycleState
from sqlalchemy import delete
from clients.agent_backend.workspace_files_client import (
WorkspaceDownloadResult,
WorkspaceFileEntry,
WorkspaceListResult,
WorkspacePreviewResult,
)
from core.db.session_factory import session_factory
from models.agent import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus
from services.agent_app_workspace_service import (
AgentAppWorkspaceService,
AgentWorkspaceInspectorError,
WorkflowAgentWorkspaceService,
_default_client_factory,
)
def _snapshot(*, shell: bool = True, session_id: str | None = "abc1234") -> CompositorSessionSnapshot:
layers = [LayerSessionSnapshot(name="history", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={})]
if shell and session_id is not None:
layers.append(
LayerSessionSnapshot(
name="shell",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={"session_id": session_id, "workspace_cwd": f"~/workspace/{session_id}"},
)
)
elif shell:
layers.append(LayerSessionSnapshot(name="shell", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}))
return CompositorSessionSnapshot(layers=layers)
class FakeStore:
def __init__(self, snapshot: CompositorSessionSnapshot | None) -> None:
self._snapshot = snapshot
self.scope: tuple[str, str, str] | None = None
def load_active_snapshot_for_conversation(
self, *, tenant_id: str, app_id: str, conversation_id: str
) -> CompositorSessionSnapshot | None:
self.scope = (tenant_id, app_id, conversation_id)
return self._snapshot
class FakeClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str, str]] = []
def list_files(self, session_id: str, path: str) -> WorkspaceListResult:
self.calls.append(("list", session_id, path))
return WorkspaceListResult(
path=path, entries=[WorkspaceFileEntry(name="a.txt", type="file", size=1, mtime=1)], truncated=False
)
def preview(self, session_id: str, path: str) -> WorkspacePreviewResult:
self.calls.append(("preview", session_id, path))
return WorkspacePreviewResult(path=path, size=5, truncated=False, binary=False, text="hello")
def download(self, session_id: str, path: str) -> WorkspaceDownloadResult:
self.calls.append(("download", session_id, path))
return WorkspaceDownloadResult(path=path, size=3, truncated=False, content=b"abc")
def _service(
snapshot: CompositorSessionSnapshot | None,
) -> tuple[AgentAppWorkspaceService, FakeClient, FakeStore]:
store = FakeStore(snapshot)
client = FakeClient()
service = AgentAppWorkspaceService(session_store=store, client_factory=lambda: client) # type: ignore[arg-type]
return service, client, store
def test_list_resolves_session_id_and_proxies() -> None:
service, client, store = _service(_snapshot(session_id="abc1234"))
result = service.list_files(tenant_id="t1", app_id="app1", conversation_id="conv1", path="sub")
assert result.entries[0].name == "a.txt"
assert client.calls == [("list", "abc1234", "sub")]
assert store.scope == ("t1", "app1", "conv1")
def test_preview_and_download_use_resolved_session() -> None:
service, client, _ = _service(_snapshot(session_id="abc1234"))
preview = service.preview(tenant_id="t", app_id="a", conversation_id="c", path="n.txt")
download = service.download(tenant_id="t", app_id="a", conversation_id="c", path="b.bin")
assert preview.text == "hello"
assert download.content == b"abc"
assert client.calls == [("preview", "abc1234", "n.txt"), ("download", "abc1234", "b.bin")]
def test_no_active_session_raises_404() -> None:
service, client, _ = _service(None)
with pytest.raises(AgentWorkspaceInspectorError) as exc_info:
service.list_files(tenant_id="t", app_id="a", conversation_id="c", path=".")
assert exc_info.value.code == "no_active_session"
assert exc_info.value.status_code == 404
assert client.calls == []
def test_snapshot_without_shell_layer_raises_no_sandbox() -> None:
service, _, _ = _service(_snapshot(shell=False))
with pytest.raises(AgentWorkspaceInspectorError) as exc_info:
service.list_files(tenant_id="t", app_id="a", conversation_id="c", path=".")
assert exc_info.value.code == "no_sandbox"
assert exc_info.value.status_code == 404
def test_shell_layer_without_session_id_raises_no_sandbox() -> None:
service, _, _ = _service(_snapshot(session_id=None))
with pytest.raises(AgentWorkspaceInspectorError) as exc_info:
service.preview(tenant_id="t", app_id="a", conversation_id="c", path="n.txt")
assert exc_info.value.code == "no_sandbox"
def test_default_client_factory_requires_agent_backend_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("services.agent_app_workspace_service.dify_config.AGENT_BACKEND_BASE_URL", "")
with pytest.raises(AgentWorkspaceInspectorError) as exc_info:
_default_client_factory()
assert exc_info.value.code == "inspector_unavailable"
assert exc_info.value.status_code == 503
@pytest.fixture
def _runtime_session_table() -> Generator[None, None, None]:
engine = session_factory.get_session_maker().kw["bind"]
AgentRuntimeSession.__table__.create(bind=engine, checkfirst=True)
yield
with session_factory.create_session() as session:
session.execute(delete(AgentRuntimeSession))
session.commit()
AgentRuntimeSession.__table__.drop(bind=engine, checkfirst=True)
def _insert_workflow_session(
*,
workflow_run_id: str = "run-1",
node_id: str = "node-1",
node_execution_id: str = "node-exec-1",
binding_id: str = "binding-1",
session_id: str = "abc1234",
) -> None:
with session_factory.create_session() as session:
session.add(
AgentRuntimeSession(
tenant_id="tenant-1",
app_id="app-1",
owner_type=AgentRuntimeSessionOwnerType.WORKFLOW_RUN,
workflow_id="workflow-1",
workflow_run_id=workflow_run_id,
node_id=node_id,
node_execution_id=node_execution_id,
binding_id=binding_id,
agent_id="agent-1",
agent_config_snapshot_id="snapshot-1",
backend_run_id="backend-run-1",
session_snapshot=_snapshot(session_id=session_id).model_dump_json(),
composition_layer_specs="[]",
status=AgentRuntimeSessionStatus.ACTIVE,
)
)
session.commit()
@pytest.mark.usefixtures("_runtime_session_table")
def test_workflow_workspace_service_resolves_run_node_session_and_proxies() -> None:
_insert_workflow_session(session_id="def5678")
client = FakeClient()
service = WorkflowAgentWorkspaceService(client_factory=lambda: client) # type: ignore[arg-type]
result = service.list_files(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id="node-exec-1",
path=".",
)
assert result.entries[0].name == "a.txt"
assert client.calls == [("list", "def5678", ".")]
@pytest.mark.usefixtures("_runtime_session_table")
def test_workflow_workspace_service_filters_by_node_execution_id() -> None:
_insert_workflow_session(node_execution_id="node-exec-1", session_id="abc1234")
_insert_workflow_session(node_execution_id="node-exec-2", binding_id="binding-2", session_id="def5678")
client = FakeClient()
service = WorkflowAgentWorkspaceService(client_factory=lambda: client) # type: ignore[arg-type]
_ = service.preview(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id="node-exec-2",
path="out.txt",
)
assert client.calls == [("preview", "def5678", "out.txt")]
@pytest.mark.usefixtures("_runtime_session_table")
def test_workflow_workspace_service_download_uses_latest_active_session_when_execution_id_is_omitted() -> None:
_insert_workflow_session(node_execution_id="node-exec-1", session_id="abc1234")
client = FakeClient()
service = WorkflowAgentWorkspaceService(client_factory=lambda: client) # type: ignore[arg-type]
result = service.download(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id=None,
path="out.bin",
)
assert result.content == b"abc"
assert client.calls == [("download", "abc1234", "out.bin")]
@pytest.mark.usefixtures("_runtime_session_table")
def test_workflow_workspace_service_raises_when_no_active_session_exists() -> None:
client = FakeClient()
service = WorkflowAgentWorkspaceService(client_factory=lambda: client) # type: ignore[arg-type]
with pytest.raises(AgentWorkspaceInspectorError) as exc_info:
service.list_files(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id=None,
path=".",
)
assert exc_info.value.code == "no_active_session"
assert exc_info.value.status_code == 404
assert client.calls == []
@pytest.mark.usefixtures("_runtime_session_table")
def test_workflow_workspace_service_raises_when_snapshot_has_no_shell_session() -> None:
_insert_workflow_session(session_id="")
client = FakeClient()
service = WorkflowAgentWorkspaceService(client_factory=lambda: client) # type: ignore[arg-type]
with pytest.raises(AgentWorkspaceInspectorError) as exc_info:
service.preview(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id=None,
path="out.txt",
)
assert exc_info.value.code == "no_sandbox"
assert client.calls == []

View File

@ -1,4 +1,4 @@
"""Unified sync and async Python client for the Dify Agent run API."""
"""Unified sync and async Python client for the Dify Agent HTTP API."""
from ._client import (
Client,

View File

@ -1,13 +1,12 @@
"""HTTPX-based client for Dify Agent runs.
"""HTTPX-based client for the Dify Agent HTTP API.
The client uses the public DTOs from ``dify_agent.protocol.schemas`` for all
normal request and response parsing. It intentionally does not retry
``POST /runs`` because create-run is not idempotent, and create helpers require a
``CreateRunRequest`` instance rather than accepting raw payload dicts. SSE
streams are the only operation with reconnect logic: transient stream, connect,
or read failures, stream timeouts, and HTTP 5xx stream responses reconnect with
the latest observed event id, while HTTP 4xx responses, DTO validation failures,
and malformed SSE frames fail immediately.
The client uses the public DTOs from ``dify_agent.protocol`` for request and
response parsing across both run-management and sandbox-file endpoints. It
intentionally does not retry non-idempotent ``POST`` requests such as
``/runs``. SSE streams are the only operation with reconnect logic: transient
stream, connect, or read failures, stream timeouts, and HTTP 5xx stream
responses reconnect with the latest observed event id, while HTTP 4xx
responses, DTO validation failures, and malformed SSE frames fail immediately.
"""
from __future__ import annotations
@ -26,7 +25,7 @@ import httpx
from pydantic import BaseModel, ValidationError
from pydantic_ai.messages import FunctionToolResultEvent
from dify_agent.protocol.schemas import (
from dify_agent.protocol import (
CancelRunRequest,
CancelRunResponse,
CreateRunRequest,
@ -35,6 +34,13 @@ from dify_agent.protocol.schemas import (
RunEvent,
RunEventsResponse,
RunStatusResponse,
SandboxListRequest,
SandboxListResponse,
SandboxLocator,
SandboxReadRequest,
SandboxReadResponse,
SandboxUploadRequest,
SandboxUploadResponse,
)
_ResponseModelT = TypeVar("_ResponseModelT", bound=BaseModel)
@ -60,7 +66,7 @@ class DifyAgentHTTPError(DifyAgentClientError):
class DifyAgentNotFoundError(DifyAgentHTTPError):
"""Raised when the server returns ``404`` for a run resource."""
"""Raised when the server returns ``404`` for a requested Dify Agent resource."""
class DifyAgentValidationError(DifyAgentHTTPError):
@ -201,13 +207,15 @@ def _normalize_run_event_payload_for_local_pydantic_ai(payload: Any) -> Any:
class Client:
"""Unified synchronous and asynchronous client for Dify Agent runs.
"""Unified synchronous and asynchronous client for the Dify Agent HTTP API.
The instance is intentionally small and stateful: it stores base URL, default
headers, timeout settings, optional external HTTPX clients, and lazy-owned
clients for whichever sync/async side is used. External clients are never
closed by this wrapper. Owned sync clients close via ``close_sync`` or the
sync context manager; owned async clients close via ``aclose`` or the async
clients for whichever sync/async side is used. It is the shared transport
boundary for both run-management endpoints (create/status/events/cancel) and
sandbox-file endpoints (list/read/upload). External clients are never closed
by this wrapper. Owned sync clients close via ``close_sync`` or the sync
context manager; owned async clients close via ``aclose`` or the async
context manager.
"""
@ -419,6 +427,62 @@ class Client:
raise DifyAgentClientError(f"get_events_sync request failed: {exc}") from exc
return _parse_model_response(response, RunEventsResponse)
async def list_sandbox_files(self, locator: SandboxLocator, path: str) -> SandboxListResponse:
"""List a sandbox directory through ``POST /sandbox/files/list``."""
request_model = _build_request_model(SandboxListRequest, locator=locator, path=path)
response = await self._post_async_json("list_sandbox_files", "/sandbox/files/list", request_model)
return _parse_model_response(response, SandboxListResponse)
def list_sandbox_files_sync(self, locator: SandboxLocator, path: str) -> SandboxListResponse:
"""Synchronous variant of ``list_sandbox_files``."""
request_model = _build_request_model(SandboxListRequest, locator=locator, path=path)
response = self._post_sync_json("list_sandbox_files_sync", "/sandbox/files/list", request_model)
return _parse_model_response(response, SandboxListResponse)
async def read_sandbox_file(
self,
locator: SandboxLocator,
path: str,
max_bytes: int = 262144,
) -> SandboxReadResponse:
"""Read a sandbox file preview through ``POST /sandbox/files/read``."""
request_model = _build_request_model(
SandboxReadRequest,
locator=locator,
path=path,
max_bytes=max_bytes,
)
response = await self._post_async_json("read_sandbox_file", "/sandbox/files/read", request_model)
return _parse_model_response(response, SandboxReadResponse)
def read_sandbox_file_sync(
self,
locator: SandboxLocator,
path: str,
max_bytes: int = 262144,
) -> SandboxReadResponse:
"""Synchronous variant of ``read_sandbox_file``."""
request_model = _build_request_model(
SandboxReadRequest,
locator=locator,
path=path,
max_bytes=max_bytes,
)
response = self._post_sync_json("read_sandbox_file_sync", "/sandbox/files/read", request_model)
return _parse_model_response(response, SandboxReadResponse)
async def upload_sandbox_file(self, locator: SandboxLocator, path: str) -> SandboxUploadResponse:
"""Upload a sandbox file mapping through ``POST /sandbox/files/upload``."""
request_model = _build_request_model(SandboxUploadRequest, locator=locator, path=path)
response = await self._post_async_json("upload_sandbox_file", "/sandbox/files/upload", request_model)
return _parse_model_response(response, SandboxUploadResponse)
def upload_sandbox_file_sync(self, locator: SandboxLocator, path: str) -> SandboxUploadResponse:
"""Synchronous variant of ``upload_sandbox_file``."""
request_model = _build_request_model(SandboxUploadRequest, locator=locator, path=path)
response = self._post_sync_json("upload_sandbox_file_sync", "/sandbox/files/upload", request_model)
return _parse_model_response(response, SandboxUploadResponse)
async def stream_events(
self,
run_id: str,
@ -631,6 +695,32 @@ class Client:
headers.update(extra)
return headers
async def _post_async_json(self, operation: str, path: str, request_model: BaseModel) -> httpx.Response:
try:
return await self._get_async_http_client().post(
self._url(path),
content=request_model.model_dump_json(),
headers=self._merged_headers({"Content-Type": "application/json"}),
timeout=self._timeout,
)
except httpx.TimeoutException as exc:
raise DifyAgentTimeoutError(f"{operation} timed out") from exc
except httpx.RequestError as exc:
raise DifyAgentClientError(f"{operation} request failed: {exc}") from exc
def _post_sync_json(self, operation: str, path: str, request_model: BaseModel) -> httpx.Response:
try:
return self._get_sync_http_client().post(
self._url(path),
content=request_model.model_dump_json(),
headers=self._merged_headers({"Content-Type": "application/json"}),
timeout=self._timeout,
)
except httpx.TimeoutException as exc:
raise DifyAgentTimeoutError(f"{operation} timed out") from exc
except httpx.RequestError as exc:
raise DifyAgentClientError(f"{operation} request failed: {exc}") from exc
def _validate_create_run_request(request: CreateRunRequest) -> CreateRunRequest:
"""Reject raw payloads so create-run uses the public request DTO boundary."""
@ -639,6 +729,16 @@ def _validate_create_run_request(request: CreateRunRequest) -> CreateRunRequest:
raise DifyAgentValidationError(detail="request must be a CreateRunRequest")
def _build_request_model[_RequestModelT: BaseModel](
model_type: type[_RequestModelT], /, **payload: object
) -> _RequestModelT:
"""Validate one request DTO built from method parameters."""
try:
return model_type.model_validate(payload)
except ValidationError as exc:
raise DifyAgentValidationError(detail=exc.errors(include_url=False)) from exc
def _parse_model_response(response: httpx.Response, model_type: type[_ResponseModelT]) -> _ResponseModelT:
"""Map HTTP errors and parse a Pydantic response DTO."""
_raise_for_status(response)

View File

@ -19,12 +19,13 @@ Agenton still exits ``resource_context()`` but never transitions the layer to
``ACTIVE``. In that failed-enter path, normal suspend/delete hooks do not run,
so the enter hook itself must perform best-effort business compensation before
re-raising the failure. Agent Stub env injection uses shellctl's native per-run
``env`` argument only for user-visible ``shell.run``.
``env`` argument for user-visible ``shell.run`` and for trusted server-owned
fixed scripts executed through ``run_remote_script()``.
"""
from __future__ import annotations
from collections.abc import AsyncGenerator, Callable, Mapping, Sequence
from collections.abc import AsyncGenerator, Callable, Sequence
from contextlib import asynccontextmanager
import json
import logging
@ -60,6 +61,7 @@ _SESSION_TIME_HEX_MASK = 0xFFFFF
_SESSION_RANDOM_HEX_LENGTH = 2
_SESSION_ID_ATTEMPT_LIMIT = 256
_SESSION_ID_PATTERN = re.compile(r"^[0-9a-f]{7}$")
_REMOTE_COMMAND_MAX_OUTPUT_WINDOWS = 64
_SHELL_LAYER_PREFIX_PROMPT = """You have access to a shell layer. It provides four tools:
1. shell_run
@ -179,7 +181,7 @@ class ShellctlClientProtocol(Protocol):
script: str,
*,
cwd: str | None = None,
env: Mapping[str, str] | None = None,
env: dict[str, str] | None = None,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
) -> JobResult: ...
@ -275,6 +277,20 @@ class DifyShellRuntimeState(BaseModel):
return self
@dataclass(frozen=True, slots=True)
class RemoteCommandResult:
"""Completed remote sandbox command returned to server-owned callers."""
job_id: str
status: str
done: bool
exit_code: int | None
output: str
offset: int
truncated: bool
output_path: str
@dataclass(slots=True)
class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerConfig, DifyShellRuntimeState]):
"""Shell tool layer backed by a live shellctl client while active.
@ -500,6 +516,35 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
except (RuntimeError, ValueError, ShellctlClientError) as exc:
return _tool_error(str(exc), job_id=job_id)
async def run_remote_script(
self,
script: str,
*,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
inject_agent_stub_env: bool = False,
) -> RemoteCommandResult:
"""Run one trusted server-side script inside the sandbox workspace.
The sandbox file service uses this boundary for fixed list/read/upload
helpers. The layer owns output paging, transient shellctl job cleanup,
and optional Agent Stub env injection.
Unlike model-visible ``shell.run``, this server-owned boundary does not
source ``.dify/env.sh``. That file is user-controlled shell config, so
sourcing it here would let sandbox code clobber trusted Agent Stub env
values before ``dify-agent file upload`` executes.
"""
env = None
if inject_agent_stub_env:
env = self._build_user_shell_run_env()
if env is None:
raise RuntimeError("Agent Stub environment injection is not available for this shell session.")
return await self._run_remote_job_to_completion(
script,
timeout=timeout,
env=env,
)
async def _allocate_workspace(self) -> tuple[str, str]:
"""Allocate a unique ``~/workspace/<session_id>`` directory by mkdir collision checks."""
for _attempt in range(_SESSION_ID_ATTEMPT_LIMIT):
@ -564,6 +609,44 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
self._track_job_result(result)
return _job_result_observation(result)
async def _run_remote_job_to_completion(
self,
script: str,
*,
timeout: float,
env: dict[str, str] | None,
) -> RemoteCommandResult:
"""Run a workspace-scoped script to completion and delete its job state."""
client = self._require_client()
job_id: str | None = None
try:
result = await client.run(script, cwd=self._require_workspace_cwd(), env=env, timeout=timeout)
job_id = result.job_id
self._track_job_result(result)
output_parts = [result.output]
truncated = result.truncated
windows = 1
while (result.truncated or not result.done) and windows < _REMOTE_COMMAND_MAX_OUTPUT_WINDOWS:
result = await client.wait(result.job_id, offset=self._tracked_offset(result.job_id), timeout=timeout)
self._track_job_result(result)
output_parts.append(result.output)
truncated = truncated or result.truncated
windows += 1
return RemoteCommandResult(
job_id=result.job_id,
status=result.status.value,
done=result.done,
exit_code=result.exit_code,
output="".join(output_parts),
offset=result.offset,
truncated=truncated,
output_path=result.output_path,
)
finally:
if job_id is not None:
await self._delete_job_best_effort(job_id)
self._forget_tracked_job(job_id)
def _require_client(self) -> ShellctlClientProtocol:
"""Return the live client or reject tool/lifecycle use without one."""
if self._shellctl_client is None:
@ -635,31 +718,44 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
async def _delete_tracked_jobs_best_effort(self, job_ids: Sequence[str]) -> None:
"""Force-delete tracked shellctl jobs, ignoring already-missing ones."""
client = self._require_client()
for job_id in _deduplicate_preserving_order(job_ids):
try:
_ = await client.delete(job_id, force=True)
except ShellctlClientError as exc:
if exc.code == "job_not_found":
continue
logger.warning(
"Failed to delete shellctl job %s for session %s: %s",
job_id,
self.runtime_state.session_id,
exc,
)
except RuntimeError as exc:
logger.warning(
"Failed to delete shellctl job %s for session %s: %s",
job_id,
self.runtime_state.session_id,
exc,
)
await self._delete_job_best_effort(job_id)
def _clear_tracked_jobs(self) -> None:
self.runtime_state.job_offsets = {}
self.runtime_state.job_ids = []
async def _delete_job_best_effort(self, job_id: str) -> None:
client = self._require_client()
try:
_ = await client.delete(job_id, force=True)
except ShellctlClientError as exc:
if exc.code == "job_not_found":
return
logger.warning(
"Failed to delete shellctl job %s for session %s: %s",
job_id,
self.runtime_state.session_id,
exc,
)
except RuntimeError as exc:
logger.warning(
"Failed to delete shellctl job %s for session %s: %s",
job_id,
self.runtime_state.session_id,
exc,
)
def _forget_tracked_job(self, job_id: str) -> None:
if job_id not in self.runtime_state.job_ids and job_id not in self.runtime_state.job_offsets:
return
job_offsets = dict(self.runtime_state.job_offsets)
_ = job_offsets.pop(job_id, None)
self.runtime_state.job_offsets = job_offsets
self.runtime_state.job_ids = [
tracked_job_id for tracked_job_id in self.runtime_state.job_ids if tracked_job_id != job_id
]
def _build_user_shell_run_env(self) -> dict[str, str] | None:
"""Build per-command Agent Stub env only for user-visible ``shell.run``."""
execution_context_layer = self.deps.execution_context
@ -834,6 +930,7 @@ __all__ = [
"DifyShellLayerDeps",
"DifyShellLayer",
"DifyShellRuntimeState",
"RemoteCommandResult",
"ShellctlClientFactory",
"ShellctlClientProtocol",
"create_shellctl_client_factory",

View File

@ -37,6 +37,21 @@ from .schemas import (
normalize_composition,
utc_now,
)
from .sandbox import (
RuntimeLayerSpec,
SandboxFileEntry,
SandboxListRequest,
SandboxListResponse,
SandboxLocator,
SandboxReadRequest,
SandboxReadResponse,
SandboxUploadRequest,
SandboxUploadResponse,
SandboxUploadedFile,
build_sandbox_locator_from_layer_specs,
build_sandbox_locator_from_run_request,
extract_runtime_layer_specs,
)
__all__ = [
"BaseRunEvent",
@ -68,6 +83,19 @@ __all__ = [
"RunStatusResponse",
"RunSucceededEvent",
"RunSucceededEventData",
"RuntimeLayerSpec",
"SandboxFileEntry",
"SandboxListRequest",
"SandboxListResponse",
"SandboxLocator",
"SandboxReadRequest",
"SandboxReadResponse",
"SandboxUploadRequest",
"SandboxUploadResponse",
"SandboxUploadedFile",
"build_sandbox_locator_from_layer_specs",
"build_sandbox_locator_from_run_request",
"extract_runtime_layer_specs",
"normalize_composition",
"utc_now",
]

View File

@ -0,0 +1,246 @@
"""Public sandbox DTOs shared by the API and Dify Agent backends.
The sandbox file APIs must rebuild only the minimum runtime needed to re-enter a
prior shell session: ``dify.execution_context`` for Dify-owned identity and
``dify.shell`` for the sandbox workspace itself. ``SandboxLocator`` therefore
contains a safe composition subset plus the matching filtered session snapshot.
Credential-bearing plugin layers are intentionally excluded from persisted
runtime specs and from sandbox locators.
"""
from __future__ import annotations
from typing import ClassVar, Literal, cast
from agenton.compositor import CompositorSessionSnapshot
from agenton.compositor.schemas import LayerSessionSnapshot
from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID
from pydantic import BaseModel, ConfigDict, Field, JsonValue
from .schemas import CreateRunRequest, RunComposition, RunLayerSpec
_SENSITIVE_LAYER_TYPES = frozenset({DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID})
class RuntimeLayerSpec(BaseModel):
"""Persistable non-sensitive layer spec derived from a run composition.
API-side runtime-session rows store these specs so later cleanup or sandbox
requests can rebuild the minimal layer graph without persisting model or
tool credentials.
"""
name: str
type: str
deps: dict[str, str] = Field(default_factory=dict)
metadata: dict[str, JsonValue] = Field(default_factory=dict)
config: JsonValue = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class SandboxLocator(BaseModel):
"""Safe subset of one prior run request needed to re-enter a sandbox shell."""
composition: RunComposition
session_snapshot: CompositorSessionSnapshot
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class SandboxFileEntry(BaseModel):
"""One directory entry returned by ``/sandbox/files/list``."""
name: str
type: Literal["file", "dir", "symlink", "other"]
size: int | None = None
mtime: int | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class SandboxListRequest(BaseModel):
"""Request body for listing a sandbox directory."""
locator: SandboxLocator
path: str = "."
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class SandboxListResponse(BaseModel):
"""Structured sandbox directory listing."""
path: str
entries: list[SandboxFileEntry]
truncated: bool
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class SandboxReadRequest(BaseModel):
"""Request body for reading a sandbox file preview."""
locator: SandboxLocator
path: str
max_bytes: int = 262144
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class SandboxReadResponse(BaseModel):
"""Text preview returned by ``/sandbox/files/read``."""
path: str
size: int | None = None
truncated: bool
binary: bool
text: str | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class SandboxUploadedFile(BaseModel):
"""Canonical ToolFile mapping returned after sandbox upload."""
transfer_method: Literal["tool_file"] = "tool_file"
reference: str
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class SandboxUploadRequest(BaseModel):
"""Request body for uploading one sandbox file through the Agent Stub."""
locator: SandboxLocator
path: str
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class SandboxUploadResponse(BaseModel):
"""Result returned after sandbox upload creates a ToolFile mapping."""
path: str
file: SandboxUploadedFile
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
def extract_runtime_layer_specs(composition: RunComposition) -> list[RuntimeLayerSpec]:
"""Project a run composition into the persistable non-sensitive layer list."""
specs: list[RuntimeLayerSpec] = []
for layer in composition.layers:
if layer.type in _SENSITIVE_LAYER_TYPES:
continue
config_value: JsonValue = None
if isinstance(layer.config, BaseModel):
config_value = layer.config.model_dump(mode="json", warnings=False)
else:
config_value = cast(JsonValue, layer.config)
specs.append(
RuntimeLayerSpec(
name=layer.name,
type=layer.type,
deps=dict(layer.deps),
metadata=dict(layer.metadata),
config=config_value,
)
)
return specs
def build_sandbox_locator_from_run_request(request: CreateRunRequest) -> SandboxLocator:
"""Build a safe sandbox locator from a full create-run request.
Raises:
ValueError: if the request has no resumable session snapshot or lacks the
execution-context/shell layers needed for sandbox access.
"""
if request.session_snapshot is None:
raise ValueError("Sandbox locator requires a non-empty session_snapshot.")
return build_sandbox_locator_from_layer_specs(
layer_specs=extract_runtime_layer_specs(request.composition),
session_snapshot=request.session_snapshot,
)
def build_sandbox_locator_from_layer_specs(
*,
layer_specs: list[RuntimeLayerSpec],
session_snapshot: CompositorSessionSnapshot,
) -> SandboxLocator:
"""Build a sandbox locator from persisted runtime specs plus a saved snapshot."""
if not layer_specs:
raise ValueError("Sandbox locator requires persisted runtime layer specs.")
for spec in layer_specs:
if spec.type in _SENSITIVE_LAYER_TYPES:
raise ValueError(f"Sandbox locator runtime specs must not include sensitive layer type {spec.type!r}.")
execution_context_index = next(
(index for index, spec in enumerate(layer_specs) if spec.name == "execution_context"),
None,
)
shell_index = next((index for index, spec in enumerate(layer_specs) if spec.name == "shell"), None)
if execution_context_index is None:
raise ValueError("Sandbox locator requires an 'execution_context' runtime layer spec.")
if shell_index is None:
raise ValueError("Sandbox locator requires a 'shell' runtime layer spec.")
if execution_context_index > shell_index:
raise ValueError("Sandbox locator requires 'execution_context' to appear before 'shell'.")
execution_context_spec = layer_specs[execution_context_index]
shell_spec = layer_specs[shell_index]
if shell_spec.deps.get("execution_context") != execution_context_spec.name:
raise ValueError("Sandbox shell layer must depend on the execution_context layer.")
kept_specs = [execution_context_spec, shell_spec]
kept_names = [spec.name for spec in kept_specs]
snapshot_layers = [layer for layer in session_snapshot.layers if layer.name in set(kept_names)]
if [layer.name for layer in snapshot_layers] != kept_names:
raise ValueError("Sandbox locator session_snapshot must contain execution_context and shell layers in order.")
return SandboxLocator(
composition=RunComposition(
schema_version=1,
layers=[
RunLayerSpec(
name=spec.name,
type=spec.type,
deps=dict(spec.deps),
metadata=dict(spec.metadata),
config=spec.config,
)
for spec in kept_specs
],
),
session_snapshot=CompositorSessionSnapshot(
schema_version=session_snapshot.schema_version,
layers=[
LayerSessionSnapshot(
name=layer.name,
lifecycle_state=layer.lifecycle_state,
runtime_state=dict(layer.runtime_state),
)
for layer in snapshot_layers
],
),
)
__all__ = [
"RuntimeLayerSpec",
"SandboxFileEntry",
"SandboxListRequest",
"SandboxListResponse",
"SandboxLocator",
"SandboxReadRequest",
"SandboxReadResponse",
"SandboxUploadRequest",
"SandboxUploadResponse",
"SandboxUploadedFile",
"build_sandbox_locator_from_layer_specs",
"build_sandbox_locator_from_run_request",
"extract_runtime_layer_specs",
]

View File

@ -25,9 +25,9 @@ from dify_agent.agent_stub.server.router import create_agent_stub_router
from dify_agent.runtime.compositor_factory import create_default_layer_providers
from dify_agent.runtime.run_scheduler import RunScheduler
from dify_agent.server.routes.runs import create_runs_router
from dify_agent.server.routes.workspace_files import create_workspace_files_router
from dify_agent.server.routes.sandbox_files import create_sandbox_files_router
from dify_agent.server.sandbox_files import SandboxFileService
from dify_agent.server.settings import ServerSettings
from dify_agent.server.workspace_files import WorkspaceFileService
from dify_agent.storage.redis_run_store import RedisRunStore
@ -44,13 +44,8 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
agent_stub_url=resolved_settings.agent_stub_url,
agent_stub_token_codec=agent_stub_token_codec,
)
workspace_file_service = (
WorkspaceFileService(
shellctl_entrypoint=resolved_settings.shellctl_entrypoint,
shellctl_auth_token=resolved_settings.shellctl_auth_token,
)
if resolved_settings.shellctl_entrypoint
else None
sandbox_file_service = (
SandboxFileService(layer_providers=layer_providers) if resolved_settings.shellctl_entrypoint else None
)
state: dict[str, object] = {}
@ -100,8 +95,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
return state["scheduler"] # pyright: ignore[reportReturnType]
app.include_router(create_runs_router(get_store, get_scheduler))
# TODO: refactor
app.include_router(create_workspace_files_router(lambda: workspace_file_service))
app.include_router(create_sandbox_files_router(lambda: sandbox_file_service))
app.include_router(
create_agent_stub_router(
token_codec=agent_stub_token_codec,

View File

@ -0,0 +1,73 @@
"""FastAPI routes for sandbox file operations.
The agent backend receives a structured ``SandboxLocator`` rather than a raw
shell session id. Routes stay private-network only like ``/runs`` and forward
all sandbox work to ``SandboxFileService``.
"""
from collections.abc import Callable
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from dify_agent.protocol import (
SandboxListRequest,
SandboxListResponse,
SandboxReadRequest,
SandboxReadResponse,
SandboxUploadRequest,
SandboxUploadResponse,
)
from dify_agent.server.sandbox_files import SandboxFileError, SandboxFileService
def create_sandbox_files_router(get_service: Callable[[], SandboxFileService | None]) -> APIRouter:
"""Create sandbox file routes bound to the app's service provider."""
router = APIRouter(prefix="/sandbox", tags=["sandbox"])
def service_dep() -> SandboxFileService:
service = get_service()
if service is None:
raise HTTPException(
status_code=503,
detail={"code": "sandbox_backend_unavailable", "message": "sandbox service is not configured"},
)
return service
def raise_http(exc: SandboxFileError) -> HTTPException:
return HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message})
@router.post("/files/list", response_model=SandboxListResponse)
async def list_files(
request: SandboxListRequest,
service: Annotated[SandboxFileService, Depends(service_dep)],
) -> SandboxListResponse:
try:
return await service.list_files(request)
except SandboxFileError as exc:
raise raise_http(exc) from exc
@router.post("/files/read", response_model=SandboxReadResponse)
async def read_file(
request: SandboxReadRequest,
service: Annotated[SandboxFileService, Depends(service_dep)],
) -> SandboxReadResponse:
try:
return await service.read_file(request)
except SandboxFileError as exc:
raise raise_http(exc) from exc
@router.post("/files/upload", response_model=SandboxUploadResponse)
async def upload_file(
request: SandboxUploadRequest,
service: Annotated[SandboxFileService, Depends(service_dep)],
) -> SandboxUploadResponse:
try:
return await service.upload_file(request)
except SandboxFileError as exc:
raise raise_http(exc) from exc
return router
__all__ = ["create_sandbox_files_router"]

View File

@ -1,78 +0,0 @@
"""FastAPI routes for read-only inspection of shell-layer workspaces.
These endpoints back the Dify "sandbox file system" inspector. They are
read-only and scoped to a single ``~/workspace/<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,399 @@
"""Sandbox file service that re-enters prior shell sessions through the shell layer.
Unlike the removed workspace inspector, this service never talks to shellctl
directly and never reads sandbox files outside the shell layer. It rebuilds a
minimal compositor from ``SandboxLocator``, enters the saved
``execution_context`` + ``shell`` layers, and executes fixed scripts through
``DifyShellLayer.run_remote_script()``.
The scripts still frame their structured payloads with a PTY-safe
base64-between-sentinels envelope. shellctl jobs are tmux-backed, so raw JSON can
be wrapped or surrounded by prompt noise; the framing keeps list/read/upload
responses parseable without falling back to direct shellctl file access.
"""
from __future__ import annotations
import json
import base64
import binascii
import shlex
import textwrap
from dataclasses import dataclass
from typing import TypeVar, cast
from dify_agent.layers.shell.layer import DifyShellLayer, RemoteCommandResult
from dify_agent.protocol import (
SandboxListRequest,
SandboxListResponse,
SandboxLocator,
SandboxReadRequest,
SandboxReadResponse,
SandboxUploadRequest,
SandboxUploadResponse,
normalize_composition,
)
from pydantic import BaseModel, ValidationError
from dify_agent.runtime.compositor_factory import DifyAgentLayerProvider, build_pydantic_ai_compositor
_LIST_MAX_ENTRIES = 1000
_LIST_TIMEOUT_SECONDS = 10.0
_READ_TIMEOUT_SECONDS = 15.0
_UPLOAD_TIMEOUT_SECONDS = 30.0
_OUTPUT_BEGIN = "<<<DIFY_SANDBOX_BEGIN>>>"
_OUTPUT_END = "<<<DIFY_SANDBOX_END>>>"
ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel)
_LIST_SCRIPT = """
import base64
import json
import stat
import sys
from pathlib import Path
BEGIN = "<<<DIFY_SANDBOX_BEGIN>>>"
END = "<<<DIFY_SANDBOX_END>>>"
def emit(payload):
blob = base64.b64encode(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("ascii")
print(BEGIN + blob + END)
def resolve_target(root: Path, raw_path: str) -> tuple[Path | None, str]:
target = (root / raw_path).resolve()
if target != root and root not in target.parents:
emit({"error": "invalid_sandbox_path", "message": "path escapes the sandbox workspace"})
return None, raw_path
return target, raw_path
root = Path.cwd().resolve()
raw_path = sys.argv[1]
limit = int(sys.argv[2])
target_info = resolve_target(root, raw_path)
if target_info[0] is None:
sys.exit(0)
target, normalized_path = target_info
if not target.exists():
emit({"error": "sandbox_path_not_found", "message": "path not found in sandbox"})
sys.exit(0)
if not target.is_dir():
emit({"error": "sandbox_path_not_readable", "message": "path is not a directory"})
sys.exit(0)
entries = []
for child in sorted(target.iterdir(), key=lambda item: item.name)[:limit]:
child_stat = child.lstat()
mode = child_stat.st_mode
if stat.S_ISLNK(mode):
entry_type = "symlink"
elif stat.S_ISDIR(mode):
entry_type = "dir"
elif stat.S_ISREG(mode):
entry_type = "file"
else:
entry_type = "other"
entries.append(
{
"name": child.name,
"type": entry_type,
"size": int(child_stat.st_size),
"mtime": int(child_stat.st_mtime),
}
)
emit(
{
"path": normalized_path,
"entries": entries,
"truncated": len(list(target.iterdir())) > limit,
}
)
"""
_READ_SCRIPT = """
import base64
import json
import sys
from pathlib import Path
BEGIN = "<<<DIFY_SANDBOX_BEGIN>>>"
END = "<<<DIFY_SANDBOX_END>>>"
def emit(payload):
blob = base64.b64encode(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("ascii")
print(BEGIN + blob + END)
root = Path.cwd().resolve()
raw_path = sys.argv[1]
max_bytes = int(sys.argv[2])
target = (root / raw_path).resolve()
if target != root and root not in target.parents:
emit({"error": "invalid_sandbox_path", "message": "path escapes the sandbox workspace"})
sys.exit(0)
if not target.exists():
emit({"error": "sandbox_path_not_found", "message": "path not found in sandbox"})
sys.exit(0)
if not target.is_file():
emit({"error": "sandbox_path_not_readable", "message": "path is not a readable file"})
sys.exit(0)
size = int(target.stat().st_size)
with target.open("rb") as file_obj:
data = file_obj.read(max_bytes + 1)
truncated = len(data) > max_bytes
data = data[:max_bytes]
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
emit(
{
"path": raw_path,
"size": size,
"truncated": truncated,
"binary": True,
"text": None,
}
)
sys.exit(0)
emit(
{
"path": raw_path,
"size": size,
"truncated": truncated,
"binary": False,
"text": text,
}
)
"""
_UPLOAD_SCRIPT = """
import base64
import json
import subprocess
import sys
from pathlib import Path
BEGIN = "<<<DIFY_SANDBOX_BEGIN>>>"
END = "<<<DIFY_SANDBOX_END>>>"
def emit(payload):
blob = base64.b64encode(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("ascii")
print(BEGIN + blob + END)
root = Path.cwd().resolve()
raw_path = sys.argv[1]
target = (root / raw_path).resolve()
if target != root and root not in target.parents:
emit({"error": "invalid_sandbox_path", "message": "path escapes the sandbox workspace"})
sys.exit(0)
if not target.exists():
emit({"error": "sandbox_path_not_found", "message": "path not found in sandbox"})
sys.exit(0)
if not target.is_file():
emit({"error": "sandbox_path_not_readable", "message": "path is not a readable file"})
sys.exit(0)
command = ["dify-agent", "file", "upload", raw_path]
completed = subprocess.run(command, capture_output=True, text=True, check=False)
if completed.returncode != 0:
emit(
{
"error": "agent_stub_upload_failed",
"message": (completed.stderr or completed.stdout or f"upload exited with code {completed.returncode}").strip(),
}
)
sys.exit(0)
try:
file_mapping = json.loads(completed.stdout)
except ValueError as exc:
emit({"error": "agent_stub_upload_failed", "message": f"upload returned invalid JSON: {exc}"})
sys.exit(0)
emit({"path": raw_path, "file": file_mapping})
"""
class SandboxFileError(Exception):
"""Sandbox file failure mapped to HTTP by the FastAPI route layer."""
code: str
message: str
status_code: int
def __init__(self, code: str, message: str, *, status_code: int = 400) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
@dataclass(slots=True)
class SandboxFileService:
"""Execute fixed sandbox file operations through the saved shell session."""
layer_providers: tuple[DifyAgentLayerProvider, ...]
async def list_files(self, request: SandboxListRequest) -> SandboxListResponse:
normalized_path = _normalize_sandbox_path(request.path, allow_current_directory=True)
payload = await self._run_locator_script(
request.locator,
script_source=_LIST_SCRIPT,
args=[normalized_path, str(_LIST_MAX_ENTRIES)],
timeout=_LIST_TIMEOUT_SECONDS,
inject_agent_stub_env=False,
)
return _validate_response_model(SandboxListResponse, payload)
async def read_file(self, request: SandboxReadRequest) -> SandboxReadResponse:
normalized_path = _normalize_sandbox_path(request.path, allow_current_directory=False)
payload = await self._run_locator_script(
request.locator,
script_source=_READ_SCRIPT,
args=[normalized_path, str(request.max_bytes)],
timeout=_READ_TIMEOUT_SECONDS,
inject_agent_stub_env=False,
)
return _validate_response_model(SandboxReadResponse, payload)
async def upload_file(self, request: SandboxUploadRequest) -> SandboxUploadResponse:
normalized_path = _normalize_sandbox_path(request.path, allow_current_directory=False)
payload = await self._run_locator_script(
request.locator,
script_source=_UPLOAD_SCRIPT,
args=[normalized_path],
timeout=_UPLOAD_TIMEOUT_SECONDS,
inject_agent_stub_env=True,
)
return _validate_response_model(SandboxUploadResponse, payload)
async def _run_locator_script(
self,
locator: SandboxLocator,
*,
script_source: str,
args: list[str],
timeout: float,
inject_agent_stub_env: bool,
) -> dict[str, object]:
try:
graph_config, layer_configs = normalize_composition(locator.composition)
compositor = build_pydantic_ai_compositor(graph_config, providers=self.layer_providers)
async with compositor.enter(configs=layer_configs, session_snapshot=locator.session_snapshot) as run:
run.suspend_on_exit()
shell_layer = run.get_layer("shell", DifyShellLayer)
result = await shell_layer.run_remote_script(
_build_python_script_command(script_source=script_source, args=args),
timeout=timeout,
inject_agent_stub_env=inject_agent_stub_env,
)
except (KeyError, TypeError, ValueError) as exc:
raise SandboxFileError("invalid_sandbox_locator", str(exc), status_code=400) from exc
except RuntimeError as exc:
raise SandboxFileError("sandbox_command_failed", str(exc), status_code=502) from exc
return _decode_sandbox_payload(result)
def _normalize_sandbox_path(path: str, *, allow_current_directory: bool) -> str:
normalized = (path or "").strip()
if normalized in {"", ".", "./"}:
if allow_current_directory:
return "."
raise SandboxFileError("invalid_sandbox_path", "path must not be blank", status_code=400)
if normalized.startswith("/") or normalized.startswith("~"):
raise SandboxFileError(
"invalid_sandbox_path", "path must be relative to the sandbox workspace", status_code=400
)
if "\x00" in normalized or any(ord(ch) < 0x20 for ch in normalized):
raise SandboxFileError("invalid_sandbox_path", "path contains unsupported control characters", status_code=400)
return normalized
def _build_python_script_command(*, script_source: str, args: list[str]) -> str:
quoted_args = " ".join(shlex.quote(value) for value in args)
script = textwrap.dedent(script_source).strip()
return f"python3 - {quoted_args} <<'PY'\n{script}\nPY"
def _decode_sandbox_payload(result: RemoteCommandResult) -> dict[str, object]:
if result.exit_code not in (0, None):
raise SandboxFileError(
"sandbox_command_failed",
f"sandbox command exited with code {result.exit_code}: {_output_tail(result.output)!r}",
status_code=502,
)
begin = result.output.find(_OUTPUT_BEGIN)
end = result.output.find(_OUTPUT_END, begin + len(_OUTPUT_BEGIN)) if begin != -1 else -1
if begin == -1 or end == -1:
raise SandboxFileError(
"sandbox_command_failed",
"sandbox command returned no framed payload",
status_code=502,
)
blob = result.output[begin + len(_OUTPUT_BEGIN) : end]
compact = "".join(blob.split())
try:
decoded = base64.b64decode(compact, validate=True)
loaded = cast(object, json.loads(decoded.decode("utf-8")))
except (binascii.Error, ValueError) as exc:
raise SandboxFileError(
"sandbox_command_failed",
f"sandbox command returned invalid framed payload: {exc}",
status_code=502,
) from exc
if not isinstance(loaded, dict):
raise SandboxFileError(
"sandbox_command_failed", "sandbox command returned a non-object payload", status_code=502
)
payload = cast(dict[str, object], loaded)
error = payload.get("error")
if isinstance(error, str):
status_code = (
404
if error in {"sandbox_not_found", "sandbox_path_not_found"}
else 502
if error == "agent_stub_upload_failed"
else 400
)
if error in {"sandbox_command_failed", "agent_stub_upload_failed"}:
status_code = 502
message = payload.get("message")
raise SandboxFileError(
error,
str(message) if isinstance(message, str) and message else error,
status_code=status_code,
)
return payload
def _output_tail(output: str) -> str:
stripped = output.strip()
return stripped[-200:]
def _validate_response_model(
model_type: type[ResponseModelT],
payload: dict[str, object],
) -> ResponseModelT:
try:
return model_type.model_validate(payload)
except ValidationError as exc:
raise SandboxFileError(
"sandbox_command_failed", f"sandbox command returned invalid payload: {exc}", status_code=502
) from exc
__all__ = ["SandboxFileError", "SandboxFileService"]

View File

@ -1,418 +0,0 @@
"""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

@ -20,7 +20,7 @@ from dify_agent.client import (
DifyAgentTimeoutError,
DifyAgentValidationError,
)
from dify_agent.protocol.schemas import (
from dify_agent.protocol import (
CancelRunRequest,
CancelRunResponse,
CreateRunRequest,
@ -31,6 +31,10 @@ from dify_agent.protocol.schemas import (
RunStartedEvent,
RunSucceededEvent,
RunSucceededEventData,
SandboxListResponse,
SandboxLocator,
SandboxReadResponse,
SandboxUploadResponse,
)
@ -65,6 +69,44 @@ def _run_status_json(status: str) -> dict[str, object]:
return {"run_id": "run-1", "status": status, "created_at": now, "updated_at": now, "error": None}
def _sandbox_locator() -> SandboxLocator:
return SandboxLocator.model_validate(
{
"composition": {
"schema_version": 1,
"layers": [
{
"name": "execution_context",
"type": "dify.execution_context",
"config": {
"tenant_id": "tenant-1",
"user_from": "account",
"agent_mode": "agent_app",
"invoke_from": "service-api",
},
},
{
"name": "shell",
"type": "dify.shell",
"deps": {"execution_context": "execution_context"},
"config": {},
},
],
},
"session_snapshot": {
"layers": [
{"name": "execution_context", "lifecycle_state": "suspended", "runtime_state": {}},
{
"name": "shell",
"lifecycle_state": "suspended",
"runtime_state": {"session_id": "abc12ff", "workspace_cwd": "~/workspace/abc12ff"},
},
]
},
}
)
def _function_tool_result_payload(key: str) -> dict[str, object]:
return {
"type": "pydantic_ai_event",
@ -195,6 +237,99 @@ def test_async_methods_and_wait_run_parse_protocol_dtos() -> None:
asyncio.run(scenario())
def test_sync_sandbox_methods_post_dtos_and_parse_responses() -> None:
locator = _sandbox_locator()
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/sandbox/files/list":
payload = cast(dict[str, object], json.loads(request.content))
assert payload["path"] == "."
return httpx.Response(200, json={"path": ".", "entries": [], "truncated": False})
if request.url.path == "/sandbox/files/read":
payload = cast(dict[str, object], json.loads(request.content))
assert payload["path"] == "note.txt"
assert payload["max_bytes"] == 128
return httpx.Response(
200, json={"path": "note.txt", "size": 5, "truncated": False, "binary": False, "text": "hello"}
)
if request.url.path == "/sandbox/files/upload":
payload = cast(dict[str, object], json.loads(request.content))
assert payload["path"] == "report.txt"
return httpx.Response(
200,
json={
"path": "report.txt",
"file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
},
)
raise AssertionError(f"unexpected request: {request.method} {request.url}")
client = Client(base_url="http://testserver", sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)))
listing = client.list_sandbox_files_sync(locator, ".")
preview = client.read_sandbox_file_sync(locator, "note.txt", max_bytes=128)
uploaded = client.upload_sandbox_file_sync(locator, "report.txt")
assert isinstance(listing, SandboxListResponse)
assert listing.path == "."
assert isinstance(preview, SandboxReadResponse)
assert preview.text == "hello"
assert isinstance(uploaded, SandboxUploadResponse)
assert uploaded.file.reference == "dify-file-ref:file-1"
def test_async_sandbox_methods_post_dtos_and_parse_responses() -> None:
locator = _sandbox_locator()
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/sandbox/files/list":
return httpx.Response(200, json={"path": ".", "entries": [], "truncated": False})
if request.url.path == "/sandbox/files/read":
return httpx.Response(
200, json={"path": "note.txt", "size": 5, "truncated": False, "binary": False, "text": "hello"}
)
if request.url.path == "/sandbox/files/upload":
return httpx.Response(
200,
json={
"path": "report.txt",
"file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
},
)
raise AssertionError(f"unexpected request: {request.method} {request.url}")
async def scenario() -> None:
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
client = Client(base_url="http://testserver", async_http_client=http_client)
listing = await client.list_sandbox_files(locator, ".")
preview = await client.read_sandbox_file(locator, "note.txt")
uploaded = await client.upload_sandbox_file(locator, "report.txt")
assert listing.path == "."
assert preview.text == "hello"
assert uploaded.file.reference == "dify-file-ref:file-1"
await http_client.aclose()
asyncio.run(scenario())
def test_sync_sandbox_methods_map_invalid_json_to_validation_error() -> None:
responses = iter([httpx.Response(200, text="not-json"), httpx.Response(404, json={"detail": "missing"})])
def handler(_request: httpx.Request) -> httpx.Response:
return next(responses)
client = Client(base_url="http://testserver", sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)))
with pytest.raises(DifyAgentValidationError):
_ = client.list_sandbox_files_sync(_sandbox_locator(), ".")
with pytest.raises(DifyAgentHTTPError) as http_error:
_ = client.read_sandbox_file_sync(_sandbox_locator(), "missing.txt")
assert http_error.value.status_code == 404
def test_error_mapping_and_create_run_input_validation() -> None:
responses = iter(
[

View File

@ -636,6 +636,148 @@ def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() ->
assert all(call.env is None for call in internal_run_calls)
def test_run_remote_script_uses_workspace_cwd_accumulates_output_and_deletes_job() -> None:
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
assert '. ".dify/env.sh"' not in script
assert script == "printf 'hello world'"
assert cwd == "~/workspace/abc12ff"
assert env is None
assert timeout == 7.5
return _job_result(
"remote-job",
status=JobStatusName.RUNNING,
done=False,
output="hello ",
offset=6,
truncated=True,
)
def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult:
assert job_id == "remote-job"
assert offset == 6
assert timeout == 7.5
return _job_result(
"remote-job",
status=JobStatusName.EXITED,
done=True,
exit_code=0,
output="world",
offset=11,
)
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
layer = _shell_layer(client_factory=lambda _entrypoint: client)
async def scenario() -> None:
async with layer.resource_context():
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
result = await layer.run_remote_script("printf 'hello world'", timeout=7.5)
assert result.output == "hello world"
assert result.exit_code == 0
asyncio.run(scenario())
assert [call.job_id for call in client.delete_calls] == ["remote-job"]
assert layer.runtime_state.job_ids == []
assert layer.runtime_state.job_offsets == {}
def test_run_remote_script_deletes_job_even_when_command_exits_non_zero() -> None:
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
assert script == "exit 17"
assert cwd == "~/workspace/abc12ff"
assert env is None
assert timeout == 3.0
return _job_result(
"remote-failed-job",
status=JobStatusName.EXITED,
done=True,
exit_code=17,
output="failed\n",
offset=7,
)
client = FakeShellctlClient(run_handler=run_handler)
layer = _shell_layer(client_factory=lambda _entrypoint: client)
async def scenario() -> None:
async with layer.resource_context():
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
result = await layer.run_remote_script("exit 17", timeout=3.0)
assert result.exit_code == 17
assert result.output == "failed\n"
asyncio.run(scenario())
assert [call.job_id for call in client.delete_calls] == ["remote-failed-job"]
assert layer.runtime_state.job_ids == []
assert layer.runtime_state.job_offsets == {}
def test_run_remote_script_can_inject_agent_stub_env_for_server_owned_uploads() -> None:
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
assert script == "dify-agent file upload report.txt"
assert '. ".dify/env.sh"' not in script
del timeout
assert cwd == "~/workspace/abc12ff"
assert env == {
AGENT_STUB_URL_ENV_VAR: "https://agent.example.com/agent-stub",
AGENT_STUB_AUTH_JWE_ENV_VAR: "token-for:tenant-1:abc12ff",
}
return _job_result("remote-upload", status=JobStatusName.EXITED, done=True, exit_code=0, output="{}")
client = FakeShellctlClient(run_handler=run_handler)
layer = DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig(),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=lambda _entrypoint: client,
agent_stub_url="https://agent.example.com/agent-stub",
agent_stub_token_factory=lambda execution_context, *, session_id: (
f"token-for:{execution_context.tenant_id}:{session_id}"
),
)
layer.deps = layer.deps_type(execution_context=_execution_context_layer())
async def scenario() -> None:
async with layer.resource_context():
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
_ = await layer.run_remote_script("dify-agent file upload report.txt", inject_agent_stub_env=True)
asyncio.run(scenario())
assert [call.job_id for call in client.delete_calls] == ["remote-upload"]
def test_run_remote_script_raises_when_agent_stub_env_is_unavailable() -> None:
client = FakeShellctlClient(
run_handler=lambda _script, _cwd, _env, _timeout: _job_result(
"unexpected-run",
status=JobStatusName.EXITED,
done=True,
exit_code=0,
)
)
layer = DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig(),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=lambda _entrypoint: client,
agent_stub_url="https://agent.example.com/agent-stub",
agent_stub_token_factory=lambda execution_context, *, session_id: (
f"token-for:{execution_context.tenant_id}:{session_id}"
),
)
async def scenario() -> None:
async with layer.resource_context():
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
with pytest.raises(RuntimeError, match="Agent Stub environment injection is not available"):
await layer.run_remote_script("dify-agent file upload report.txt", inject_agent_stub_env=True)
asyncio.run(scenario())
assert client.run_calls == []
def test_shell_layer_skips_agent_stub_env_without_execution_context_dependency() -> None:
client = FakeShellctlClient(
run_handler=lambda _script, _cwd, _env, _timeout: _job_result(

View File

@ -0,0 +1,169 @@
from __future__ import annotations
import pytest
from agenton.compositor import CompositorSessionSnapshot
from agenton.compositor.schemas import LayerSessionSnapshot
from agenton.layers.base import LifecycleState
from agenton_collections.layers.plain import PromptLayerConfig
from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
from dify_agent.protocol import (
CreateRunRequest,
RunComposition,
RunLayerSpec,
RuntimeLayerSpec,
build_sandbox_locator_from_layer_specs,
build_sandbox_locator_from_run_request,
extract_runtime_layer_specs,
)
def _request() -> CreateRunRequest:
composition = RunComposition(
layers=[
RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(prefix="hi")),
RunLayerSpec(
name="execution_context",
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
config=DifyExecutionContextLayerConfig(
tenant_id="tenant-1",
user_from="account",
agent_mode="workflow_run",
invoke_from="service-api",
),
),
RunLayerSpec(name="llm", type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID),
RunLayerSpec(
name="shell",
type=DIFY_SHELL_LAYER_TYPE_ID,
deps={"execution_context": "execution_context"},
config=DifyShellLayerConfig(),
),
]
)
snapshot = CompositorSessionSnapshot(
layers=[
LayerSessionSnapshot(name="prompt", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}),
LayerSessionSnapshot(
name="execution_context",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={},
),
LayerSessionSnapshot(name="llm", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}),
LayerSessionSnapshot(
name="shell",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={"session_id": "abc12ff", "workspace_cwd": "~/workspace/abc12ff"},
),
]
)
return CreateRunRequest(composition=composition, session_snapshot=snapshot)
def test_build_sandbox_locator_from_run_request_filters_to_execution_context_and_shell() -> None:
locator = build_sandbox_locator_from_run_request(_request())
assert [layer.name for layer in locator.composition.layers] == ["execution_context", "shell"]
assert [layer.type for layer in locator.composition.layers] == [
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
DIFY_SHELL_LAYER_TYPE_ID,
]
assert [layer.name for layer in locator.session_snapshot.layers] == ["execution_context", "shell"]
def test_build_sandbox_locator_from_run_request_rejects_missing_session_snapshot() -> None:
request = _request()
request.session_snapshot = None
with pytest.raises(ValueError, match="session_snapshot"):
build_sandbox_locator_from_run_request(request)
def test_extract_runtime_layer_specs_drops_sensitive_plugin_layers() -> None:
specs = extract_runtime_layer_specs(_request().composition)
assert [spec.name for spec in specs] == ["prompt", "execution_context", "shell"]
def test_build_sandbox_locator_from_layer_specs_rejects_missing_shell() -> None:
with pytest.raises(ValueError, match="shell"):
build_sandbox_locator_from_layer_specs(
layer_specs=[RuntimeLayerSpec(name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID)],
session_snapshot=CompositorSessionSnapshot(layers=[]),
)
def test_build_sandbox_locator_from_layer_specs_rejects_missing_snapshot_layer() -> None:
specs = [
RuntimeLayerSpec(name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID),
RuntimeLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, deps={"execution_context": "execution_context"}),
]
with pytest.raises(ValueError, match="session_snapshot"):
build_sandbox_locator_from_layer_specs(
layer_specs=specs,
session_snapshot=CompositorSessionSnapshot(
layers=[
LayerSessionSnapshot(
name="execution_context", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}
)
]
),
)
def test_build_sandbox_locator_from_layer_specs_rejects_shell_dep_mismatch() -> None:
specs = [
RuntimeLayerSpec(name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID),
RuntimeLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, deps={"execution_context": "wrong-layer"}),
]
with pytest.raises(ValueError, match="depend on the execution_context"):
build_sandbox_locator_from_layer_specs(
layer_specs=specs,
session_snapshot=CompositorSessionSnapshot(
layers=[
LayerSessionSnapshot(
name="execution_context",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={},
),
LayerSessionSnapshot(
name="shell",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={},
),
]
),
)
def test_build_sandbox_locator_from_layer_specs_rejects_order_mismatch() -> None:
specs = [
RuntimeLayerSpec(name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID),
RuntimeLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, deps={"execution_context": "execution_context"}),
]
with pytest.raises(ValueError, match="in order"):
build_sandbox_locator_from_layer_specs(
layer_specs=specs,
session_snapshot=CompositorSessionSnapshot(
layers=[
LayerSessionSnapshot(name="shell", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}),
LayerSessionSnapshot(
name="execution_context",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={},
),
]
),
)
def test_build_sandbox_locator_from_layer_specs_rejects_sensitive_runtime_specs() -> None:
with pytest.raises(ValueError, match="sensitive"):
build_sandbox_locator_from_layer_specs(
layer_specs=[RuntimeLayerSpec(name="llm", type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID)],
session_snapshot=CompositorSessionSnapshot(layers=[]),
)

View File

@ -0,0 +1,446 @@
from __future__ import annotations
import asyncio
import base64
import json
from collections.abc import Callable, Mapping
from dataclasses import dataclass
import pytest
from agenton.compositor import CompositorSessionSnapshot, LayerProvider
from agenton.compositor.schemas import LayerSessionSnapshot
from agenton.layers.base import LifecycleState
from dify_agent.agent_stub.server.shell_agent_stub_env import AGENT_STUB_AUTH_JWE_ENV_VAR, AGENT_STUB_URL_ENV_VAR
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
from dify_agent.layers.shell import DifyShellLayerConfig
from dify_agent.layers.shell.layer import DifyShellLayer
from dify_agent.protocol import (
CreateRunRequest,
RunComposition,
RunLayerSpec,
SandboxLocator,
SandboxListRequest,
SandboxReadRequest,
SandboxUploadRequest,
build_sandbox_locator_from_run_request,
)
from dify_agent.server.routes.sandbox_files import create_sandbox_files_router
from dify_agent.server.sandbox_files import (
SandboxFileError,
SandboxFileService,
_OUTPUT_BEGIN,
_OUTPUT_END,
)
from fastapi import FastAPI
from fastapi.testclient import TestClient
from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName
@dataclass(slots=True)
class RunCall:
script: str
cwd: str | None
env: Mapping[str, str] | None
timeout: float
class FakeShellctlClient:
def __init__(
self,
*,
run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], JobResult],
) -> None:
self.run_handler = run_handler
self.run_calls: list[RunCall] = []
self.delete_calls: list[str] = []
async def run(
self,
script: str,
*,
cwd: str | None = None,
env: Mapping[str, str] | None = None,
timeout: float = 10.0,
) -> JobResult:
self.run_calls.append(RunCall(script=script, cwd=cwd, env=env, timeout=timeout))
return self.run_handler(script, cwd, env, timeout)
async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> JobResult:
raise AssertionError(f"Unexpected wait() call for {job_id} offset={offset} timeout={timeout}")
async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0) -> JobResult:
raise AssertionError(f"Unexpected input() call for {job_id} text={text!r}")
async def terminate(self, job_id: str, grace_seconds: float = 2.0):
raise AssertionError(f"Unexpected terminate() call for {job_id} grace={grace_seconds}")
async def delete(
self,
job_id: str,
*,
force: bool = False,
grace_seconds: float | None = None,
) -> DeleteJobResponse:
del force, grace_seconds
self.delete_calls.append(job_id)
return DeleteJobResponse(job_id=job_id)
async def close(self) -> None:
return None
def _wrap(payload: dict[str, object], *, pty_wrap: int = 0, noise: bool = False) -> str:
blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
if pty_wrap:
blob = "\n".join(blob[index : index + pty_wrap] for index in range(0, len(blob), pty_wrap))
framed = f"{_OUTPUT_BEGIN}{blob}{_OUTPUT_END}\n"
if noise:
framed = f"user@host:~/workspace/abc12ff$ python3 - ...\r\n{framed}user@host:~/workspace/abc12ff$ \r\n"
return framed
def _job_result(*, output: dict[str, object] | str, job_id: str = "sandbox-job") -> JobResult:
return JobResult(
job_id=job_id,
status=JobStatusName.EXITED,
done=True,
exit_code=0,
output=_wrap(output) if isinstance(output, dict) else output,
offset=0,
truncated=False,
output_path="/tmp/sandbox-job.out",
)
def _failed_job_result(*, output: str, exit_code: int, job_id: str = "sandbox-job") -> JobResult:
return JobResult(
job_id=job_id,
status=JobStatusName.EXITED,
done=True,
exit_code=exit_code,
output=output,
offset=0,
truncated=False,
output_path="/tmp/sandbox-job.out",
)
def _execution_context() -> DifyExecutionContextLayerConfig:
return DifyExecutionContextLayerConfig(
tenant_id="tenant-1",
user_id="user-1",
user_from="account",
app_id="app-1",
conversation_id="conv-1",
agent_id="agent-1",
agent_config_version_id="snapshot-1",
agent_mode="agent_app",
invoke_from="service-api",
)
def _locator() -> SandboxLocator:
request = CreateRunRequest(
composition=RunComposition(
layers=[
RunLayerSpec(name="execution_context", type="dify.execution_context", config=_execution_context()),
RunLayerSpec(
name="shell",
type="dify.shell",
deps={"execution_context": "execution_context"},
config=DifyShellLayerConfig(),
),
]
),
session_snapshot=CompositorSessionSnapshot(
layers=[
LayerSessionSnapshot(
name="execution_context",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={},
),
LayerSessionSnapshot(
name="shell",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={"session_id": "abc12ff", "workspace_cwd": "~/workspace/abc12ff"},
),
]
),
)
return build_sandbox_locator_from_run_request(request)
def _service(
run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], JobResult],
) -> tuple[SandboxFileService, FakeShellctlClient]:
client = FakeShellctlClient(run_handler=run_handler)
execution_context_provider = LayerProvider.from_factory(
layer_type=DifyExecutionContextLayer,
create=lambda config: DifyExecutionContextLayer.from_config_with_settings(
DifyExecutionContextLayerConfig.model_validate(config),
daemon_url="http://plugin-daemon",
daemon_api_key="daemon-secret",
),
)
shell_provider = LayerProvider.from_factory(
layer_type=DifyShellLayer,
create=lambda config: DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig.model_validate(config),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=lambda _entrypoint: client,
agent_stub_url="https://agent.example.com/agent-stub",
agent_stub_token_factory=lambda execution_context, *, session_id: (
f"token-for:{execution_context.tenant_id}:{session_id}"
),
),
)
return SandboxFileService(layer_providers=(execution_context_provider, shell_provider)), client
def test_list_files_runs_fixed_script_and_parses_response() -> None:
service, client = _service(
lambda script, cwd, env, timeout: _job_result(
output={
"path": ".",
"entries": [{"name": "notes.txt", "type": "file", "size": 5, "mtime": 1}],
"truncated": False,
}
)
)
result = asyncio.run(service.list_files(SandboxListRequest(locator=_locator(), path=".")))
assert result.entries[0].name == "notes.txt"
assert client.run_calls[0].cwd == "~/workspace/abc12ff"
assert client.run_calls[0].env is None
assert "python3 - . 1000 <<'PY'" in client.run_calls[0].script
assert client.delete_calls == ["sandbox-job"]
@pytest.mark.parametrize("bad_path", ["/etc/passwd", "~/secret-dir", "bad\x00path"])
def test_list_files_rejects_invalid_paths_before_shell_execution(bad_path: str) -> None:
service, client = _service(
lambda script, cwd, env, timeout: _job_result(
output={"path": ".", "entries": [], "truncated": False},
)
)
with pytest.raises(SandboxFileError) as exc_info:
asyncio.run(service.list_files(SandboxListRequest(locator=_locator(), path=bad_path)))
assert exc_info.value.code == "invalid_sandbox_path"
assert client.run_calls == []
def test_decode_tolerates_pty_wrapped_base64_and_shell_noise() -> None:
service, _client = _service(
lambda script, cwd, env, timeout: _job_result(
output=_wrap(
{
"path": "note.txt",
"size": 40,
"truncated": False,
"binary": False,
"text": "hello from sandbox\n" * 4,
},
pty_wrap=12,
noise=True,
)
)
)
result = asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="note.txt")))
assert result.text == "hello from sandbox\n" * 4
def test_read_file_maps_script_error_codes() -> None:
service, _client = _service(
lambda script, cwd, env, timeout: _job_result(
output={"error": "sandbox_path_not_found", "message": "path not found in sandbox"}
)
)
with pytest.raises(SandboxFileError) as exc_info:
asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="missing.txt")))
assert exc_info.value.code == "sandbox_path_not_found"
assert exc_info.value.status_code == 404
@pytest.mark.parametrize("bad_path", ["", "/etc/passwd", "~/secret.txt", "bad\x00path"])
def test_read_file_rejects_invalid_paths_before_shell_execution(bad_path: str) -> None:
service, client = _service(
lambda script, cwd, env, timeout: _job_result(
output={
"path": "should-not-run",
"size": 1,
"truncated": False,
"binary": False,
"text": "x",
}
)
)
with pytest.raises(SandboxFileError) as exc_info:
asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path=bad_path)))
assert exc_info.value.code == "invalid_sandbox_path"
assert client.run_calls == []
def test_read_file_returns_binary_payload_without_text() -> None:
service, _client = _service(
lambda script, cwd, env, timeout: _job_result(
output={
"path": "blob.bin",
"size": 64,
"truncated": False,
"binary": True,
"text": None,
}
)
)
result = asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="blob.bin")))
assert result.binary is True
assert result.text is None
assert result.truncated is False
def test_read_file_preserves_truncated_flag_for_large_text() -> None:
service, _client = _service(
lambda script, cwd, env, timeout: _job_result(
output={
"path": "large.txt",
"size": 1024,
"truncated": True,
"binary": False,
"text": "partial preview",
}
)
)
result = asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="large.txt")))
assert result.binary is False
assert result.text == "partial preview"
assert result.truncated is True
def test_upload_file_injects_agent_stub_env_and_returns_mapping() -> None:
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
assert cwd == "~/workspace/abc12ff"
assert timeout == 30.0
assert env == {
AGENT_STUB_URL_ENV_VAR: "https://agent.example.com/agent-stub",
AGENT_STUB_AUTH_JWE_ENV_VAR: "token-for:tenant-1:abc12ff",
}
assert 'dify-agent", "file", "upload"' in script
return _job_result(
output={
"path": "report.txt",
"file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
},
job_id="upload-job",
)
service, client = _service(run_handler)
result = asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path="report.txt")))
assert result.file.reference == "dify-file-ref:file-1"
assert client.delete_calls == ["upload-job"]
def test_upload_file_maps_agent_stub_upload_failed_payload() -> None:
service, _client = _service(
lambda script, cwd, env, timeout: _job_result(
output={
"error": "agent_stub_upload_failed",
"message": "upload returned invalid JSON",
},
job_id="upload-failed-job",
)
)
with pytest.raises(SandboxFileError) as exc_info:
asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path="report.txt")))
assert exc_info.value.code == "agent_stub_upload_failed"
assert exc_info.value.status_code == 502
def test_read_file_maps_non_zero_command_exit_to_sandbox_command_failed() -> None:
service, _client = _service(
lambda script, cwd, env, timeout: _failed_job_result(
output="python traceback or stderr tail",
exit_code=17,
)
)
with pytest.raises(SandboxFileError) as exc_info:
asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="note.txt")))
assert exc_info.value.code == "sandbox_command_failed"
assert exc_info.value.status_code == 502
def test_upload_file_maps_missing_framed_payload_to_sandbox_command_failed() -> None:
service, _client = _service(
lambda script, cwd, env, timeout: _job_result(output="plain output without sentinel framing")
)
with pytest.raises(SandboxFileError) as exc_info:
asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path="report.txt")))
assert exc_info.value.code == "sandbox_command_failed"
assert exc_info.value.status_code == 502
@pytest.mark.parametrize("bad_path", ["", "/etc/passwd", "~/secret.txt", "bad\x00path"])
def test_upload_file_rejects_invalid_paths_before_shell_execution(bad_path: str) -> None:
service, client = _service(
lambda script, cwd, env, timeout: _job_result(
output={
"path": "should-not-run",
"file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
}
)
)
with pytest.raises(SandboxFileError) as exc_info:
asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path=bad_path)))
assert exc_info.value.code == "invalid_sandbox_path"
assert client.run_calls == []
def _client(service: SandboxFileService | None) -> TestClient:
app = FastAPI()
app.include_router(create_sandbox_files_router(lambda: service))
return TestClient(app)
def test_router_list_ok() -> None:
service, _client_instance = _service(
lambda script, cwd, env, timeout: _job_result(output={"path": ".", "entries": [], "truncated": False})
)
response = _client(service).post(
"/sandbox/files/list", json={"locator": _locator().model_dump(mode="json"), "path": "."}
)
assert response.status_code == 200
assert response.json()["path"] == "."
def test_router_returns_503_when_service_unconfigured() -> None:
response = _client(None).post(
"/sandbox/files/list", json={"locator": _locator().model_dump(mode="json"), "path": "."}
)
assert response.status_code == 503
assert response.json()["detail"]["code"] == "sandbox_backend_unavailable"

View File

@ -1,270 +0,0 @@
"""Unit tests for the read-only workspace file inspector (agent-backend side).
A fake shellctl client returns reader-style output (base64-of-JSON between
sentinels) so the tests cover decode/error-mapping/paging and PTY-newline
tolerance without a live shellctl.
"""
from __future__ import annotations
import asyncio
import base64
import json
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from shell_session_manager.shellctl.shared import JobResult, JobStatusName
from dify_agent.server.routes.workspace_files import create_workspace_files_router
from dify_agent.server.workspace_files import (
_BEGIN,
_END,
WorkspaceFileError,
WorkspaceFileService,
_validate_rel_path,
)
SID = "abc1234" # valid 5+2 lowercase hex
def _wrap(payload: dict[str, object], *, pty_wrap: int = 0, noise: bool = True) -> str:
"""Render a reader result the way the in-workspace Python reader would."""
blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
if pty_wrap:
blob = "\n".join(blob[i : i + pty_wrap] for i in range(0, len(blob), pty_wrap))
body = f"{_BEGIN}{blob}{_END}\n"
if noise:
body = f"user@host:~/workspace/{SID}$ python3 -c ...\r\n" + body + f"user@host:~/workspace/{SID}$ \r\n"
return body
class FakeShellctlClient:
"""Returns queued output windows; records cleanup calls."""
def __init__(self, windows: list[str]) -> None:
self.windows = windows
self._cursor = 0
self.run_scripts: list[str] = []
self.deleted: list[str] = []
self.closed = False
def _result(self, chunk: str, *, last: bool) -> JobResult:
return JobResult(
job_id="job-1",
done=last,
status=JobStatusName.EXITED,
exit_code=0,
output_path="/tmp/job-1.out",
output=chunk,
offset=64 * (self._cursor + 1),
truncated=not last,
)
async def run(self, script: str, *, timeout: float = 30.0, terminal: object | None = None) -> JobResult:
del timeout, terminal
self.run_scripts.append(script)
self._cursor = 0
return self._result(self.windows[0], last=len(self.windows) == 1)
async def wait(self, job_id: str, *, offset: int, timeout: float = 30.0) -> JobResult:
del job_id, offset, timeout
self._cursor += 1
chunk = self.windows[self._cursor]
return self._result(chunk, last=self._cursor == len(self.windows) - 1)
async def delete(self, job_id: str, *, force: bool = False) -> object:
del force
self.deleted.append(job_id)
return {"deleted": True}
async def close(self) -> None:
self.closed = True
def _service(windows: list[str]) -> tuple[WorkspaceFileService, FakeShellctlClient]:
fake = FakeShellctlClient(windows)
service = WorkspaceFileService(shellctl_entrypoint="http://shellctl", client_factory=lambda: fake)
return service, fake
# --- service: happy paths ------------------------------------------------------
def test_list_dir_returns_entries_and_cleans_up() -> None:
payload = {
"entries": [
{"name": "notes.txt", "type": "file", "size": 12, "mtime": 1700000000},
{"name": "sub", "type": "dir", "size": 4096, "mtime": 1700000001},
],
"truncated": False,
}
service, fake = _service([_wrap(payload)])
result = asyncio.run(service.list_dir(SID, "."))
assert [e.name for e in result.entries] == ["notes.txt", "sub"]
assert result.entries[1].type == "dir"
assert result.truncated is False
# cleanup: read job deleted and client closed
assert fake.deleted == ["job-1"]
assert fake.closed is True
def test_preview_text_decodes_content() -> None:
content = "hello ZEBRA\nsecond line\n"
payload = {
"size": len(content),
"truncated": False,
"binary": False,
"content_base64": base64.b64encode(content.encode()).decode(),
}
service, _ = _service([_wrap(payload)])
result = asyncio.run(service.preview(SID, "notes.txt"))
assert result.binary is False
assert result.text == content
assert result.size == len(content)
def test_preview_binary_has_no_text() -> None:
payload = {"size": 300, "truncated": True, "binary": True, "content_base64": base64.b64encode(b"\x00\x01").decode()}
service, _ = _service([_wrap(payload)])
result = asyncio.run(service.preview(SID, "blob.bin"))
assert result.binary is True
assert result.text is None
assert result.truncated is True
def test_download_roundtrips_bytes() -> None:
raw = bytes(range(256))
payload = {"size": len(raw), "truncated": False, "content_base64": base64.b64encode(raw).decode()}
service, _ = _service([_wrap(payload)])
result = asyncio.run(service.download(SID, "sub/data.bin"))
assert base64.b64decode(result.content_base64) == raw
assert result.size == 256
# --- service: PTY tolerance + paging ------------------------------------------
def test_decode_tolerates_pty_inserted_newlines() -> None:
raw = bytes(range(200))
payload = {"size": len(raw), "truncated": False, "content_base64": base64.b64encode(raw).decode()}
# wrap the base64 blob every 10 chars with newlines, as a narrow PTY would
service, _ = _service([_wrap(payload, pty_wrap=10)])
result = asyncio.run(service.download(SID, "blob.bin"))
assert base64.b64decode(result.content_base64) == raw
def test_reads_across_multiple_output_windows() -> None:
raw = bytes(range(128))
payload = {"size": len(raw), "truncated": False, "content_base64": base64.b64encode(raw).decode()}
full = _wrap(payload, noise=False)
third = len(full) // 3
windows = [full[:third], full[third : 2 * third], full[2 * third :]]
service, fake = _service(windows)
result = asyncio.run(service.download(SID, "blob.bin"))
assert base64.b64decode(result.content_base64) == raw
assert fake._cursor == 2 # paged through all three windows
# --- service: error mapping ----------------------------------------------------
@pytest.mark.parametrize(
("error_code", "status"),
[
("workspace_not_found", 404),
("not_found", 404),
("path_escape", 400),
("not_a_directory", 400),
("is_a_directory", 400),
],
)
def test_reader_error_maps_to_status(error_code: str, status: int) -> None:
service, _ = _service([_wrap({"error": error_code})])
with pytest.raises(WorkspaceFileError) as exc_info:
asyncio.run(service.list_dir(SID, "."))
assert exc_info.value.code == error_code
assert exc_info.value.status_code == status
def test_invalid_session_id_rejected_before_any_shell_call() -> None:
service, fake = _service([_wrap({"entries": [], "truncated": False})])
with pytest.raises(WorkspaceFileError) as exc_info:
asyncio.run(service.list_dir("NOTHEX", "."))
assert exc_info.value.code == "invalid_session_id"
assert exc_info.value.status_code == 400
assert fake.run_scripts == [] # never reached shellctl
def test_missing_sentinel_is_reader_failure() -> None:
service, _ = _service(["command not found: python3\r\n"])
with pytest.raises(WorkspaceFileError) as exc_info:
asyncio.run(service.list_dir(SID, "."))
assert exc_info.value.code == "reader_failed"
assert exc_info.value.status_code == 502
# --- path validation -----------------------------------------------------------
@pytest.mark.parametrize("good", [".", "", "notes.txt", "sub/inner.txt", "a/b/c.json"])
def test_validate_rel_path_accepts(good: str) -> None:
_validate_rel_path(good)
@pytest.mark.parametrize("bad", ["/etc/passwd", "../escape", "sub/../../etc", "~/secrets", "a/\x00b"])
def test_validate_rel_path_rejects(bad: str) -> None:
with pytest.raises(WorkspaceFileError):
_validate_rel_path(bad)
# --- router --------------------------------------------------------------------
def _client(service: WorkspaceFileService | None) -> TestClient:
app = FastAPI()
app.include_router(create_workspace_files_router(lambda: service))
return TestClient(app)
def test_router_list_ok() -> None:
payload = {"entries": [{"name": "a.txt", "type": "file", "size": 1, "mtime": 1}], "truncated": False}
service, _ = _service([_wrap(payload)])
response = _client(service).get(f"/workspaces/{SID}/files", params={"path": "."})
assert response.status_code == 200
assert response.json()["entries"][0]["name"] == "a.txt"
def test_router_maps_reader_error_to_status() -> None:
service, _ = _service([_wrap({"error": "not_found"})])
response = _client(service).get(f"/workspaces/{SID}/files/preview", params={"path": "missing.txt"})
assert response.status_code == 404
assert response.json()["detail"]["code"] == "not_found"
def test_router_returns_503_when_inspector_unconfigured() -> None:
response = _client(None).get(f"/workspaces/{SID}/files", params={"path": "."})
assert response.status_code == 503

File diff suppressed because it is too large Load Diff

View File

@ -223,20 +223,30 @@ export type AgentReferencingWorkflowsResponse = {
data?: Array<AgentReferencingWorkflowResponse>
}
export type WorkspaceListResponse = {
entries?: Array<WorkspaceFileEntryResponse>
export type SandboxListResponse = {
entries?: Array<SandboxFileEntryResponse>
path: string
truncated?: boolean
}
export type WorkspacePreviewResponse = {
export type SandboxReadResponse = {
binary: boolean
path: string
size: number
size?: number | null
text?: string | null
truncated: boolean
}
export type AgentSandboxUploadPayload = {
conversation_id: string
path: string
}
export type SandboxUploadResponse = {
file: SandboxToolFileResponse
path: string
}
export type AnnotationReplyPayload = {
embedding_model_name: string
embedding_provider_name: string
@ -638,6 +648,11 @@ export type WorkflowRunNodeExecutionListResponse = {
data: Array<WorkflowRunNodeExecutionResponse>
}
export type WorkflowAgentSandboxUploadPayload = {
node_execution_id?: string | null
path: string
}
export type WorkflowCommentBasicList = {
data: Array<WorkflowCommentBasic>
}
@ -1198,11 +1213,16 @@ export type AgentReferencingWorkflowResponse = {
workflow_id: string
}
export type WorkspaceFileEntryResponse = {
mtime: number
export type SandboxFileEntryResponse = {
mtime?: number | null
name: string
size: number
type: 'dir' | 'file' | 'symlink'
size?: number | null
type: 'dir' | 'file' | 'other' | 'symlink'
}
export type SandboxToolFileResponse = {
reference: string
transfer_method?: string
}
export type AnnotationHitHistory = {
@ -2655,7 +2675,7 @@ export type GetAppsByAppIdAgentReferencingWorkflowsResponses = {
export type GetAppsByAppIdAgentReferencingWorkflowsResponse
= GetAppsByAppIdAgentReferencingWorkflowsResponses[keyof GetAppsByAppIdAgentReferencingWorkflowsResponses]
export type GetAppsByAppIdAgentWorkspaceFilesData = {
export type GetAppsByAppIdAgentSandboxFilesData = {
body?: never
path: {
app_id: string
@ -2664,17 +2684,17 @@ export type GetAppsByAppIdAgentWorkspaceFilesData = {
conversation_id: string
path?: string
}
url: '/apps/{app_id}/agent-workspace/files'
url: '/apps/{app_id}/agent-sandbox/files'
}
export type GetAppsByAppIdAgentWorkspaceFilesResponses = {
200: WorkspaceListResponse
export type GetAppsByAppIdAgentSandboxFilesResponses = {
200: SandboxListResponse
}
export type GetAppsByAppIdAgentWorkspaceFilesResponse
= GetAppsByAppIdAgentWorkspaceFilesResponses[keyof GetAppsByAppIdAgentWorkspaceFilesResponses]
export type GetAppsByAppIdAgentSandboxFilesResponse
= GetAppsByAppIdAgentSandboxFilesResponses[keyof GetAppsByAppIdAgentSandboxFilesResponses]
export type GetAppsByAppIdAgentWorkspaceFilesDownloadData = {
export type GetAppsByAppIdAgentSandboxFilesReadData = {
body?: never
path: {
app_id: string
@ -2683,43 +2703,31 @@ export type GetAppsByAppIdAgentWorkspaceFilesDownloadData = {
conversation_id: string
path: string
}
url: '/apps/{app_id}/agent-workspace/files/download'
url: '/apps/{app_id}/agent-sandbox/files/read'
}
export type GetAppsByAppIdAgentWorkspaceFilesDownloadErrors = {
413: {
[key: string]: unknown
}
export type GetAppsByAppIdAgentSandboxFilesReadResponses = {
200: SandboxReadResponse
}
export type GetAppsByAppIdAgentWorkspaceFilesDownloadError
= GetAppsByAppIdAgentWorkspaceFilesDownloadErrors[keyof GetAppsByAppIdAgentWorkspaceFilesDownloadErrors]
export type GetAppsByAppIdAgentSandboxFilesReadResponse
= GetAppsByAppIdAgentSandboxFilesReadResponses[keyof GetAppsByAppIdAgentSandboxFilesReadResponses]
export type GetAppsByAppIdAgentWorkspaceFilesDownloadResponses = {
200: Blob | File
}
export type GetAppsByAppIdAgentWorkspaceFilesDownloadResponse
= GetAppsByAppIdAgentWorkspaceFilesDownloadResponses[keyof GetAppsByAppIdAgentWorkspaceFilesDownloadResponses]
export type GetAppsByAppIdAgentWorkspaceFilesPreviewData = {
body?: never
export type PostAppsByAppIdAgentSandboxFilesUploadData = {
body: AgentSandboxUploadPayload
path: {
app_id: string
}
query: {
conversation_id: string
path: string
}
url: '/apps/{app_id}/agent-workspace/files/preview'
query?: never
url: '/apps/{app_id}/agent-sandbox/files/upload'
}
export type GetAppsByAppIdAgentWorkspaceFilesPreviewResponses = {
200: WorkspacePreviewResponse
export type PostAppsByAppIdAgentSandboxFilesUploadResponses = {
200: SandboxUploadResponse
}
export type GetAppsByAppIdAgentWorkspaceFilesPreviewResponse
= GetAppsByAppIdAgentWorkspaceFilesPreviewResponses[keyof GetAppsByAppIdAgentWorkspaceFilesPreviewResponses]
export type PostAppsByAppIdAgentSandboxFilesUploadResponse
= PostAppsByAppIdAgentSandboxFilesUploadResponses[keyof PostAppsByAppIdAgentSandboxFilesUploadResponses]
export type GetAppsByAppIdAgentLogsData = {
body?: never
@ -4553,7 +4561,7 @@ export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses = {
export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse
= GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses[keyof GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses]
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesData = {
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesData = {
body?: never
path: {
app_id: string
@ -4564,50 +4572,17 @@ export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspace
node_execution_id?: string
path?: string
}
url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files'
url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files'
}
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponses = {
200: WorkspaceListResponse
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses = {
200: SandboxListResponse
}
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponse
= GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponses]
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse
= GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses]
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadData
= {
body?: never
path: {
app_id: string
node_id: string
workflow_run_id: string
}
query: {
node_execution_id?: string
path: string
}
url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/download'
}
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadErrors
= {
413: {
[key: string]: unknown
}
}
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadError
= GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadErrors[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadErrors]
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponses
= {
200: Blob | File
}
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponse
= GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponses]
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewData = {
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadData = {
body?: never
path: {
app_id: string
@ -4618,16 +4593,34 @@ export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspace
node_execution_id?: string
path: string
}
url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/workspace/files/preview'
url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/read'
}
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponses
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses = {
200: SandboxReadResponse
}
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse
= GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses]
export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadData = {
body: WorkflowAgentSandboxUploadPayload
path: {
app_id: string
node_id: string
workflow_run_id: string
}
query?: never
url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/upload'
}
export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses
= {
200: WorkspacePreviewResponse
200: SandboxUploadResponse
}
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponse
= GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponses]
export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse
= PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses[keyof PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses]
export type GetAppsByAppIdWorkflowCommentsData = {
body?: never

View File

@ -85,16 +85,24 @@ export const zSimpleResultResponse = z.object({
})
/**
* WorkspacePreviewResponse
* SandboxReadResponse
*/
export const zWorkspacePreviewResponse = z.object({
export const zSandboxReadResponse = z.object({
binary: z.boolean(),
path: z.string(),
size: z.int(),
size: z.int().nullish(),
text: z.string().nullish(),
truncated: z.boolean(),
})
/**
* AgentSandboxUploadPayload
*/
export const zAgentSandboxUploadPayload = z.object({
conversation_id: z.string().min(1),
path: z.string().min(1),
})
/**
* AnnotationReplyPayload
*/
@ -395,6 +403,14 @@ export const zWorkflowRunExportResponse = z.object({
status: z.string(),
})
/**
* WorkflowAgentSandboxUploadPayload
*/
export const zWorkflowAgentSandboxUploadPayload = z.object({
node_execution_id: z.string().nullish(),
path: z.string().min(1),
})
/**
* WorkflowCommentCreatePayload
*/
@ -867,24 +883,40 @@ export const zAgentReferencingWorkflowsResponse = z.object({
})
/**
* WorkspaceFileEntryResponse
* SandboxFileEntryResponse
*/
export const zWorkspaceFileEntryResponse = z.object({
mtime: z.int(),
export const zSandboxFileEntryResponse = z.object({
mtime: z.int().nullish(),
name: z.string(),
size: z.int(),
type: z.enum(['dir', 'file', 'symlink']),
size: z.int().nullish(),
type: z.enum(['dir', 'file', 'other', 'symlink']),
})
/**
* WorkspaceListResponse
* SandboxListResponse
*/
export const zWorkspaceListResponse = z.object({
entries: z.array(zWorkspaceFileEntryResponse).optional(),
export const zSandboxListResponse = z.object({
entries: z.array(zSandboxFileEntryResponse).optional(),
path: z.string(),
truncated: z.boolean().optional().default(false),
})
/**
* SandboxToolFileResponse
*/
export const zSandboxToolFileResponse = z.object({
reference: z.string(),
transfer_method: z.string().optional().default('tool_file'),
})
/**
* SandboxUploadResponse
*/
export const zSandboxUploadResponse = z.object({
file: zSandboxToolFileResponse,
path: z.string(),
})
/**
* AnnotationHitHistory
*/
@ -3178,11 +3210,11 @@ export const zGetAppsByAppIdAgentReferencingWorkflowsPath = z.object({
*/
export const zGetAppsByAppIdAgentReferencingWorkflowsResponse = zAgentReferencingWorkflowsResponse
export const zGetAppsByAppIdAgentWorkspaceFilesPath = z.object({
export const zGetAppsByAppIdAgentSandboxFilesPath = z.object({
app_id: z.string(),
})
export const zGetAppsByAppIdAgentWorkspaceFilesQuery = z.object({
export const zGetAppsByAppIdAgentSandboxFilesQuery = z.object({
conversation_id: z.string().min(1),
path: z.string().optional().default('.'),
})
@ -3190,27 +3222,13 @@ export const zGetAppsByAppIdAgentWorkspaceFilesQuery = z.object({
/**
* Listing returned
*/
export const zGetAppsByAppIdAgentWorkspaceFilesResponse = zWorkspaceListResponse
export const zGetAppsByAppIdAgentSandboxFilesResponse = zSandboxListResponse
export const zGetAppsByAppIdAgentWorkspaceFilesDownloadPath = z.object({
export const zGetAppsByAppIdAgentSandboxFilesReadPath = z.object({
app_id: z.string(),
})
export const zGetAppsByAppIdAgentWorkspaceFilesDownloadQuery = z.object({
conversation_id: z.string().min(1),
path: z.string().min(1),
})
/**
* File bytes
*/
export const zGetAppsByAppIdAgentWorkspaceFilesDownloadResponse = z.custom<Blob | File>()
export const zGetAppsByAppIdAgentWorkspaceFilesPreviewPath = z.object({
app_id: z.string(),
})
export const zGetAppsByAppIdAgentWorkspaceFilesPreviewQuery = z.object({
export const zGetAppsByAppIdAgentSandboxFilesReadQuery = z.object({
conversation_id: z.string().min(1),
path: z.string().min(1),
})
@ -3218,7 +3236,18 @@ export const zGetAppsByAppIdAgentWorkspaceFilesPreviewQuery = z.object({
/**
* Preview returned
*/
export const zGetAppsByAppIdAgentWorkspaceFilesPreviewResponse = zWorkspacePreviewResponse
export const zGetAppsByAppIdAgentSandboxFilesReadResponse = zSandboxReadResponse
export const zPostAppsByAppIdAgentSandboxFilesUploadBody = zAgentSandboxUploadPayload
export const zPostAppsByAppIdAgentSandboxFilesUploadPath = z.object({
app_id: z.string(),
})
/**
* Uploaded
*/
export const zPostAppsByAppIdAgentSandboxFilesUploadResponse = zSandboxUploadResponse
export const zGetAppsByAppIdAgentLogsPath = z.object({
app_id: z.string(),
@ -4124,14 +4153,14 @@ export const zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsPath = z.object({
export const zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse
= zWorkflowRunNodeExecutionListResponse
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPath
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesPath
= z.object({
app_id: z.string(),
node_id: z.string(),
workflow_run_id: z.string(),
})
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesQuery
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesQuery
= z.object({
node_execution_id: z.string().optional(),
path: z.string().optional().default('.'),
@ -4140,36 +4169,17 @@ export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspa
/**
* Listing returned
*/
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesResponse
= zWorkspaceListResponse
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse
= zSandboxListResponse
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadPath
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadPath
= z.object({
app_id: z.string(),
node_id: z.string(),
workflow_run_id: z.string(),
})
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadQuery
= z.object({
node_execution_id: z.string().optional(),
path: z.string().min(1),
})
/**
* File bytes
*/
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesDownloadResponse
= z.custom<Blob | File>()
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewPath
= z.object({
app_id: z.string(),
node_id: z.string(),
workflow_run_id: z.string(),
})
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewQuery
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadQuery
= z.object({
node_execution_id: z.string().optional(),
path: z.string().min(1),
@ -4178,8 +4188,24 @@ export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspa
/**
* Preview returned
*/
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdWorkspaceFilesPreviewResponse
= zWorkspacePreviewResponse
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse
= zSandboxReadResponse
export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadBody
= zWorkflowAgentSandboxUploadPayload
export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadPath
= z.object({
app_id: z.string(),
node_id: z.string(),
workflow_run_id: z.string(),
})
/**
* Uploaded
*/
export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse
= zSandboxUploadResponse
export const zGetAppsByAppIdWorkflowCommentsPath = z.object({
app_id: z.string(),

View File

@ -0,0 +1,26 @@
import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import { dirname, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const thisDir = dirname(fileURLToPath(import.meta.url))
const sourcePath = resolve(thisDir, './generated/api/console/apps/orpc.gen.ts')
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === './zod.gen' || specifier.endsWith('/zod.gen'))
return nextResolve(`${specifier}.ts`, context)
return nextResolve(specifier, context)
},
})
const { agentSandbox, sandbox } = await import(pathToFileURL(sourcePath).href)
assert.ok(agentSandbox.files.get)
assert.ok(agentSandbox.files.read.get)
assert.ok(agentSandbox.files.upload.post)
assert.ok(sandbox.files.get)
assert.ok(sandbox.files.read.get)
assert.ok(sandbox.files.upload.post)