refactor(dify-agent): download Binding files through Agent Stub (#40143)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
盐粒 Yanliandautofix-ci[bot] authored and GitHub committed 2026-08-07 14:56:52 +00:00
1 parent c094117ca2
commit 916d7e1c96
71 files changed
+4692 -2484

No files matched your search

@@ -1,8 +1,8 @@
"""Console routes for Agent App and workflow Agent sandbox file access.
The API accepts product-facing Conversation, Build Draft, or Workflow Node
Execution locators and proxies list/read/upload to the agent backend's
``/sandbox`` contract.
Execution locators and proxies list/read/download to the agent backend's
``/execution-bindings/files`` contract.
"""
from __future__ import annotations
@@ -13,7 +13,6 @@ from uuid import UUID
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from controllers.common.schema import (
query_params_from_model,
@@ -21,9 +20,8 @@ from controllers.common.schema import (
register_response_schema_models,
register_schema_models,
)
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.error import AppNotFoundError
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
account_initialization_required,
@@ -43,11 +41,17 @@ from services.agent_app_sandbox_service import (
WorkflowAgentSandboxService,
)
_BINDING_PATH_DESCRIPTION = (
"Binding path: relative paths start in Workspace; exact `~` and paths beginning with `~/` start in Home; "
"`~user` is an ordinary relative path from Workspace; absolute paths remain absolute; `..` and paths outside "
"Workspace are governed by backend isolation, not a Workspace-root restriction"
)
class AgentSandboxListQuery(BaseModel):
caller_type: Literal["conversation", "build_draft"]
caller_id: str = Field(min_length=1, description="Agent App caller ID")
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
path: str = Field(default=".", description=_BINDING_PATH_DESCRIPTION)
class AgentSandboxInfoQuery(BaseModel):
@@ -58,28 +62,28 @@ class AgentSandboxInfoQuery(BaseModel):
class AgentSandboxFileQuery(BaseModel):
caller_type: Literal["conversation", "build_draft"]
caller_id: str = Field(min_length=1, description="Agent App caller ID")
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
path: str = Field(min_length=1, description=_BINDING_PATH_DESCRIPTION)
class AgentSandboxUploadPayload(BaseModel):
class AgentSandboxDownloadPayload(BaseModel):
caller_type: Literal["conversation", "build_draft"]
caller_id: str = Field(min_length=1, description="Agent App caller ID")
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
path: str = Field(min_length=1, description=_BINDING_PATH_DESCRIPTION)
class WorkflowAgentSandboxListQuery(BaseModel):
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
path: str = Field(default=".", description=_BINDING_PATH_DESCRIPTION)
class WorkflowAgentSandboxFileQuery(BaseModel):
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
path: str = Field(min_length=1, description=_BINDING_PATH_DESCRIPTION)
class WorkflowAgentSandboxUploadPayload(BaseModel):
class WorkflowAgentSandboxDownloadPayload(BaseModel):
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
path: str = Field(min_length=1, description=_BINDING_PATH_DESCRIPTION)
class SandboxFileEntryResponse(ResponseModel):
@@ -107,21 +111,21 @@ class SandboxReadResponse(ResponseModel):
text: str | None = None
class SandboxUploadResponse(ResponseModel):
class SandboxDownloadResponse(ResponseModel):
url: str
register_schema_models(
console_ns,
AgentSandboxUploadPayload,
WorkflowAgentSandboxUploadPayload,
AgentSandboxDownloadPayload,
WorkflowAgentSandboxDownloadPayload,
)
register_response_schema_models(
console_ns,
SandboxInfoResponse,
SandboxListResponse,
SandboxReadResponse,
SandboxUploadResponse,
SandboxDownloadResponse,
)
@@ -152,14 +156,14 @@ class AgentAppSandboxInfoResource(Resource):
@account_initialization_required
@with_current_tenant_id
@with_current_user
@with_session(write=False)
def get(self, session: Session, current_user: Account, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
service = AgentAppSandboxService()
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
query = query_params_from_request(AgentSandboxInfoQuery)
try:
result = AgentAppSandboxService().get_info(
result = service.get_info(
tenant_id=tenant_id,
app_id=app_model.id,
app_id=app_id,
agent_id=str(agent_id),
caller_type=query.caller_type,
caller_id=query.caller_id,
@@ -181,14 +185,14 @@ class AgentAppSandboxListResource(Resource):
@account_initialization_required
@with_current_tenant_id
@with_current_user
@with_session(write=False)
def get(self, session: Session, current_user: Account, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
service = AgentAppSandboxService()
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
query = query_params_from_request(AgentSandboxListQuery)
try:
result = AgentAppSandboxService().list_files(
result = service.list_files(
tenant_id=tenant_id,
app_id=app_model.id,
app_id=app_id,
agent_id=str(agent_id),
caller_type=query.caller_type,
caller_id=query.caller_id,
@@ -211,14 +215,14 @@ class AgentAppSandboxReadResource(Resource):
@account_initialization_required
@with_current_tenant_id
@with_current_user
@with_session(write=False)
def get(self, session: Session, current_user: Account, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
service = AgentAppSandboxService()
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
query = query_params_from_request(AgentSandboxFileQuery)
try:
result = AgentAppSandboxService().read_file(
result = service.read_file(
tenant_id=tenant_id,
app_id=app_model.id,
app_id=app_id,
agent_id=str(agent_id),
caller_type=query.caller_type,
caller_id=query.caller_id,
@@ -230,32 +234,31 @@ class AgentAppSandboxReadResource(Resource):
return result.model_dump()
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/upload")
class AgentAppSandboxUploadResource(Resource):
@console_ns.doc("upload_agent_app_sandbox_file")
@console_ns.doc(description="Upload one Agent App sandbox file and return a signed download URL")
@console_ns.expect(console_ns.models[AgentSandboxUploadPayload.__name__])
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/download")
class AgentAppSandboxDownloadResource(Resource):
@console_ns.doc("download_agent_app_sandbox_file")
@console_ns.doc(description="Create a ToolFile from one Agent App Binding file and return its download URL")
@console_ns.expect(console_ns.models[AgentSandboxDownloadPayload.__name__])
@console_ns.response(200, "Download URL returned", console_ns.models[SandboxDownloadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_current_user
@with_session(write=False)
@model_validate(AgentSandboxUploadPayload)
@model_validate(AgentSandboxDownloadPayload)
def post(
self,
req_data: AgentSandboxUploadPayload,
session: Session,
req_data: AgentSandboxDownloadPayload,
current_user: Account,
tenant_id: str,
agent_id: UUID,
):
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
service = AgentAppSandboxService()
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
try:
result = AgentAppSandboxService().upload_file(
result = service.download_file(
tenant_id=tenant_id,
app_id=app_model.id,
app_id=app_id,
agent_id=str(agent_id),
caller_type=req_data.caller_type,
caller_id=req_data.caller_id,
@@ -340,36 +343,41 @@ class WorkflowAgentSandboxReadResource(Resource):
@console_ns.route(
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files/upload"
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files/download"
)
class WorkflowAgentSandboxUploadResource(Resource):
@console_ns.doc("upload_workflow_agent_sandbox_file")
@console_ns.doc(description="Upload one workflow Agent sandbox file and return a signed download URL")
@console_ns.expect(console_ns.models[WorkflowAgentSandboxUploadPayload.__name__])
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
class WorkflowAgentSandboxDownloadResource(Resource):
@console_ns.doc("download_workflow_agent_sandbox_file")
@console_ns.doc(description="Create a ToolFile from one workflow Agent Binding file and return its download URL")
@console_ns.expect(console_ns.models[WorkflowAgentSandboxDownloadPayload.__name__])
@console_ns.response(200, "Download URL returned", console_ns.models[SandboxDownloadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@with_current_user
@with_current_tenant_id
@model_validate(WorkflowAgentSandboxUploadPayload)
@model_validate(WorkflowAgentSandboxDownloadPayload)
def post(
self,
req_data: WorkflowAgentSandboxUploadPayload,
req_data: WorkflowAgentSandboxDownloadPayload,
tenant_id: str,
app_model: App,
current_user: Account,
app_id: UUID,
workflow_run_id: UUID,
node_id: str,
):
service = WorkflowAgentSandboxService()
resolved_app_id = service.resolve_app_id(tenant_id=tenant_id, app_id=str(app_id))
if resolved_app_id is None:
raise AppNotFoundError()
try:
result = WorkflowAgentSandboxService().upload_file(
result = service.download_file(
tenant_id=tenant_id,
app_id=app_model.id,
app_id=resolved_app_id,
workflow_run_id=str(workflow_run_id),
node_id=node_id,
node_execution_id=req_data.node_execution_id,
account_id=current_user.id,
path=req_data.path,
session=db.session(),
)
except Exception as exc:
return _handle(exc)
+22 -4
View File
@@ -1,3 +1,5 @@
from typing import Literal
from flask import request
from flask_restx import Resource
from flask_restx.api import HTTPStatus
@@ -5,10 +7,12 @@ from pydantic import BaseModel, Field
from werkzeug.exceptions import Forbidden
import services
from core.db.session_factory import session_factory
from core.tools.signature import verify_plugin_file_signature
from core.tools.tool_file_manager import ToolFileManager, resolve_extension
from core.workflow.file_reference import build_file_reference
from fields.file_fields import FileResponse
from services.account_service import TenantService
from ..common.errors import (
FileTooLargeError,
@@ -26,6 +30,7 @@ class PluginUploadQuery(BaseModel):
sign: str = Field(..., description="HMAC signature")
tenant_id: str = Field(..., description="Tenant identifier")
user_id: str | None = Field(default=None, description="User identifier")
user_from: Literal["account", "end-user"] | None = Field(default=None, description="User identity type")
conversation_id: str | None = Field(default=None, description="Conversation identifier")
@@ -77,8 +82,20 @@ class PluginUploadFileApi(Resource):
nonce = args.nonce
sign = args.sign
tenant_id = args.tenant_id
user_id = args.user_id
user = get_user(tenant_id, user_id)
if args.user_from == "account":
if args.user_id is None:
raise Forbidden("Invalid request.")
with session_factory.create_session() as session:
is_tenant_member = TenantService.account_belongs_to_tenant(
args.user_id,
tenant_id,
session=session,
)
if not is_tenant_member:
raise Forbidden("Invalid request.")
owner_id = args.user_id
else:
owner_id = get_user(tenant_id, args.user_id).id
filename = file.filename
mimetype = file.mimetype
@@ -90,8 +107,9 @@ class PluginUploadFileApi(Resource):
filename=filename,
mimetype=mimetype,
tenant_id=tenant_id,
user_id=user.id,
user_id=owner_id,
conversation_id=args.conversation_id,
user_from=args.user_from,
timestamp=timestamp,
nonce=nonce,
sign=sign,
@@ -100,7 +118,7 @@ class PluginUploadFileApi(Resource):
try:
tool_file = ToolFileManager().create_file_by_raw(
user_id=user.id,
user_id=owner_id,
tenant_id=tenant_id,
file_binary=file.stream.read(),
mimetype=mimetype,
+9 -2
View File
@@ -38,6 +38,7 @@ class AgentFileRequestHttpError(BaseHTTPException):
class AgentFileUploadRequestPayload(RequestRequestUploadFile):
tenant_id: str
user_id: str
user_from: Literal["account", "end-user"] | None = None
model_config = ConfigDict(extra="forbid")
@@ -113,13 +114,19 @@ class AgentFileUploadRequestApi(Resource):
status_code=404,
)
try:
user = get_user(tenant.id, payload.user_id)
if payload.user_from == "account":
if not TenantService.account_belongs_to_tenant(payload.user_id, tenant.id, session=session):
raise ValueError("account not found")
owner_id = payload.user_id
else:
owner_id = get_user(tenant.id, payload.user_id).id
upload_uri = get_signed_file_uri_for_plugin(
filename=payload.filename,
mimetype=payload.mimetype,
tenant_id=tenant.id,
user_id=user.id,
user_id=owner_id,
conversation_id=payload.conversation_id,
user_from=payload.user_from,
)
except ValueError as exc:
raise AgentFileRequestHttpError(
+54 -3
View File
@@ -4,6 +4,7 @@ import hmac
import os
import time
import urllib.parse
from typing import Literal
from urllib.parse import urlsplit
from configs import dify_config
@@ -88,13 +89,27 @@ def verify_tool_file_signature(file_id: str, timestamp: str, nonce: str, sign: s
def get_signed_file_uri_for_plugin(
filename: str, mimetype: str, tenant_id: str, user_id: str, conversation_id: str | None = None
filename: str,
mimetype: str,
tenant_id: str,
user_id: str,
conversation_id: str | None = None,
user_from: Literal["account", "end-user"] | None = None,
) -> str:
"""Build a signed plugin-upload URI without selecting a network origin."""
timestamp = str(int(time.time()))
nonce = os.urandom(16).hex()
data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{conversation_id or ''}|{timestamp}|{nonce}"
data_to_sign = _plugin_upload_signature_payload(
filename=filename,
mimetype=mimetype,
tenant_id=tenant_id,
user_id=user_id,
conversation_id=conversation_id,
timestamp=timestamp,
nonce=nonce,
user_from=user_from,
)
sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
encoded_sign = base64.urlsafe_b64encode(sign).decode()
query_params = {
@@ -106,6 +121,8 @@ def get_signed_file_uri_for_plugin(
}
if conversation_id:
query_params["conversation_id"] = conversation_id
if user_from is not None:
query_params["user_from"] = user_from
query = urllib.parse.urlencode(query_params)
return f"/files/upload/for-plugin?{query}"
@@ -117,13 +134,23 @@ def verify_plugin_file_signature(
tenant_id: str,
user_id: str,
conversation_id: str | None = None,
user_from: Literal["account", "end-user"] | None = None,
timestamp: str,
nonce: str,
sign: str,
) -> bool:
"""Verify the signature used by the plugin-facing file upload endpoint."""
data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{conversation_id or ''}|{timestamp}|{nonce}"
data_to_sign = _plugin_upload_signature_payload(
filename=filename,
mimetype=mimetype,
tenant_id=tenant_id,
user_id=user_id,
conversation_id=conversation_id,
timestamp=timestamp,
nonce=nonce,
user_from=user_from,
)
recalculated_sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode()
@@ -132,3 +159,27 @@ def verify_plugin_file_signature(
current_time = int(time.time())
return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT
def _plugin_upload_signature_payload(
*,
filename: str,
mimetype: str,
tenant_id: str,
user_id: str,
conversation_id: str | None,
timestamp: str,
nonce: str,
user_from: Literal["account", "end-user"] | None,
) -> str:
"""Build the compatible upload signature payload with optional identity ownership.
Omitting ``user_from`` preserves the legacy payload. When present, the
identity kind is appended and HMAC-protected so account/end-user ownership
cannot be altered.
"""
payload = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{conversation_id or ''}|{timestamp}|{nonce}"
if user_from is not None:
payload = f"{payload}|{user_from}"
return payload
+63 -63
View File
@@ -1274,7 +1274,7 @@ List a directory in an Agent App conversation sandbox
| agent_id | path | Agent ID | Yes | string (uuid) |
| caller_id | query | Agent App caller ID | Yes | string |
| caller_type | query | | Yes | string, <br>**Available values:** "build_draft", "conversation" |
| path | query | Directory path relative to the sandbox workspace | No | string, <br>**Default:** . |
| path | query | Binding path: relative paths start in Workspace; exact `~` and paths beginning with `~/` start in Home; `~user` is an ordinary relative path from Workspace; absolute paths remain absolute; `..` and paths outside Workspace are governed by backend isolation, not a Workspace-root restriction | No | string, <br>**Default:** . |
#### Responses
@@ -1282,26 +1282,8 @@ List a directory in an Agent App conversation sandbox
| ---- | ----------- | ------ |
| 200 | Listing returned | **application/json**: [SandboxListResponse](#sandboxlistresponse)<br> |
### [GET] /agent/{agent_id}/sandbox/files/read
Read a text/binary preview file in an Agent App conversation sandbox
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| caller_id | query | Agent App caller ID | Yes | string |
| caller_type | query | | Yes | string, <br>**Available values:** "build_draft", "conversation" |
| path | query | File path relative to the sandbox workspace | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)<br> |
### [POST] /agent/{agent_id}/sandbox/files/upload
Upload one Agent App sandbox file and return a signed download URL
### [POST] /agent/{agent_id}/sandbox/files/download
Create a ToolFile from one Agent App Binding file and return its download URL
#### Parameters
@@ -1313,13 +1295,31 @@ Upload one Agent App sandbox file and return a signed download URL
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AgentSandboxUploadPayload](#agentsandboxuploadpayload)<br> |
| Yes | **application/json**: [AgentSandboxDownloadPayload](#agentsandboxdownloadpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Uploaded | **application/json**: [SandboxUploadResponse](#sandboxuploadresponse)<br> |
| 200 | Download URL returned | **application/json**: [SandboxDownloadResponse](#sandboxdownloadresponse)<br> |
### [GET] /agent/{agent_id}/sandbox/files/read
Read a text/binary preview file in an Agent App conversation sandbox
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| caller_id | query | Agent App caller ID | Yes | string |
| caller_type | query | | Yes | string, <br>**Available values:** "build_draft", "conversation" |
| path | query | Binding path: relative paths start in Workspace; exact `~` and paths beginning with `~/` start in Home; `~user` is an ordinary relative path from Workspace; absolute paths remain absolute; `..` and paths outside Workspace are governed by backend isolation, not a Workspace-root restriction | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)<br> |
### [POST] /agent/{agent_id}/skills/upload
Upload + standardize a Skill into an Agent App drive
@@ -3822,7 +3822,7 @@ List a directory in a workflow Agent node sandbox
| node_id | path | Workflow Agent node ID | Yes | string |
| workflow_run_id | path | Workflow run ID | Yes | string (uuid) |
| node_execution_id | query | Workflow node execution ID | Yes | string |
| path | query | Directory path relative to the sandbox workspace | No | string, <br>**Default:** . |
| path | query | Binding path: relative paths start in Workspace; exact `~` and paths beginning with `~/` start in Home; `~user` is an ordinary relative path from Workspace; absolute paths remain absolute; `..` and paths outside Workspace are governed by backend isolation, not a Workspace-root restriction | No | string, <br>**Default:** . |
#### Responses
@@ -3830,27 +3830,8 @@ List a directory in a workflow Agent node sandbox
| ---- | ----------- | ------ |
| 200 | Listing returned | **application/json**: [SandboxListResponse](#sandboxlistresponse)<br> |
### [GET] /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/read
Read a text/binary preview file in a workflow Agent node sandbox
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| node_id | path | Workflow Agent node ID | Yes | string |
| workflow_run_id | path | Workflow run ID | Yes | string (uuid) |
| node_execution_id | query | Workflow node execution ID | Yes | string |
| path | query | File path relative to the sandbox workspace | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)<br> |
### [POST] /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/upload
Upload one workflow Agent sandbox file and return a signed download URL
### [POST] /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/download
Create a ToolFile from one workflow Agent Binding file and return its download URL
#### Parameters
@@ -3864,13 +3845,32 @@ Upload one workflow Agent sandbox file and return a signed download URL
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [WorkflowAgentSandboxUploadPayload](#workflowagentsandboxuploadpayload)<br> |
| Yes | **application/json**: [WorkflowAgentSandboxDownloadPayload](#workflowagentsandboxdownloadpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Uploaded | **application/json**: [SandboxUploadResponse](#sandboxuploadresponse)<br> |
| 200 | Download URL returned | **application/json**: [SandboxDownloadResponse](#sandboxdownloadresponse)<br> |
### [GET] /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/read
Read a text/binary preview file in a workflow Agent node sandbox
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| node_id | path | Workflow Agent node ID | Yes | string |
| workflow_run_id | path | Workflow run ID | Yes | string (uuid) |
| node_execution_id | query | Workflow node execution ID | Yes | string |
| path | query | Binding path: relative paths start in Workspace; exact `~` and paths beginning with `~/` start in Home; `~user` is an ordinary relative path from Workspace; absolute paths remain absolute; `..` and paths outside Workspace are governed by backend isolation, not a Workspace-root restriction | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)<br> |
### [GET] /apps/{app_id}/workflow/comments
**Get all comments for a workflow**
@@ -14703,6 +14703,14 @@ section may be empty, which is how callers express "no knowledge layer".
| workflow_id | string | | No |
| workflow_node_id | string | | No |
#### AgentSandboxDownloadPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| caller_id | string | Agent App caller ID | Yes |
| caller_type | string, <br>**Available values:** "build_draft", "conversation" | *Enum:* `"build_draft"`, `"conversation"` | Yes |
| path | string | Binding path: relative paths start in Workspace; exact `~` and paths beginning with `~/` start in Home; `~user` is an ordinary relative path from Workspace; absolute paths remain absolute; `..` and paths outside Workspace are governed by backend isolation, not a Workspace-root restriction | Yes |
#### AgentSandboxProviderConfig
| Name | Type | Description | Required |
@@ -14712,14 +14720,6 @@ section may be empty, which is how callers express "no knowledge layer".
| image | string | | No |
| working_dir | string | | No |
#### AgentSandboxUploadPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| caller_id | string | Agent App caller ID | Yes |
| caller_type | string, <br>**Available values:** "build_draft", "conversation" | *Enum:* `"build_draft"`, `"conversation"` | Yes |
| path | string | File path relative to the sandbox workspace | Yes |
#### AgentScope
Visibility and lifecycle scope of an Agent record.
@@ -21522,6 +21522,12 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
| ---- | ---- | ----------- | -------- |
| SSOProtocol | string | | |
#### SandboxDownloadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| url | string | | Yes |
#### SandboxFileEntryResponse
| Name | Type | Description | Required |
@@ -21555,12 +21561,6 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
| text | string | | No |
| truncated | boolean | | Yes |
#### SandboxUploadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| url | string | | Yes |
#### SavedMessageCreatePayload
| Name | Type | Description | Required |
@@ -23367,12 +23367,12 @@ How a workflow node is bound to an Agent.
| variant | string | | Yes |
| workflow_id | string | | No |
#### WorkflowAgentSandboxUploadPayload
#### WorkflowAgentSandboxDownloadPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| node_execution_id | string | Workflow node execution ID | Yes |
| path | string | File path relative to the sandbox workspace | Yes |
| path | string | Binding path: relative paths start in Workspace; exact `~` and paths beginning with `~/` start in Home; `~user` is an ordinary relative path from Workspace; absolute paths remain absolute; `..` and paths outside Workspace are governed by backend isolation, not a Workspace-root restriction | Yes |
#### WorkflowAppLogPaginationResponse
+111 -57
View File
@@ -4,23 +4,26 @@ from __future__ import annotations
import urllib.parse
from collections.abc import Callable
from typing import Any, Literal, cast
from dataclasses import dataclass
from typing import Literal, cast
from dify_agent.client import Client
from dify_agent.layers.execution_context import (
DifyExecutionContextAgentConfigVersionKind,
DifyExecutionContextLayerConfig,
)
from dify_agent.protocol import WorkspaceListResponse, WorkspaceReadResponse, WorkspaceUploadRequest
from dify_agent.protocol import (
BindingFileDownloadRequest,
BindingFileListResponse,
BindingFileReadResponse,
)
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from core.app.file_access import DatabaseFileAccessController
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
from core.db.session_factory import session_factory
from factories import file_factory
from core.tools.signature import bind_file_uri
from models.agent import (
Agent,
AgentConfigDraft,
@@ -28,10 +31,11 @@ from models.agent import (
AgentWorkspaceBinding,
AgentWorkspaceOwnerType,
)
from models.model import App, Conversation
from models.model import App, AppMode, Conversation
from models.workflow import WorkflowNodeExecutionModel
from services.agent.roster_service import AgentRosterService
from services.agent.workspace_service import AgentWorkspaceService, WorkspaceOwnerScope
from services.file_request_service import FileRequestService
class AgentSandboxInspectorError(Exception):
@@ -50,14 +54,37 @@ class AgentSandboxInfo(BaseModel):
workspace_cwd: str
class AgentSandboxUploadDownload(BaseModel):
class AgentSandboxDownload(BaseModel):
url: str
@dataclass(frozen=True, slots=True)
class _ResolvedBinding:
"""Detached scalar Binding data safe to carry beyond its read transaction.
Resolvers must end the transaction before Dify Agent network I/O; ORM and
session-bound objects never cross that boundary.
"""
backend_binding_ref: str
agent_id: str
agent_config_version_id: str
agent_config_version_kind: str
class AgentAppSandboxService:
def __init__(self, *, client_factory: Callable[[], Client] | None = None) -> None:
self._client_factory = client_factory or _default_client_factory
@staticmethod
def resolve_app_id(*, tenant_id: str, agent_id: str) -> str:
with session_factory.create_session() as session:
app = AgentRosterService(session).get_agent_runtime_app_model(
tenant_id=tenant_id,
agent_id=agent_id,
)
return app.id
def get_info(
self,
*,
@@ -88,7 +115,7 @@ class AgentAppSandboxService:
caller_id: str,
account_id: str,
path: str,
) -> WorkspaceListResponse:
) -> BindingFileListResponse:
binding = self._resolve_binding(
tenant_id=tenant_id,
app_id=app_id,
@@ -98,7 +125,7 @@ class AgentAppSandboxService:
account_id=account_id,
)
with self._client_factory() as client:
return client.list_workspace_files_sync(binding.backend_binding_ref, path)
return client.list_binding_files_sync(binding.backend_binding_ref, path)
def read_file(
self,
@@ -110,7 +137,7 @@ class AgentAppSandboxService:
caller_id: str,
account_id: str,
path: str,
) -> WorkspaceReadResponse:
) -> BindingFileReadResponse:
binding = self._resolve_binding(
tenant_id=tenant_id,
app_id=app_id,
@@ -120,9 +147,9 @@ class AgentAppSandboxService:
account_id=account_id,
)
with self._client_factory() as client:
return client.read_workspace_file_sync(binding.backend_binding_ref, path)
return client.read_binding_file_sync(binding.backend_binding_ref, path)
def upload_file(
def download_file(
self,
*,
tenant_id: str,
@@ -132,7 +159,7 @@ class AgentAppSandboxService:
caller_id: str,
account_id: str,
path: str,
) -> AgentSandboxUploadDownload:
) -> AgentSandboxDownload:
binding = self._resolve_binding(
tenant_id=tenant_id,
app_id=app_id,
@@ -142,26 +169,28 @@ class AgentAppSandboxService:
account_id=account_id,
)
with self._client_factory() as client:
uploaded = client.upload_workspace_file_sync(
WorkspaceUploadRequest(
downloaded = client.download_binding_file_sync(
BindingFileDownloadRequest(
backend_binding_ref=binding.backend_binding_ref,
path=path,
execution_context=DifyExecutionContextLayerConfig(
tenant_id=tenant_id,
user_id=account_id,
user_from="account",
app_id=app_id,
conversation_id=caller_id if caller_type == "conversation" else None,
agent_id=agent_id,
agent_config_version_id=binding.agent_config_version_id,
agent_config_version_kind=cast(
DifyExecutionContextAgentConfigVersionKind,
binding.agent_config_version_kind.value,
binding.agent_config_version_kind,
),
agent_mode="agent_app",
invoke_from="debugger",
),
)
)
return _upload_download_response(tenant_id=tenant_id, file_mapping=uploaded.file.model_dump(mode="python"))
return _download_response(tenant_id=tenant_id, account_id=account_id, reference=downloaded.reference)
@staticmethod
def _resolve_binding(
@@ -172,7 +201,7 @@ class AgentAppSandboxService:
caller_type: Literal["conversation", "build_draft"],
caller_id: str,
account_id: str,
) -> AgentWorkspaceBinding:
) -> _ResolvedBinding:
with session_factory.create_session() as session:
caller: AgentConfigDraft | Conversation | None
if caller_type == "build_draft":
@@ -236,14 +265,25 @@ class AgentAppSandboxService:
"this caller has no active Agent Workspace Binding",
status_code=404,
)
session.expunge(binding)
return binding
return _binding_value(binding)
class WorkflowAgentSandboxService:
def __init__(self, *, client_factory: Callable[[], Client] | None = None) -> None:
self._client_factory = client_factory or _default_client_factory
@staticmethod
def resolve_app_id(*, tenant_id: str, app_id: str) -> str | None:
with session_factory.create_session() as session:
return session.scalar(
select(App.id).where(
App.id == app_id,
App.tenant_id == tenant_id,
App.status == "normal",
App.mode.in_((AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value)),
)
)
def list_files(
self,
*,
@@ -254,7 +294,7 @@ class WorkflowAgentSandboxService:
node_execution_id: str,
path: str,
session: Session,
) -> WorkspaceListResponse:
) -> BindingFileListResponse:
binding = self._resolve_binding(
tenant_id=tenant_id,
app_id=app_id,
@@ -264,7 +304,7 @@ class WorkflowAgentSandboxService:
session=session,
)
with self._client_factory() as client:
return client.list_workspace_files_sync(binding.backend_binding_ref, path)
return client.list_binding_files_sync(binding.backend_binding_ref, path)
def read_file(
self,
@@ -276,7 +316,7 @@ class WorkflowAgentSandboxService:
node_execution_id: str,
path: str,
session: Session,
) -> WorkspaceReadResponse:
) -> BindingFileReadResponse:
binding = self._resolve_binding(
tenant_id=tenant_id,
app_id=app_id,
@@ -286,9 +326,9 @@ class WorkflowAgentSandboxService:
session=session,
)
with self._client_factory() as client:
return client.read_workspace_file_sync(binding.backend_binding_ref, path)
return client.read_binding_file_sync(binding.backend_binding_ref, path)
def upload_file(
def download_file(
self,
*,
tenant_id: str,
@@ -296,39 +336,43 @@ class WorkflowAgentSandboxService:
workflow_run_id: str,
node_id: str,
node_execution_id: str,
account_id: str,
path: str,
session: Session,
) -> AgentSandboxUploadDownload:
binding = self._resolve_binding(
tenant_id=tenant_id,
app_id=app_id,
workflow_run_id=workflow_run_id,
node_id=node_id,
node_execution_id=node_execution_id,
session=session,
)
) -> AgentSandboxDownload:
with session_factory.create_session() as session:
binding = self._resolve_binding(
tenant_id=tenant_id,
app_id=app_id,
workflow_run_id=workflow_run_id,
node_id=node_id,
node_execution_id=node_execution_id,
session=session,
)
with self._client_factory() as client:
uploaded = client.upload_workspace_file_sync(
WorkspaceUploadRequest(
downloaded = client.download_binding_file_sync(
BindingFileDownloadRequest(
backend_binding_ref=binding.backend_binding_ref,
path=path,
execution_context=DifyExecutionContextLayerConfig(
tenant_id=tenant_id,
user_id=account_id,
user_from="account",
app_id=app_id,
workflow_run_id=workflow_run_id,
node_id=node_id,
node_execution_id=node_execution_id,
agent_id=binding.agent_id,
agent_config_version_id=binding.agent_config_version_id,
agent_config_version_kind=cast(
DifyExecutionContextAgentConfigVersionKind,
binding.agent_config_version_kind.value,
binding.agent_config_version_kind,
),
agent_mode="workflow_run",
invoke_from="debugger",
),
)
)
return _upload_download_response(tenant_id=tenant_id, file_mapping=uploaded.file.model_dump(mode="python"))
return _download_response(tenant_id=tenant_id, account_id=account_id, reference=downloaded.reference)
@staticmethod
def _resolve_binding(
@@ -339,7 +383,7 @@ class WorkflowAgentSandboxService:
node_id: str,
node_execution_id: str,
session: Session,
) -> AgentWorkspaceBinding:
) -> _ResolvedBinding:
execution = session.scalar(
select(WorkflowNodeExecutionModel).where(
WorkflowNodeExecutionModel.id == node_execution_id,
@@ -379,28 +423,38 @@ class WorkflowAgentSandboxService:
"this Workflow Agent node execution has no active Workspace Binding",
status_code=404,
)
return binding
resolved = _binding_value(binding)
# Deliberately end the read transaction before the caller performs Dify Agent I/O.
session.rollback()
return resolved
def _upload_download_response(*, tenant_id: str, file_mapping: dict[str, Any]) -> AgentSandboxUploadDownload:
controller = DatabaseFileAccessController()
runtime = DifyWorkflowFileRuntime(file_access_controller=controller)
def _binding_value(binding: AgentWorkspaceBinding) -> _ResolvedBinding:
return _ResolvedBinding(
backend_binding_ref=binding.backend_binding_ref,
agent_id=binding.agent_id,
agent_config_version_id=binding.agent_config_version_id,
agent_config_version_kind=binding.agent_config_version_kind.value,
)
def _download_response(*, tenant_id: str, account_id: str, reference: str) -> AgentSandboxDownload:
try:
file = file_factory.build_from_mapping(mapping=file_mapping, tenant_id=tenant_id, access_controller=controller)
url = runtime.resolve_file_url(file=file, for_external=True)
result = FileRequestService().request_download(
tenant_id=tenant_id,
user_id=account_id,
user_from="account",
invoke_from="debugger",
file_mapping={"transfer_method": "tool_file", "reference": reference},
)
url = bind_file_uri(result.download_uri, dify_config.FILES_URL)
except ValueError as exc:
raise AgentSandboxInspectorError(
"workspace_upload_download_unavailable",
"uploaded Workspace file could not be converted to a download URL",
"binding_file_download_unavailable",
"Binding file could not be converted to a download URL",
status_code=502,
) from exc
if not url:
raise AgentSandboxInspectorError(
"workspace_upload_download_unavailable",
"uploaded Workspace file does not support download URL generation",
status_code=502,
)
return AgentSandboxUploadDownload(url=_with_as_attachment(url))
return AgentSandboxDownload(url=_with_as_attachment(url))
def _with_as_attachment(url: str) -> str:
@@ -415,7 +469,7 @@ def _default_client_factory() -> Client:
if not base_url:
raise AgentSandboxInspectorError(
"inspector_unavailable",
"the Workspace file inspector is not available (Agent backend not configured)",
"the Binding file inspector is not available (Agent backend not configured)",
status_code=503,
)
return Client(base_url=base_url)
@@ -423,8 +477,8 @@ def _default_client_factory() -> Client:
__all__ = [
"AgentAppSandboxService",
"AgentSandboxDownload",
"AgentSandboxInfo",
"AgentSandboxInspectorError",
"AgentSandboxUploadDownload",
"WorkflowAgentSandboxService",
]
@@ -2,22 +2,23 @@ from __future__ import annotations
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
from dify_agent.protocol import WorkspaceListResponse, WorkspaceReadResponse
from sqlalchemy.orm import Session
from dify_agent.protocol import BindingFileListResponse, BindingFileReadResponse
from controllers.console import agent_app_sandbox as module
from models.model import App, AppMode, IconType
from services.agent_app_sandbox_service import AgentSandboxInfo, AgentSandboxInspectorError, AgentSandboxUploadDownload
from services.agent_app_sandbox_service import AgentSandboxDownload, AgentSandboxInfo, AgentSandboxInspectorError
class _AgentAppService:
def __init__(self) -> None:
self.calls: list[tuple[str, str, str, str, str, str, str, str]] = []
def resolve_app_id(self, *, tenant_id: str, agent_id: str) -> str:
return "app-1"
def get_info(
self,
*,
@@ -41,9 +42,9 @@ class _AgentAppService:
caller_id: str,
account_id: str,
path: str,
) -> WorkspaceListResponse:
) -> BindingFileListResponse:
self.calls.append(("list", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
return WorkspaceListResponse(path=path, entries=[], truncated=False)
return BindingFileListResponse(path=path, entries=[], truncated=False)
def read_file(
self,
@@ -55,11 +56,11 @@ class _AgentAppService:
caller_id: str,
account_id: str,
path: str,
) -> WorkspaceReadResponse:
) -> BindingFileReadResponse:
self.calls.append(("read", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
return WorkspaceReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
return BindingFileReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
def upload_file(
def download_file(
self,
*,
tenant_id: str,
@@ -69,14 +70,17 @@ class _AgentAppService:
caller_id: str,
account_id: str,
path: str,
) -> AgentSandboxUploadDownload:
self.calls.append(("upload", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
return AgentSandboxUploadDownload(url="https://files.example/report.txt")
) -> AgentSandboxDownload:
self.calls.append(("download", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
return AgentSandboxDownload(url="https://files.example/report.txt")
class _WorkflowService:
def __init__(self) -> None:
self.calls: list[tuple[str, str, str, str, str, str]] = []
self.calls: list[tuple[str, ...]] = []
def resolve_app_id(self, *, tenant_id: str, app_id: str) -> str:
return app_id
def list_files(
self,
@@ -88,9 +92,9 @@ class _WorkflowService:
node_execution_id: str,
path: str,
session,
) -> WorkspaceListResponse:
) -> BindingFileListResponse:
self.calls.append(("list", tenant_id, app_id, workflow_run_id, node_id, path))
return WorkspaceListResponse(path=path, entries=[], truncated=False)
return BindingFileListResponse(path=path, entries=[], truncated=False)
def read_file(
self,
@@ -102,11 +106,11 @@ class _WorkflowService:
node_execution_id: str,
path: str,
session,
) -> WorkspaceReadResponse:
) -> BindingFileReadResponse:
self.calls.append(("read", tenant_id, app_id, workflow_run_id, node_id, path))
return WorkspaceReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
return BindingFileReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
def upload_file(
def download_file(
self,
*,
tenant_id: str,
@@ -114,11 +118,11 @@ class _WorkflowService:
workflow_run_id: str,
node_id: str,
node_execution_id: str,
account_id: str,
path: str,
session,
) -> AgentSandboxUploadDownload:
self.calls.append(("upload", tenant_id, app_id, workflow_run_id, node_id, path))
return AgentSandboxUploadDownload(url="https://files.example/upload.txt")
) -> AgentSandboxDownload:
self.calls.append(("download", tenant_id, app_id, workflow_run_id, node_id, account_id, path))
return AgentSandboxDownload(url="https://files.example/download.txt")
def _app_model(app_id: str = "app-1") -> App:
@@ -160,46 +164,40 @@ def test_handle_maps_sandbox_and_agent_backend_errors() -> None:
module._handle(RuntimeError("boom"))
def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch, unbound_session: Session) -> None:
def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None:
service = _AgentAppService()
session = unbound_session
account = SimpleNamespace(id="account-1")
resolver = MagicMock(return_value=_app_model())
monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service)
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", resolver)
monkeypatch.setattr(
module,
"query_params_from_request",
lambda model: SimpleNamespace(caller_type="build_draft", caller_id="build-1", path="sub/report.txt"),
)
info = unwrap(module.AgentAppSandboxInfoResource.get)(object(), session, account, "tenant-1", "agent-1")
listing = unwrap(module.AgentAppSandboxListResource.get)(object(), session, account, "tenant-1", "agent-1")
preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), session, account, "tenant-1", "agent-1")
req_data = module.AgentSandboxUploadPayload.model_validate(
info = unwrap(module.AgentAppSandboxInfoResource.get)(object(), account, "tenant-1", "agent-1")
listing = unwrap(module.AgentAppSandboxListResource.get)(object(), account, "tenant-1", "agent-1")
preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), account, "tenant-1", "agent-1")
req_data = module.AgentSandboxDownloadPayload.model_validate(
{"caller_type": "build_draft", "caller_id": "build-1", "path": "report.txt"}
)
upload = unwrap(module.AgentAppSandboxUploadResource.post)(
object(), req_data, session, account, "tenant-1", "agent-1"
)
download = unwrap(module.AgentAppSandboxDownloadResource.post)(object(), req_data, account, "tenant-1", "agent-1")
assert info == {"workspace_cwd": "."}
assert listing["path"] == "sub/report.txt"
assert preview["text"] == "hello"
assert upload == {"url": "https://files.example/report.txt"}
assert download == {"url": "https://files.example/report.txt"}
assert service.calls == [
("info", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", ""),
("list", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "sub/report.txt"),
("read", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "sub/report.txt"),
("upload", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "report.txt"),
("download", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "report.txt"),
]
assert all(call.kwargs["session"] is session for call in resolver.call_args_list)
def test_agent_app_sandbox_resource_returns_normalized_errors(
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytest.MonkeyPatch) -> None:
class FailingService:
def resolve_app_id(self, **kwargs):
return "app-1"
def get_info(self, **kwargs):
raise AgentSandboxInspectorError("no_active_binding", "no active binding", status_code=404)
@@ -207,20 +205,18 @@ def test_agent_app_sandbox_resource_returns_normalized_errors(
raise AgentSandboxInspectorError("no_active_binding", "no active binding", status_code=404)
monkeypatch.setattr(module, "AgentAppSandboxService", FailingService)
session = unbound_session
account = SimpleNamespace(id="account-1")
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", MagicMock(return_value=_app_model()))
monkeypatch.setattr(
module,
"query_params_from_request",
lambda model: SimpleNamespace(caller_type="conversation", caller_id="conv-1", path="."),
)
assert unwrap(module.AgentAppSandboxInfoResource.get)(object(), session, account, "tenant-1", "agent-1") == (
assert unwrap(module.AgentAppSandboxInfoResource.get)(object(), account, "tenant-1", "agent-1") == (
{"code": "no_active_binding", "message": "no active binding"},
404,
)
assert unwrap(module.AgentAppSandboxListResource.get)(object(), session, account, "tenant-1", "agent-1") == (
assert unwrap(module.AgentAppSandboxListResource.get)(object(), account, "tenant-1", "agent-1") == (
{"code": "no_active_binding", "message": "no active binding"},
404,
)
@@ -242,18 +238,19 @@ def test_workflow_agent_sandbox_resources_proxy_service(monkeypatch: pytest.Monk
preview = unwrap(module.WorkflowAgentSandboxReadResource.get)(
object(), "tenant-1", app_model, "run-1", "agent-node"
)
req_data = module.WorkflowAgentSandboxUploadPayload.model_validate(
{"node_execution_id": "execution-1", "path": "upload.txt"}
req_data = module.WorkflowAgentSandboxDownloadPayload.model_validate(
{"node_execution_id": "execution-1", "path": "download.txt"}
)
upload = unwrap(module.WorkflowAgentSandboxUploadResource.post)(
object(), req_data, "tenant-1", app_model, "run-1", "agent-node"
account = SimpleNamespace(id="account-1")
download = unwrap(module.WorkflowAgentSandboxDownloadResource.post)(
object(), req_data, "tenant-1", account, "app-1", "run-1", "agent-node"
)
assert listing["path"] == "out.txt"
assert preview["text"] == "hello"
assert upload == {"url": "https://files.example/upload.txt"}
assert download == {"url": "https://files.example/download.txt"}
assert service.calls == [
("list", "tenant-1", "app-1", "run-1", "agent-node", "out.txt"),
("read", "tenant-1", "app-1", "run-1", "agent-node", "out.txt"),
("upload", "tenant-1", "app-1", "run-1", "agent-node", "upload.txt"),
("download", "tenant-1", "app-1", "run-1", "agent-node", "account-1", "download.txt"),
]
@@ -1,13 +1,17 @@
import io
import types
from contextlib import contextmanager
from inspect import unwrap
from unittest.mock import patch
import pytest
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
import controllers.files.upload as module
from core.workflow.file_reference import build_file_reference
from models import Account, TenantAccountJoin
from models.account import AccountStatus
def fake_request(args: dict, file=None):
@@ -17,6 +21,22 @@ def fake_request(args: dict, file=None):
)
def _persist_account_memberships(session: Session) -> None:
account = Account(name="Tenant member", email="member@example.com", status=AccountStatus.ACTIVE)
account.id = "account-1"
decoy = Account(name="Other tenant member", email="decoy@example.com", status=AccountStatus.ACTIVE)
decoy.id = "account-outside-tenant"
session.add_all(
[
account,
decoy,
TenantAccountJoin(tenant_id="tenant-1", account_id=account.id),
TenantAccountJoin(tenant_id="tenant-other", account_id=decoy.id),
]
)
session.commit()
class DummyUser:
def __init__(self, user_id="user-1"):
self.id = user_id
@@ -33,6 +53,16 @@ class DummyFile:
return self.stream.read()
class RecordingStream(io.BytesIO):
def __init__(self, content: bytes, events: list[str]):
super().__init__(content)
self.events = events
def read(self, *args, **kwargs):
self.events.append("file-read")
return super().read(*args, **kwargs)
class DummyToolFile:
def __init__(self, name="test.txt", mimetype="text/plain"):
self.id = "file-id"
@@ -94,6 +124,138 @@ class TestPluginUploadFileApi:
assert tool_file_manager_instance.create_file_by_raw.call_args.kwargs["conversation_id"] == "conversation-1"
mock_tool_file_manager.sign_file.assert_called_once_with(tool_file_id="file-id", extension=".docx")
@patch.object(module, "get_user")
@patch.object(module, "ToolFileManager")
@pytest.mark.parametrize("sqlite_session", [(Account, TenantAccountJoin)], indirect=True)
def test_account_upload_preserves_signed_account_owner(
self,
mock_tool_file_manager,
mock_get_user,
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
):
_persist_account_memberships(sqlite_session)
events: list[str] = []
dummy_file = DummyFile(filename="report.pdf", mimetype="application/pdf", content=b"account-owned")
dummy_file.stream = RecordingStream(b"account-owned", events)
@contextmanager
def membership_session():
events.append("membership-session-enter")
try:
yield sqlite_session
finally:
events.append("membership-session-exit")
monkeypatch.setattr(module.session_factory, "create_session", membership_session)
monkeypatch.setattr(
module,
"request",
fake_request(
{
"timestamp": "123",
"nonce": "abc",
"sign": "sig",
"tenant_id": "tenant-1",
"user_id": "account-1",
"user_from": "account",
},
file=dummy_file,
),
)
tool_file_manager = mock_tool_file_manager.return_value
tool_file_manager.create_file_by_raw.side_effect = lambda **_kwargs: (
events.append("storage-create-file") or DummyToolFile(name="report.pdf", mimetype="application/pdf")
)
mock_tool_file_manager.sign_file.return_value = "signed-url"
with patch.object(
module,
"verify_plugin_file_signature",
side_effect=lambda **_kwargs: events.append("signature-verify") or True,
) as verify_signature:
api = module.PluginUploadFileApi()
result, status_code = unwrap(api.post)(api)
assert status_code == 201
assert result["reference"] == build_file_reference(record_id="file-id")
assert events == [
"membership-session-enter",
"membership-session-exit",
"signature-verify",
"file-read",
"storage-create-file",
]
mock_get_user.assert_not_called()
verify_signature.assert_called_once_with(
filename="report.pdf",
mimetype="application/pdf",
tenant_id="tenant-1",
user_id="account-1",
conversation_id=None,
user_from="account",
timestamp="123",
nonce="abc",
sign="sig",
)
tool_file_manager.create_file_by_raw.assert_called_once_with(
user_id="account-1",
tenant_id="tenant-1",
file_binary=b"account-owned",
mimetype="application/pdf",
filename="report.pdf",
conversation_id=None,
)
@patch.object(module, "verify_plugin_file_signature")
@patch.object(module, "get_user")
@patch.object(module, "ToolFileManager")
@pytest.mark.parametrize("sqlite_session", [(Account, TenantAccountJoin)], indirect=True)
def test_account_upload_rejects_owner_outside_tenant(
self,
mock_tool_file_manager,
mock_get_user,
mock_verify_signature,
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
):
_persist_account_memberships(sqlite_session)
events: list[str] = []
@contextmanager
def membership_session():
events.append("membership-session-enter")
try:
yield sqlite_session
finally:
events.append("membership-session-exit")
monkeypatch.setattr(module.session_factory, "create_session", membership_session)
monkeypatch.setattr(
module,
"request",
fake_request(
{
"timestamp": "123",
"nonce": "abc",
"sign": "sig",
"tenant_id": "tenant-1",
"user_id": "account-outside-tenant",
"user_from": "account",
},
file=DummyFile(),
),
)
api = module.PluginUploadFileApi()
with pytest.raises(Forbidden):
unwrap(api.post)(api)
assert events == ["membership-session-enter", "membership-session-exit"]
mock_get_user.assert_not_called()
mock_verify_signature.assert_not_called()
mock_tool_file_manager.assert_not_called()
def test_missing_file(self):
module.request = fake_request(
{
@@ -7,7 +7,11 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.inner_api.agent.files import AgentFileDownloadRequestApi, AgentFileUploadRequestApi
from controllers.inner_api.agent.files import (
AgentFileDownloadRequestApi,
AgentFileRequestHttpError,
AgentFileUploadRequestApi,
)
from core.workflow.file_reference import build_file_reference
from services.file_request_service import DownloadFileRequestResult
@@ -46,9 +50,74 @@ def test_upload_request_returns_origin_free_uri(app: Flask) -> None:
tenant_id="tenant-1",
user_id="canonical-end-user-1",
conversation_id="conversation-1",
user_from=None,
)
def test_upload_request_preserves_tenant_scoped_account_owner(app: Flask) -> None:
payload = {
"tenant_id": "tenant-1",
"user_id": "account-1",
"user_from": "account",
"filename": "report.pdf",
"mimetype": "application/pdf",
"conversation_id": "conversation-1",
}
tenant = SimpleNamespace(id="tenant-1")
session = MagicMock()
with app.test_request_context("/", method="POST", json=payload):
with (
patch(f"{MODULE}.TenantService") as tenant_service,
patch(f"{MODULE}.get_user") as get_user,
patch(f"{MODULE}.get_signed_file_uri_for_plugin", return_value="/files/upload/for-plugin?sign=1") as sign,
):
tenant_service.get_tenant_by_id.return_value = tenant
tenant_service.account_belongs_to_tenant.return_value = True
response = _raw(AgentFileUploadRequestApi.post)(AgentFileUploadRequestApi(), session)
assert response == {"upload_uri": "/files/upload/for-plugin?sign=1"}
get_user.assert_not_called()
tenant_service.account_belongs_to_tenant.assert_called_once_with("account-1", "tenant-1", session=session)
sign.assert_called_once_with(
filename="report.pdf",
mimetype="application/pdf",
tenant_id="tenant-1",
user_id="account-1",
conversation_id="conversation-1",
user_from="account",
)
def test_upload_request_rejects_account_outside_tenant_without_signing(app: Flask) -> None:
payload = {
"tenant_id": "tenant-1",
"user_id": "account-outside-tenant",
"user_from": "account",
"filename": "report.pdf",
"mimetype": "application/pdf",
}
tenant = SimpleNamespace(id="tenant-1")
session = MagicMock()
with app.test_request_context("/", method="POST", json=payload):
with (
patch(f"{MODULE}.TenantService") as tenant_service,
patch(f"{MODULE}.get_user") as get_user,
patch(f"{MODULE}.get_signed_file_uri_for_plugin") as sign,
):
tenant_service.get_tenant_by_id.return_value = tenant
tenant_service.account_belongs_to_tenant.return_value = False
with pytest.raises(AgentFileRequestHttpError) as exc_info:
_raw(AgentFileUploadRequestApi.post)(AgentFileUploadRequestApi(), session)
assert exc_info.value.error_code == "user_not_found"
assert exc_info.value.code == 404
tenant_service.account_belongs_to_tenant.assert_called_once_with(
"account-outside-tenant", "tenant-1", session=session
)
get_user.assert_not_called()
sign.assert_not_called()
def test_download_request_returns_origin_free_uri_for_sandbox(app: Flask) -> None:
reference = build_file_reference(record_id="tool-file-1")
payload = {
@@ -187,6 +187,36 @@ def test_get_signed_file_uri_for_plugin_and_verify_roundtrip(monkeypatch: pytest
)
def test_plugin_upload_signature_binds_account_user_from(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x09" * 16)
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_ACCESS_TIMEOUT", 60)
uri = get_signed_file_uri_for_plugin(
filename="report.pdf",
mimetype="application/pdf",
tenant_id="tenant-id",
user_id="account-id",
user_from="account",
)
query = parse_qs(urlparse(uri).query)
assert query["user_from"] == ["account"]
signed = {
"filename": "report.pdf",
"mimetype": "application/pdf",
"tenant_id": "tenant-id",
"user_id": "account-id",
"timestamp": query["timestamp"][0],
"nonce": query["nonce"][0],
"sign": query["sign"][0],
}
assert verify_plugin_file_signature(**signed, user_from="account") is True
assert verify_plugin_file_signature(**signed, user_from="end-user") is False
assert verify_plugin_file_signature(**signed) is False
def test_verify_plugin_file_signature_rejects_invalid_signatures(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x07" * 16)
@@ -199,9 +199,13 @@ def test_load_or_create_persists_binding_on_node_execution(monkeypatch, home_sna
session=session,
)
assert resolved.id == "binding-1"
assert resolved.backend_binding_ref == "backend-binding-1"
assert resolved.agent_id == "agent-1"
assert resolved.agent_config_version_id == "config-1"
assert resolved.agent_config_version_kind == "snapshot"
owner_scope = get_active.call_args.kwargs["expected_owner_scope"]
assert owner_scope.owner_scope_key == "node-1:workflow-binding-1"
session.rollback.assert_called_once_with()
def test_load_existing_pointer_rejects_missing_workflow_identity(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -1,24 +1,39 @@
from contextlib import nullcontext
import json
from contextlib import contextmanager, nullcontext
from datetime import datetime
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock
import pytest
from dify_agent.protocol import WorkspaceListResponse, WorkspaceReadResponse
from dify_agent.client import Client
from dify_agent.protocol import BindingFileDownloadResponse, BindingFileListResponse, BindingFileReadResponse
from sqlalchemy.orm import Session
from graphon.enums import WorkflowNodeExecutionStatus
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigVersionKind,
AgentKind,
AgentScope,
AgentSource,
AgentStatus,
AgentWorkingResourceStatus,
AgentWorkspace,
AgentWorkspaceBinding,
AgentWorkspaceOwnerType,
)
from models.enums import ConversationFromSource
from models.agent_config_entities import AgentSoulConfig
from models.enums import ConversationFromSource, CreatorUserRole
from models.model import App, AppMode, Conversation, IconType
from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom
from services import agent_app_sandbox_service as sandbox_module
from services.agent.workspace_service import AgentWorkspaceService
from services.agent_app_sandbox_service import (
AgentAppSandboxService,
AgentSandboxDownload,
AgentSandboxInspectorError,
WorkflowAgentSandboxService,
)
@@ -104,6 +119,79 @@ def _use_session(monkeypatch: pytest.MonkeyPatch, session: Session) -> None:
)
def _add_app(session: Session, *, app_id: str, tenant_id: str) -> None:
session.add(
App(
id=app_id,
tenant_id=tenant_id,
name=f"App {app_id}",
description="",
mode=AppMode.AGENT,
icon_type=IconType.EMOJI,
icon="robot",
icon_background="#FFFFFF",
enable_site=False,
enable_api=False,
max_active_requests=0,
)
)
def _add_binding(
session: Session,
*,
binding_id: str,
workspace_id: str,
tenant_id: str = "tenant-1",
app_id: str = "app-1",
agent_id: str = "agent-1",
owner_type: AgentWorkspaceOwnerType,
owner_id: str,
owner_scope_key: str = "root",
status: AgentWorkingResourceStatus = AgentWorkingResourceStatus.ACTIVE,
) -> AgentWorkspaceBinding:
active_guard = 1 if status is AgentWorkingResourceStatus.ACTIVE else None
workspace = AgentWorkspace(
id=workspace_id,
tenant_id=tenant_id,
app_id=app_id,
owner_type=owner_type,
owner_id=owner_id,
owner_scope_key=owner_scope_key,
backend_workspace_ref=f"{workspace_id}-ref",
status=status,
active_guard=active_guard,
)
binding = AgentWorkspaceBinding(
id=binding_id,
tenant_id=tenant_id,
app_id=app_id,
workspace_id=workspace_id,
agent_id=agent_id,
base_home_snapshot_id=None,
agent_config_version_id=f"{binding_id}-config",
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
backend_binding_ref=f"{binding_id}-ref",
status=status,
)
session.add_all([workspace, binding])
return binding
def _download_client() -> MagicMock:
client = MagicMock()
client.download_binding_file_sync.return_value = BindingFileDownloadResponse(reference="dify-file-ref:canonical")
return client
def _stub_download_response(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
sandbox_module,
"_download_response",
lambda **_kwargs: AgentSandboxDownload(url="https://files.example/report.txt"),
)
@pytest.mark.parametrize(
"sqlite_session",
[(AgentWorkspace, AgentWorkspaceBinding, App, Conversation)],
@@ -117,8 +205,8 @@ def test_agent_app_file_browsing_uses_conversation_pointer(
sqlite_session.commit()
_use_session(monkeypatch, sqlite_session)
client = MagicMock()
response = WorkspaceListResponse(path=".", entries=[], truncated=False)
client.list_workspace_files_sync.return_value = response
response = BindingFileListResponse(path=".", entries=[], truncated=False)
client.list_binding_files_sync.return_value = response
result = AgentAppSandboxService(client_factory=lambda: nullcontext(client)).list_files(
tenant_id="tenant-1",
@@ -131,7 +219,7 @@ def test_agent_app_file_browsing_uses_conversation_pointer(
)
assert result is response
client.list_workspace_files_sync.assert_called_once_with(expected.backend_binding_ref, ".")
client.list_binding_files_sync.assert_called_once_with(expected.backend_binding_ref, ".")
@pytest.mark.parametrize(
@@ -160,7 +248,443 @@ def test_agent_app_file_browsing_rejects_other_account(
)
assert exc_info.value.code == "no_active_binding"
client.list_workspace_files_sync.assert_not_called()
client.list_binding_files_sync.assert_not_called()
@pytest.mark.parametrize(
"sqlite_session",
[(AgentWorkspace, AgentWorkspaceBinding, App, Conversation)],
indirect=True,
)
def test_agent_conversation_download_resolves_only_exact_active_owner_chain(
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
) -> None:
_add_app(sqlite_session, app_id="app-1", tenant_id="tenant-1")
_add_app(sqlite_session, app_id="app-other", tenant_id="tenant-other")
valid = _add_binding(
sqlite_session,
binding_id="binding-valid",
workspace_id="workspace-valid",
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
owner_id="conversation-valid",
)
wrong_owner = _add_binding(
sqlite_session,
binding_id="binding-wrong-owner",
workspace_id="workspace-wrong-owner",
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
owner_id="conversation-not-the-caller",
)
retired = _add_binding(
sqlite_session,
binding_id="binding-retired",
workspace_id="workspace-retired",
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
owner_id="conversation-retired",
status=AgentWorkingResourceStatus.RETIRED,
)
cross_tenant = _add_binding(
sqlite_session,
binding_id="binding-cross-tenant",
workspace_id="workspace-cross-tenant",
tenant_id="tenant-other",
app_id="app-other",
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
owner_id="conversation-cross-tenant",
)
conversations = [
Conversation(
id="conversation-valid",
app_id="app-1",
mode=AppMode.AGENT,
name="Valid",
from_source=ConversationFromSource.CONSOLE,
from_account_id="account-1",
is_deleted=False,
agent_workspace_binding_id=valid.id,
),
Conversation(
id="conversation-wrong-owner",
app_id="app-1",
mode=AppMode.AGENT,
name="Wrong owner",
from_source=ConversationFromSource.CONSOLE,
from_account_id="account-1",
is_deleted=False,
agent_workspace_binding_id=wrong_owner.id,
),
Conversation(
id="conversation-retired",
app_id="app-1",
mode=AppMode.AGENT,
name="Retired",
from_source=ConversationFromSource.CONSOLE,
from_account_id="account-1",
is_deleted=False,
agent_workspace_binding_id=retired.id,
),
Conversation(
id="conversation-cross-tenant",
app_id="app-other",
mode=AppMode.AGENT,
name="Cross tenant",
from_source=ConversationFromSource.CONSOLE,
from_account_id="account-other",
is_deleted=False,
agent_workspace_binding_id=cross_tenant.id,
),
]
for conversation in conversations:
conversation._inputs = {}
sqlite_session.add_all(conversations)
sqlite_session.commit()
_use_session(monkeypatch, sqlite_session)
_stub_download_response(monkeypatch)
client = _download_client()
service = AgentAppSandboxService(client_factory=lambda: nullcontext(cast(Client, client)))
result = service.download_file(
tenant_id="tenant-1",
app_id="app-1",
agent_id="agent-1",
caller_type="conversation",
caller_id="conversation-valid",
account_id="account-1",
path="report.txt",
)
assert result.url == "https://files.example/report.txt"
request = client.download_binding_file_sync.call_args.args[0]
assert request.backend_binding_ref == "binding-valid-ref"
client.download_binding_file_sync.reset_mock()
rejected_locators = [
{"account_id": "account-other"},
{"app_id": "app-other"},
{"caller_id": "conversation-wrong-owner"},
{"caller_id": "conversation-retired"},
{
"tenant_id": "tenant-1",
"app_id": "app-other",
"caller_id": "conversation-cross-tenant",
"account_id": "account-other",
},
]
for override in rejected_locators:
locator = {
"tenant_id": "tenant-1",
"app_id": "app-1",
"agent_id": "agent-1",
"caller_type": "conversation",
"caller_id": "conversation-valid",
"account_id": "account-1",
"path": "report.txt",
}
locator.update(override)
with pytest.raises(AgentSandboxInspectorError, match="active Agent Workspace Binding"):
service.download_file(**locator) # type: ignore[arg-type]
client.download_binding_file_sync.assert_not_called()
@pytest.mark.parametrize(
"sqlite_session",
[(Agent, AgentConfigDraft, AgentWorkspace, AgentWorkspaceBinding)],
indirect=True,
)
def test_agent_build_draft_download_resolves_only_exact_active_owner_chain(
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
) -> None:
sqlite_session.add_all(
[
Agent(
id="agent-1",
tenant_id="tenant-1",
name="Agent",
description="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
app_id="app-1",
status=AgentStatus.ACTIVE,
),
Agent(
id="agent-cross-tenant",
tenant_id="tenant-other",
name="Other tenant Agent",
description="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
app_id="app-other",
status=AgentStatus.ACTIVE,
),
]
)
valid = _add_binding(
sqlite_session,
binding_id="binding-build-valid",
workspace_id="workspace-build-valid",
owner_type=AgentWorkspaceOwnerType.BUILD_DRAFT,
owner_id="draft-valid",
)
wrong_owner = _add_binding(
sqlite_session,
binding_id="binding-build-wrong-owner",
workspace_id="workspace-build-wrong-owner",
owner_type=AgentWorkspaceOwnerType.BUILD_DRAFT,
owner_id="draft-not-the-caller",
)
retired = _add_binding(
sqlite_session,
binding_id="binding-build-retired",
workspace_id="workspace-build-retired",
owner_type=AgentWorkspaceOwnerType.BUILD_DRAFT,
owner_id="draft-retired",
status=AgentWorkingResourceStatus.RETIRED,
)
drafts = [
AgentConfigDraft(
id="draft-valid",
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id="account-1",
draft_owner_key="account-1",
agent_workspace_binding_id=valid.id,
config_snapshot=AgentSoulConfig(),
),
AgentConfigDraft(
id="draft-wrong-owner",
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id="account-2",
draft_owner_key="account-2",
agent_workspace_binding_id=wrong_owner.id,
config_snapshot=AgentSoulConfig(),
),
AgentConfigDraft(
id="draft-retired",
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id="account-3",
draft_owner_key="account-3",
agent_workspace_binding_id=retired.id,
config_snapshot=AgentSoulConfig(),
),
AgentConfigDraft(
id="draft-cross-tenant",
tenant_id="tenant-other",
agent_id="agent-cross-tenant",
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id="account-other",
draft_owner_key="account-other",
agent_workspace_binding_id=None,
config_snapshot=AgentSoulConfig(),
),
]
sqlite_session.add_all(drafts)
sqlite_session.commit()
_use_session(monkeypatch, sqlite_session)
_stub_download_response(monkeypatch)
client = _download_client()
service = AgentAppSandboxService(client_factory=lambda: nullcontext(cast(Client, client)))
result = service.download_file(
tenant_id="tenant-1",
app_id="app-1",
agent_id="agent-1",
caller_type="build_draft",
caller_id="draft-valid",
account_id="account-1",
path="report.txt",
)
assert result.url == "https://files.example/report.txt"
assert client.download_binding_file_sync.call_args.args[0].backend_binding_ref == "binding-build-valid-ref"
client.download_binding_file_sync.reset_mock()
rejected_locators = [
{"account_id": "account-other"},
{"app_id": "app-other"},
{"caller_id": "draft-wrong-owner", "account_id": "account-2"},
{"caller_id": "draft-retired", "account_id": "account-3"},
{
"tenant_id": "tenant-1",
"app_id": "app-other",
"agent_id": "agent-cross-tenant",
"caller_id": "draft-cross-tenant",
"account_id": "account-other",
},
]
for override in rejected_locators:
locator = {
"tenant_id": "tenant-1",
"app_id": "app-1",
"agent_id": "agent-1",
"caller_type": "build_draft",
"caller_id": "draft-valid",
"account_id": "account-1",
"path": "report.txt",
}
locator.update(override)
with pytest.raises(AgentSandboxInspectorError, match="active Agent Workspace Binding"):
service.download_file(**locator) # type: ignore[arg-type]
client.download_binding_file_sync.assert_not_called()
def _workflow_execution(
*,
execution_id: str,
tenant_id: str = "tenant-1",
app_id: str = "app-1",
workflow_run_id: str = "run-1",
node_id: str = "node-1",
binding_id: str,
created_by: str = "historical-account",
) -> WorkflowNodeExecutionModel:
return WorkflowNodeExecutionModel(
id=execution_id,
tenant_id=tenant_id,
app_id=app_id,
workflow_id="workflow-1",
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
workflow_run_id=workflow_run_id,
index=1,
node_id=node_id,
node_type="agent",
title=node_id,
agent_workspace_binding_id=binding_id,
inputs=None,
process_data=json.dumps({"workflow_agent_binding_id": "workflow-binding-1"}),
outputs=None,
status=WorkflowNodeExecutionStatus.SUCCEEDED,
error=None,
created_by_role=CreatorUserRole.ACCOUNT,
created_by=created_by,
)
@pytest.mark.parametrize(
"sqlite_session",
[(WorkflowNodeExecutionModel, AgentWorkspace, AgentWorkspaceBinding)],
indirect=True,
)
def test_workflow_download_resolves_only_exact_active_owner_chain(
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
) -> None:
valid = _add_binding(
sqlite_session,
binding_id="binding-workflow-valid",
workspace_id="workspace-workflow-valid",
owner_type=AgentWorkspaceOwnerType.WORKFLOW_RUN,
owner_id="run-1",
owner_scope_key="node-1:workflow-binding-1",
)
wrong_owner = _add_binding(
sqlite_session,
binding_id="binding-workflow-wrong-owner",
workspace_id="workspace-workflow-wrong-owner",
owner_type=AgentWorkspaceOwnerType.WORKFLOW_RUN,
owner_id="run-not-the-caller",
owner_scope_key="node-1:workflow-binding-1",
)
retired = AgentWorkspaceBinding(
id="binding-workflow-retired",
tenant_id="tenant-1",
app_id="app-1",
workspace_id=valid.workspace_id,
agent_id="agent-1",
base_home_snapshot_id=None,
agent_config_version_id="binding-workflow-retired-config",
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
backend_binding_ref="binding-workflow-retired-ref",
status=AgentWorkingResourceStatus.RETIRED,
)
sqlite_session.add(retired)
sqlite_session.add_all(
[
_workflow_execution(execution_id="execution-valid", binding_id=valid.id),
_workflow_execution(
execution_id="execution-cross-tenant",
tenant_id="tenant-other",
binding_id=valid.id,
),
_workflow_execution(execution_id="execution-wrong-app", app_id="app-other", binding_id=valid.id),
_workflow_execution(execution_id="execution-wrong-run", workflow_run_id="run-other", binding_id=valid.id),
_workflow_execution(execution_id="execution-wrong-node", node_id="node-other", binding_id=valid.id),
_workflow_execution(execution_id="execution-wrong-owner", binding_id=wrong_owner.id),
_workflow_execution(execution_id="execution-retired", binding_id=retired.id),
]
)
sqlite_session.commit()
persisted_execution = sqlite_session.get(WorkflowNodeExecutionModel, "execution-valid")
assert persisted_execution is not None
assert persisted_execution.created_by == "historical-account"
_use_session(monkeypatch, sqlite_session)
client = _download_client()
request_download = MagicMock(
return_value=SimpleNamespace(download_uri="/files/tools/report.txt?timestamp=1&sign=2")
)
monkeypatch.setattr(
sandbox_module,
"FileRequestService",
lambda: SimpleNamespace(request_download=request_download),
)
monkeypatch.setattr(sandbox_module.dify_config, "FILES_URL", "https://files.example")
service = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(cast(Client, client)))
result = service.download_file(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id="execution-valid",
account_id="authenticated-account",
path="report.txt",
)
assert result.url == "https://files.example/files/tools/report.txt?timestamp=1&sign=2&as_attachment=true"
request = client.download_binding_file_sync.call_args.args[0]
assert request.backend_binding_ref == "binding-workflow-valid-ref"
assert request.execution_context.user_id == "authenticated-account"
assert request.execution_context.user_from == "account"
request_download.assert_called_once_with(
tenant_id="tenant-1",
user_id="authenticated-account",
user_from="account",
invoke_from="debugger",
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:canonical"},
)
client.download_binding_file_sync.reset_mock()
for node_execution_id in (
"execution-cross-tenant",
"execution-wrong-app",
"execution-wrong-run",
"execution-wrong-node",
"execution-wrong-owner",
"execution-retired",
):
with pytest.raises(AgentSandboxInspectorError, match="active Workspace Binding"):
service.download_file(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id=node_execution_id,
account_id="authenticated-account",
path="report.txt",
)
client.download_binding_file_sync.assert_not_called()
assert request_download.call_count == 1
@pytest.mark.parametrize(
@@ -185,12 +709,14 @@ def test_agent_app_file_browsing_uses_build_draft_caller(
binding = SimpleNamespace(
agent_id="agent-1",
backend_binding_ref="binding-build-ref",
agent_config_version_id="config-1",
agent_config_version_kind=AgentConfigVersionKind.DRAFT,
)
get_binding = MagicMock(return_value=binding)
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_binding)
client = MagicMock()
response = WorkspaceListResponse(path=".", entries=[], truncated=False)
client.list_workspace_files_sync.return_value = response
response = BindingFileListResponse(path=".", entries=[], truncated=False)
client.list_binding_files_sync.return_value = response
result = AgentAppSandboxService(client_factory=lambda: nullcontext(client)).list_files(
tenant_id="tenant-1",
@@ -207,7 +733,7 @@ def test_agent_app_file_browsing_uses_build_draft_caller(
assert owner_scope.app_id == runtime_app_id
assert owner_scope.owner_type is AgentWorkspaceOwnerType.BUILD_DRAFT
assert owner_scope.owner_id == "build-1"
client.list_workspace_files_sync.assert_called_once_with("binding-build-ref", ".")
client.list_binding_files_sync.assert_called_once_with("binding-build-ref", ".")
def test_workflow_file_access_uses_node_execution_pointer(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -217,12 +743,17 @@ def test_workflow_file_access_uses_node_execution_pointer(monkeypatch: pytest.Mo
)
session = MagicMock()
session.scalar.return_value = execution
binding = SimpleNamespace(backend_binding_ref="binding-workflow-ref")
binding = SimpleNamespace(
agent_id="agent-1",
backend_binding_ref="binding-workflow-ref",
agent_config_version_id="config-1",
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
)
get_binding = MagicMock(return_value=binding)
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_binding)
client = MagicMock()
response = WorkspaceReadResponse(path="report.txt", size=2, truncated=False, binary=False, text="ok")
client.read_workspace_file_sync.return_value = response
response = BindingFileReadResponse(path="report.txt", size=2, truncated=False, binary=False, text="ok")
client.read_binding_file_sync.return_value = response
result = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).read_file(
tenant_id="tenant-1",
@@ -236,4 +767,238 @@ def test_workflow_file_access_uses_node_execution_pointer(monkeypatch: pytest.Mo
assert result is response
assert get_binding.call_args.kwargs["binding_id"] == "binding-workflow"
client.read_workspace_file_sync.assert_called_once_with("binding-workflow-ref", "report.txt")
client.read_binding_file_sync.assert_called_once_with("binding-workflow-ref", "report.txt")
session.rollback.assert_called_once_with()
def test_workflow_download_uses_authenticated_account_and_trusted_file_request(
monkeypatch: pytest.MonkeyPatch,
) -> None:
execution = SimpleNamespace(
agent_workspace_binding_id="binding-workflow",
process_data_dict={"workflow_agent_binding_id": "workflow-binding-1"},
)
session = MagicMock()
session.scalar.return_value = execution
binding = SimpleNamespace(
agent_id="agent-1",
backend_binding_ref="binding-workflow-ref",
agent_config_version_id="config-1",
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
)
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=binding))
events: list[str] = []
@contextmanager
def session_scope():
try:
yield session
finally:
events.append("session-exit")
monkeypatch.setattr(sandbox_module.session_factory, "create_session", session_scope)
client = MagicMock()
client.download_binding_file_sync.side_effect = lambda _request: (
events.append("client-download") or BindingFileDownloadResponse(reference="dify-file-ref:canonical")
)
request_download = MagicMock(
side_effect=lambda **_kwargs: (
events.append("file-request") or SimpleNamespace(download_uri="/files/tools/report.txt?timestamp=1&sign=2")
)
)
monkeypatch.setattr(
"services.agent_app_sandbox_service.FileRequestService",
lambda: SimpleNamespace(request_download=request_download),
)
monkeypatch.setattr("services.agent_app_sandbox_service.dify_config.FILES_URL", "https://files.example")
result = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).download_file(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id="execution-1",
account_id="account-1",
path="report.txt",
)
request = client.download_binding_file_sync.call_args.args[0]
assert request.execution_context.user_id == "account-1"
assert request.execution_context.user_from == "account"
assert request.execution_context.node_execution_id == "execution-1"
session.rollback.assert_called_once_with()
assert events == ["session-exit", "client-download", "file-request"]
request_download.assert_called_once_with(
tenant_id="tenant-1",
user_id="account-1",
user_from="account",
invoke_from="debugger",
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:canonical"},
)
assert result.url == "https://files.example/files/tools/report.txt?timestamp=1&sign=2&as_attachment=true"
def test_agent_app_download_uses_complete_account_context_after_session_exit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
events: list[str] = []
session = MagicMock()
session.scalar.side_effect = [
SimpleNamespace(app_id="app-1", backing_app_id=None),
SimpleNamespace(agent_workspace_binding_id="binding-build"),
]
@contextmanager
def session_scope():
try:
yield session
finally:
events.append("session-exit")
monkeypatch.setattr(sandbox_module.session_factory, "create_session", session_scope)
binding = SimpleNamespace(
agent_id="agent-1",
backend_binding_ref="binding-build-ref",
agent_config_version_id="config-1",
agent_config_version_kind=AgentConfigVersionKind.DRAFT,
)
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=binding))
client = MagicMock()
client.download_binding_file_sync.side_effect = lambda _request: (
events.append("client-download") or BindingFileDownloadResponse(reference="dify-file-ref:canonical")
)
request_download = MagicMock(
side_effect=lambda **_kwargs: (
events.append("file-request") or SimpleNamespace(download_uri="/files/tools/report.txt?timestamp=1&sign=2")
)
)
monkeypatch.setattr(
sandbox_module,
"FileRequestService",
lambda: SimpleNamespace(request_download=request_download),
)
monkeypatch.setattr(sandbox_module.dify_config, "FILES_URL", "https://files.example")
result = AgentAppSandboxService(client_factory=lambda: nullcontext(client)).download_file(
tenant_id="tenant-1",
app_id="app-1",
agent_id="agent-1",
caller_type="build_draft",
caller_id="build-1",
account_id="account-1",
path="report.txt",
)
request = client.download_binding_file_sync.call_args.args[0]
assert request.backend_binding_ref == "binding-build-ref"
assert request.path == "report.txt"
assert request.execution_context.model_dump(exclude_none=True) == {
"tenant_id": "tenant-1",
"user_id": "account-1",
"user_from": "account",
"app_id": "app-1",
"agent_id": "agent-1",
"agent_config_version_id": "config-1",
"agent_config_version_kind": "draft",
"agent_mode": "agent_app",
"invoke_from": "debugger",
}
assert events == ["session-exit", "client-download", "file-request"]
request_download.assert_called_once_with(
tenant_id="tenant-1",
user_id="account-1",
user_from="account",
invoke_from="debugger",
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:canonical"},
)
assert result.url == "https://files.example/files/tools/report.txt?timestamp=1&sign=2&as_attachment=true"
def test_file_request_rejection_maps_to_download_unavailable(monkeypatch: pytest.MonkeyPatch) -> None:
request_download = MagicMock(side_effect=ValueError("reference is not accessible"))
monkeypatch.setattr(
sandbox_module,
"FileRequestService",
lambda: SimpleNamespace(request_download=request_download),
)
with pytest.raises(AgentSandboxInspectorError) as exc_info:
sandbox_module._download_response(
tenant_id="tenant-1",
account_id="account-1",
reference="dify-file-ref:untrusted",
)
assert exc_info.value.code == "binding_file_download_unavailable"
assert exc_info.value.status_code == 502
@pytest.mark.parametrize(
"execution",
[
pytest.param(
SimpleNamespace(
agent_workspace_binding_id=None,
process_data_dict={"workflow_agent_binding_id": "workflow-binding-1"},
),
id="missing-binding-pointer",
),
pytest.param(
SimpleNamespace(
agent_workspace_binding_id="binding-workflow",
process_data_dict={},
),
id="missing-process-data",
),
],
)
def test_workflow_download_rejects_missing_binding_metadata_before_network(
monkeypatch: pytest.MonkeyPatch,
execution: SimpleNamespace,
) -> None:
session = MagicMock()
session.scalar.return_value = execution
_use_session(monkeypatch, session)
client = MagicMock()
with pytest.raises(AgentSandboxInspectorError) as exc_info:
WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).download_file(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id="execution-1",
account_id="account-1",
path="report.txt",
)
assert exc_info.value.code == "no_active_binding"
client.download_binding_file_sync.assert_not_called()
def test_workflow_download_rejects_non_active_or_mismatched_binding_before_network(
monkeypatch: pytest.MonkeyPatch,
) -> None:
execution = SimpleNamespace(
agent_workspace_binding_id="binding-workflow",
process_data_dict={"workflow_agent_binding_id": "workflow-binding-1"},
)
session = MagicMock()
session.scalar.return_value = execution
_use_session(monkeypatch, session)
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=None))
client = MagicMock()
with pytest.raises(AgentSandboxInspectorError) as exc_info:
WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).download_file(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id="execution-1",
account_id="account-1",
path="report.txt",
)
assert exc_info.value.code == "no_active_binding"
client.download_binding_file_sync.assert_not_called()