diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index f664274ba75..4c631d0180c 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -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="", ) diff --git a/api/controllers/inner_api/plugin/plugin.py b/api/controllers/inner_api/plugin/plugin.py index 4e6dbf5dcbe..1171761fc53 100644 --- a/api/controllers/inner_api/plugin/plugin.py +++ b/api/controllers/inner_api/plugin/plugin.py @@ -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={ diff --git a/api/core/plugin/entities/request.py b/api/core/plugin/entities/request.py index 65f7d044ea2..468055faea1 100644 --- a/api/core/plugin/entities/request.py +++ b/api/core/plugin/entities/request.py @@ -276,6 +276,7 @@ class RequestRequestDownloadFile(BaseModel): "validation", ] file: RequestDownloadFileMapping + for_external: bool = True model_config = ConfigDict(extra="forbid") diff --git a/api/services/file_request_service.py b/api/services/file_request_service.py index a4be0bbec88..a795595d095 100644 --- a/api/services/file_request_service.py +++ b/api/services/file_request_service.py @@ -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") diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py index 539b7bff4a4..eaeb15a7090 100644 --- a/api/tests/unit_tests/configs/test_dify_config.py +++ b/api/tests/unit_tests/configs/test_dify_config.py @@ -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): diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py index 1b0a82ed7c5..b3690d26c6c 100644 --- a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py +++ b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py @@ -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", diff --git a/api/tests/unit_tests/core/plugin/entities/test_request.py b/api/tests/unit_tests/core/plugin/entities/test_request.py index 05ff074ea92..57bc7c9aae8 100644 --- a/api/tests/unit_tests/core/plugin/entities/test_request.py +++ b/api/tests/unit_tests/core/plugin/entities/test_request.py @@ -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( diff --git a/api/tests/unit_tests/services/test_file_request_service.py b/api/tests/unit_tests/services/test_file_request_service.py index 57abdeaa322..3e2ea0a258a 100644 --- a/api/tests/unit_tests/services/test_file_request_service.py +++ b/api/tests/unit_tests/services/test_file_request_service.py @@ -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()) diff --git a/dify-agent/proto/dify/agent/stub/v1/agent_stub.proto b/dify-agent/proto/dify/agent/stub/v1/agent_stub.proto index 11a1a4aa54f..9240c195262 100644 --- a/dify-agent/proto/dify/agent/stub/v1/agent_stub.proto +++ b/dify-agent/proto/dify/agent/stub/v1/agent_stub.proto @@ -36,6 +36,7 @@ message FileMapping { message FileDownloadRequest { FileMapping file = 1; + optional bool for_external = 2; } message FileDownloadResponse { diff --git a/dify-agent/src/dify_agent/agent_stub/cli/_files.py b/dify-agent/src/dify_agent/agent_stub/cli/_files.py index 5e3635fde14..1ccd57f26da 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/_files.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/_files.py @@ -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 diff --git a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py index 704b099a4de..1f5150b429e 100644 --- a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py +++ b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py @@ -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, ) diff --git a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_grpc.py b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_grpc.py index 84d02aadc2b..7c5f5c4fbbb 100644 --- a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_grpc.py +++ b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_grpc.py @@ -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, diff --git a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py index beb777c58bd..543446ad894 100644 --- a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py +++ b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py @@ -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, ) diff --git a/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_pb2.py b/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_pb2.py index 5b07bec9eb1..414cab95a0f 100644 --- a/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_pb2.py +++ b/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_pb2.py @@ -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) diff --git a/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_pb2.pyi b/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_pb2.pyi index 32f1fc443d4..617956bd845 100644 --- a/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_pb2.pyi +++ b/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_pb2.pyi @@ -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: ... diff --git a/dify-agent/src/dify_agent/agent_stub/grpc/conversions.py b/dify-agent/src/dify_agent/agent_stub/grpc/conversions.py index 3bfa4c50665..a79e446c85e 100644 --- a/dify-agent/src/dify_agent/agent_stub/grpc/conversions.py +++ b/dify-agent/src/dify_agent/agent_stub/grpc/conversions.py @@ -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: diff --git a/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py b/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py index 1d257287671..734ca50d53e 100644 --- a/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py +++ b/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py @@ -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") diff --git a/dify-agent/src/dify_agent/agent_stub/server/agent_stub_files.py b/dify-agent/src/dify_agent/agent_stub/server/agent_stub_files.py index 06cb78d32fa..f3efe34f00a 100644 --- a/dify-agent/src/dify_agent/agent_stub/server/agent_stub_files.py +++ b/dify-agent/src/dify_agent/agent_stub/server/agent_stub_files.py @@ -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) diff --git a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py index 6a65daccb9a..3cf22e04d8e 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py @@ -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() diff --git a/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_client.py b/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_client.py index 7722ff940bd..be4fb1def39 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_client.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_client.py @@ -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, ), ) diff --git a/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_grpc_conversions.py b/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_grpc_conversions.py index eaefac6a2b5..8c061254151 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_grpc_conversions.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_grpc_conversions.py @@ -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: diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_files.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_files.py index 164fd7487d7..54e7863aeb4 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_files.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_files.py @@ -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", diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_grpc_service.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_grpc_service.py index addcec7cf66..149ce1cbffe 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/server/test_grpc_service.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_grpc_service.py @@ -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}"),), ) diff --git a/docker/README.md b/docker/README.md index d65b6c624f5..c3b1011bd68 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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.