mirror of
https://github.com/langgenius/dify.git
synced 2026-08-30 17:11:50 +08:00
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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -292,7 +292,7 @@ func (x *FileMapping) GetUrl() string {
|
||||
type FileDownloadRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
File *FileMapping `protobuf:"bytes,1,opt,name=file,proto3" json:"file,omitempty"`
|
||||
ForExternal *bool `protobuf:"varint,2,opt,name=for_external,json=forExternal,proto3,oneof" json:"for_external,omitempty"`
|
||||
ForFrontend *bool `protobuf:"varint,2,opt,name=for_frontend,json=forFrontend,proto3,oneof" json:"for_frontend,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -334,9 +334,9 @@ func (x *FileDownloadRequest) GetFile() *FileMapping {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *FileDownloadRequest) GetForExternal() bool {
|
||||
if x != nil && x.ForExternal != nil {
|
||||
return *x.ForExternal
|
||||
func (x *FileDownloadRequest) GetForFrontend() bool {
|
||||
if x != nil && x.ForFrontend != nil {
|
||||
return *x.ForFrontend
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -436,8 +436,8 @@ const file_dify_agent_stub_v1_agent_stub_proto_rawDesc = "" +
|
||||
"\x04_url\"\x83\x01\n" +
|
||||
"\x13FileDownloadRequest\x123\n" +
|
||||
"\x04file\x18\x01 \x01(\v2\x1f.dify.agent.stub.v1.FileMappingR\x04file\x12&\n" +
|
||||
"\ffor_external\x18\x02 \x01(\bH\x00R\vforExternal\x88\x01\x01B\x0f\n" +
|
||||
"\r_for_external\"\x99\x01\n" +
|
||||
"\ffor_frontend\x18\x02 \x01(\bH\x00R\vforFrontend\x88\x01\x01B\x0f\n" +
|
||||
"\r_for_frontend\"\x99\x01\n" +
|
||||
"\x14FileDownloadResponse\x12\x1a\n" +
|
||||
"\bfilename\x18\x01 \x01(\tR\bfilename\x12 \n" +
|
||||
"\tmime_type\x18\x02 \x01(\tH\x00R\bmimeType\x88\x01\x01\x12\x12\n" +
|
||||
|
||||
@@ -8,7 +8,7 @@ type StubClient interface {
|
||||
// Control-plane: available via gRPC or HTTP
|
||||
Connect(ctx context.Context, argv []string, metadataJSON string) (*ConnectResponse, error)
|
||||
CreateFileUploadURL(ctx context.Context, filename, mimetype string) (string, error)
|
||||
CreateFileDownloadURL(ctx context.Context, transferMethod string, reference, url *string, forExternal bool) (*FileDownloadResponse, error)
|
||||
CreateFileDownloadURL(ctx context.Context, transferMethod string, reference, url *string, forFrontend bool) (*FileDownloadResponse, error)
|
||||
|
||||
// Drive operations (HTTP-only control-plane)
|
||||
GetDriveManifest(ctx context.Context, prefix string, includeDownloadURL bool) (*DriveManifestResponse, error)
|
||||
|
||||
@@ -55,8 +55,8 @@ func (c *grpcStubClient) CreateFileUploadURL(ctx context.Context, filename, mime
|
||||
return result.UploadURL, nil
|
||||
}
|
||||
|
||||
func (c *grpcStubClient) CreateFileDownloadURL(ctx context.Context, transferMethod string, reference, url *string, forExternal bool) (*FileDownloadResponse, error) {
|
||||
result, err := c.grpc.CreateFileDownload(ctx, transferMethod, reference, url, forExternal)
|
||||
func (c *grpcStubClient) CreateFileDownloadURL(ctx context.Context, transferMethod string, reference, url *string, forFrontend bool) (*FileDownloadResponse, error) {
|
||||
result, err := c.grpc.CreateFileDownload(ctx, transferMethod, reference, url, forFrontend)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func (c *httpStubClient) CreateFileUploadURL(_ context.Context, filename, mimety
|
||||
return resp.UploadURL, nil
|
||||
}
|
||||
|
||||
func (c *httpStubClient) CreateFileDownloadURL(_ context.Context, transferMethod string, reference, url *string, forExternal bool) (*FileDownloadResponse, error) {
|
||||
func (c *httpStubClient) CreateFileDownloadURL(_ context.Context, transferMethod string, reference, url *string, forFrontend bool) (*FileDownloadResponse, error) {
|
||||
fileMapping := map[string]any{
|
||||
"transfer_method": transferMethod,
|
||||
}
|
||||
@@ -85,7 +85,7 @@ func (c *httpStubClient) CreateFileDownloadURL(_ context.Context, transferMethod
|
||||
|
||||
payload := map[string]any{
|
||||
"file": fileMapping,
|
||||
"for_external": forExternal,
|
||||
"for_frontend": forFrontend,
|
||||
}
|
||||
body, statusCode, err := c.http.postJSON("/files/download-request", payload)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -12,9 +13,9 @@ import (
|
||||
|
||||
// FileUploadResponse is the JSON output for `dify-agent file upload`.
|
||||
type FileUploadResponse struct {
|
||||
TransferMethod string `json:"transfer_method"`
|
||||
Reference string `json:"reference"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
TransferMethod string `json:"transfer_method"`
|
||||
Reference string `json:"reference"`
|
||||
PublicDownloadURL string `json:"public_download_url"`
|
||||
}
|
||||
|
||||
// FileDownloadResponse is the response from a file download request.
|
||||
@@ -27,6 +28,27 @@ type FileDownloadResponse struct {
|
||||
|
||||
// RunFileUpload executes the `file upload` command.
|
||||
func RunFileUpload(env *Environment, path string) error {
|
||||
client, err := NewStubClient(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
return runFileUpload(client, path, os.Stdout)
|
||||
}
|
||||
|
||||
type fileUploadClient interface {
|
||||
CreateFileUploadURL(ctx context.Context, filename, mimetype string) (string, error)
|
||||
UploadFileToURL(uploadURL, filePath, filename, mimetype string) ([]byte, error)
|
||||
CreateFileDownloadURL(
|
||||
ctx context.Context,
|
||||
transferMethod string,
|
||||
reference, url *string,
|
||||
forFrontend bool,
|
||||
) (*FileDownloadResponse, error)
|
||||
}
|
||||
|
||||
func runFileUpload(client fileUploadClient, path string, output io.Writer) error {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve path: %w", err)
|
||||
@@ -40,12 +62,6 @@ func RunFileUpload(env *Environment, path string) error {
|
||||
mimetype := guessMIMEType(filename)
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := NewStubClient(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
// Step 1: Request a signed upload URL
|
||||
uploadURL, err := client.CreateFileUploadURL(ctx, filename, mimetype)
|
||||
if err != nil {
|
||||
@@ -70,18 +86,18 @@ func RunFileUpload(env *Environment, path string) error {
|
||||
|
||||
// Step 3: Request download URL for the uploaded file
|
||||
ref := reference
|
||||
dlResp, err := client.CreateFileDownloadURL(ctx, "tool_file", &ref, nil, false)
|
||||
dlResp, err := client.CreateFileDownloadURL(ctx, "tool_file", &ref, nil, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := FileUploadResponse{
|
||||
TransferMethod: "tool_file",
|
||||
Reference: reference,
|
||||
DownloadURL: dlResp.DownloadURL,
|
||||
TransferMethod: "tool_file",
|
||||
Reference: reference,
|
||||
PublicDownloadURL: dlResp.DownloadURL,
|
||||
}
|
||||
out, _ := json.Marshal(result)
|
||||
fmt.Println(string(out))
|
||||
_, _ = fmt.Fprintln(output, string(out))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package agentcli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeFileUploadClient struct {
|
||||
forFrontend bool
|
||||
}
|
||||
|
||||
func (f *fakeFileUploadClient) CreateFileUploadURL(_ context.Context, filename, mimetype string) (string, error) {
|
||||
return "https://sandbox-files.example.com/files/upload/for-plugin?sign=1", nil
|
||||
}
|
||||
|
||||
func (f *fakeFileUploadClient) UploadFileToURL(uploadURL, filePath, filename, mimetype string) ([]byte, error) {
|
||||
return []byte(`{"reference":"dify-file-ref:canonical"}`), nil
|
||||
}
|
||||
|
||||
func (f *fakeFileUploadClient) CreateFileDownloadURL(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
_, _ *string,
|
||||
forFrontend bool,
|
||||
) (*FileDownloadResponse, error) {
|
||||
f.forFrontend = forFrontend
|
||||
return &FileDownloadResponse{
|
||||
Filename: "report.pdf",
|
||||
MimeType: "application/pdf",
|
||||
Size: 123,
|
||||
DownloadURL: "/files/tools/report.pdf?sign=2",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestRunFileUploadReturnsFrontendDisplayURL(t *testing.T) {
|
||||
filePath := t.TempDir() + "/report.pdf"
|
||||
if err := os.WriteFile(filePath, []byte("report"), 0o600); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
client := &fakeFileUploadClient{}
|
||||
var output bytes.Buffer
|
||||
if err := runFileUpload(client, filePath, &output); err != nil {
|
||||
t.Fatalf("run file upload: %v", err)
|
||||
}
|
||||
|
||||
if !client.forFrontend {
|
||||
t.Fatal("download request did not select frontend display URL")
|
||||
}
|
||||
got := strings.TrimSpace(output.String())
|
||||
want := `{"transfer_method":"tool_file","reference":"dify-file-ref:canonical","public_download_url":"/files/tools/report.pdf?sign=2"}`
|
||||
if got != want {
|
||||
t.Fatalf("output = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFileDownloadRequestsSandboxURLAndWritesFile(t *testing.T) {
|
||||
var requestPayload map[string]json.RawMessage
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/agent-stub/files/download-request":
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestPayload); err != nil {
|
||||
t.Errorf("decode download request: %v", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"filename":"report.pdf","mime_type":"application/pdf","size":6,"download_url":"` + server.URL + `/files/report.pdf"}`))
|
||||
case "/files/report.pdf":
|
||||
_, _ = w.Write([]byte("report"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
targetDir := t.TempDir()
|
||||
err := RunFileDownload(
|
||||
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "test-token"},
|
||||
"tool_file",
|
||||
"dify-file-ref:canonical",
|
||||
targetDir,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("run file download: %v", err)
|
||||
}
|
||||
|
||||
var forFrontend bool
|
||||
if err := json.Unmarshal(requestPayload["for_frontend"], &forFrontend); err != nil {
|
||||
t.Fatalf("decode for_frontend: %v", err)
|
||||
}
|
||||
if forFrontend {
|
||||
t.Fatal("download request selected a frontend URL")
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(targetDir, "report.pdf"))
|
||||
if err != nil {
|
||||
t.Fatalf("read downloaded file: %v", err)
|
||||
}
|
||||
if string(data) != "report" {
|
||||
t.Fatalf("downloaded file = %q, want report", data)
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,7 @@ type FileDownloadResult struct {
|
||||
}
|
||||
|
||||
// CreateFileDownload requests a download URL from the Agent Stub server.
|
||||
func (c *Client) CreateFileDownload(ctx context.Context, transferMethod string, reference, url *string, forExternal bool) (*FileDownloadResult, error) {
|
||||
func (c *Client) CreateFileDownload(ctx context.Context, transferMethod string, reference, url *string, forFrontend bool) (*FileDownloadResult, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -129,7 +129,7 @@ func (c *Client) CreateFileDownload(ctx context.Context, transferMethod string,
|
||||
|
||||
resp, err := c.stub.CreateFileDownloadRequest(ctx, &stubv1.FileDownloadRequest{
|
||||
File: fileMapping,
|
||||
ForExternal: &forExternal,
|
||||
ForFrontend: &forFrontend,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stubclient: file download: %w", err)
|
||||
|
||||
@@ -99,7 +99,8 @@ the request's product context;
|
||||
`DifyRuntimeLayerConfig.backend_binding_ref` carries only that opaque ref and
|
||||
opens a new operation-scoped `RuntimeLease` for the run. When shell jobs need to
|
||||
call back with the `dify-agent` command, also set
|
||||
`DIFY_AGENT_STUB_API_BASE_URL`. The supplied default configs include a
|
||||
`DIFY_AGENT_STUB_API_BASE_URL` and the Sandbox-reachable Dify API base
|
||||
`DIFY_AGENT_SANDBOX_FILES_BASE_URL`. The supplied default configs include a
|
||||
development `DIFY_AGENT_SERVER_SECRET_KEY`, but production deployments should
|
||||
override it with a unique 32-byte base64url value as documented in
|
||||
`.example.env`.
|
||||
|
||||
@@ -56,7 +56,8 @@ also reads `.env` and `dify-agent/.env` when present.
|
||||
| `DIFY_AGENT_E2B_SHELLCTL_PORT` | `5004` | shellctl port exposed by the E2B template. |
|
||||
| `DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES` | `52428800` | Standalone Dify Agent maximum for whole-file Workspace upload capture; 50 MiB by default. Docker Compose derives it from `PLUGIN_MAX_FILE_SIZE`. |
|
||||
| `DIFY_AGENT_SHELL_REDACT_PATTERNS` | empty | JSON array of additional regex patterns redacted from Shell output. |
|
||||
| `DIFY_AGENT_STUB_API_BASE_URL` | empty | Public Agent Stub API base URL reachable from shellctl-managed remote machines. HTTP may be the service root or `/agent-stub`; gRPC must be `grpc://host:port`. Enables `DIFY_AGENT_STUB_*` env injection for user `shell.run` jobs. |
|
||||
| `DIFY_AGENT_STUB_API_BASE_URL` | empty | Agent Stub API base URL reachable from the Sandbox. HTTP may be the service root or `/agent-stub`; gRPC must be `grpc://host:port`. Enables `DIFY_AGENT_STUB_*` env injection for user `shell.run` jobs. |
|
||||
| `DIFY_AGENT_SANDBOX_FILES_BASE_URL` | empty | Dify API base URL reachable from the Sandbox for signed `/files/*` upload/download bytes. Required when Agent Stub file operations are enabled. May include an ingress path prefix, but not a query or fragment. |
|
||||
| `DIFY_AGENT_STUB_GRPC_BIND_ADDRESS` | empty | Optional `host:port` bind override used only when `DIFY_AGENT_STUB_API_BASE_URL` uses `grpc://`. |
|
||||
| `DIFY_AGENT_SERVER_SECRET_KEY` | empty | Security-sensitive server-wide root secret used to derive the JWE encryption key for Agent Stub bearer tokens; required when `DIFY_AGENT_STUB_API_BASE_URL` is set. The supplied default config uses a development value; set a unique unpadded base64url 32-byte secret in production. |
|
||||
| `DIFY_AGENT_OUTBOUND_HTTP_CONNECT_TIMEOUT` | `10` | Shared outbound HTTP connect timeout in seconds. |
|
||||
@@ -87,12 +88,30 @@ DIFY_AGENT_LOCAL_SANDBOX_WORKSPACE_ROOT=/tmp/dify-agent/workspaces
|
||||
DIFY_AGENT_LOCAL_SANDBOX_HOME_SNAPSHOT_ROOT=/tmp/dify-agent/home-snapshots
|
||||
DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES=52428800
|
||||
DIFY_AGENT_STUB_API_BASE_URL=https://agent.example.com/agent-stub
|
||||
DIFY_AGENT_SANDBOX_FILES_BASE_URL=https://dify.example.com
|
||||
# This is security-sensitive: it derives the JWE encryption key for Agent Stub bearer tokens.
|
||||
# Replace this development default in production.
|
||||
# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
|
||||
DIFY_AGENT_SERVER_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY
|
||||
```
|
||||
|
||||
The two Sandbox-facing base URLs have different owners. Agent Stub control
|
||||
requests use `DIFY_AGENT_STUB_API_BASE_URL`; signed file bytes use
|
||||
`DIFY_AGENT_SANDBOX_FILES_BASE_URL`. `DIFY_AGENT_INNER_API_URL` remains a
|
||||
trusted service-to-service URL and is never returned to the Sandbox.
|
||||
|
||||
For a remote Sandbox, expose only `/agent-stub/*` from Agent Backend and the
|
||||
existing `/files/*` Dify API data plane. The `/files/*` ingress must preserve
|
||||
the complete signed query string, allow the configured upload body size, and
|
||||
use response streaming and timeouts suitable for large downloads. Do not expose
|
||||
Agent Backend `/runs`, Workspace, or Binding management routes through the
|
||||
Sandbox ingress.
|
||||
|
||||
Browser presentation URLs are independent. Configure Dify API `FILES_URL` to a
|
||||
browser-reachable public origin, or leave it empty so responses use same-origin
|
||||
relative `/files/...` URIs. Never set `FILES_URL` to a Docker-only service name
|
||||
such as `http://api:5001`.
|
||||
|
||||
`DIFY_AGENT_SHELLCTL_ENTRYPOINT` and `DIFY_AGENT_SHELLCTL_AUTH_TOKEN` remain
|
||||
accepted only as legacy aliases for the two Local settings. New deployments
|
||||
must use `DIFY_AGENT_LOCAL_SANDBOX_ENDPOINT` and
|
||||
|
||||
@@ -80,16 +80,40 @@ DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN=replace-with-shellctl-token
|
||||
The auth token may be empty when shellctl authentication is disabled. E2B uses
|
||||
`DIFY_AGENT_E2B_API_KEY`, the prepared template, and its shellctl settings.
|
||||
|
||||
To let shell jobs call the Agent Stub with `dify-agent ...`, configure a public
|
||||
Agent Stub URL and a unique production secret:
|
||||
To let shell jobs call the Agent Stub with `dify-agent ...`, configure a
|
||||
Sandbox-reachable Agent Stub URL and a unique production secret. Remote
|
||||
deployments normally use a public Agent ingress. Local Compose uses
|
||||
`http://agent_backend:5050/agent-stub`, reached through the existing
|
||||
`agent_ssrf_proxy`; this configuration does not change the Compose network
|
||||
topology.
|
||||
|
||||
```env
|
||||
DIFY_AGENT_STUB_API_BASE_URL=https://agent.example.com/agent-stub
|
||||
DIFY_AGENT_SANDBOX_FILES_BASE_URL=https://dify.example.com
|
||||
DIFY_AGENT_SERVER_SECRET_KEY=replace-with-unpadded-base64url-for-32-random-bytes
|
||||
```
|
||||
|
||||
HTTP URLs may be either the service root or the explicit `/agent-stub` root.
|
||||
The server normalizes a service root and rejects unrelated paths.
|
||||
The server normalizes a service root and rejects unrelated paths. The separate
|
||||
Sandbox file base must point to the Dify API ingress serving `/files/*`; it is
|
||||
used for CLI upload/download bytes even when Agent Stub control calls use gRPC.
|
||||
|
||||
After `dify-agent file upload <path>` succeeds, the CLI prints JSON such as:
|
||||
|
||||
```json
|
||||
{
|
||||
"transfer_method": "tool_file",
|
||||
"reference": "dify-file-ref:...",
|
||||
"public_download_url": "https://dify.example.com/files/tools/..."
|
||||
}
|
||||
```
|
||||
|
||||
`reference` is the persistent canonical file identity and should be stored in
|
||||
structured output. `public_download_url` is a short-lived frontend presentation
|
||||
address: it is an absolute URL when Dify API `FILES_URL` has a public origin,
|
||||
or a same-origin `/files/...` relative URI when `FILES_URL` is empty. The CLI
|
||||
does not access this field. The former ambiguous `download_url` upload-output
|
||||
key is now named `public_download_url`.
|
||||
|
||||
## Request graph
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ message FileMapping {
|
||||
|
||||
message FileDownloadRequest {
|
||||
FileMapping file = 1;
|
||||
optional bool for_external = 2;
|
||||
optional bool for_frontend = 2;
|
||||
}
|
||||
|
||||
message FileDownloadResponse {
|
||||
|
||||
@@ -20,14 +20,15 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
|
||||
b'\n#dify/agent/stub/v1/agent_stub.proto\x12\x12\x64ify.agent.stub.v1"O\n\x0e\x43onnectRequest\x12\x18\n\x10protocol_version\x18\x01 \x01(\x05\x12\x0c\n\x04\x61rgv\x18\x02 \x03(\t\x12\x15\n\rmetadata_json\x18\x03 \x01(\t"8\n\x0f\x43onnectResponse\x12\x15\n\rconnection_id\x18\x01 \x01(\t\x12\x0e\n\x06status\x18\x02 \x01(\t"7\n\x11\x46ileUploadRequest\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x10\n\x08mimetype\x18\x02 \x01(\t"(\n\x12\x46ileUploadResponse\x12\x12\n\nupload_url\x18\x01 \x01(\t"f\n\x0b\x46ileMapping\x12\x17\n\x0ftransfer_method\x18\x01 \x01(\t\x12\x16\n\treference\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x10\n\x03url\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x0c\n\n_referenceB\x06\n\x04_url"p\n\x13\x46ileDownloadRequest\x12-\n\x04\x66ile\x18\x01 \x01(\x0b\x32\x1f.dify.agent.stub.v1.FileMapping\x12\x19\n\x0c\x66or_external\x18\x02 \x01(\x08H\x00\x88\x01\x01\x42\x0f\n\r_for_external"r\n\x14\x46ileDownloadResponse\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x16\n\tmime_type\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x0c\n\x04size\x18\x03 \x01(\x03\x12\x14\n\x0c\x64ownload_url\x18\x04 \x01(\tB\x0c\n\n_mime_type2\xc0\x02\n\x10\x41gentStubService\x12R\n\x07\x43onnect\x12".dify.agent.stub.v1.ConnectRequest\x1a#.dify.agent.stub.v1.ConnectResponse\x12h\n\x17\x43reateFileUploadRequest\x12%.dify.agent.stub.v1.FileUploadRequest\x1a&.dify.agent.stub.v1.FileUploadResponse\x12n\n\x19\x43reateFileDownloadRequest\x12\'.dify.agent.stub.v1.FileDownloadRequest\x1a(.dify.agent.stub.v1.FileDownloadResponseb\x06proto3'
|
||||
b'\n#dify/agent/stub/v1/agent_stub.proto\x12\x12\x64ify.agent.stub.v1"O\n\x0e\x43onnectRequest\x12\x18\n\x10protocol_version\x18\x01 \x01(\x05\x12\x0c\n\x04\x61rgv\x18\x02 \x03(\t\x12\x15\n\rmetadata_json\x18\x03 \x01(\t"8\n\x0f\x43onnectResponse\x12\x15\n\rconnection_id\x18\x01 \x01(\t\x12\x0e\n\x06status\x18\x02 \x01(\t"7\n\x11\x46ileUploadRequest\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x10\n\x08mimetype\x18\x02 \x01(\t"(\n\x12\x46ileUploadResponse\x12\x12\n\nupload_url\x18\x01 \x01(\t"f\n\x0b\x46ileMapping\x12\x17\n\x0ftransfer_method\x18\x01 \x01(\t\x12\x16\n\treference\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x10\n\x03url\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x0c\n\n_referenceB\x06\n\x04_url"p\n\x13\x46ileDownloadRequest\x12-\n\x04\x66ile\x18\x01 \x01(\x0b\x32\x1f.dify.agent.stub.v1.FileMapping\x12\x19\n\x0c\x66or_frontend\x18\x02 \x01(\x08H\x00\x88\x01\x01\x42\x0f\n\r_for_frontend"r\n\x14\x46ileDownloadResponse\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x16\n\tmime_type\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x0c\n\x04size\x18\x03 \x01(\x03\x12\x14\n\x0c\x64ownload_url\x18\x04 \x01(\tB\x0c\n\n_mime_type2\xc0\x02\n\x10\x41gentStubService\x12R\n\x07\x43onnect\x12".dify.agent.stub.v1.ConnectRequest\x1a#.dify.agent.stub.v1.ConnectResponse\x12h\n\x17\x43reateFileUploadRequest\x12%.dify.agent.stub.v1.FileUploadRequest\x1a&.dify.agent.stub.v1.FileUploadResponse\x12n\n\x19\x43reateFileDownloadRequest\x12\'.dify.agent.stub.v1.FileDownloadRequest\x1a(.dify.agent.stub.v1.FileDownloadResponseB\x1bZ\x19\x64ify/agent/stub/v1;stubv1b\x06proto3'
|
||||
)
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "dify.agent.stub.v1.agent_stub_pb2", _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
DESCRIPTOR._loaded_options = None
|
||||
_globals["DESCRIPTOR"]._loaded_options = None
|
||||
_globals["DESCRIPTOR"]._serialized_options = b"Z\031dify/agent/stub/v1;stubv1"
|
||||
_globals["_CONNECTREQUEST"]._serialized_start = 59
|
||||
_globals["_CONNECTREQUEST"]._serialized_end = 138
|
||||
_globals["_CONNECTRESPONSE"]._serialized_start = 140
|
||||
|
||||
@@ -49,12 +49,12 @@ class FileMapping(_message.Message):
|
||||
def __init__(self, transfer_method: _Optional[str] = ..., reference: _Optional[str] = ..., url: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class FileDownloadRequest(_message.Message):
|
||||
__slots__ = ("file", "for_external")
|
||||
__slots__ = ("file", "for_frontend")
|
||||
FILE_FIELD_NUMBER: _ClassVar[int]
|
||||
FOR_EXTERNAL_FIELD_NUMBER: _ClassVar[int]
|
||||
FOR_FRONTEND_FIELD_NUMBER: _ClassVar[int]
|
||||
file: FileMapping
|
||||
for_external: bool
|
||||
def __init__(self, file: _Optional[_Union[FileMapping, _Mapping]] = ..., for_external: _Optional[bool] = ...) -> None: ...
|
||||
for_frontend: bool
|
||||
def __init__(self, file: _Optional[_Union[FileMapping, _Mapping]] = ..., for_frontend: _Optional[bool] = ...) -> None: ...
|
||||
|
||||
class FileDownloadResponse(_message.Message):
|
||||
__slots__ = ("filename", "mime_type", "size", "download_url")
|
||||
|
||||
@@ -101,7 +101,7 @@ def file_download_request_from_proto(message: agent_stub_pb2.FileDownloadRequest
|
||||
return AgentStubFileDownloadRequest.model_validate(
|
||||
{
|
||||
"file": file_mapping_kwargs,
|
||||
"for_external": message.for_external if message.HasField("for_external") else True,
|
||||
"for_frontend": message.for_frontend if message.HasField("for_frontend") else True,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -110,7 +110,7 @@ def proto_file_download_request(
|
||||
pb2_module,
|
||||
*,
|
||||
file: AgentStubFileMapping,
|
||||
for_external: bool = True,
|
||||
for_frontend: bool = True,
|
||||
) -> agent_stub_pb2.FileDownloadRequest:
|
||||
"""Build one protobuf file-download request from the public DTO."""
|
||||
mapping = pb2_module.FileMapping(transfer_method=file.transfer_method)
|
||||
@@ -119,7 +119,7 @@ def proto_file_download_request(
|
||||
if file.url is not None:
|
||||
mapping.url = file.url
|
||||
request = pb2_module.FileDownloadRequest(file=mapping)
|
||||
request.for_external = for_external
|
||||
request.for_frontend = for_frontend
|
||||
return request
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from dataclasses import dataclass
|
||||
from typing import ClassVar, Final, Literal
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, JsonValue, model_validator
|
||||
|
||||
from dify_agent.agent_stub._constants import AGENT_STUB_DRIVE_BASE_ENV_VAR, DEFAULT_AGENT_STUB_DRIVE_BASE
|
||||
|
||||
@@ -246,10 +246,19 @@ class AgentStubFileMapping(BaseModel):
|
||||
|
||||
|
||||
class AgentStubFileDownloadRequest(BaseModel):
|
||||
"""Request body for one signed download URL allocation."""
|
||||
"""Request one file URL for a specific consumer audience.
|
||||
|
||||
``for_frontend=True`` allocates a frontend-display URL that the CLI only
|
||||
returns to its caller. ``False`` allocates a Sandbox byte-transfer URL that
|
||||
the CLI immediately fetches. The deprecated HTTP input name
|
||||
``for_external`` remains accepted for one compatibility cycle.
|
||||
"""
|
||||
|
||||
file: AgentStubFileMapping
|
||||
for_external: bool = True
|
||||
for_frontend: bool = Field(
|
||||
default=True,
|
||||
validation_alias=AliasChoices("for_frontend", "for_external"),
|
||||
)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -14,11 +14,12 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
import posixpath
|
||||
from typing import Any, Protocol
|
||||
from urllib.parse import urljoin
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubFileDownloadRequest,
|
||||
@@ -71,36 +72,28 @@ class AgentStubFileRequestError(RuntimeError):
|
||||
super().__init__(str(detail))
|
||||
|
||||
|
||||
class _BackwardsInvocationEnvelope(BaseModel):
|
||||
"""Minimal parser for Dify API plugin-style inner API envelopes."""
|
||||
|
||||
data: object | None = None
|
||||
error: str | None = None
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyApiAgentStubFileRequestHandler:
|
||||
"""Call Dify API inner file request endpoints on behalf of the sandbox.
|
||||
|
||||
The upload path calls ``/inner/api/upload/file/request`` and injects the
|
||||
The upload path calls ``/inner/api/agent/files/upload-request`` and injects the
|
||||
authenticated execution context's ``tenant_id``, ``user_id``, and optional
|
||||
``conversation_id`` along with the requested filename and mimetype. The download path calls
|
||||
``/inner/api/download/file/request`` and injects ``tenant_id``,
|
||||
``/inner/api/agent/files/download-request`` and injects ``tenant_id``,
|
||||
``user_id``, ``user_from``, and ``invoke_from`` plus the validated public
|
||||
file mapping.
|
||||
|
||||
``user_id`` is mandatory for both operations. Missing user context is
|
||||
rejected before any network call with ``AgentStubFileRequestError(400, ...)``.
|
||||
Timeouts, transport failures, non-2xx responses, invalid JSON, invalid
|
||||
plugin-style envelopes, and invalid success schemas are all normalized into
|
||||
Timeouts, transport failures, non-2xx responses, invalid JSON, and invalid
|
||||
success schemas are all normalized into
|
||||
``AgentStubFileRequestError`` so the stub routes can preserve a stable HTTP
|
||||
contract without exposing raw ``httpx`` or Pydantic exceptions.
|
||||
"""
|
||||
|
||||
inner_api_url: str
|
||||
inner_api_key: str
|
||||
sandbox_files_base_url: str
|
||||
timeout: httpx.Timeout | float = 30.0
|
||||
|
||||
async def create_upload_request(
|
||||
@@ -119,7 +112,7 @@ class DifyApiAgentStubFileRequestHandler:
|
||||
Raises:
|
||||
AgentStubFileRequestError: when user context is incomplete, the
|
||||
inner API times out or fails, the response is non-2xx, or the
|
||||
success payload does not contain a non-empty ``url`` string.
|
||||
success payload does not contain a valid ``upload_uri``.
|
||||
"""
|
||||
execution_context = self._require_user_context(principal.execution_context)
|
||||
payload = {
|
||||
@@ -129,12 +122,12 @@ class DifyApiAgentStubFileRequestHandler:
|
||||
"mimetype": request.mimetype,
|
||||
"conversation_id": execution_context.conversation_id,
|
||||
}
|
||||
data = await self._post_inner_api("/inner/api/upload/file/request", payload)
|
||||
upload_url = data.get("url")
|
||||
if not isinstance(upload_url, str) or not upload_url:
|
||||
raise AgentStubFileRequestError(502, "Dify API upload request response is missing url")
|
||||
data = await self._post_inner_api("/inner/api/agent/files/upload-request", payload)
|
||||
upload_uri = data.get("upload_uri")
|
||||
if not isinstance(upload_uri, str) or not upload_uri:
|
||||
raise AgentStubFileRequestError(502, "Dify API upload request response is missing upload_uri")
|
||||
return AgentStubFileUploadResponse(
|
||||
upload_url=urljoin(f"{self.inner_api_url.rstrip('/')}/", upload_url),
|
||||
upload_url=self._bind_sandbox_files_base_url(upload_uri),
|
||||
)
|
||||
|
||||
async def create_download_request(
|
||||
@@ -153,8 +146,7 @@ class DifyApiAgentStubFileRequestHandler:
|
||||
Raises:
|
||||
AgentStubFileRequestError: when user context is incomplete, the
|
||||
inner API times out or fails, the response is non-2xx, the
|
||||
plugin-style envelope is malformed, or the success payload does
|
||||
not match ``AgentStubFileDownloadResponse``.
|
||||
success payload does not contain safe download metadata.
|
||||
"""
|
||||
execution_context = self._require_user_context(principal.execution_context)
|
||||
payload: dict[str, object] = {
|
||||
@@ -164,11 +156,14 @@ class DifyApiAgentStubFileRequestHandler:
|
||||
"invoke_from": execution_context.invoke_from,
|
||||
"file": request.file.model_dump(mode="json", exclude_none=True),
|
||||
}
|
||||
if request.for_external is False:
|
||||
payload["for_external"] = False
|
||||
data = await self._post_inner_api("/inner/api/download/file/request", payload)
|
||||
payload["for_frontend"] = request.for_frontend
|
||||
data = await self._post_inner_api("/inner/api/agent/files/download-request", payload)
|
||||
download_uri = data.get("download_uri")
|
||||
if not isinstance(download_uri, str) or not download_uri:
|
||||
raise AgentStubFileRequestError(502, "Dify API download request response is missing download_uri")
|
||||
download_url = self._resolve_download_url(request=request, download_uri=download_uri)
|
||||
try:
|
||||
return AgentStubFileDownloadResponse.model_validate(data)
|
||||
return AgentStubFileDownloadResponse.model_validate({**data, "download_url": download_url})
|
||||
except ValidationError as exc:
|
||||
raise AgentStubFileRequestError(502, "Dify API download request response is invalid") from exc
|
||||
|
||||
@@ -197,15 +192,46 @@ class DifyApiAgentStubFileRequestHandler:
|
||||
if response.is_error:
|
||||
detail = raw_payload.get("detail", raw_payload) if isinstance(raw_payload, dict) else raw_payload
|
||||
raise AgentStubFileRequestError(response.status_code, detail)
|
||||
try:
|
||||
envelope = _BackwardsInvocationEnvelope.model_validate(raw_payload)
|
||||
except ValidationError as exc:
|
||||
raise AgentStubFileRequestError(502, "Dify API file request response is invalid") from exc
|
||||
if envelope.error:
|
||||
raise AgentStubFileRequestError(400, envelope.error)
|
||||
if not isinstance(envelope.data, dict):
|
||||
raise AgentStubFileRequestError(502, "Dify API file request response is missing data")
|
||||
return dict(envelope.data)
|
||||
if not isinstance(raw_payload, dict):
|
||||
raise AgentStubFileRequestError(502, "Dify API file request response is invalid")
|
||||
return raw_payload
|
||||
|
||||
def _resolve_download_url(self, *, request: AgentStubFileDownloadRequest, download_uri: str) -> str:
|
||||
if request.file.transfer_method == "remote_url":
|
||||
if self._is_absolute_http_url(download_uri):
|
||||
return download_uri
|
||||
raise AgentStubFileRequestError(502, "Dify API returned an invalid remote download URL")
|
||||
|
||||
if request.for_frontend:
|
||||
if self._is_absolute_http_url(download_uri) or self._is_safe_dify_file_uri(download_uri):
|
||||
return download_uri
|
||||
raise AgentStubFileRequestError(502, "Dify API returned an unsafe frontend download URI")
|
||||
|
||||
return self._bind_sandbox_files_base_url(download_uri)
|
||||
|
||||
def _bind_sandbox_files_base_url(self, uri: str) -> str:
|
||||
if not self._is_safe_dify_file_uri(uri):
|
||||
raise AgentStubFileRequestError(502, "Dify API returned an unsafe Dify file URI")
|
||||
return f"{self.sandbox_files_base_url.rstrip('/')}{uri}"
|
||||
|
||||
@staticmethod
|
||||
def _is_absolute_http_url(value: str) -> bool:
|
||||
parsed = urlsplit(value)
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||
|
||||
@staticmethod
|
||||
def _is_safe_dify_file_uri(value: str) -> bool:
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme or parsed.netloc or parsed.fragment or value.startswith("//"):
|
||||
return False
|
||||
|
||||
decoded_path = parsed.path
|
||||
for _ in range(2):
|
||||
decoded_path = unquote(decoded_path)
|
||||
if "\\" in decoded_path:
|
||||
return False
|
||||
normalized_path = posixpath.normpath(decoded_path)
|
||||
return decoded_path.startswith("/files/") and normalized_path.startswith("/files/")
|
||||
|
||||
@staticmethod
|
||||
def _parse_json(response: httpx.Response) -> object:
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
AGENT_FILE_UPLOAD_REPLY_HINT = (
|
||||
"When you want to provide a generated or sandbox-local file to the user in a "
|
||||
"natural-language reply, run the installed CLI command `dify-agent file upload PATH` and include the returned "
|
||||
"`download_url` so the user can open or download the file."
|
||||
"`public_download_url` so the user can open or download the file."
|
||||
)
|
||||
|
||||
@@ -74,6 +74,10 @@ class ServerSettings(BaseSettings):
|
||||
e2b_shellctl_port: int = Field(default=5004, ge=1, le=65535)
|
||||
sandbox_file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, ge=1)
|
||||
agent_stub_api_base_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_API_BASE_URL")
|
||||
sandbox_files_base_url: str | None = Field(
|
||||
default=None,
|
||||
validation_alias="DIFY_AGENT_SANDBOX_FILES_BASE_URL",
|
||||
)
|
||||
agent_stub_grpc_bind_address: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_GRPC_BIND_ADDRESS")
|
||||
server_secret_key: str | None = None
|
||||
api_token: str | None = None
|
||||
@@ -107,6 +111,22 @@ class ServerSettings(BaseSettings):
|
||||
return normalize_agent_stub_api_base_url(validated)
|
||||
return normalize_agent_stub_api_base_url(stripped)
|
||||
|
||||
@field_validator("sandbox_files_base_url")
|
||||
@classmethod
|
||||
def normalize_sandbox_files_base_url_value(cls, value: str | None) -> str | None:
|
||||
"""Normalize the Dify API base URL reachable from the Sandbox."""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
validated = str(TypeAdapter(AnyHttpUrl).validate_python(stripped))
|
||||
parsed = validated.rstrip("/")
|
||||
if "?" in parsed or "#" in parsed:
|
||||
raise ValueError("DIFY_AGENT_SANDBOX_FILES_BASE_URL must not include a query string or fragment")
|
||||
return parsed
|
||||
|
||||
@field_validator("agent_stub_grpc_bind_address")
|
||||
@classmethod
|
||||
def normalize_agent_stub_grpc_bind_address_value(cls, value: str | None) -> str | None:
|
||||
@@ -169,6 +189,14 @@ class ServerSettings(BaseSettings):
|
||||
"""Require Agent Stub settings while allowing deployments without inner API calls."""
|
||||
if self.agent_stub_api_base_url is not None and self.server_secret_key is None:
|
||||
raise ValueError("DIFY_AGENT_SERVER_SECRET_KEY is required when DIFY_AGENT_STUB_API_BASE_URL is set.")
|
||||
if (
|
||||
self.agent_stub_api_base_url is not None
|
||||
and self.inner_api_key is not None
|
||||
and self.sandbox_files_base_url is None
|
||||
):
|
||||
raise ValueError(
|
||||
"DIFY_AGENT_SANDBOX_FILES_BASE_URL is required when Agent Stub file operations are enabled."
|
||||
)
|
||||
if self.agent_stub_grpc_bind_address is not None:
|
||||
if self.agent_stub_api_base_url is None:
|
||||
raise ValueError(
|
||||
@@ -209,12 +237,14 @@ class ServerSettings(BaseSettings):
|
||||
return AgentStubTokenCodec.from_server_secret(self.server_secret_key)
|
||||
|
||||
def create_agent_stub_file_request_handler(self) -> DifyApiAgentStubFileRequestHandler | None:
|
||||
"""Return the Dify API file bridge when both Dify API settings are configured."""
|
||||
if self.inner_api_key is None:
|
||||
"""Return the file bridge when inner API and Sandbox data-plane settings are configured."""
|
||||
if self.inner_api_key is None or self.sandbox_files_base_url is None:
|
||||
return None
|
||||
return DifyApiAgentStubFileRequestHandler(
|
||||
inner_api_url=self.inner_api_url,
|
||||
inner_api_key=self.inner_api_key,
|
||||
sandbox_files_base_url=self.sandbox_files_base_url,
|
||||
timeout=self.create_outbound_http_timeout(),
|
||||
)
|
||||
|
||||
def create_agent_stub_config_request_handler(self) -> DifyApiAgentStubConfigRequestHandler | None:
|
||||
|
||||
@@ -114,7 +114,7 @@ class AgentStubWorkspaceFileUploader:
|
||||
mapping = AgentStubFileMapping(transfer_method="tool_file", reference=payload.reference)
|
||||
download_request = await self.file_request_handler.create_download_request(
|
||||
principal=principal,
|
||||
request=AgentStubFileDownloadRequest(file=mapping, for_external=False),
|
||||
request=AgentStubFileDownloadRequest(file=mapping, for_frontend=False),
|
||||
)
|
||||
return WorkspaceUploadedFile(
|
||||
reference=payload.reference,
|
||||
|
||||
@@ -12,6 +12,7 @@ from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubDriveCommitRequest,
|
||||
AgentStubDriveFileRef,
|
||||
AgentStubDriveManifestResponse,
|
||||
AgentStubFileDownloadRequest,
|
||||
AgentStubFileMapping,
|
||||
agent_stub_connections_url,
|
||||
agent_stub_drive_base_for_ref,
|
||||
@@ -150,6 +151,21 @@ def test_agent_stub_file_mapping_rejects_remote_url_with_reference() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_agent_stub_file_download_request_accepts_legacy_http_audience_alias() -> None:
|
||||
mapping = {"transfer_method": "tool_file", "reference": _reference("tool-file-1")}
|
||||
|
||||
request = AgentStubFileDownloadRequest.model_validate({"file": mapping, "for_external": False})
|
||||
|
||||
assert request.for_frontend is False
|
||||
assert request.model_dump() == {
|
||||
"file": {"transfer_method": "tool_file", "reference": _reference("tool-file-1"), "url": None},
|
||||
"for_frontend": False,
|
||||
}
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
_ = AgentStubFileDownloadRequest.model_validate({"file": mapping, "for_frontend": True, "for_external": False})
|
||||
|
||||
|
||||
def test_agent_stub_drive_commit_request_validates_file_refs() -> None:
|
||||
request = AgentStubDriveCommitRequest(
|
||||
items=[
|
||||
|
||||
@@ -53,32 +53,33 @@ def test_file_download_request_from_proto_respects_optional_reference() -> None:
|
||||
|
||||
assert request.file.reference == _reference("tool-file-1")
|
||||
assert request.file.url is None
|
||||
assert request.for_external is True
|
||||
assert request.for_frontend is True
|
||||
|
||||
|
||||
def test_file_download_request_from_proto_preserves_explicit_internal_audience() -> None:
|
||||
def test_file_download_request_from_proto_preserves_explicit_sandbox_transfer_audience() -> None:
|
||||
message = agent_stub_pb2.FileDownloadRequest(
|
||||
file=agent_stub_pb2.FileMapping(
|
||||
transfer_method="tool_file",
|
||||
reference=_reference("tool-file-1"),
|
||||
),
|
||||
for_external=False,
|
||||
for_frontend=False,
|
||||
)
|
||||
|
||||
request = file_download_request_from_proto(message)
|
||||
|
||||
assert request.for_external is False
|
||||
assert request.for_frontend is False
|
||||
|
||||
|
||||
def test_proto_file_download_request_preserves_selected_audience() -> None:
|
||||
message = proto_file_download_request(
|
||||
agent_stub_pb2,
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1")),
|
||||
for_external=False,
|
||||
for_frontend=False,
|
||||
)
|
||||
|
||||
assert message.HasField("for_external") is True
|
||||
assert message.for_external is False
|
||||
assert message.HasField("for_frontend") is True
|
||||
assert message.for_frontend is False
|
||||
assert agent_stub_pb2.FileDownloadRequest.DESCRIPTOR.fields_by_name["for_frontend"].number == 2
|
||||
|
||||
|
||||
def test_connect_request_from_proto_rejects_invalid_metadata_json() -> None:
|
||||
|
||||
@@ -64,6 +64,7 @@ def test_create_agent_stub_app_wires_configured_file_handler_for_upload_requests
|
||||
server_secret_key=_base64url_secret(b"1" * 32),
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
sandbox_files_base_url="https://files.example.com",
|
||||
)
|
||||
token_codec = settings.create_agent_stub_token_codec()
|
||||
assert token_codec is not None
|
||||
@@ -72,9 +73,9 @@ def test_create_agent_stub_app_wires_configured_file_handler_for_upload_requests
|
||||
original_async_client = httpx.AsyncClient
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert str(request.url) == "https://api.example.com/inner/api/upload/file/request"
|
||||
assert str(request.url) == "https://api.example.com/inner/api/agent/files/upload-request"
|
||||
assert request.headers["X-Inner-Api-Key"] == "inner-secret"
|
||||
return httpx.Response(200, json={"data": {"url": "https://files.example.com/upload"}})
|
||||
return httpx.Response(200, json={"upload_uri": "/files/upload/for-plugin?sign=1"})
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.server.agent_stub_files.httpx.AsyncClient",
|
||||
@@ -89,7 +90,7 @@ def test_create_agent_stub_app_wires_configured_file_handler_for_upload_requests
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"upload_url": "https://files.example.com/upload"}
|
||||
assert response.json() == {"upload_url": "https://files.example.com/files/upload/for-plugin?sign=1"}
|
||||
|
||||
|
||||
def test_create_agent_stub_app_wires_configured_drive_handler_for_manifest_requests(monkeypatch) -> None:
|
||||
@@ -98,6 +99,7 @@ def test_create_agent_stub_app_wires_configured_drive_handler_for_manifest_reque
|
||||
server_secret_key=_base64url_secret(b"1" * 32),
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
sandbox_files_base_url="https://files.example.com",
|
||||
)
|
||||
token_codec = settings.create_agent_stub_token_codec()
|
||||
assert token_codec is not None
|
||||
|
||||
@@ -5,6 +5,7 @@ import base64
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubFileDownloadRequest,
|
||||
@@ -33,7 +34,7 @@ def _principal() -> AgentStubPrincipal:
|
||||
)
|
||||
|
||||
|
||||
def _patch_async_client(monkeypatch, handler) -> None:
|
||||
def _patch_async_client(monkeypatch: pytest.MonkeyPatch, handler) -> None:
|
||||
original_async_client = httpx.AsyncClient
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.server.agent_stub_files.httpx.AsyncClient",
|
||||
@@ -41,14 +42,22 @@ def _patch_async_client(monkeypatch, handler) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _file_handler(*, sandbox_files_base_url: str = "https://sandbox-files.example.com/dify"):
|
||||
return DifyApiAgentStubFileRequestHandler(
|
||||
inner_api_url="https://api.internal.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
sandbox_files_base_url=sandbox_files_base_url,
|
||||
)
|
||||
|
||||
|
||||
def _reference(record_id: str) -> str:
|
||||
payload = base64.urlsafe_b64encode(json.dumps({"record_id": record_id}, separators=(",", ":")).encode()).decode()
|
||||
return f"dify-file-ref:{payload}"
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_injects_execution_context_for_upload(monkeypatch) -> None:
|
||||
def test_upload_request_uses_agent_inner_endpoint_and_binds_sandbox_base(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert str(request.url) == "https://api.example.com/inner/api/upload/file/request"
|
||||
assert str(request.url) == "https://api.internal.example.com/inner/api/agent/files/upload-request"
|
||||
assert request.headers["X-Inner-Api-Key"] == "inner-secret"
|
||||
assert json.loads(request.content) == {
|
||||
"tenant_id": "tenant-1",
|
||||
@@ -57,240 +66,220 @@ def test_dify_api_agent_stub_file_handler_injects_execution_context_for_upload(m
|
||||
"mimetype": "application/pdf",
|
||||
"conversation_id": "conversation-1",
|
||||
}
|
||||
return httpx.Response(200, json={"data": {"url": "https://files.example.com/upload"}})
|
||||
return httpx.Response(200, json={"upload_uri": "/files/upload/for-plugin?signed=yes"})
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
response = await file_handler.create_upload_request(
|
||||
response = await _file_handler().create_upload_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileUploadRequest(filename="report.pdf", mimetype="application/pdf"),
|
||||
)
|
||||
assert response.upload_url == "https://files.example.com/upload"
|
||||
assert response.upload_url == "https://sandbox-files.example.com/dify/files/upload/for-plugin?signed=yes"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_resolves_relative_upload_url(monkeypatch) -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"data": {"url": "/files/upload/for-plugin?signed=yes"}})
|
||||
def test_sandbox_download_request_binds_origin_free_uri(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
reference = _reference("tool-file-1")
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
response = await file_handler.create_upload_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileUploadRequest(filename="report.pdf", mimetype="application/pdf"),
|
||||
)
|
||||
assert response.upload_url == "https://api.example.com/files/upload/for-plugin?signed=yes"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_injects_execution_context_for_download(monkeypatch) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert str(request.url) == "https://api.example.com/inner/api/download/file/request"
|
||||
assert str(request.url) == "https://api.internal.example.com/inner/api/agent/files/download-request"
|
||||
assert json.loads(request.content) == {
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"user_from": "account",
|
||||
"invoke_from": "service-api",
|
||||
"file": {"transfer_method": "tool_file", "reference": _reference("tool-file-1")},
|
||||
"file": {"transfer_method": "tool_file", "reference": reference},
|
||||
"for_frontend": False,
|
||||
}
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"data": {
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 123,
|
||||
"download_url": "https://files.example.com/download",
|
||||
}
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 123,
|
||||
"download_uri": "/files/tools/tool-file-1.pdf?sign=1",
|
||||
},
|
||||
)
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
response = await file_handler.create_download_request(
|
||||
response = await _file_handler().create_download_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileDownloadRequest(
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1"))
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=reference),
|
||||
for_frontend=False,
|
||||
),
|
||||
)
|
||||
assert response.download_url == "https://files.example.com/download"
|
||||
assert response.download_url == "https://sandbox-files.example.com/dify/files/tools/tool-file-1.pdf?sign=1"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_forwards_internal_download_audience(monkeypatch) -> None:
|
||||
@pytest.mark.parametrize(
|
||||
"download_uri",
|
||||
[
|
||||
"https://dify.example.com/files/tools/tool-file-1.pdf?sign=1",
|
||||
"/files/tools/tool-file-1.pdf?sign=1",
|
||||
],
|
||||
)
|
||||
def test_frontend_download_request_preserves_public_or_relative_uri(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
download_uri: str,
|
||||
) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert str(request.url) == "https://api.example.com/inner/api/download/file/request"
|
||||
assert json.loads(request.content)["for_external"] is False
|
||||
assert json.loads(request.content)["for_frontend"] is True
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"data": {
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 123,
|
||||
"download_url": "http://internal-files/report.pdf",
|
||||
}
|
||||
},
|
||||
json={"filename": "report.pdf", "mime_type": "application/pdf", "size": 123, "download_uri": download_uri},
|
||||
)
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
response = await file_handler.create_download_request(
|
||||
response = await _file_handler().create_download_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileDownloadRequest(
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1")),
|
||||
for_external=False,
|
||||
for_frontend=True,
|
||||
),
|
||||
)
|
||||
assert response.download_url == "http://internal-files/report.pdf"
|
||||
assert response.download_url == download_uri
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_rejects_missing_user_id() -> None:
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
def test_remote_download_url_is_never_rewritten(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
remote_url = "https://remote.example.com/report.pdf"
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"filename": "report.pdf", "mime_type": "application/pdf", "size": 123, "download_uri": remote_url},
|
||||
)
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
|
||||
async def scenario() -> None:
|
||||
response = await _file_handler().create_download_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileDownloadRequest(
|
||||
file=AgentStubFileMapping(transfer_method="remote_url", url=remote_url),
|
||||
for_frontend=False,
|
||||
),
|
||||
)
|
||||
assert response.download_url == remote_url
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_remote_download_rejects_relative_uri_for_frontend_audience(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"filename": "report.pdf", "mime_type": "application/pdf", "size": 123, "download_uri": "/files/x"},
|
||||
)
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(AgentStubFileRequestError, match="invalid remote download URL"):
|
||||
await _file_handler().create_download_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileDownloadRequest(
|
||||
file=AgentStubFileMapping(
|
||||
transfer_method="remote_url", url="https://remote.example.com/report.pdf"
|
||||
),
|
||||
for_frontend=True,
|
||||
),
|
||||
)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unsafe_uri",
|
||||
[
|
||||
"//attacker.example/files/x",
|
||||
"/files/../admin",
|
||||
"/files/%252e%252e/admin",
|
||||
"http://api:5001/files/tools/x",
|
||||
"/not-files/x",
|
||||
],
|
||||
)
|
||||
def test_sandbox_download_rejects_unsafe_dify_file_uri(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
unsafe_uri: str,
|
||||
) -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"filename": "x", "mime_type": None, "size": 1, "download_uri": unsafe_uri},
|
||||
)
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(AgentStubFileRequestError, match="unsafe Dify file URI"):
|
||||
await _file_handler().create_download_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileDownloadRequest(
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1")),
|
||||
for_frontend=False,
|
||||
),
|
||||
)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_handler_rejects_missing_execution_user_before_network() -> None:
|
||||
principal = _principal()
|
||||
principal.execution_context = principal.execution_context.model_copy(update={"user_id": None})
|
||||
|
||||
async def scenario() -> None:
|
||||
try:
|
||||
await file_handler.create_upload_request(
|
||||
with pytest.raises(AgentStubFileRequestError, match="user_id"):
|
||||
await _file_handler().create_upload_request(
|
||||
principal=principal,
|
||||
request=AgentStubFileUploadRequest(filename="report.pdf", mimetype="application/pdf"),
|
||||
)
|
||||
except AgentStubFileRequestError as exc:
|
||||
assert "user_id" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected AgentStubFileRequestError")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_maps_non_2xx_response(monkeypatch) -> None:
|
||||
def test_handler_preserves_inner_api_error_status(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(403, json={"detail": "forbidden"})
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
try:
|
||||
await file_handler.create_upload_request(
|
||||
with pytest.raises(AgentStubFileRequestError) as exc_info:
|
||||
await _file_handler().create_upload_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileUploadRequest(filename="report.pdf", mimetype="application/pdf"),
|
||||
)
|
||||
except AgentStubFileRequestError as exc:
|
||||
assert exc.status_code == 403
|
||||
assert exc.detail == "forbidden"
|
||||
else:
|
||||
raise AssertionError("expected AgentStubFileRequestError")
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.detail == "forbidden"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_maps_error_envelope(monkeypatch) -> None:
|
||||
def test_handler_rejects_missing_download_uri(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"error": "bad request"})
|
||||
return httpx.Response(200, json={"filename": "report.pdf", "size": 1})
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
try:
|
||||
await file_handler.create_download_request(
|
||||
with pytest.raises(AgentStubFileRequestError, match="missing download_uri"):
|
||||
await _file_handler().create_download_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileDownloadRequest(
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1"))
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1")),
|
||||
for_frontend=False,
|
||||
),
|
||||
)
|
||||
except AgentStubFileRequestError as exc:
|
||||
assert exc.status_code == 400
|
||||
assert exc.detail == "bad request"
|
||||
else:
|
||||
raise AssertionError("expected AgentStubFileRequestError")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_rejects_upload_response_missing_url(monkeypatch) -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"data": {}})
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
try:
|
||||
await file_handler.create_upload_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileUploadRequest(filename="report.pdf", mimetype="application/pdf"),
|
||||
)
|
||||
except AgentStubFileRequestError as exc:
|
||||
assert exc.status_code == 502
|
||||
assert exc.detail == "Dify API upload request response is missing url"
|
||||
else:
|
||||
raise AssertionError("expected AgentStubFileRequestError")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_rejects_invalid_download_response_schema(monkeypatch) -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"data": {"filename": "report.pdf"}})
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
try:
|
||||
await file_handler.create_download_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileDownloadRequest(
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1"))
|
||||
),
|
||||
)
|
||||
except AgentStubFileRequestError as exc:
|
||||
assert exc.status_code == 502
|
||||
assert exc.detail == "Dify API download request response is invalid"
|
||||
else:
|
||||
raise AssertionError("expected AgentStubFileRequestError")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@@ -130,7 +130,7 @@ def test_agent_stub_grpc_transport_delegates_file_download_requests() -> None:
|
||||
async def create_download_request(self, *, principal, request):
|
||||
assert principal.execution_context.user_id == "user-1"
|
||||
assert request.file.reference == _reference("tool-file-1")
|
||||
assert request.for_external is False
|
||||
assert request.for_frontend is False
|
||||
return type(
|
||||
"Response",
|
||||
(),
|
||||
@@ -160,7 +160,7 @@ def test_agent_stub_grpc_transport_delegates_file_download_requests() -> None:
|
||||
transfer_method="tool_file",
|
||||
reference=_reference("tool-file-1"),
|
||||
),
|
||||
for_external=False,
|
||||
for_frontend=False,
|
||||
),
|
||||
metadata=(("authorization", f"Bearer {token}"),),
|
||||
)
|
||||
|
||||
@@ -192,6 +192,7 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
|
||||
plugin_daemon_api_key="daemon-secret",
|
||||
inner_api_url="http://dify-api",
|
||||
inner_api_key="inner-secret",
|
||||
sandbox_files_base_url="http://api:5001",
|
||||
local_sandbox_endpoint="http://shellctl",
|
||||
local_sandbox_auth_token="shell-secret",
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
@@ -230,7 +231,9 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
|
||||
assert execution_context_layer.daemon_api_key == "daemon-secret"
|
||||
assert shell_layer.agent_stub_token_factory is not None
|
||||
token = shell_layer.agent_stub_token_factory(_execution_context(), session_id="abc12ff")
|
||||
decoded = settings.create_agent_stub_token_codec().decode_token(token)
|
||||
token_codec = settings.create_agent_stub_token_codec()
|
||||
assert token_codec is not None
|
||||
decoded = token_codec.decode_token(token)
|
||||
assert decoded.execution_context == _execution_context()
|
||||
assert decoded.session_id == "abc12ff"
|
||||
knowledge_provider = next(provider for provider in layer_providers if provider.type_id == "dify.knowledge_base")
|
||||
@@ -318,6 +321,7 @@ def test_create_app_wires_authenticated_agent_stub_file_upload_route(monkeypatch
|
||||
server_secret_key=_base64url_secret(b"1" * 32),
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
sandbox_files_base_url="https://files.example.com",
|
||||
)
|
||||
token_codec = settings.create_agent_stub_token_codec()
|
||||
assert token_codec is not None
|
||||
@@ -326,9 +330,9 @@ def test_create_app_wires_authenticated_agent_stub_file_upload_route(monkeypatch
|
||||
original_async_client = httpx.AsyncClient
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert str(request.url) == "https://api.example.com/inner/api/upload/file/request"
|
||||
assert str(request.url) == "https://api.example.com/inner/api/agent/files/upload-request"
|
||||
assert request.headers["X-Inner-Api-Key"] == "inner-secret"
|
||||
return httpx.Response(200, json={"data": {"url": "https://files.example.com/upload"}})
|
||||
return httpx.Response(200, json={"upload_uri": "/files/upload/for-plugin?sign=1"})
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.server.agent_stub_files.httpx.AsyncClient",
|
||||
@@ -343,7 +347,7 @@ def test_create_app_wires_authenticated_agent_stub_file_upload_route(monkeypatch
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"upload_url": "https://files.example.com/upload"}
|
||||
assert response.json() == {"upload_url": "https://files.example.com/files/upload/for-plugin?sign=1"}
|
||||
assert FakeRunScheduler.created[0].shutdown_called is True
|
||||
assert fake_http_client.is_closed is True
|
||||
assert fake_redis.closed is True
|
||||
@@ -357,6 +361,7 @@ def test_create_app_wires_authenticated_agent_stub_drive_manifest_route(monkeypa
|
||||
server_secret_key=_base64url_secret(b"1" * 32),
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
sandbox_files_base_url="https://files.example.com",
|
||||
)
|
||||
token_codec = settings.create_agent_stub_token_codec()
|
||||
assert token_codec is not None
|
||||
|
||||
@@ -71,11 +71,13 @@ def test_server_settings_defaults_shellctl_auth_token_to_none(
|
||||
|
||||
def test_server_settings_reads_agent_stub_settings_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub/")
|
||||
monkeypatch.setenv("DIFY_AGENT_SANDBOX_FILES_BASE_URL", "https://dify.example.com/prefix/")
|
||||
monkeypatch.setenv("DIFY_AGENT_SERVER_SECRET_KEY", _base64url_secret(secrets.token_bytes(32)))
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
assert settings.agent_stub_api_base_url == "https://agent.example.com/agent-stub"
|
||||
assert settings.sandbox_files_base_url == "https://dify.example.com/prefix"
|
||||
|
||||
|
||||
def test_server_settings_normalizes_agent_stub_service_root_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -125,6 +127,23 @@ def test_server_settings_rejects_public_agent_stub_api_base_url_without_secret_k
|
||||
_ = ServerSettings(agent_stub_api_base_url="https://agent.example.com/agent-stub")
|
||||
|
||||
|
||||
def test_server_settings_requires_sandbox_files_base_url_for_agent_stub_file_operations() -> None:
|
||||
with pytest.raises(ValidationError, match="DIFY_AGENT_SANDBOX_FILES_BASE_URL"):
|
||||
_ = ServerSettings(
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
inner_api_key="inner-secret",
|
||||
server_secret_key=_base64url_secret(secrets.token_bytes(32)),
|
||||
)
|
||||
|
||||
|
||||
def test_server_settings_rejects_sandbox_files_base_url_query_or_fragment() -> None:
|
||||
with pytest.raises(ValidationError, match="query string or fragment"):
|
||||
_ = ServerSettings(sandbox_files_base_url="https://dify.example.com?x=1")
|
||||
|
||||
with pytest.raises(ValidationError, match="query string or fragment"):
|
||||
_ = ServerSettings(sandbox_files_base_url="https://dify.example.com#fragment")
|
||||
|
||||
|
||||
def test_server_settings_accepts_grpc_agent_stub_api_base_url_and_bind_override() -> None:
|
||||
settings = ServerSettings(
|
||||
agent_stub_api_base_url="grpc://agent.example.com:9091",
|
||||
@@ -210,6 +229,7 @@ def test_server_settings_create_agent_stub_file_request_handler_returns_handler_
|
||||
settings = ServerSettings(
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
sandbox_files_base_url="https://sandbox-files.example.com/dify",
|
||||
)
|
||||
|
||||
handler = settings.create_agent_stub_file_request_handler()
|
||||
@@ -217,6 +237,7 @@ def test_server_settings_create_agent_stub_file_request_handler_returns_handler_
|
||||
assert isinstance(handler, DifyApiAgentStubFileRequestHandler)
|
||||
assert handler.inner_api_url == "https://api.example.com"
|
||||
assert handler.inner_api_key == "inner-secret"
|
||||
assert handler.sandbox_files_base_url == "https://sandbox-files.example.com/dify"
|
||||
|
||||
|
||||
def test_server_settings_create_agent_stub_drive_request_handler_returns_none_without_full_settings() -> None:
|
||||
|
||||
@@ -283,6 +283,9 @@ DIFY_AGENT_E2B_TEMPLATE=difys-default-team/dify-agent-local-sandbox
|
||||
DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS=3600
|
||||
DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN=
|
||||
DIFY_AGENT_E2B_SHELLCTL_PORT=5004
|
||||
# Sandbox-reachable Dify API base for dify-agent CLI /files/* transfers.
|
||||
# Remote Sandboxes should use the public Dify ingress; local Compose uses api via agent_ssrf_proxy.
|
||||
DIFY_AGENT_SANDBOX_FILES_BASE_URL=http://api:5001
|
||||
DIFY_AGENT_STUB_API_BASE_URL=http://agent_backend:5050/agent-stub
|
||||
# This is security-sensitive: it derives the JWE encryption key for Agent Stub bearer tokens.
|
||||
# Replace this development default in production.
|
||||
|
||||
@@ -681,6 +681,7 @@ services:
|
||||
DIFY_AGENT_E2B_SHELLCTL_PORT: ${DIFY_AGENT_E2B_SHELLCTL_PORT:-5004}
|
||||
DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES: ${PLUGIN_MAX_FILE_SIZE:-52428800}
|
||||
DIFY_AGENT_STUB_API_BASE_URL: ${DIFY_AGENT_STUB_API_BASE_URL:-http://agent_backend:5050/agent-stub}
|
||||
DIFY_AGENT_SANDBOX_FILES_BASE_URL: ${DIFY_AGENT_SANDBOX_FILES_BASE_URL:-http://api:5001}
|
||||
# This is security-sensitive: it derives the JWE encryption key for Agent Stub bearer tokens.
|
||||
# Replace this development default in production.
|
||||
# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
|
||||
|
||||
@@ -687,6 +687,7 @@ services:
|
||||
DIFY_AGENT_E2B_SHELLCTL_PORT: ${DIFY_AGENT_E2B_SHELLCTL_PORT:-5004}
|
||||
DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES: ${PLUGIN_MAX_FILE_SIZE:-52428800}
|
||||
DIFY_AGENT_STUB_API_BASE_URL: ${DIFY_AGENT_STUB_API_BASE_URL:-http://agent_backend:5050/agent-stub}
|
||||
DIFY_AGENT_SANDBOX_FILES_BASE_URL: ${DIFY_AGENT_SANDBOX_FILES_BASE_URL:-http://api:5001}
|
||||
# This is security-sensitive: it derives the JWE encryption key for Agent Stub bearer tokens.
|
||||
# Replace this development default in production.
|
||||
# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
|
||||
|
||||
@@ -35,6 +35,8 @@ DIFY_AGENT_E2B_SHELLCTL_PORT=5004
|
||||
# Standalone/direct-container byte limit. Full Docker Compose derives this from
|
||||
# PLUGIN_MAX_FILE_SIZE in docker/.env.
|
||||
DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES=52428800
|
||||
# Sandbox-reachable Dify API base for signed /files/* transfers.
|
||||
DIFY_AGENT_SANDBOX_FILES_BASE_URL=http://api:5001
|
||||
DIFY_AGENT_STUB_API_BASE_URL=http://agent_backend:5050/agent-stub
|
||||
# This is security-sensitive: it derives the JWE encryption key for Agent Stub bearer tokens.
|
||||
# Replace this development default in production.
|
||||
|
||||
@@ -39,6 +39,24 @@ http_code_for() {
|
||||
printf '%s\n' "$output" | awk '$1 ~ /^HTTP\// { code = $2 } END { print code }'
|
||||
}
|
||||
|
||||
http_code_for_post() {
|
||||
local proxy_url="$1"
|
||||
local target_url="$2"
|
||||
local output
|
||||
|
||||
output="$(
|
||||
docker run \
|
||||
--rm \
|
||||
--network "$NETWORK_NAME" \
|
||||
--env "http_proxy=$proxy_url" \
|
||||
--env "https_proxy=$proxy_url" \
|
||||
"$CLIENT_IMAGE" \
|
||||
wget -S -O /dev/null -T 10 --post-data=file-bytes "$target_url" 2>&1 || true
|
||||
)"
|
||||
|
||||
printf '%s\n' "$output" | awk '$1 ~ /^HTTP\// { code = $2 } END { print code }'
|
||||
}
|
||||
|
||||
direct_http_code_for() {
|
||||
local target_url="$1"
|
||||
local output
|
||||
@@ -80,6 +98,19 @@ assert_public_target_allowed() {
|
||||
fi
|
||||
}
|
||||
|
||||
assert_post_target_not_blocked() {
|
||||
local proxy_url="$1"
|
||||
local target_url="$2"
|
||||
local status_code
|
||||
|
||||
status_code="$(http_code_for_post "$proxy_url" "$target_url")"
|
||||
if [[ -z "$status_code" || "$status_code" == "403" ]]; then
|
||||
echo "Expected POST $target_url to pass the proxy ACL, got ${status_code:-no response}."
|
||||
docker logs "$AGENT_PROXY_CONTAINER_NAME" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_sandbox_bridge_allowed() {
|
||||
local target_url="$1"
|
||||
local status_code
|
||||
@@ -214,6 +245,8 @@ assert_private_target_blocked "$agent_proxy_url" "http://agent_backend:5050/inde
|
||||
|
||||
# api /files/* must be allowed.
|
||||
assert_public_target_allowed "$agent_proxy_url" "http://api:5001/files/test"
|
||||
assert_public_target_allowed "$agent_proxy_url" "http://api:5001/files/test?timestamp=1&nonce=2&sign=3"
|
||||
assert_post_target_not_blocked "$agent_proxy_url" "http://api:5001/files/upload/for-plugin?timestamp=1&nonce=2&sign=3"
|
||||
|
||||
# api non-/files paths must be blocked.
|
||||
assert_private_target_blocked "$agent_proxy_url" "http://api:5001/index.html"
|
||||
|
||||
Reference in New Issue
Block a user