diff --git a/dify-agent/src/dify_agent/server/binding_files.py b/dify-agent/src/dify_agent/server/binding_files.py index 8fee4be39c1..2ee12828ae9 100644 --- a/dify-agent/src/dify_agent/server/binding_files.py +++ b/dify-agent/src/dify_agent/server/binding_files.py @@ -50,6 +50,10 @@ import os import stat import sys +# The shell transport may expose stdout only. Keep Python errors in the same +# stream so the server can classify unavailable paths instead of returning 502. +sys.stderr = sys.stdout + path = sys.argv[1] response_path = sys.argv[2] limit = int(sys.argv[3]) @@ -89,6 +93,8 @@ import os import stat import sys +sys.stderr = sys.stdout + path = sys.argv[1] response_path = sys.argv[2] max_bytes = int(sys.argv[3]) diff --git a/dify-agent/tests/local/dify_agent/server/test_binding_files.py b/dify-agent/tests/local/dify_agent/server/test_binding_files.py index 1df092289d2..53eb9f2be90 100644 --- a/dify-agent/tests/local/dify_agent/server/test_binding_files.py +++ b/dify-agent/tests/local/dify_agent/server/test_binding_files.py @@ -117,6 +117,8 @@ class _ProviderErrorCommands(_Commands): @dataclass(slots=True) class _LocalCommands(_Commands): + capture_stderr: bool = True + async def run( self, script: str, @@ -133,7 +135,7 @@ class _LocalCommands(_Commands): cwd=cwd, env={**os.environ, **(env or {})}, stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, + stderr=asyncio.subprocess.STDOUT if self.capture_stderr else asyncio.subprocess.DEVNULL, ) output, _ = await asyncio.wait_for(process.communicate(), timeout=timeout) text = output.decode(errors="replace") @@ -195,12 +197,16 @@ def _service( return service, backend -def _local_service(tmp_path: Path) -> tuple[BindingFileService, _Backend, _LocalCommands, Path, Path]: +def _local_service( + tmp_path: Path, + *, + capture_stderr: bool = True, +) -> tuple[BindingFileService, _Backend, _LocalCommands, Path, Path]: workspace = tmp_path / "workspace" home = tmp_path / "home" workspace.mkdir() home.mkdir() - commands = _LocalCommands(outputs=[]) + commands = _LocalCommands(outputs=[], capture_stderr=capture_stderr) backend = _Backend( lease=cast( RuntimeLease, @@ -412,6 +418,21 @@ async def test_browse_preserves_binding_file_error_and_releases_lease(operation: assert backend.releases == 1 +@pytest.mark.parametrize("operation", ["list", "read"]) +@pytest.mark.anyio +async def test_missing_path_is_classified_when_transport_only_captures_stdout(tmp_path: Path, operation: str) -> None: + service, backend, commands, _, _ = _local_service(tmp_path, capture_stderr=False) + with pytest.raises(BindingFileError) as error: + if operation == "list": + await service.list_files(BindingFileListRequest(backend_binding_ref="binding-ref", path="missing")) + else: + await service.read_file(BindingFileReadRequest(backend_binding_ref="binding-ref", path="missing")) + assert error.value.code == "invalid_binding_path" + assert error.value.status_code == 400 + assert backend.releases == 1 + assert commands.deletes == ["local-job-1"] + + @pytest.mark.parametrize("operation", ["list", "read"]) @pytest.mark.anyio async def test_browse_maps_malformed_backend_response_to_unavailable_and_releases_lease(operation: str) -> None: