feat(agent): separate CLI file URL audiences (#39952)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
盐粒 Yanli
2026-08-04 13:14:02 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent 9d81f0da10
commit b462cb041d
50 changed files with 1091 additions and 573 deletions
+2
View File
@@ -17,6 +17,7 @@ inner_api_ns = Namespace("inner_api", description="Internal API operations", pat
from . import mail as _mail
from . import runtime_credentials as _runtime_credentials
from .agent import files as _agent_files
from .agent import tools as _agent_tools
from .app import dsl as _app_dsl
from .knowledge import retrieval as _knowledge_retrieval
@@ -30,6 +31,7 @@ api.add_namespace(inner_api_ns)
__all__ = [
"_agent_config",
"_agent_drive",
"_agent_files",
"_agent_tools",
"_app_dsl",
"_knowledge_retrieval",
+198
View File
@@ -0,0 +1,198 @@
"""Agent-owned inner endpoints for CLI file URL allocation."""
from __future__ import annotations
from typing import Literal
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, ValidationError
from sqlalchemy.orm import Session
from configs import dify_config
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.common.session import with_session
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.plugin.wraps import get_user
from controllers.inner_api.wraps import plugin_inner_api_only
from core.plugin.entities.request import RequestDownloadFileMapping, RequestRequestUploadFile
from core.tools.signature import bind_file_uri, get_signed_file_uri_for_plugin
from fields.base import ResponseModel
from libs.exception import BaseHTTPException
from services.account_service import TenantService
from services.file_request_service import FileRequestService
class AgentFileRequestHttpError(BaseHTTPException):
error_code = "agent_file_request_failed"
description = "Agent file request failed."
code = 500
def __init__(self, *, error_code: str, description: str, status_code: int) -> None:
self.error_code = error_code
self.description = description
self.code = status_code
super().__init__(description)
class AgentFileUploadRequestPayload(RequestRequestUploadFile):
tenant_id: str
user_id: str
model_config = ConfigDict(extra="forbid")
class AgentFileDownloadRequestPayload(BaseModel):
tenant_id: str
user_id: str
user_from: Literal["account", "end-user"]
invoke_from: Literal[
"service-api",
"openapi",
"web-app",
"trigger",
"explore",
"debugger",
"published",
"validation",
]
file: RequestDownloadFileMapping
for_frontend: bool = True
model_config = ConfigDict(extra="forbid")
class AgentFileUploadRequestResponse(ResponseModel):
upload_uri: str
class AgentFileDownloadRequestResponse(ResponseModel):
filename: str
mime_type: str | None = None
size: int
download_uri: str
register_schema_models(inner_api_ns, AgentFileUploadRequestPayload, AgentFileDownloadRequestPayload)
register_response_schema_models(
inner_api_ns,
AgentFileUploadRequestResponse,
AgentFileDownloadRequestResponse,
)
@inner_api_ns.route("/agent/files/upload-request")
class AgentFileUploadRequestApi(Resource):
"""Allocate an origin-free signed upload URI for the Agent CLI."""
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("inner_agent_file_upload_request")
@inner_api_ns.expect(inner_api_ns.models[AgentFileUploadRequestPayload.__name__])
@inner_api_ns.response(
200,
"Upload URI allocated",
inner_api_ns.models[AgentFileUploadRequestResponse.__name__],
)
@with_session(write=False)
def post(self, session: Session) -> dict[str, object]:
try:
payload = AgentFileUploadRequestPayload.model_validate(inner_api_ns.payload or {})
except ValidationError as exc:
raise AgentFileRequestHttpError(
error_code="invalid_request",
description=str(exc),
status_code=400,
) from exc
tenant = TenantService.get_tenant_by_id(payload.tenant_id, session=session)
if tenant is None:
raise AgentFileRequestHttpError(
error_code="tenant_not_found",
description="tenant not found",
status_code=404,
)
try:
user = get_user(tenant.id, payload.user_id)
upload_uri = get_signed_file_uri_for_plugin(
filename=payload.filename,
mimetype=payload.mimetype,
tenant_id=tenant.id,
user_id=user.id,
conversation_id=payload.conversation_id,
)
except ValueError as exc:
raise AgentFileRequestHttpError(
error_code="user_not_found",
description=str(exc),
status_code=404,
) from exc
return AgentFileUploadRequestResponse(upload_uri=upload_uri).model_dump(mode="json")
@inner_api_ns.route("/agent/files/download-request")
class AgentFileDownloadRequestApi(Resource):
"""Allocate a transfer URI or frontend URL for one Agent CLI file."""
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("inner_agent_file_download_request")
@inner_api_ns.expect(inner_api_ns.models[AgentFileDownloadRequestPayload.__name__])
@inner_api_ns.response(
200,
"Download URI allocated",
inner_api_ns.models[AgentFileDownloadRequestResponse.__name__],
)
@with_session(write=False)
def post(self, session: Session) -> dict[str, object]:
try:
payload = AgentFileDownloadRequestPayload.model_validate(inner_api_ns.payload or {})
except ValidationError as exc:
raise AgentFileRequestHttpError(
error_code="invalid_request",
description=str(exc),
status_code=400,
) from exc
if TenantService.get_tenant_by_id(payload.tenant_id, session=session) is None:
raise AgentFileRequestHttpError(
error_code="tenant_not_found",
description="tenant not found",
status_code=404,
)
try:
result = FileRequestService().request_download(
tenant_id=payload.tenant_id,
user_id=payload.user_id,
user_from=payload.user_from,
invoke_from=payload.invoke_from,
file_mapping=payload.file.model_dump(mode="python", exclude_none=True),
)
except ValueError as exc:
raise AgentFileRequestHttpError(
error_code="file_not_accessible",
description=str(exc),
status_code=404,
) from exc
download_uri = result.download_uri
if payload.for_frontend:
download_uri = bind_file_uri(download_uri, dify_config.FILES_URL)
return AgentFileDownloadRequestResponse(
filename=result.filename,
mime_type=result.mime_type,
size=result.size,
download_uri=download_uri,
).model_dump(mode="json")
__all__ = [
"AgentFileDownloadRequestApi",
"AgentFileDownloadRequestPayload",
"AgentFileDownloadRequestResponse",
"AgentFileUploadRequestApi",
"AgentFileUploadRequestPayload",
"AgentFileUploadRequestResponse",
]
+14 -15
View File
@@ -1,6 +1,7 @@
from flask_restx import Resource
from sqlalchemy.orm import Session
from configs import dify_config
from controllers.console.app.wraps import with_session
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
@@ -31,7 +32,7 @@ from core.plugin.entities.request import (
RequestRequestUploadFile,
)
from core.tools.entities.tool_entities import ToolProviderType
from core.tools.signature import get_signed_file_url_for_plugin
from core.tools.signature import bind_file_uri, get_signed_file_uri_for_plugin
from extensions.ext_database import db
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.helper import length_prefixed_response
@@ -429,13 +430,14 @@ class PluginUploadFileRequestApi(Resource):
)
def post(self, user_model: Account | EndUser, tenant_model: Tenant, payload: RequestRequestUploadFile):
# generate signed url
url = get_signed_file_url_for_plugin(
uri = get_signed_file_uri_for_plugin(
filename=payload.filename,
mimetype=payload.mimetype,
tenant_id=tenant_model.id,
user_id=user_model.id,
conversation_id=payload.conversation_id,
)
url = bind_file_uri(uri, dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL)
return BaseBackwardsInvocationResponse(data={"url": url}).model_dump()
@@ -454,36 +456,33 @@ class PluginDownloadFileRequestApi(Resource):
}
)
def post(self, payload: RequestRequestDownloadFile):
"""Resolve signed download metadata for trusted external runtimes.
"""Adapt a shared file request result to the Plugin backward contract.
Unlike end-user-facing upload/download APIs, this inner endpoint serves
trusted callers such as the ``dify-agent`` back proxy. The caller sends
flattened ``tenant_id`` / ``user_id`` / ``user_from`` / ``invoke_from``
context explicitly in the body, and ``FileRequestService`` rebuilds the
corresponding ``FileAccessScope`` before resolving the signed URL.
The response is control-plane metadata only: filename, mime type, size,
and the signed download URL. File bytes still flow through the existing
signed file endpoints rather than through this inner API.
``FileRequestService`` rebuilds the caller's ``FileAccessScope`` and
resolves one origin-free signed URI. This controller binds that URI to
the Plugin-selected external or internal files base URL, then returns
the existing backward-invocation envelope.
"""
tenant_model = db.session.get(Tenant, payload.tenant_id)
if tenant_model is None:
raise ValueError("tenant not found")
result = FileRequestService().request_download_url(
result = FileRequestService().request_download(
tenant_id=tenant_model.id,
user_id=payload.user_id,
user_from=payload.user_from,
invoke_from=payload.invoke_from,
file_mapping=payload.file.model_dump(mode="python", exclude_none=True),
for_external=payload.for_external,
)
base_url = (
dify_config.FILES_URL if payload.for_external else (dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL)
)
return BaseBackwardsInvocationResponse(
data={
"filename": result.filename,
"mime_type": result.mime_type,
"size": result.size,
"download_url": result.download_url,
"download_url": bind_file_uri(result.download_uri, base_url),
}
).model_dump()
+37 -11
View File
@@ -13,7 +13,7 @@ from configs import dify_config
from core.app.file_access import DatabaseFileAccessController, FileAccessControllerProtocol
from core.db.session_factory import session_factory
from core.file import remote_fetcher
from core.tools.signature import sign_tool_file
from core.tools.signature import bind_file_uri, sign_tool_file_uri
from core.workflow.file_reference import parse_file_reference
from extensions.ext_storage import storage
from graphon.file import FileTransferMethod
@@ -62,32 +62,42 @@ class DifyWorkflowFileRuntime(WorkflowFileRuntimeProtocol):
@override
def resolve_file_url(self, *, file: File, for_external: bool = True) -> str | None:
uri = self.resolve_file_uri(file=file)
if uri is None or file.transfer_method == FileTransferMethod.REMOTE_URL:
return uri
return bind_file_uri(uri, self._base_url(for_external=for_external))
def resolve_file_uri(self, *, file: File) -> str | None:
"""Resolve a signed file URI without binding Dify-owned files to an origin.
Remote URLs retain their external absolute URL. Dify-owned files return
a signed ``/files/...`` URI that callers can bind to their own network
audience without exposing ``FILES_URL`` or ``INTERNAL_FILES_URL``.
"""
if file.transfer_method == FileTransferMethod.REMOTE_URL:
return file.remote_url
parsed_reference = parse_file_reference(file.reference)
if parsed_reference is None:
raise ValueError("Missing file reference")
if file.transfer_method == FileTransferMethod.LOCAL_FILE:
return self.resolve_upload_file_url(
return self.resolve_upload_file_uri(
upload_file_id=parsed_reference.record_id,
for_external=for_external,
)
if file.transfer_method == FileTransferMethod.DATASOURCE_FILE:
if file.extension is None:
raise ValueError("Missing file extension")
self._assert_upload_file_access(upload_file_id=parsed_reference.record_id)
return sign_tool_file(
return sign_tool_file_uri(
tool_file_id=parsed_reference.record_id,
extension=file.extension,
for_external=for_external,
)
if file.transfer_method == FileTransferMethod.TOOL_FILE:
if file.extension is None:
raise ValueError("Missing file extension")
return self.resolve_tool_file_url(
return self.resolve_tool_file_uri(
tool_file_id=parsed_reference.record_id,
extension=file.extension,
for_external=for_external,
)
return None
@@ -99,18 +109,34 @@ class DifyWorkflowFileRuntime(WorkflowFileRuntimeProtocol):
as_attachment: bool = False,
for_external: bool = True,
) -> str:
uri = self.resolve_upload_file_uri(upload_file_id=upload_file_id, as_attachment=as_attachment)
return bind_file_uri(uri, self._base_url(for_external=for_external))
def resolve_upload_file_uri(
self,
*,
upload_file_id: str,
as_attachment: bool = False,
) -> str:
"""Resolve a signed UploadFile URI without selecting an origin."""
self._assert_upload_file_access(upload_file_id=upload_file_id)
base_url = self._base_url(for_external=for_external)
url = f"{base_url}/files/{upload_file_id}/file-preview"
uri = f"/files/{upload_file_id}/file-preview"
query = self._sign_query(payload=f"file-preview|{upload_file_id}")
if as_attachment:
query["as_attachment"] = "true"
return f"{url}?{urllib.parse.urlencode(query)}"
return f"{uri}?{urllib.parse.urlencode(query)}"
@override
def resolve_tool_file_url(self, *, tool_file_id: str, extension: str, for_external: bool = True) -> str:
uri = self.resolve_tool_file_uri(tool_file_id=tool_file_id, extension=extension)
return bind_file_uri(uri, self._base_url(for_external=for_external))
def resolve_tool_file_uri(self, *, tool_file_id: str, extension: str) -> str:
"""Resolve a signed ToolFile URI without selecting an origin."""
self._assert_tool_file_access(tool_file_id=tool_file_id)
return sign_tool_file(tool_file_id=tool_file_id, extension=extension, for_external=for_external)
return sign_tool_file_uri(tool_file_id=tool_file_id, extension=extension)
@override
def verify_preview_signature(
+35 -14
View File
@@ -4,29 +4,52 @@ import hmac
import os
import time
import urllib.parse
from urllib.parse import urlsplit
from configs import dify_config
def bind_file_uri(uri: str, base_url: str) -> str:
"""Bind a Dify-owned file URI to one caller-selected origin.
Explicit remote HTTP(S) URLs are already complete and pass through. Other
values must be origin-free ``/files/...`` URIs.
"""
parsed = urlsplit(uri)
if parsed.scheme in {"http", "https"} and parsed.netloc:
return uri
if (
parsed.scheme
or parsed.netloc
or parsed.fragment
or uri.startswith("//")
or not parsed.path.startswith("/files/")
):
raise ValueError("file URI must be an absolute HTTP(S) URL or a /files/ URI")
return f"{base_url}{uri}"
def _secret_key() -> bytes:
return dify_config.SECRET_KEY.encode()
def sign_tool_file(tool_file_id: str, extension: str, for_external: bool = True) -> str:
"""
sign file to get a temporary url for plugin access
"""
# Use internal URL for plugin/tool file access in Docker environments, unless for_external is True
base_url = dify_config.FILES_URL if for_external else (dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL)
file_preview_url = f"{base_url}/files/tools/{tool_file_id}{extension}"
def sign_tool_file_uri(tool_file_id: str, extension: str) -> str:
"""Sign a ToolFile path without selecting a network origin."""
timestamp = str(int(time.time()))
nonce = os.urandom(16).hex()
data_to_sign = f"file-preview|{tool_file_id}|{timestamp}|{nonce}"
sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
encoded_sign = base64.urlsafe_b64encode(sign).decode()
return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}"
return f"/files/tools/{tool_file_id}{extension}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}"
def sign_tool_file(tool_file_id: str, extension: str, for_external: bool = True) -> str:
"""Sign a ToolFile URL for the browser or an internal Dify service."""
base_url = dify_config.FILES_URL if for_external else (dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL)
return bind_file_uri(sign_tool_file_uri(tool_file_id, extension), base_url)
def sign_upload_file_preview_url(upload_file_id: str, extension: str) -> str:
@@ -64,13 +87,11 @@ def verify_tool_file_signature(file_id: str, timestamp: str, nonce: str, sign: s
return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT
def get_signed_file_url_for_plugin(
def get_signed_file_uri_for_plugin(
filename: str, mimetype: str, tenant_id: str, user_id: str, conversation_id: str | None = None
) -> str:
"""Build the signed upload URL used by the plugin-facing file upload endpoint."""
"""Build a signed plugin-upload URI without selecting a network origin."""
base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL
upload_url = f"{base_url}/files/upload/for-plugin"
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}"
@@ -86,7 +107,7 @@ def get_signed_file_url_for_plugin(
if conversation_id:
query_params["conversation_id"] = conversation_id
query = urllib.parse.urlencode(query_params)
return f"{upload_url}?{query}"
return f"/files/upload/for-plugin?{query}"
def verify_plugin_file_signature(
@@ -701,7 +701,7 @@ class WorkflowAgentRuntimeRequestBuilder:
"only the accepted file-mapping shape and the returned `reference`; never invent the `reference` "
"value.",
"If you are replying to the user in natural language and want them to open or download the produced "
"file, include the returned `download_url` in that reply instead of copying it into structured "
"file, include the returned `public_download_url` in that reply instead of copying it into structured "
"`final_output` unless the schema explicitly asks for it.",
*file_output_lines,
]
+2 -2
View File
@@ -320,7 +320,7 @@ def _format_output_mention(output: DeclaredOutputConfig) -> str:
f"{output.name} (file output; create the file locally, run "
f"`dify-agent file upload <path>`, then set final_output.{output.name} to a `tool_file` mapping "
f"using the returned `reference`; if replying to the user in natural language, use the returned "
f"`download_url`; do not call final_output before upload succeeds, and do not use the local path, "
f"`public_download_url`; do not call final_output before upload succeeds, and do not use the local path, "
"filename, URL, or a synthesized dify-file-ref as the reference)"
)
if (
@@ -332,7 +332,7 @@ def _format_output_mention(output: DeclaredOutputConfig) -> str:
f"{output.name} (array[file] output; upload each produced file with "
f"`dify-agent file upload <path>`, then set final_output.{output.name} to `tool_file` mappings "
f"using the returned `reference` values; if replying to the user in natural language, use the returned "
f"`download_url`; do not call final_output before all uploads succeed, and do not use local paths, "
f"`public_download_url`; do not call final_output before all uploads succeed, and do not use local paths, "
"filenames, URLs, or synthesized dify-file-ref values as references)"
)
return f"{output.name} ({output.type.value})"
@@ -1,93 +0,0 @@
"""Resolve a download request for a workflow file ref to a signed URL (Agent Files §3.1.1/§4.5).
The dify-agent server calls this on behalf of a sandbox that needs to pull a
``File`` / ``Array[File]`` workflow input. It binds the flattened file-access
context as a ``FileAccessScope``, rebuilds the graphon ``File`` from the mapping
(reusing tenant/user access checks), and returns an internal signed download URL
plus metadata — never the file bytes. The dify-agent server / sandbox then GETs
the URL directly from Dify API.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from core.app.file_access.controller import DatabaseFileAccessController
from core.app.file_access.scope import FileAccessScope, bind_file_access_scope
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
from factories import file_factory
class FileDownloadRequestError(Exception):
"""A download-request 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 AgentFileDownloadRequestService:
"""Resolve a workflow file ref to a sandbox-accessible internal signed download URL."""
@classmethod
def resolve(
cls,
*,
tenant_id: str,
user_id: str,
user_from: str,
invoke_from: str,
file_mapping: Mapping[str, Any],
) -> dict[str, Any]:
try:
scope_user_from = UserFrom(user_from)
scope_invoke_from = InvokeFrom(invoke_from)
except ValueError as exc:
raise FileDownloadRequestError("invalid_access_context", str(exc), status_code=400) from exc
if not isinstance(file_mapping, Mapping) or not file_mapping.get("transfer_method"):
raise FileDownloadRequestError("invalid_file_mapping", "file.transfer_method is required", status_code=400)
scope = FileAccessScope(
tenant_id=tenant_id,
user_id=user_id,
user_from=scope_user_from,
invoke_from=scope_invoke_from,
)
controller = DatabaseFileAccessController()
runtime = DifyWorkflowFileRuntime(file_access_controller=controller)
try:
with bind_file_access_scope(scope):
file = file_factory.build_from_mapping(
mapping=file_mapping,
tenant_id=tenant_id,
access_controller=controller,
)
# Internal URL (for_external=False): the consumer is the agent backend /
# sandbox, not a browser. Resolves against INTERNAL_FILES_URL, falling
# back to FILES_URL when not configured.
download_url = runtime.resolve_file_url(file=file, for_external=False)
except ValueError as exc:
raise FileDownloadRequestError("file_not_accessible", str(exc), status_code=404) from exc
if not download_url:
raise FileDownloadRequestError(
"download_url_unavailable", "could not resolve a download URL for the file", status_code=502
)
return {
"filename": file.filename,
"mime_type": file.mime_type,
"size": file.size,
"download_url": download_url,
}
__all__ = ["AgentFileDownloadRequestService", "FileDownloadRequestError"]
+14 -13
View File
@@ -1,9 +1,9 @@
"""Service helpers for trusted file request control-plane endpoints.
These helpers are used by inner APIs that return signed upload/download URLs to
trusted external runtimes such as ``dify-agent``. They do not transfer file
bytes themselves; they only rebuild access-scoped ``graphon.file.File`` values
and resolve the signed URL that the caller should use directly.
These helpers are used by inner APIs that allocate file access for trusted
external runtimes. They rebuild access-scoped ``graphon.file.File`` values and
return origin-free signed URIs so each transport adapter can select its own
network origin without signing the file twice.
"""
from __future__ import annotations
@@ -14,30 +14,32 @@ from typing import Any
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from core.app.file_access import DatabaseFileAccessController, FileAccessScope, bind_file_access_scope
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
from factories.file_factory.builders import build_from_mapping
from graphon.file import File
from graphon.file import helpers as file_helpers
@dataclass(frozen=True, slots=True)
class DownloadFileRequestResult:
"""Resolved metadata and signed URL returned to trusted download callers."""
"""Resolved metadata and signed URI returned to trusted download callers."""
filename: str
mime_type: str | None
size: int
download_url: str
download_uri: str
class FileRequestService:
"""Resolve signed download URLs for trusted external file consumers."""
"""Resolve signed download URIs for trusted external file consumers."""
_access_controller: DatabaseFileAccessController
_runtime: DifyWorkflowFileRuntime
def __init__(self, access_controller: DatabaseFileAccessController | None = None) -> None:
self._access_controller = access_controller or DatabaseFileAccessController()
self._runtime = DifyWorkflowFileRuntime(file_access_controller=self._access_controller)
def request_download_url(
def request_download(
self,
*,
tenant_id: str,
@@ -45,7 +47,6 @@ class FileRequestService:
user_from: UserFrom | str,
invoke_from: InvokeFrom | str,
file_mapping: Mapping[str, Any],
for_external: bool = True,
) -> DownloadFileRequestResult:
"""Resolve one file mapping into signed download metadata.
@@ -62,15 +63,15 @@ class FileRequestService:
)
with bind_file_access_scope(scope):
file = self._build_file(mapping=file_mapping, tenant_id=tenant_id)
download_url = file_helpers.resolve_file_url(file, for_external=for_external)
download_uri = self._runtime.resolve_file_uri(file=file)
if not download_url:
if not download_uri:
raise ValueError("file does not support signed download")
return DownloadFileRequestResult(
filename=file.filename or "download.bin",
mime_type=file.mime_type,
size=file.size,
download_url=download_url,
download_uri=download_uri,
)
def _build_file(self, *, mapping: Mapping[str, Any], tenant_id: str) -> File:
@@ -202,6 +202,16 @@ def test_internal_files_url_prefers_explicit_value(monkeypatch: pytest.MonkeyPat
assert config.INTERNAL_FILES_URL == "http://files-internal:5001"
def test_empty_files_url_overrides_console_api_url_for_relative_browser_uris(monkeypatch: pytest.MonkeyPatch):
_clear_environment(monkeypatch)
monkeypatch.setenv("FILES_URL", "")
monkeypatch.setenv("CONSOLE_API_URL", "http://api:5001")
config = DifyConfig(_env_file=None)
assert config.FILES_URL == ""
# NOTE: If there is a `.env` file in your Workspace, this test might not succeed as expected.
# This is due to `pymilvus` loading all the variables from the `.env` file into `os.environ`.
def test_flask_configs(monkeypatch: pytest.MonkeyPatch):
@@ -263,11 +263,12 @@ class TestPluginUploadFileRequestApi:
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
@patch("controllers.inner_api.plugin.plugin.get_signed_file_url_for_plugin")
def test_post_returns_signed_url(self, mock_get_url, api_instance, app: Flask):
@patch("controllers.inner_api.plugin.plugin.get_signed_file_uri_for_plugin")
def test_post_returns_signed_url(self, mock_get_uri, api_instance, app: Flask, monkeypatch: pytest.MonkeyPatch):
"""Test that post() generates a signed URL and returns it"""
# Arrange
mock_get_url.return_value = "https://storage.example.com/signed-upload-url"
mock_get_uri.return_value = "/files/upload/for-plugin?sign=1"
monkeypatch.setattr(plugin_module.dify_config, "INTERNAL_FILES_URL", "http://api:5001")
mock_tenant = MagicMock()
mock_tenant.id = "tenant-id"
mock_user = MagicMock()
@@ -282,14 +283,14 @@ class TestPluginUploadFileRequestApi:
result = raw_post(api_instance, user_model=mock_user, tenant_model=mock_tenant, payload=mock_payload)
# Assert
mock_get_url.assert_called_once_with(
mock_get_uri.assert_called_once_with(
filename="test.pdf",
mimetype="application/pdf",
tenant_id="tenant-id",
user_id="user-id",
conversation_id="conversation-id",
)
assert result["data"]["url"] == "https://storage.example.com/signed-upload-url"
assert result["data"]["url"] == "http://api:5001/files/upload/for-plugin?sign=1"
class TestPluginDownloadFileRequestApi:
@@ -304,6 +305,13 @@ class TestPluginDownloadFileRequestApi:
assert callable(api_instance.post)
@pytest.mark.parametrize("sqlite_session", [(Tenant,)], indirect=True)
@pytest.mark.parametrize(
("for_external", "expected_url"),
[
(True, "https://files.example.com/files/tools/report.pdf?sign=1"),
(False, "http://api:5001/files/tools/report.pdf?sign=1"),
],
)
@patch("controllers.inner_api.plugin.plugin.FileRequestService")
def test_post_returns_signed_download_url(
self,
@@ -312,6 +320,8 @@ class TestPluginDownloadFileRequestApi:
app: Flask,
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
for_external: bool,
expected_url: str,
):
tenant = Tenant(
name="Plugin Tenant",
@@ -324,18 +334,20 @@ class TestPluginDownloadFileRequestApi:
sqlite_session.commit()
monkeypatch.setattr(plugin_module.db, "session", sqlite_session)
mock_service = mock_service_cls.return_value
mock_service.request_download_url.return_value = MagicMock(
mock_service.request_download.return_value = MagicMock(
filename="report.pdf",
mime_type="application/pdf",
size=123,
download_url="https://files.example.com/download",
download_uri="/files/tools/report.pdf?sign=1",
)
monkeypatch.setattr(plugin_module.dify_config, "FILES_URL", "https://files.example.com")
monkeypatch.setattr(plugin_module.dify_config, "INTERNAL_FILES_URL", "http://api:5001")
mock_payload = MagicMock()
mock_payload.tenant_id = tenant.id
mock_payload.user_id = "user-id"
mock_payload.user_from = "account"
mock_payload.invoke_from = "debugger"
mock_payload.for_external = False
mock_payload.for_external = for_external
reference = build_file_reference(record_id="tool-file-1")
mock_payload.file.model_dump.return_value = {
"transfer_method": "tool_file",
@@ -345,19 +357,18 @@ class TestPluginDownloadFileRequestApi:
raw_post = _extract_raw_post(PluginDownloadFileRequestApi)
result = raw_post(api_instance, payload=mock_payload)
mock_service.request_download_url.assert_called_once_with(
mock_service.request_download.assert_called_once_with(
tenant_id=tenant.id,
user_id="user-id",
user_from="account",
invoke_from="debugger",
file_mapping={"transfer_method": "tool_file", "reference": reference},
for_external=False,
)
assert result["data"] == {
"filename": "report.pdf",
"mime_type": "application/pdf",
"size": 123,
"download_url": "https://files.example.com/download",
"download_url": expected_url,
}
@@ -0,0 +1,118 @@
import inspect
from collections.abc import Callable
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.inner_api.agent.files import AgentFileDownloadRequestApi, AgentFileUploadRequestApi
from core.workflow.file_reference import build_file_reference
from services.file_request_service import DownloadFileRequestResult
MODULE = "controllers.inner_api.agent.files"
def _raw[R](method: Callable[..., R]) -> Callable[..., R]:
return cast(Callable[..., R], inspect.unwrap(method))
def test_upload_request_returns_origin_free_uri(app: Flask) -> None:
payload = {
"tenant_id": "tenant-1",
"user_id": "execution-user-1",
"filename": "report.pdf",
"mimetype": "application/pdf",
"conversation_id": "conversation-1",
}
tenant = SimpleNamespace(id="tenant-1")
user = SimpleNamespace(id="canonical-end-user-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", return_value=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
response = _raw(AgentFileUploadRequestApi.post)(AgentFileUploadRequestApi(), session)
assert response == {"upload_uri": "/files/upload/for-plugin?sign=1"}
tenant_service.get_tenant_by_id.assert_called_once_with("tenant-1", session=session)
sign.assert_called_once_with(
filename="report.pdf",
mimetype="application/pdf",
tenant_id="tenant-1",
user_id="canonical-end-user-1",
conversation_id="conversation-1",
)
def test_download_request_returns_origin_free_uri_for_sandbox(app: Flask) -> None:
reference = build_file_reference(record_id="tool-file-1")
payload = {
"tenant_id": "tenant-1",
"user_id": "user-1",
"user_from": "account",
"invoke_from": "debugger",
"file": {"transfer_method": "tool_file", "reference": reference},
"for_frontend": False,
}
session = MagicMock()
with app.test_request_context("/", method="POST", json=payload):
with (
patch(f"{MODULE}.TenantService") as tenant_service,
patch(f"{MODULE}.FileRequestService") as service,
):
tenant_service.get_tenant_by_id.return_value = MagicMock()
service.return_value.request_download.return_value = DownloadFileRequestResult(
filename="report.pdf",
mime_type="application/pdf",
size=123,
download_uri="/files/tools/tool-file-1.pdf?sign=1",
)
response = _raw(AgentFileDownloadRequestApi.post)(AgentFileDownloadRequestApi(), session)
assert response == {
"filename": "report.pdf",
"mime_type": "application/pdf",
"size": 123,
"download_uri": "/files/tools/tool-file-1.pdf?sign=1",
}
service.return_value.request_download.assert_called_once_with(
tenant_id="tenant-1",
user_id="user-1",
user_from="account",
invoke_from="debugger",
file_mapping={"transfer_method": "tool_file", "reference": reference},
)
def test_download_request_binds_frontend_url(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
reference = build_file_reference(record_id="tool-file-1")
payload = {
"tenant_id": "tenant-1",
"user_id": "user-1",
"user_from": "account",
"invoke_from": "debugger",
"file": {"transfer_method": "tool_file", "reference": reference},
"for_frontend": True,
}
monkeypatch.setattr(f"{MODULE}.dify_config.FILES_URL", "https://files.example.com")
session = MagicMock()
with app.test_request_context("/", method="POST", json=payload):
with (
patch(f"{MODULE}.TenantService") as tenant_service,
patch(f"{MODULE}.FileRequestService") as service,
):
tenant_service.get_tenant_by_id.return_value = MagicMock()
service.return_value.request_download.return_value = DownloadFileRequestResult(
filename="report.pdf",
mime_type="application/pdf",
size=123,
download_uri="/files/tools/tool-file-1.pdf?sign=1",
)
response = _raw(AgentFileDownloadRequestApi.post)(AgentFileDownloadRequestApi(), session)
assert response["download_uri"] == "https://files.example.com/files/tools/tool-file-1.pdf?sign=1"
@@ -141,8 +141,9 @@ def test_resolve_file_url_requires_extension_for_tool_files() -> None:
def test_resolve_file_url_uses_tool_signatures_for_tool_and_datasource_files(
monkeypatch: pytest.MonkeyPatch,
) -> None:
sign_tool_file = MagicMock(return_value="https://signed.example.com/file")
monkeypatch.setattr(file_runtime, "sign_tool_file", sign_tool_file)
sign_tool_file_uri = MagicMock(return_value="/files/signed")
monkeypatch.setattr(file_runtime, "sign_tool_file_uri", sign_tool_file_uri)
monkeypatch.setattr(file_runtime.dify_config, "FILES_URL", "https://files.example.com")
runtime = _build_runtime()
tool_file = _build_file(
@@ -156,9 +157,35 @@ def test_resolve_file_url_uses_tool_signatures_for_tool_and_datasource_files(
extension=".png",
)
assert runtime.resolve_file_url(file=tool_file) == "https://signed.example.com/file"
assert runtime.resolve_file_url(file=datasource_file) == "https://signed.example.com/file"
assert sign_tool_file.call_count == 2
assert runtime.resolve_file_url(file=tool_file) == "https://files.example.com/files/signed"
assert runtime.resolve_file_url(file=datasource_file) == "https://files.example.com/files/signed"
assert sign_tool_file_uri.call_count == 2
def test_resolve_file_uri_keeps_dify_owned_file_origin_free(monkeypatch: pytest.MonkeyPatch) -> None:
sign_tool_file_uri = MagicMock(return_value="/files/tools/tool-file-id.png?sign=1")
monkeypatch.setattr(file_runtime, "sign_tool_file_uri", sign_tool_file_uri)
runtime = _build_runtime()
file = _build_file(
transfer_method=FileTransferMethod.TOOL_FILE,
reference=build_file_reference(record_id="tool-file-id"),
extension=".png",
)
assert runtime.resolve_file_uri(file=file) == "/files/tools/tool-file-id.png?sign=1"
def test_resolve_file_url_returns_relative_uri_when_files_url_is_empty(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(file_runtime, "sign_tool_file_uri", lambda **_: "/files/tools/tool-file-id.png?sign=1")
monkeypatch.setattr(file_runtime.dify_config, "FILES_URL", "")
runtime = _build_runtime()
file = _build_file(
transfer_method=FileTransferMethod.TOOL_FILE,
reference=build_file_reference(record_id="tool-file-id"),
extension=".png",
)
assert runtime.resolve_file_url(file=file, for_external=True) == "/files/tools/tool-file-id.png?sign=1"
def test_resolve_upload_file_url_signs_internal_urls_and_supports_attachments(
@@ -7,14 +7,40 @@ from urllib.parse import parse_qs, urlparse
import pytest
from core.tools.signature import (
get_signed_file_url_for_plugin,
bind_file_uri,
get_signed_file_uri_for_plugin,
sign_tool_file,
sign_tool_file_uri,
sign_upload_file_preview_url,
verify_plugin_file_signature,
verify_tool_file_signature,
)
def test_bind_file_uri_uses_selected_base_and_preserves_remote_url() -> None:
uri = "/files/tools/tool-file-id.png?sign=1"
assert bind_file_uri(uri, "https://files.example.com") == f"https://files.example.com{uri}"
assert bind_file_uri(uri, "") == uri
assert bind_file_uri("https://remote.example.com/report.pdf", "https://files.example.com") == (
"https://remote.example.com/report.pdf"
)
def test_sign_tool_file_uri_has_no_origin(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x08" * 16)
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
uri = sign_tool_file_uri("tool-file-id", ".png")
parsed = urlparse(uri)
assert parsed.scheme == ""
assert parsed.netloc == ""
assert parsed.path == "/files/tools/tool-file-id.png"
assert parse_qs(parsed.query)["timestamp"] == ["1700000000"]
def test_sign_tool_file_and_verify_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x01" * 16)
@@ -125,25 +151,23 @@ def test_sign_upload_file_preview_url_ignores_internal_files_url(monkeypatch: py
assert query["sign"][0]
def test_get_signed_file_url_for_plugin_and_verify_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
def test_get_signed_file_uri_for_plugin_and_verify_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x06" * 16)
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_URL", "https://files.example.com")
monkeypatch.setattr("core.tools.signature.dify_config.INTERNAL_FILES_URL", "https://internal.example.com")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_ACCESS_TIMEOUT", 60)
url = get_signed_file_url_for_plugin(
uri = get_signed_file_uri_for_plugin(
filename="report.pdf",
mimetype="application/pdf",
tenant_id="tenant-id",
user_id="user-id",
conversation_id="conversation-id",
)
parsed = urlparse(url)
parsed = urlparse(uri)
query = parse_qs(parsed.query)
assert parsed.netloc == "internal.example.com"
assert parsed.netloc == ""
assert parsed.path == "/files/upload/for-plugin"
assert query["tenant_id"] == ["tenant-id"]
assert query["user_id"] == ["user-id"]
@@ -167,17 +191,15 @@ def test_verify_plugin_file_signature_rejects_invalid_signatures(monkeypatch: py
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x07" * 16)
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_URL", "https://files.example.com")
monkeypatch.setattr("core.tools.signature.dify_config.INTERNAL_FILES_URL", "")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_ACCESS_TIMEOUT", 30)
url = get_signed_file_url_for_plugin(
uri = get_signed_file_uri_for_plugin(
filename="report.pdf",
mimetype="application/pdf",
tenant_id="tenant-id",
user_id="user-id",
)
query = parse_qs(urlparse(url).query)
query = parse_qs(urlparse(uri).query)
assert (
verify_plugin_file_signature(
@@ -432,7 +432,7 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada
assert "never invent the `reference` value" in output_description
assert "Do not call `final_output` before the upload command succeeds" in output_description
assert "accepted file-mapping shape and the returned `reference`" in output_description
assert "include the returned `download_url` in that reply" in output_description
assert "include the returned `public_download_url` in that reply" in output_description
assert output_schema["properties"]["confidence"]["type"] == "number"
assert output_schema["required"] == ["report"]
assert layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["model_settings"] == {"temperature": 0.2}
-1
View File
@@ -820,7 +820,6 @@ project-excludes = [
"services/test_agent_app_sandbox_service.py",
"services/test_agent_config_service.py",
"services/test_agent_drive_service.py",
"services/test_agent_file_request_service.py",
"services/test_annotation_service.py",
"services/test_api_token_service.py",
"services/test_app_generate_service.py",
@@ -246,7 +246,7 @@ def test_node_job_resolver_resolves_each_kind(node_job: WorkflowNodeJobConfig):
"Read START/tenders and produce qna_report (file output; create the file locally, run "
"`dify-agent file upload <path>`, then set final_output.qna_report to a `tool_file` mapping "
"using the returned `reference`; if replying to the user in natural language, use the returned "
"`download_url`; do not call final_output before upload succeeds, and do not use the local path, "
"`public_download_url`; do not call final_output before upload succeeds, and do not use the local path, "
"filename, URL, or a synthesized dify-file-ref as the reference); "
"if unsure contact EMAIL · David Hayes."
)
@@ -1,105 +0,0 @@
"""Unit tests for the Agent Files download-request service (ENG-592)."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from services.agent_file_request_service import AgentFileDownloadRequestService, FileDownloadRequestError
_MOD = "services.agent_file_request_service"
def _fake_file() -> SimpleNamespace:
return SimpleNamespace(filename="report.pdf", mime_type="application/pdf", size=12)
def test_resolve_returns_metadata_and_internal_url():
with (
patch(f"{_MOD}.file_factory.build_from_mapping", return_value=_fake_file()) as build,
patch(f"{_MOD}.DifyWorkflowFileRuntime") as runtime_cls,
):
runtime_cls.return_value.resolve_file_url.return_value = "http://internal/files/x?sign=1"
data = AgentFileDownloadRequestService.resolve(
tenant_id="tenant-1",
user_id="user-1",
user_from="account",
invoke_from="service-api",
file_mapping={"transfer_method": "tool_file", "reference": "tool-file-1"},
)
assert data == {
"filename": "report.pdf",
"mime_type": "application/pdf",
"size": 12,
"download_url": "http://internal/files/x?sign=1",
}
assert build.call_args.kwargs["tenant_id"] == "tenant-1"
# Sandbox/agent backend consumes the URL -> must be internal, not external.
assert runtime_cls.return_value.resolve_file_url.call_args.kwargs["for_external"] is False
@pytest.mark.parametrize(
("user_from", "invoke_from", "code"),
[
("bogus", "service-api", "invalid_access_context"),
("account", "not-a-source", "invalid_access_context"),
],
)
def test_invalid_access_context_rejected(user_from: str, invoke_from: str, code: str):
with pytest.raises(FileDownloadRequestError) as exc_info:
AgentFileDownloadRequestService.resolve(
tenant_id="t",
user_id="u",
user_from=user_from,
invoke_from=invoke_from,
file_mapping={"transfer_method": "tool_file", "reference": "x"},
)
assert exc_info.value.status_code == 400
assert exc_info.value.code == code
def test_missing_transfer_method_rejected():
with pytest.raises(FileDownloadRequestError) as exc_info:
AgentFileDownloadRequestService.resolve(
tenant_id="t",
user_id="u",
user_from="account",
invoke_from="service-api",
file_mapping={},
)
assert exc_info.value.status_code == 400
assert exc_info.value.code == "invalid_file_mapping"
def test_inaccessible_file_maps_to_404():
with patch(f"{_MOD}.file_factory.build_from_mapping", side_effect=ValueError("ToolFile x not found")):
with pytest.raises(FileDownloadRequestError) as exc_info:
AgentFileDownloadRequestService.resolve(
tenant_id="t",
user_id="u",
user_from="end-user",
invoke_from="web-app",
file_mapping={"transfer_method": "tool_file", "reference": "x"},
)
assert exc_info.value.status_code == 404
assert exc_info.value.code == "file_not_accessible"
def test_unresolved_url_maps_to_502():
with (
patch(f"{_MOD}.file_factory.build_from_mapping", return_value=_fake_file()),
patch(f"{_MOD}.DifyWorkflowFileRuntime") as runtime_cls,
):
runtime_cls.return_value.resolve_file_url.return_value = None
with pytest.raises(FileDownloadRequestError) as exc_info:
AgentFileDownloadRequestService.resolve(
tenant_id="t",
user_id="u",
user_from="account",
invoke_from="service-api",
file_mapping={"transfer_method": "tool_file", "reference": "x"},
)
assert exc_info.value.status_code == 502
@@ -15,7 +15,7 @@ from services.file_request_service import FileRequestService
("end-user", "service-api", UserFrom.END_USER, InvokeFrom.SERVICE_API),
],
)
def test_request_download_url_builds_file_under_bound_scope(
def test_request_download_builds_file_under_bound_scope(
user_from: UserFrom | str,
invoke_from: InvokeFrom | str,
expected_user_from: UserFrom,
@@ -29,12 +29,9 @@ def test_request_download_url_builds_file_under_bound_scope(
with (
patch("services.file_request_service.bind_file_access_scope", return_value=nullcontext()) as bind_scope,
patch.object(service, "_build_file", return_value=fake_file) as build_file,
patch(
"services.file_request_service.file_helpers.resolve_file_url",
return_value="https://files.example.com/x",
) as resolve_file_url,
patch.object(service._runtime, "resolve_file_uri", return_value="/files/tools/x?sign=1") as resolve_file_uri,
):
result = service.request_download_url(
result = service.request_download(
tenant_id="tenant-1",
user_id="user-1",
user_from=user_from,
@@ -52,48 +49,23 @@ def test_request_download_url_builds_file_under_bound_scope(
build_file.assert_called_once_with(
mapping={"transfer_method": "tool_file", "reference": reference}, tenant_id="tenant-1"
)
resolve_file_url.assert_called_once_with(fake_file, for_external=True)
resolve_file_uri.assert_called_once_with(file=fake_file)
assert result.filename == "report.pdf"
assert result.mime_type == "application/pdf"
assert result.size == 123
assert result.download_url == "https://files.example.com/x"
assert result.download_uri == "/files/tools/x?sign=1"
def test_request_download_url_supports_internal_download_urls() -> None:
fake_file = MagicMock(filename="report.pdf", mime_type="application/pdf", size=123)
service = FileRequestService(access_controller=MagicMock())
with (
patch("services.file_request_service.bind_file_access_scope", return_value=nullcontext()),
patch.object(service, "_build_file", return_value=fake_file),
patch(
"services.file_request_service.file_helpers.resolve_file_url",
return_value="http://internal-files/report.pdf",
) as resolve_file_url,
):
result = service.request_download_url(
tenant_id="tenant-1",
user_id="user-1",
user_from="account",
invoke_from="debugger",
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:tool-file-1"},
for_external=False,
)
resolve_file_url.assert_called_once_with(fake_file, for_external=False)
assert result.download_url == "http://internal-files/report.pdf"
def test_request_download_url_rejects_unsupported_files() -> None:
def test_request_download_rejects_unsupported_files() -> None:
service = FileRequestService(access_controller=MagicMock())
with (
patch("services.file_request_service.bind_file_access_scope", return_value=nullcontext()),
patch.object(service, "_build_file", return_value=MagicMock(filename="report.pdf", mime_type=None, size=1)),
patch("services.file_request_service.file_helpers.resolve_file_url", return_value=None),
patch.object(service._runtime, "resolve_file_uri", return_value=None),
):
with pytest.raises(ValueError, match="file does not support signed download"):
service.request_download_url(
service.request_download(
tenant_id="tenant-1",
user_id="user-1",
user_from="account",