mirror of
https://github.com/langgenius/dify.git
synced 2026-06-12 19:53:38 +08:00
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:
parent
09bb87d089
commit
92df792e4a
@ -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",
|
||||
]
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
]
|
||||
@ -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",
|
||||
|
||||
306
api/controllers/console/app/agent_app_sandbox.py
Normal file
306
api/controllers/console/app/agent_app_sandbox.py
Normal 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()
|
||||
@ -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)
|
||||
@ -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: "
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -40,7 +40,7 @@ class WorkflowAgentSessionCleanupLayer(GraphEngineLayer):
|
||||
the composition, the run fails asynchronously with ``run_failed`` — but
|
||||
the initial ``POST /runs`` already returned 202, so the API side has no
|
||||
visibility of the failure unless it waits for terminal status. The
|
||||
``composition_layer_specs`` persistence in A.1–A.4 plus the
|
||||
``runtime_layer_specs`` persistence in A.1–A.4 plus the
|
||||
``_filter_snapshot_to_specs`` shape in ``build_cleanup_request`` keeps
|
||||
the two name lists in sync.
|
||||
|
||||
@ -144,13 +144,13 @@ class WorkflowAgentSessionCleanupLayer(GraphEngineLayer):
|
||||
)
|
||||
return
|
||||
|
||||
if not stored_session.composition_layer_specs:
|
||||
if not stored_session.runtime_layer_specs:
|
||||
# Sessions persisted before A.1 landed do not carry the spec list,
|
||||
# so we cannot replay a valid cleanup composition. Leave the row
|
||||
# ACTIVE and warn so the absence shows up in observability rather
|
||||
# than being silently swallowed by a doomed cleanup run.
|
||||
logger.warning(
|
||||
"Skipping Agent backend cleanup: no composition_layer_specs persisted. "
|
||||
"Skipping Agent backend cleanup: no runtime_layer_specs persisted. "
|
||||
"workflow_run_id=%s node_id=%s agent_id=%s",
|
||||
scope.workflow_run_id,
|
||||
scope.node_id,
|
||||
@ -160,7 +160,7 @@ class WorkflowAgentSessionCleanupLayer(GraphEngineLayer):
|
||||
|
||||
request = self._request_builder.build_cleanup_request(
|
||||
session_snapshot=stored_session.session_snapshot,
|
||||
composition_layer_specs=stored_session.composition_layer_specs,
|
||||
runtime_layer_specs=stored_session.runtime_layer_specs,
|
||||
idempotency_key=f"{scope.workflow_run_id}:{scope.node_id}:{scope.binding_id}:agent-session-cleanup",
|
||||
metadata={
|
||||
"tenant_id": scope.tenant_id,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 |
|
||||
|
||||
225
api/services/agent_app_sandbox_service.py
Normal file
225
api/services/agent_app_sandbox_service.py
Normal 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"]
|
||||
@ -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"]
|
||||
@ -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``
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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"),
|
||||
]
|
||||
@ -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"),
|
||||
]
|
||||
@ -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():
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
313
api/tests/unit_tests/services/test_agent_app_sandbox_service.py
Normal file
313
api/tests/unit_tests/services/test_agent_app_sandbox_service.py
Normal 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"
|
||||
@ -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 == []
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
246
dify-agent/src/dify_agent/protocol/sandbox.py
Normal file
246
dify-agent/src/dify_agent/protocol/sandbox.py
Normal 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",
|
||||
]
|
||||
@ -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,
|
||||
|
||||
73
dify-agent/src/dify_agent/server/routes/sandbox_files.py
Normal file
73
dify-agent/src/dify_agent/server/routes/sandbox_files.py
Normal 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"]
|
||||
@ -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"]
|
||||
399
dify-agent/src/dify_agent/server/sandbox_files.py
Normal file
399
dify-agent/src/dify_agent/server/sandbox_files.py
Normal 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"]
|
||||
@ -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",
|
||||
]
|
||||
@ -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(
|
||||
[
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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=[]),
|
||||
)
|
||||
446
dify-agent/tests/local/dify_agent/server/test_sandbox_files.py
Normal file
446
dify-agent/tests/local/dify_agent/server/test_sandbox_files.py
Normal 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"
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
26
packages/contracts/sandbox-contract.smoke.test.ts
Normal file
26
packages/contracts/sandbox-contract.smoke.test.ts
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user