fix(agent): use internal URLs for stub file downloads

This commit is contained in:
盐粒 Yanli
2026-07-09 15:12:54 +08:00
parent 5bed2666bb
commit c192a3c16e
24 changed files with 323 additions and 87 deletions
+4 -1
View File
@@ -363,7 +363,10 @@ class FileAccessConfig(BaseSettings):
INTERNAL_FILES_URL: str = Field(
description="Internal base URL for file access within Docker network,"
" used for plugin daemon and internal service communication."
" Falls back to FILES_URL if not specified.",
" Explicit INTERNAL_FILES_URL takes precedence; otherwise SERVER_CONSOLE_API_URL is used,"
" then FILES_URL.",
validation_alias=AliasChoices("INTERNAL_FILES_URL", "SERVER_CONSOLE_API_URL"),
alias_priority=1,
default="",
)
@@ -476,6 +476,7 @@ class PluginDownloadFileRequestApi(Resource):
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,
)
return BaseBackwardsInvocationResponse(
data={
+1
View File
@@ -276,6 +276,7 @@ class RequestRequestDownloadFile(BaseModel):
"validation",
]
file: RequestDownloadFileMapping
for_external: bool = True
model_config = ConfigDict(extra="forbid")
+2 -1
View File
@@ -45,6 +45,7 @@ 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.
@@ -61,7 +62,7 @@ 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=True)
download_url = file_helpers.resolve_file_url(file, for_external=for_external)
if not download_url:
raise ValueError("file does not support signed download")
@@ -111,6 +111,25 @@ def test_http_timeout_defaults(monkeypatch: pytest.MonkeyPatch):
assert config.HTTP_REQUEST_MAX_WRITE_TIMEOUT == 600
def test_internal_files_url_falls_back_to_server_console_api_url(monkeypatch: pytest.MonkeyPatch):
os.environ.clear()
monkeypatch.setenv("SERVER_CONSOLE_API_URL", "http://api:5001")
config = DifyConfig(_env_file=None)
assert config.INTERNAL_FILES_URL == "http://api:5001"
def test_internal_files_url_prefers_explicit_value(monkeypatch: pytest.MonkeyPatch):
os.environ.clear()
monkeypatch.setenv("INTERNAL_FILES_URL", "http://files-internal:5001")
monkeypatch.setenv("SERVER_CONSOLE_API_URL", "http://api:5001")
config = DifyConfig(_env_file=None)
assert config.INTERNAL_FILES_URL == "http://files-internal:5001"
# 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):
@@ -318,6 +318,7 @@ class TestPluginDownloadFileRequestApi:
mock_payload.user_id = "user-id"
mock_payload.user_from = "account"
mock_payload.invoke_from = "debugger"
mock_payload.for_external = False
reference = build_file_reference(record_id="tool-file-1")
mock_payload.file.model_dump.return_value = {
"transfer_method": "tool_file",
@@ -333,6 +334,7 @@ class TestPluginDownloadFileRequestApi:
user_from="account",
invoke_from="debugger",
file_mapping={"transfer_method": "tool_file", "reference": reference},
for_external=False,
)
assert result["data"] == {
"filename": "report.pdf",
@@ -22,6 +22,7 @@ def test_request_download_file_accepts_tool_file_reference() -> None:
assert payload.file.transfer_method == "tool_file"
assert payload.file.reference == reference
assert payload.for_external is True
def test_request_download_file_accepts_remote_url() -> None:
@@ -42,6 +43,25 @@ def test_request_download_file_accepts_remote_url() -> None:
assert payload.file.url == "https://example.com/report.pdf"
def test_request_download_file_accepts_internal_download_request() -> None:
reference = build_file_reference(record_id="tool-file-1")
payload = RequestRequestDownloadFile.model_validate(
{
"tenant_id": "tenant-1",
"user_id": "user-1",
"user_from": "account",
"invoke_from": "debugger",
"file": {
"transfer_method": "tool_file",
"reference": reference,
},
"for_external": False,
}
)
assert payload.for_external is False
def test_request_download_file_rejects_remote_url_without_url() -> None:
with pytest.raises(ValidationError, match="url is required"):
_ = RequestRequestDownloadFile.model_validate(
@@ -59,6 +59,31 @@ def test_request_download_url_builds_file_under_bound_scope(
assert result.download_url == "https://files.example.com/x"
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:
service = FileRequestService(access_controller=MagicMock())
@@ -36,6 +36,7 @@ message FileMapping {
message FileDownloadRequest {
FileMapping file = 1;
optional bool for_external = 2;
}
message FileDownloadResponse {
@@ -143,6 +143,7 @@ def download_file_from_environment(
url=environment.url,
auth_jwe=environment.auth_jwe,
file=file_mapping,
for_external=False,
)
if not hasattr(download_request, "filename") or not isinstance(download_request.filename, str):
raise AgentStubTransferError("signed file download response is missing filename")
@@ -207,8 +208,10 @@ def _request_uploaded_tool_file_download_url(*, url: str, auth_jwe: str, referen
file=AgentStubFileMapping(transfer_method="tool_file", reference=reference),
),
)
if not hasattr(download_request, "download_url") or not isinstance(download_request.download_url, str):
raise AgentStubTransferError("signed file download response is missing download_url")
download_url = download_request.download_url
if not isinstance(download_url, str) or not download_url:
if not download_url:
raise AgentStubTransferError("signed file download response is missing download_url")
return download_url
@@ -97,6 +97,7 @@ def request_agent_stub_file_download_sync(
url: str,
auth_jwe: str,
file: AgentStubFileMapping,
for_external: bool = True,
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
):
@@ -109,12 +110,14 @@ def request_agent_stub_file_download_sync(
url=endpoint.url,
auth_jwe=auth_jwe,
file=file,
for_external=for_external,
timeout=timeout,
)
return request_agent_stub_file_download_http_sync(
base_url=endpoint.url,
auth_jwe=auth_jwe,
file=file,
for_external=for_external,
timeout=timeout,
sync_http_client=sync_http_client,
)
@@ -124,6 +124,7 @@ def request_agent_stub_file_download_grpc_sync(
url: str,
auth_jwe: str,
file: AgentStubFileMapping,
for_external: bool = True,
timeout: float | httpx.Timeout = 30.0,
):
"""Request one signed download URL through the gRPC Agent Stub endpoint.
@@ -144,7 +145,7 @@ def request_agent_stub_file_download_grpc_sync(
auth_jwe=auth_jwe,
method_name="CreateFileDownloadRequest",
request_factory=lambda runtime: _require_conversions().proto_file_download_request(
runtime.agent_stub_pb2, file=file
runtime.agent_stub_pb2, file=file, for_external=for_external
),
response_parser=lambda response: _require_conversions().file_download_response_from_proto(response),
timeout=timeout,
@@ -117,13 +117,14 @@ def request_agent_stub_file_download_http_sync(
base_url: str,
auth_jwe: str,
file: AgentStubFileMapping,
for_external: bool = True,
timeout: float | httpx.Timeout = 30.0,
sync_http_client: httpx.Client | None = None,
) -> AgentStubFileDownloadResponse:
"""Request one signed download URL from the HTTP Agent Stub endpoint."""
try:
request_model = AgentStubFileDownloadRequest(file=file)
request_model = AgentStubFileDownloadRequest(file=file, for_external=for_external)
except ValidationError as exc:
raise AgentStubValidationError("invalid Agent Stub file download request") from exc
response = _post_agent_stub_json(
@@ -131,7 +132,7 @@ def request_agent_stub_file_download_http_sync(
auth_jwe=auth_jwe,
endpoint_name="file download request",
endpoint_url_factory=agent_stub_file_download_request_url,
request_body=request_model.model_dump_json(exclude_none=True),
request_body=request_model.model_dump_json(exclude_none=True, exclude_defaults=True),
timeout=timeout,
sync_http_client=sync_http_client,
)
@@ -1,52 +1,50 @@
# pyright: reportAttributeAccessIssue=false
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: dify/agent/stub/v1/agent_stub.proto
# Protobuf Python Version: 6.33.5
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
33,
5,
"",
"dify/agent/stub/v1/agent_stub.proto",
'',
'dify/agent/stub/v1/agent_stub.proto'
)
# @@protoc_insertion_point(imports)
_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"D\n\x13\x46ileDownloadRequest\x12-\n\x04\x66ile\x18\x01 \x01(\x0b\x32\x1f.dify.agent.stub.v1.FileMapping"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'
)
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')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "dify_agent.agent_stub.grpc._generated.agent_stub_pb2", _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'dify.agent.stub.v1.agent_stub_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals["_CONNECTREQUEST"]._serialized_start = 59
_globals["_CONNECTREQUEST"]._serialized_end = 138
_globals["_CONNECTRESPONSE"]._serialized_start = 140
_globals["_CONNECTRESPONSE"]._serialized_end = 196
_globals["_FILEUPLOADREQUEST"]._serialized_start = 198
_globals["_FILEUPLOADREQUEST"]._serialized_end = 253
_globals["_FILEUPLOADRESPONSE"]._serialized_start = 255
_globals["_FILEUPLOADRESPONSE"]._serialized_end = 295
_globals["_FILEMAPPING"]._serialized_start = 297
_globals["_FILEMAPPING"]._serialized_end = 399
_globals["_FILEDOWNLOADREQUEST"]._serialized_start = 401
_globals["_FILEDOWNLOADREQUEST"]._serialized_end = 469
_globals["_FILEDOWNLOADRESPONSE"]._serialized_start = 471
_globals["_FILEDOWNLOADRESPONSE"]._serialized_end = 585
_globals["_AGENTSTUBSERVICE"]._serialized_start = 588
_globals["_AGENTSTUBSERVICE"]._serialized_end = 908
DESCRIPTOR._loaded_options = None
_globals['_CONNECTREQUEST']._serialized_start=59
_globals['_CONNECTREQUEST']._serialized_end=138
_globals['_CONNECTRESPONSE']._serialized_start=140
_globals['_CONNECTRESPONSE']._serialized_end=196
_globals['_FILEUPLOADREQUEST']._serialized_start=198
_globals['_FILEUPLOADREQUEST']._serialized_end=253
_globals['_FILEUPLOADRESPONSE']._serialized_start=255
_globals['_FILEUPLOADRESPONSE']._serialized_end=295
_globals['_FILEMAPPING']._serialized_start=297
_globals['_FILEMAPPING']._serialized_end=399
_globals['_FILEDOWNLOADREQUEST']._serialized_start=401
_globals['_FILEDOWNLOADREQUEST']._serialized_end=513
_globals['_FILEDOWNLOADRESPONSE']._serialized_start=515
_globals['_FILEDOWNLOADRESPONSE']._serialized_end=629
_globals['_AGENTSTUBSERVICE']._serialized_start=632
_globals['_AGENTSTUBSERVICE']._serialized_end=952
# @@protoc_insertion_point(module_scope)
@@ -1,71 +1,69 @@
from __future__ import annotations
from google.protobuf.internal import containers as _containers
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
from collections.abc import Iterable
DESCRIPTOR: _descriptor.FileDescriptor
from google.protobuf.message import Message
class ConnectRequest(Message):
class ConnectRequest(_message.Message):
__slots__ = ("protocol_version", "argv", "metadata_json")
PROTOCOL_VERSION_FIELD_NUMBER: _ClassVar[int]
ARGV_FIELD_NUMBER: _ClassVar[int]
METADATA_JSON_FIELD_NUMBER: _ClassVar[int]
protocol_version: int
argv: list[str]
argv: _containers.RepeatedScalarFieldContainer[str]
metadata_json: str
def __init__(self, protocol_version: _Optional[int] = ..., argv: _Optional[_Iterable[str]] = ..., metadata_json: _Optional[str] = ...) -> None: ...
def __init__(
self,
*,
protocol_version: int = ...,
argv: Iterable[str] = ...,
metadata_json: str = ...,
) -> None: ...
class ConnectResponse(Message):
class ConnectResponse(_message.Message):
__slots__ = ("connection_id", "status")
CONNECTION_ID_FIELD_NUMBER: _ClassVar[int]
STATUS_FIELD_NUMBER: _ClassVar[int]
connection_id: str
status: str
def __init__(self, connection_id: _Optional[str] = ..., status: _Optional[str] = ...) -> None: ...
def __init__(self, *, connection_id: str = ..., status: str = ...) -> None: ...
class FileUploadRequest(Message):
class FileUploadRequest(_message.Message):
__slots__ = ("filename", "mimetype")
FILENAME_FIELD_NUMBER: _ClassVar[int]
MIMETYPE_FIELD_NUMBER: _ClassVar[int]
filename: str
mimetype: str
def __init__(self, filename: _Optional[str] = ..., mimetype: _Optional[str] = ...) -> None: ...
def __init__(self, *, filename: str = ..., mimetype: str = ...) -> None: ...
class FileUploadResponse(Message):
class FileUploadResponse(_message.Message):
__slots__ = ("upload_url",)
UPLOAD_URL_FIELD_NUMBER: _ClassVar[int]
upload_url: str
def __init__(self, upload_url: _Optional[str] = ...) -> None: ...
def __init__(self, *, upload_url: str = ...) -> None: ...
class FileMapping(Message):
class FileMapping(_message.Message):
__slots__ = ("transfer_method", "reference", "url")
TRANSFER_METHOD_FIELD_NUMBER: _ClassVar[int]
REFERENCE_FIELD_NUMBER: _ClassVar[int]
URL_FIELD_NUMBER: _ClassVar[int]
transfer_method: str
reference: str
url: str
def __init__(self, transfer_method: _Optional[str] = ..., reference: _Optional[str] = ..., url: _Optional[str] = ...) -> None: ...
def __init__(self, *, transfer_method: str = ..., reference: str = ..., url: str = ...) -> None: ...
def HasField(self, field_name: str) -> bool: ...
class FileDownloadRequest(Message):
class FileDownloadRequest(_message.Message):
__slots__ = ("file", "for_external")
FILE_FIELD_NUMBER: _ClassVar[int]
FOR_EXTERNAL_FIELD_NUMBER: _ClassVar[int]
file: FileMapping
for_external: bool
def __init__(self, file: _Optional[_Union[FileMapping, _Mapping]] = ..., for_external: _Optional[bool] = ...) -> None: ...
def __init__(self, *, file: FileMapping | None = ...) -> None: ...
class FileDownloadResponse(Message):
class FileDownloadResponse(_message.Message):
__slots__ = ("filename", "mime_type", "size", "download_url")
FILENAME_FIELD_NUMBER: _ClassVar[int]
MIME_TYPE_FIELD_NUMBER: _ClassVar[int]
SIZE_FIELD_NUMBER: _ClassVar[int]
DOWNLOAD_URL_FIELD_NUMBER: _ClassVar[int]
filename: str
mime_type: str
size: int
download_url: str
def __init__(
self,
*,
filename: str = ...,
mime_type: str = ...,
size: int = ...,
download_url: str = ...,
) -> None: ...
def HasField(self, field_name: str) -> bool: ...
def __init__(self, filename: _Optional[str] = ..., mime_type: _Optional[str] = ..., size: _Optional[int] = ..., download_url: _Optional[str] = ...) -> None: ...
@@ -98,13 +98,19 @@ def file_download_request_from_proto(message: agent_stub_pb2.FileDownloadRequest
"reference": message.file.reference if message.file.HasField("reference") else None,
"url": message.file.url if message.file.HasField("url") else None,
}
return AgentStubFileDownloadRequest.model_validate({"file": file_mapping_kwargs})
return AgentStubFileDownloadRequest.model_validate(
{
"file": file_mapping_kwargs,
"for_external": message.for_external if message.HasField("for_external") else True,
}
)
def proto_file_download_request(
pb2_module,
*,
file: AgentStubFileMapping,
for_external: 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)
@@ -112,7 +118,9 @@ def proto_file_download_request(
mapping.reference = file.reference
if file.url is not None:
mapping.url = file.url
return pb2_module.FileDownloadRequest(file=mapping)
request = pb2_module.FileDownloadRequest(file=mapping)
request.for_external = for_external
return request
def file_download_response_from_proto(message: agent_stub_pb2.FileDownloadResponse) -> AgentStubFileDownloadResponse:
@@ -249,6 +249,7 @@ class AgentStubFileDownloadRequest(BaseModel):
"""Request body for one signed download URL allocation."""
file: AgentStubFileMapping
for_external: bool = True
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@@ -161,6 +161,8 @@ 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)
try:
return AgentStubFileDownloadResponse.model_validate(data)
@@ -53,6 +53,7 @@ def test_upload_file_from_environment_requests_signed_url_and_normalizes_output(
def fake_request_agent_stub_file_download_sync(**kwargs):
captured_download_request["file"] = kwargs["file"]
captured_download_request["for_external"] = kwargs.get("for_external", True)
return type(
"Response",
(),
@@ -85,6 +86,7 @@ def test_upload_file_from_environment_requests_signed_url_and_normalizes_output(
transfer_method="tool_file",
reference=_reference("tool-file-1"),
)
assert captured_download_request["for_external"] is True
def test_upload_tool_file_resource_from_environment_preserves_tool_file_id(
@@ -256,6 +258,7 @@ def test_download_file_from_environment_supports_mapping_json(
def fake_request_download(**kwargs):
captured["file"] = kwargs["file"]
captured["for_external"] = kwargs["for_external"]
return type(
"Response",
(),
@@ -283,10 +286,49 @@ def test_download_file_from_environment_supports_mapping_json(
"reference": _reference("tool-file-1"),
"url": None,
}
assert captured["for_external"] is False
assert result.path == target_dir / "report.pdf"
assert result.path.read_bytes() == b"downloaded"
def test_download_file_from_environment_requests_internal_download_url(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
target_dir = tmp_path / "downloads"
target_dir.mkdir()
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
captured: dict[str, object] = {}
def fake_request_download(**kwargs):
captured.update(kwargs)
return type(
"Response",
(),
{
"filename": "report.pdf",
"mime_type": "application/pdf",
"size": 12,
"download_url": "http://internal-files/report.pdf",
},
)()
monkeypatch.setattr("dify_agent.agent_stub.cli._files.request_agent_stub_file_download_sync", fake_request_download)
monkeypatch.setattr(
"dify_agent.agent_stub.cli._files.download_file_bytes_from_signed_url_sync",
lambda **_kwargs: b"downloaded",
)
_ = download_file_from_environment(
transfer_method="tool_file",
reference_or_url=_reference("tool-file-1"),
local_dir=str(target_dir),
)
assert captured["for_external"] is False
def test_download_file_from_environment_requires_mapping_or_positional_pair() -> None:
with pytest.raises(AgentStubValidationError, match="requires either --mapping or TRANSFER_METHOD REFERENCE_OR_URL"):
_ = download_file_from_environment()
@@ -157,7 +157,7 @@ def test_request_agent_stub_file_download_sync_posts_download_request() -> None:
assert request.method == "POST"
assert str(request.url) == "https://agent.example.com/agent-stub/files/download-request"
assert json.loads(request.content) == {
"file": {"transfer_method": "tool_file", "reference": _reference("tool-file-1")}
"file": {"transfer_method": "tool_file", "reference": _reference("tool-file-1")},
}
return httpx.Response(
200,
@@ -183,6 +183,39 @@ def test_request_agent_stub_file_download_sync_posts_download_request() -> None:
assert response.download_url == "https://files.example.com/download"
def test_request_agent_stub_file_download_sync_posts_internal_download_request() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert str(request.url) == "https://agent.example.com/agent-stub/files/download-request"
assert json.loads(request.content) == {
"file": {"transfer_method": "tool_file", "reference": _reference("tool-file-1")},
"for_external": False,
}
return httpx.Response(
200,
json={
"filename": "report.pdf",
"mime_type": "application/pdf",
"size": 123,
"download_url": "http://internal-files/report.pdf",
},
)
http_client = httpx.Client(transport=httpx.MockTransport(handler))
try:
response = request_agent_stub_file_download_sync(
url="https://agent.example.com/agent-stub",
auth_jwe="test-jwe",
file=AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1")),
for_external=False,
sync_http_client=http_client,
)
finally:
http_client.close()
assert response.download_url == "http://internal-files/report.pdf"
def test_request_agent_stub_drive_manifest_sync_gets_manifest_request() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "GET"
@@ -456,12 +489,14 @@ def test_request_agent_stub_file_download_sync_dispatches_grpc_urls(monkeypatch:
url="grpc://agent.example.com:9091",
auth_jwe="token",
file=file_mapping,
for_external=False,
)
assert captured == {
"url": "grpc://agent.example.com:9091",
"auth_jwe": "token",
"file": file_mapping,
"for_external": False,
"timeout": 30.0,
}
assert response.download_url == "https://files.example.com/download"
@@ -613,7 +648,10 @@ def test_request_agent_stub_file_download_grpc_sync_maps_grpc_errors(monkeypatch
grpc_module,
"_require_conversions",
lambda: SimpleNamespace(
proto_file_download_request=lambda _pb2, *, file: {"file": file},
proto_file_download_request=lambda _pb2, *, file, for_external: {
"file": file,
"for_external": for_external,
},
file_download_response_from_proto=lambda response: response,
),
)
@@ -13,9 +13,14 @@ from dify_agent.agent_stub.grpc.conversions import (
connect_response_from_proto,
file_download_request_from_proto,
proto_connect_request,
proto_file_download_request,
proto_file_download_response,
)
from dify_agent.agent_stub.protocol.agent_stub import AgentStubConnectResponse, AgentStubFileDownloadResponse
from dify_agent.agent_stub.protocol.agent_stub import (
AgentStubConnectResponse,
AgentStubFileDownloadResponse,
AgentStubFileMapping,
)
def _reference(record_id: str) -> str:
@@ -48,6 +53,32 @@ 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
def test_file_download_request_from_proto_preserves_explicit_internal_audience() -> None:
message = agent_stub_pb2.FileDownloadRequest(
file=agent_stub_pb2.FileMapping(
transfer_method="tool_file",
reference=_reference("tool-file-1"),
),
for_external=False,
)
request = file_download_request_from_proto(message)
assert request.for_external 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,
)
assert message.HasField("for_external") is True
assert message.for_external is False
def test_connect_request_from_proto_rejects_invalid_metadata_json() -> None:
@@ -115,6 +115,41 @@ def test_dify_api_agent_stub_file_handler_injects_execution_context_for_download
asyncio.run(scenario())
def test_dify_api_agent_stub_file_handler_forwards_internal_download_audience(monkeypatch) -> 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
return httpx.Response(
200,
json={
"data": {
"filename": "report.pdf",
"mime_type": "application/pdf",
"size": 123,
"download_url": "http://internal-files/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:
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,
),
)
assert response.download_url == "http://internal-files/report.pdf"
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",
@@ -130,6 +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
return type(
"Response",
(),
@@ -158,7 +159,8 @@ def test_agent_stub_grpc_transport_delegates_file_download_requests() -> None:
file=agent_stub_pb2.FileMapping(
transfer_method="tool_file",
reference=_reference("tool-file-1"),
)
),
for_external=False,
),
metadata=(("authorization", f"Bearer {token}"),),
)
+1 -1
View File
@@ -84,7 +84,7 @@ The root `.env.example` file contains the essential startup settings. Optional a
1. **Common Variables**:
- `CONSOLE_API_URL`, `CONSOLE_WEB_URL`, `SERVICE_API_URL`, `APP_API_URL`, `APP_WEB_URL`: public URLs for the API and frontend services.
- `SERVER_CONSOLE_API_URL`: internal URL used by the web service for server-side console API requests. Keep the default `http://api:5001` for standard Docker Compose deployments, and only change it when your web service must reach the API through a different internal address.
- `SERVER_CONSOLE_API_URL`: internal API origin used by web server-side requests and, when `INTERNAL_FILES_URL` is unset, as the default fallback for API-side internal file URL generation. Keep the default `http://api:5001` for standard Docker Compose deployments, and only change it when services must reach the API through a different internal address.
- `FILES_URL`, `INTERNAL_FILES_URL`: Public and internal base URLs for file downloads and previews.
- `ENDPOINT_URL_TEMPLATE`, `NEXT_PUBLIC_SOCKET_URL`, `TRIGGER_URL`: Additional service URLs.