refactor(dify-agent): download Binding files through Agent Stub (#40143)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
盐粒 Yanli
2026-08-07 14:56:52 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent c094117ca2
commit 916d7e1c96
71 changed files with 4692 additions and 2484 deletions
@@ -2,22 +2,23 @@ from __future__ import annotations
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
from dify_agent.protocol import WorkspaceListResponse, WorkspaceReadResponse
from sqlalchemy.orm import Session
from dify_agent.protocol import BindingFileListResponse, BindingFileReadResponse
from controllers.console import agent_app_sandbox as module
from models.model import App, AppMode, IconType
from services.agent_app_sandbox_service import AgentSandboxInfo, AgentSandboxInspectorError, AgentSandboxUploadDownload
from services.agent_app_sandbox_service import AgentSandboxDownload, AgentSandboxInfo, AgentSandboxInspectorError
class _AgentAppService:
def __init__(self) -> None:
self.calls: list[tuple[str, str, str, str, str, str, str, str]] = []
def resolve_app_id(self, *, tenant_id: str, agent_id: str) -> str:
return "app-1"
def get_info(
self,
*,
@@ -41,9 +42,9 @@ class _AgentAppService:
caller_id: str,
account_id: str,
path: str,
) -> WorkspaceListResponse:
) -> BindingFileListResponse:
self.calls.append(("list", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
return WorkspaceListResponse(path=path, entries=[], truncated=False)
return BindingFileListResponse(path=path, entries=[], truncated=False)
def read_file(
self,
@@ -55,11 +56,11 @@ class _AgentAppService:
caller_id: str,
account_id: str,
path: str,
) -> WorkspaceReadResponse:
) -> BindingFileReadResponse:
self.calls.append(("read", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
return WorkspaceReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
return BindingFileReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
def upload_file(
def download_file(
self,
*,
tenant_id: str,
@@ -69,14 +70,17 @@ class _AgentAppService:
caller_id: str,
account_id: str,
path: str,
) -> AgentSandboxUploadDownload:
self.calls.append(("upload", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
return AgentSandboxUploadDownload(url="https://files.example/report.txt")
) -> AgentSandboxDownload:
self.calls.append(("download", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
return AgentSandboxDownload(url="https://files.example/report.txt")
class _WorkflowService:
def __init__(self) -> None:
self.calls: list[tuple[str, str, str, str, str, str]] = []
self.calls: list[tuple[str, ...]] = []
def resolve_app_id(self, *, tenant_id: str, app_id: str) -> str:
return app_id
def list_files(
self,
@@ -88,9 +92,9 @@ class _WorkflowService:
node_execution_id: str,
path: str,
session,
) -> WorkspaceListResponse:
) -> BindingFileListResponse:
self.calls.append(("list", tenant_id, app_id, workflow_run_id, node_id, path))
return WorkspaceListResponse(path=path, entries=[], truncated=False)
return BindingFileListResponse(path=path, entries=[], truncated=False)
def read_file(
self,
@@ -102,11 +106,11 @@ class _WorkflowService:
node_execution_id: str,
path: str,
session,
) -> WorkspaceReadResponse:
) -> BindingFileReadResponse:
self.calls.append(("read", tenant_id, app_id, workflow_run_id, node_id, path))
return WorkspaceReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
return BindingFileReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
def upload_file(
def download_file(
self,
*,
tenant_id: str,
@@ -114,11 +118,11 @@ class _WorkflowService:
workflow_run_id: str,
node_id: str,
node_execution_id: str,
account_id: str,
path: str,
session,
) -> AgentSandboxUploadDownload:
self.calls.append(("upload", tenant_id, app_id, workflow_run_id, node_id, path))
return AgentSandboxUploadDownload(url="https://files.example/upload.txt")
) -> AgentSandboxDownload:
self.calls.append(("download", tenant_id, app_id, workflow_run_id, node_id, account_id, path))
return AgentSandboxDownload(url="https://files.example/download.txt")
def _app_model(app_id: str = "app-1") -> App:
@@ -160,46 +164,40 @@ def test_handle_maps_sandbox_and_agent_backend_errors() -> None:
module._handle(RuntimeError("boom"))
def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch, unbound_session: Session) -> None:
def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None:
service = _AgentAppService()
session = unbound_session
account = SimpleNamespace(id="account-1")
resolver = MagicMock(return_value=_app_model())
monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service)
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", resolver)
monkeypatch.setattr(
module,
"query_params_from_request",
lambda model: SimpleNamespace(caller_type="build_draft", caller_id="build-1", path="sub/report.txt"),
)
info = unwrap(module.AgentAppSandboxInfoResource.get)(object(), session, account, "tenant-1", "agent-1")
listing = unwrap(module.AgentAppSandboxListResource.get)(object(), session, account, "tenant-1", "agent-1")
preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), session, account, "tenant-1", "agent-1")
req_data = module.AgentSandboxUploadPayload.model_validate(
info = unwrap(module.AgentAppSandboxInfoResource.get)(object(), account, "tenant-1", "agent-1")
listing = unwrap(module.AgentAppSandboxListResource.get)(object(), account, "tenant-1", "agent-1")
preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), account, "tenant-1", "agent-1")
req_data = module.AgentSandboxDownloadPayload.model_validate(
{"caller_type": "build_draft", "caller_id": "build-1", "path": "report.txt"}
)
upload = unwrap(module.AgentAppSandboxUploadResource.post)(
object(), req_data, session, account, "tenant-1", "agent-1"
)
download = unwrap(module.AgentAppSandboxDownloadResource.post)(object(), req_data, account, "tenant-1", "agent-1")
assert info == {"workspace_cwd": "."}
assert listing["path"] == "sub/report.txt"
assert preview["text"] == "hello"
assert upload == {"url": "https://files.example/report.txt"}
assert download == {"url": "https://files.example/report.txt"}
assert service.calls == [
("info", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", ""),
("list", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "sub/report.txt"),
("read", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "sub/report.txt"),
("upload", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "report.txt"),
("download", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "report.txt"),
]
assert all(call.kwargs["session"] is session for call in resolver.call_args_list)
def test_agent_app_sandbox_resource_returns_normalized_errors(
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytest.MonkeyPatch) -> None:
class FailingService:
def resolve_app_id(self, **kwargs):
return "app-1"
def get_info(self, **kwargs):
raise AgentSandboxInspectorError("no_active_binding", "no active binding", status_code=404)
@@ -207,20 +205,18 @@ def test_agent_app_sandbox_resource_returns_normalized_errors(
raise AgentSandboxInspectorError("no_active_binding", "no active binding", status_code=404)
monkeypatch.setattr(module, "AgentAppSandboxService", FailingService)
session = unbound_session
account = SimpleNamespace(id="account-1")
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", MagicMock(return_value=_app_model()))
monkeypatch.setattr(
module,
"query_params_from_request",
lambda model: SimpleNamespace(caller_type="conversation", caller_id="conv-1", path="."),
)
assert unwrap(module.AgentAppSandboxInfoResource.get)(object(), session, account, "tenant-1", "agent-1") == (
assert unwrap(module.AgentAppSandboxInfoResource.get)(object(), account, "tenant-1", "agent-1") == (
{"code": "no_active_binding", "message": "no active binding"},
404,
)
assert unwrap(module.AgentAppSandboxListResource.get)(object(), session, account, "tenant-1", "agent-1") == (
assert unwrap(module.AgentAppSandboxListResource.get)(object(), account, "tenant-1", "agent-1") == (
{"code": "no_active_binding", "message": "no active binding"},
404,
)
@@ -242,18 +238,19 @@ def test_workflow_agent_sandbox_resources_proxy_service(monkeypatch: pytest.Monk
preview = unwrap(module.WorkflowAgentSandboxReadResource.get)(
object(), "tenant-1", app_model, "run-1", "agent-node"
)
req_data = module.WorkflowAgentSandboxUploadPayload.model_validate(
{"node_execution_id": "execution-1", "path": "upload.txt"}
req_data = module.WorkflowAgentSandboxDownloadPayload.model_validate(
{"node_execution_id": "execution-1", "path": "download.txt"}
)
upload = unwrap(module.WorkflowAgentSandboxUploadResource.post)(
object(), req_data, "tenant-1", app_model, "run-1", "agent-node"
account = SimpleNamespace(id="account-1")
download = unwrap(module.WorkflowAgentSandboxDownloadResource.post)(
object(), req_data, "tenant-1", account, "app-1", "run-1", "agent-node"
)
assert listing["path"] == "out.txt"
assert preview["text"] == "hello"
assert upload == {"url": "https://files.example/upload.txt"}
assert download == {"url": "https://files.example/download.txt"}
assert service.calls == [
("list", "tenant-1", "app-1", "run-1", "agent-node", "out.txt"),
("read", "tenant-1", "app-1", "run-1", "agent-node", "out.txt"),
("upload", "tenant-1", "app-1", "run-1", "agent-node", "upload.txt"),
("download", "tenant-1", "app-1", "run-1", "agent-node", "account-1", "download.txt"),
]
@@ -1,13 +1,17 @@
import io
import types
from contextlib import contextmanager
from inspect import unwrap
from unittest.mock import patch
import pytest
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
import controllers.files.upload as module
from core.workflow.file_reference import build_file_reference
from models import Account, TenantAccountJoin
from models.account import AccountStatus
def fake_request(args: dict, file=None):
@@ -17,6 +21,22 @@ def fake_request(args: dict, file=None):
)
def _persist_account_memberships(session: Session) -> None:
account = Account(name="Tenant member", email="member@example.com", status=AccountStatus.ACTIVE)
account.id = "account-1"
decoy = Account(name="Other tenant member", email="decoy@example.com", status=AccountStatus.ACTIVE)
decoy.id = "account-outside-tenant"
session.add_all(
[
account,
decoy,
TenantAccountJoin(tenant_id="tenant-1", account_id=account.id),
TenantAccountJoin(tenant_id="tenant-other", account_id=decoy.id),
]
)
session.commit()
class DummyUser:
def __init__(self, user_id="user-1"):
self.id = user_id
@@ -33,6 +53,16 @@ class DummyFile:
return self.stream.read()
class RecordingStream(io.BytesIO):
def __init__(self, content: bytes, events: list[str]):
super().__init__(content)
self.events = events
def read(self, *args, **kwargs):
self.events.append("file-read")
return super().read(*args, **kwargs)
class DummyToolFile:
def __init__(self, name="test.txt", mimetype="text/plain"):
self.id = "file-id"
@@ -94,6 +124,138 @@ class TestPluginUploadFileApi:
assert tool_file_manager_instance.create_file_by_raw.call_args.kwargs["conversation_id"] == "conversation-1"
mock_tool_file_manager.sign_file.assert_called_once_with(tool_file_id="file-id", extension=".docx")
@patch.object(module, "get_user")
@patch.object(module, "ToolFileManager")
@pytest.mark.parametrize("sqlite_session", [(Account, TenantAccountJoin)], indirect=True)
def test_account_upload_preserves_signed_account_owner(
self,
mock_tool_file_manager,
mock_get_user,
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
):
_persist_account_memberships(sqlite_session)
events: list[str] = []
dummy_file = DummyFile(filename="report.pdf", mimetype="application/pdf", content=b"account-owned")
dummy_file.stream = RecordingStream(b"account-owned", events)
@contextmanager
def membership_session():
events.append("membership-session-enter")
try:
yield sqlite_session
finally:
events.append("membership-session-exit")
monkeypatch.setattr(module.session_factory, "create_session", membership_session)
monkeypatch.setattr(
module,
"request",
fake_request(
{
"timestamp": "123",
"nonce": "abc",
"sign": "sig",
"tenant_id": "tenant-1",
"user_id": "account-1",
"user_from": "account",
},
file=dummy_file,
),
)
tool_file_manager = mock_tool_file_manager.return_value
tool_file_manager.create_file_by_raw.side_effect = lambda **_kwargs: (
events.append("storage-create-file") or DummyToolFile(name="report.pdf", mimetype="application/pdf")
)
mock_tool_file_manager.sign_file.return_value = "signed-url"
with patch.object(
module,
"verify_plugin_file_signature",
side_effect=lambda **_kwargs: events.append("signature-verify") or True,
) as verify_signature:
api = module.PluginUploadFileApi()
result, status_code = unwrap(api.post)(api)
assert status_code == 201
assert result["reference"] == build_file_reference(record_id="file-id")
assert events == [
"membership-session-enter",
"membership-session-exit",
"signature-verify",
"file-read",
"storage-create-file",
]
mock_get_user.assert_not_called()
verify_signature.assert_called_once_with(
filename="report.pdf",
mimetype="application/pdf",
tenant_id="tenant-1",
user_id="account-1",
conversation_id=None,
user_from="account",
timestamp="123",
nonce="abc",
sign="sig",
)
tool_file_manager.create_file_by_raw.assert_called_once_with(
user_id="account-1",
tenant_id="tenant-1",
file_binary=b"account-owned",
mimetype="application/pdf",
filename="report.pdf",
conversation_id=None,
)
@patch.object(module, "verify_plugin_file_signature")
@patch.object(module, "get_user")
@patch.object(module, "ToolFileManager")
@pytest.mark.parametrize("sqlite_session", [(Account, TenantAccountJoin)], indirect=True)
def test_account_upload_rejects_owner_outside_tenant(
self,
mock_tool_file_manager,
mock_get_user,
mock_verify_signature,
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
):
_persist_account_memberships(sqlite_session)
events: list[str] = []
@contextmanager
def membership_session():
events.append("membership-session-enter")
try:
yield sqlite_session
finally:
events.append("membership-session-exit")
monkeypatch.setattr(module.session_factory, "create_session", membership_session)
monkeypatch.setattr(
module,
"request",
fake_request(
{
"timestamp": "123",
"nonce": "abc",
"sign": "sig",
"tenant_id": "tenant-1",
"user_id": "account-outside-tenant",
"user_from": "account",
},
file=DummyFile(),
),
)
api = module.PluginUploadFileApi()
with pytest.raises(Forbidden):
unwrap(api.post)(api)
assert events == ["membership-session-enter", "membership-session-exit"]
mock_get_user.assert_not_called()
mock_verify_signature.assert_not_called()
mock_tool_file_manager.assert_not_called()
def test_missing_file(self):
module.request = fake_request(
{
@@ -7,7 +7,11 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.inner_api.agent.files import AgentFileDownloadRequestApi, AgentFileUploadRequestApi
from controllers.inner_api.agent.files import (
AgentFileDownloadRequestApi,
AgentFileRequestHttpError,
AgentFileUploadRequestApi,
)
from core.workflow.file_reference import build_file_reference
from services.file_request_service import DownloadFileRequestResult
@@ -46,9 +50,74 @@ def test_upload_request_returns_origin_free_uri(app: Flask) -> None:
tenant_id="tenant-1",
user_id="canonical-end-user-1",
conversation_id="conversation-1",
user_from=None,
)
def test_upload_request_preserves_tenant_scoped_account_owner(app: Flask) -> None:
payload = {
"tenant_id": "tenant-1",
"user_id": "account-1",
"user_from": "account",
"filename": "report.pdf",
"mimetype": "application/pdf",
"conversation_id": "conversation-1",
}
tenant = SimpleNamespace(id="tenant-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") as get_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
tenant_service.account_belongs_to_tenant.return_value = True
response = _raw(AgentFileUploadRequestApi.post)(AgentFileUploadRequestApi(), session)
assert response == {"upload_uri": "/files/upload/for-plugin?sign=1"}
get_user.assert_not_called()
tenant_service.account_belongs_to_tenant.assert_called_once_with("account-1", "tenant-1", session=session)
sign.assert_called_once_with(
filename="report.pdf",
mimetype="application/pdf",
tenant_id="tenant-1",
user_id="account-1",
conversation_id="conversation-1",
user_from="account",
)
def test_upload_request_rejects_account_outside_tenant_without_signing(app: Flask) -> None:
payload = {
"tenant_id": "tenant-1",
"user_id": "account-outside-tenant",
"user_from": "account",
"filename": "report.pdf",
"mimetype": "application/pdf",
}
tenant = SimpleNamespace(id="tenant-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") as get_user,
patch(f"{MODULE}.get_signed_file_uri_for_plugin") as sign,
):
tenant_service.get_tenant_by_id.return_value = tenant
tenant_service.account_belongs_to_tenant.return_value = False
with pytest.raises(AgentFileRequestHttpError) as exc_info:
_raw(AgentFileUploadRequestApi.post)(AgentFileUploadRequestApi(), session)
assert exc_info.value.error_code == "user_not_found"
assert exc_info.value.code == 404
tenant_service.account_belongs_to_tenant.assert_called_once_with(
"account-outside-tenant", "tenant-1", session=session
)
get_user.assert_not_called()
sign.assert_not_called()
def test_download_request_returns_origin_free_uri_for_sandbox(app: Flask) -> None:
reference = build_file_reference(record_id="tool-file-1")
payload = {
@@ -187,6 +187,36 @@ def test_get_signed_file_uri_for_plugin_and_verify_roundtrip(monkeypatch: pytest
)
def test_plugin_upload_signature_binds_account_user_from(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x09" * 16)
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
monkeypatch.setattr("core.tools.signature.dify_config.FILES_ACCESS_TIMEOUT", 60)
uri = get_signed_file_uri_for_plugin(
filename="report.pdf",
mimetype="application/pdf",
tenant_id="tenant-id",
user_id="account-id",
user_from="account",
)
query = parse_qs(urlparse(uri).query)
assert query["user_from"] == ["account"]
signed = {
"filename": "report.pdf",
"mimetype": "application/pdf",
"tenant_id": "tenant-id",
"user_id": "account-id",
"timestamp": query["timestamp"][0],
"nonce": query["nonce"][0],
"sign": query["sign"][0],
}
assert verify_plugin_file_signature(**signed, user_from="account") is True
assert verify_plugin_file_signature(**signed, user_from="end-user") is False
assert verify_plugin_file_signature(**signed) is False
def test_verify_plugin_file_signature_rejects_invalid_signatures(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x07" * 16)
@@ -199,9 +199,13 @@ def test_load_or_create_persists_binding_on_node_execution(monkeypatch, home_sna
session=session,
)
assert resolved.id == "binding-1"
assert resolved.backend_binding_ref == "backend-binding-1"
assert resolved.agent_id == "agent-1"
assert resolved.agent_config_version_id == "config-1"
assert resolved.agent_config_version_kind == "snapshot"
owner_scope = get_active.call_args.kwargs["expected_owner_scope"]
assert owner_scope.owner_scope_key == "node-1:workflow-binding-1"
session.rollback.assert_called_once_with()
def test_load_existing_pointer_rejects_missing_workflow_identity(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -1,24 +1,39 @@
from contextlib import nullcontext
import json
from contextlib import contextmanager, nullcontext
from datetime import datetime
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock
import pytest
from dify_agent.protocol import WorkspaceListResponse, WorkspaceReadResponse
from dify_agent.client import Client
from dify_agent.protocol import BindingFileDownloadResponse, BindingFileListResponse, BindingFileReadResponse
from sqlalchemy.orm import Session
from graphon.enums import WorkflowNodeExecutionStatus
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigVersionKind,
AgentKind,
AgentScope,
AgentSource,
AgentStatus,
AgentWorkingResourceStatus,
AgentWorkspace,
AgentWorkspaceBinding,
AgentWorkspaceOwnerType,
)
from models.enums import ConversationFromSource
from models.agent_config_entities import AgentSoulConfig
from models.enums import ConversationFromSource, CreatorUserRole
from models.model import App, AppMode, Conversation, IconType
from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom
from services import agent_app_sandbox_service as sandbox_module
from services.agent.workspace_service import AgentWorkspaceService
from services.agent_app_sandbox_service import (
AgentAppSandboxService,
AgentSandboxDownload,
AgentSandboxInspectorError,
WorkflowAgentSandboxService,
)
@@ -104,6 +119,79 @@ def _use_session(monkeypatch: pytest.MonkeyPatch, session: Session) -> None:
)
def _add_app(session: Session, *, app_id: str, tenant_id: str) -> None:
session.add(
App(
id=app_id,
tenant_id=tenant_id,
name=f"App {app_id}",
description="",
mode=AppMode.AGENT,
icon_type=IconType.EMOJI,
icon="robot",
icon_background="#FFFFFF",
enable_site=False,
enable_api=False,
max_active_requests=0,
)
)
def _add_binding(
session: Session,
*,
binding_id: str,
workspace_id: str,
tenant_id: str = "tenant-1",
app_id: str = "app-1",
agent_id: str = "agent-1",
owner_type: AgentWorkspaceOwnerType,
owner_id: str,
owner_scope_key: str = "root",
status: AgentWorkingResourceStatus = AgentWorkingResourceStatus.ACTIVE,
) -> AgentWorkspaceBinding:
active_guard = 1 if status is AgentWorkingResourceStatus.ACTIVE else None
workspace = AgentWorkspace(
id=workspace_id,
tenant_id=tenant_id,
app_id=app_id,
owner_type=owner_type,
owner_id=owner_id,
owner_scope_key=owner_scope_key,
backend_workspace_ref=f"{workspace_id}-ref",
status=status,
active_guard=active_guard,
)
binding = AgentWorkspaceBinding(
id=binding_id,
tenant_id=tenant_id,
app_id=app_id,
workspace_id=workspace_id,
agent_id=agent_id,
base_home_snapshot_id=None,
agent_config_version_id=f"{binding_id}-config",
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
backend_binding_ref=f"{binding_id}-ref",
status=status,
)
session.add_all([workspace, binding])
return binding
def _download_client() -> MagicMock:
client = MagicMock()
client.download_binding_file_sync.return_value = BindingFileDownloadResponse(reference="dify-file-ref:canonical")
return client
def _stub_download_response(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
sandbox_module,
"_download_response",
lambda **_kwargs: AgentSandboxDownload(url="https://files.example/report.txt"),
)
@pytest.mark.parametrize(
"sqlite_session",
[(AgentWorkspace, AgentWorkspaceBinding, App, Conversation)],
@@ -117,8 +205,8 @@ def test_agent_app_file_browsing_uses_conversation_pointer(
sqlite_session.commit()
_use_session(monkeypatch, sqlite_session)
client = MagicMock()
response = WorkspaceListResponse(path=".", entries=[], truncated=False)
client.list_workspace_files_sync.return_value = response
response = BindingFileListResponse(path=".", entries=[], truncated=False)
client.list_binding_files_sync.return_value = response
result = AgentAppSandboxService(client_factory=lambda: nullcontext(client)).list_files(
tenant_id="tenant-1",
@@ -131,7 +219,7 @@ def test_agent_app_file_browsing_uses_conversation_pointer(
)
assert result is response
client.list_workspace_files_sync.assert_called_once_with(expected.backend_binding_ref, ".")
client.list_binding_files_sync.assert_called_once_with(expected.backend_binding_ref, ".")
@pytest.mark.parametrize(
@@ -160,7 +248,443 @@ def test_agent_app_file_browsing_rejects_other_account(
)
assert exc_info.value.code == "no_active_binding"
client.list_workspace_files_sync.assert_not_called()
client.list_binding_files_sync.assert_not_called()
@pytest.mark.parametrize(
"sqlite_session",
[(AgentWorkspace, AgentWorkspaceBinding, App, Conversation)],
indirect=True,
)
def test_agent_conversation_download_resolves_only_exact_active_owner_chain(
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
) -> None:
_add_app(sqlite_session, app_id="app-1", tenant_id="tenant-1")
_add_app(sqlite_session, app_id="app-other", tenant_id="tenant-other")
valid = _add_binding(
sqlite_session,
binding_id="binding-valid",
workspace_id="workspace-valid",
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
owner_id="conversation-valid",
)
wrong_owner = _add_binding(
sqlite_session,
binding_id="binding-wrong-owner",
workspace_id="workspace-wrong-owner",
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
owner_id="conversation-not-the-caller",
)
retired = _add_binding(
sqlite_session,
binding_id="binding-retired",
workspace_id="workspace-retired",
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
owner_id="conversation-retired",
status=AgentWorkingResourceStatus.RETIRED,
)
cross_tenant = _add_binding(
sqlite_session,
binding_id="binding-cross-tenant",
workspace_id="workspace-cross-tenant",
tenant_id="tenant-other",
app_id="app-other",
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
owner_id="conversation-cross-tenant",
)
conversations = [
Conversation(
id="conversation-valid",
app_id="app-1",
mode=AppMode.AGENT,
name="Valid",
from_source=ConversationFromSource.CONSOLE,
from_account_id="account-1",
is_deleted=False,
agent_workspace_binding_id=valid.id,
),
Conversation(
id="conversation-wrong-owner",
app_id="app-1",
mode=AppMode.AGENT,
name="Wrong owner",
from_source=ConversationFromSource.CONSOLE,
from_account_id="account-1",
is_deleted=False,
agent_workspace_binding_id=wrong_owner.id,
),
Conversation(
id="conversation-retired",
app_id="app-1",
mode=AppMode.AGENT,
name="Retired",
from_source=ConversationFromSource.CONSOLE,
from_account_id="account-1",
is_deleted=False,
agent_workspace_binding_id=retired.id,
),
Conversation(
id="conversation-cross-tenant",
app_id="app-other",
mode=AppMode.AGENT,
name="Cross tenant",
from_source=ConversationFromSource.CONSOLE,
from_account_id="account-other",
is_deleted=False,
agent_workspace_binding_id=cross_tenant.id,
),
]
for conversation in conversations:
conversation._inputs = {}
sqlite_session.add_all(conversations)
sqlite_session.commit()
_use_session(monkeypatch, sqlite_session)
_stub_download_response(monkeypatch)
client = _download_client()
service = AgentAppSandboxService(client_factory=lambda: nullcontext(cast(Client, client)))
result = service.download_file(
tenant_id="tenant-1",
app_id="app-1",
agent_id="agent-1",
caller_type="conversation",
caller_id="conversation-valid",
account_id="account-1",
path="report.txt",
)
assert result.url == "https://files.example/report.txt"
request = client.download_binding_file_sync.call_args.args[0]
assert request.backend_binding_ref == "binding-valid-ref"
client.download_binding_file_sync.reset_mock()
rejected_locators = [
{"account_id": "account-other"},
{"app_id": "app-other"},
{"caller_id": "conversation-wrong-owner"},
{"caller_id": "conversation-retired"},
{
"tenant_id": "tenant-1",
"app_id": "app-other",
"caller_id": "conversation-cross-tenant",
"account_id": "account-other",
},
]
for override in rejected_locators:
locator = {
"tenant_id": "tenant-1",
"app_id": "app-1",
"agent_id": "agent-1",
"caller_type": "conversation",
"caller_id": "conversation-valid",
"account_id": "account-1",
"path": "report.txt",
}
locator.update(override)
with pytest.raises(AgentSandboxInspectorError, match="active Agent Workspace Binding"):
service.download_file(**locator) # type: ignore[arg-type]
client.download_binding_file_sync.assert_not_called()
@pytest.mark.parametrize(
"sqlite_session",
[(Agent, AgentConfigDraft, AgentWorkspace, AgentWorkspaceBinding)],
indirect=True,
)
def test_agent_build_draft_download_resolves_only_exact_active_owner_chain(
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
) -> None:
sqlite_session.add_all(
[
Agent(
id="agent-1",
tenant_id="tenant-1",
name="Agent",
description="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
app_id="app-1",
status=AgentStatus.ACTIVE,
),
Agent(
id="agent-cross-tenant",
tenant_id="tenant-other",
name="Other tenant Agent",
description="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
app_id="app-other",
status=AgentStatus.ACTIVE,
),
]
)
valid = _add_binding(
sqlite_session,
binding_id="binding-build-valid",
workspace_id="workspace-build-valid",
owner_type=AgentWorkspaceOwnerType.BUILD_DRAFT,
owner_id="draft-valid",
)
wrong_owner = _add_binding(
sqlite_session,
binding_id="binding-build-wrong-owner",
workspace_id="workspace-build-wrong-owner",
owner_type=AgentWorkspaceOwnerType.BUILD_DRAFT,
owner_id="draft-not-the-caller",
)
retired = _add_binding(
sqlite_session,
binding_id="binding-build-retired",
workspace_id="workspace-build-retired",
owner_type=AgentWorkspaceOwnerType.BUILD_DRAFT,
owner_id="draft-retired",
status=AgentWorkingResourceStatus.RETIRED,
)
drafts = [
AgentConfigDraft(
id="draft-valid",
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id="account-1",
draft_owner_key="account-1",
agent_workspace_binding_id=valid.id,
config_snapshot=AgentSoulConfig(),
),
AgentConfigDraft(
id="draft-wrong-owner",
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id="account-2",
draft_owner_key="account-2",
agent_workspace_binding_id=wrong_owner.id,
config_snapshot=AgentSoulConfig(),
),
AgentConfigDraft(
id="draft-retired",
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id="account-3",
draft_owner_key="account-3",
agent_workspace_binding_id=retired.id,
config_snapshot=AgentSoulConfig(),
),
AgentConfigDraft(
id="draft-cross-tenant",
tenant_id="tenant-other",
agent_id="agent-cross-tenant",
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id="account-other",
draft_owner_key="account-other",
agent_workspace_binding_id=None,
config_snapshot=AgentSoulConfig(),
),
]
sqlite_session.add_all(drafts)
sqlite_session.commit()
_use_session(monkeypatch, sqlite_session)
_stub_download_response(monkeypatch)
client = _download_client()
service = AgentAppSandboxService(client_factory=lambda: nullcontext(cast(Client, client)))
result = service.download_file(
tenant_id="tenant-1",
app_id="app-1",
agent_id="agent-1",
caller_type="build_draft",
caller_id="draft-valid",
account_id="account-1",
path="report.txt",
)
assert result.url == "https://files.example/report.txt"
assert client.download_binding_file_sync.call_args.args[0].backend_binding_ref == "binding-build-valid-ref"
client.download_binding_file_sync.reset_mock()
rejected_locators = [
{"account_id": "account-other"},
{"app_id": "app-other"},
{"caller_id": "draft-wrong-owner", "account_id": "account-2"},
{"caller_id": "draft-retired", "account_id": "account-3"},
{
"tenant_id": "tenant-1",
"app_id": "app-other",
"agent_id": "agent-cross-tenant",
"caller_id": "draft-cross-tenant",
"account_id": "account-other",
},
]
for override in rejected_locators:
locator = {
"tenant_id": "tenant-1",
"app_id": "app-1",
"agent_id": "agent-1",
"caller_type": "build_draft",
"caller_id": "draft-valid",
"account_id": "account-1",
"path": "report.txt",
}
locator.update(override)
with pytest.raises(AgentSandboxInspectorError, match="active Agent Workspace Binding"):
service.download_file(**locator) # type: ignore[arg-type]
client.download_binding_file_sync.assert_not_called()
def _workflow_execution(
*,
execution_id: str,
tenant_id: str = "tenant-1",
app_id: str = "app-1",
workflow_run_id: str = "run-1",
node_id: str = "node-1",
binding_id: str,
created_by: str = "historical-account",
) -> WorkflowNodeExecutionModel:
return WorkflowNodeExecutionModel(
id=execution_id,
tenant_id=tenant_id,
app_id=app_id,
workflow_id="workflow-1",
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
workflow_run_id=workflow_run_id,
index=1,
node_id=node_id,
node_type="agent",
title=node_id,
agent_workspace_binding_id=binding_id,
inputs=None,
process_data=json.dumps({"workflow_agent_binding_id": "workflow-binding-1"}),
outputs=None,
status=WorkflowNodeExecutionStatus.SUCCEEDED,
error=None,
created_by_role=CreatorUserRole.ACCOUNT,
created_by=created_by,
)
@pytest.mark.parametrize(
"sqlite_session",
[(WorkflowNodeExecutionModel, AgentWorkspace, AgentWorkspaceBinding)],
indirect=True,
)
def test_workflow_download_resolves_only_exact_active_owner_chain(
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
) -> None:
valid = _add_binding(
sqlite_session,
binding_id="binding-workflow-valid",
workspace_id="workspace-workflow-valid",
owner_type=AgentWorkspaceOwnerType.WORKFLOW_RUN,
owner_id="run-1",
owner_scope_key="node-1:workflow-binding-1",
)
wrong_owner = _add_binding(
sqlite_session,
binding_id="binding-workflow-wrong-owner",
workspace_id="workspace-workflow-wrong-owner",
owner_type=AgentWorkspaceOwnerType.WORKFLOW_RUN,
owner_id="run-not-the-caller",
owner_scope_key="node-1:workflow-binding-1",
)
retired = AgentWorkspaceBinding(
id="binding-workflow-retired",
tenant_id="tenant-1",
app_id="app-1",
workspace_id=valid.workspace_id,
agent_id="agent-1",
base_home_snapshot_id=None,
agent_config_version_id="binding-workflow-retired-config",
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
backend_binding_ref="binding-workflow-retired-ref",
status=AgentWorkingResourceStatus.RETIRED,
)
sqlite_session.add(retired)
sqlite_session.add_all(
[
_workflow_execution(execution_id="execution-valid", binding_id=valid.id),
_workflow_execution(
execution_id="execution-cross-tenant",
tenant_id="tenant-other",
binding_id=valid.id,
),
_workflow_execution(execution_id="execution-wrong-app", app_id="app-other", binding_id=valid.id),
_workflow_execution(execution_id="execution-wrong-run", workflow_run_id="run-other", binding_id=valid.id),
_workflow_execution(execution_id="execution-wrong-node", node_id="node-other", binding_id=valid.id),
_workflow_execution(execution_id="execution-wrong-owner", binding_id=wrong_owner.id),
_workflow_execution(execution_id="execution-retired", binding_id=retired.id),
]
)
sqlite_session.commit()
persisted_execution = sqlite_session.get(WorkflowNodeExecutionModel, "execution-valid")
assert persisted_execution is not None
assert persisted_execution.created_by == "historical-account"
_use_session(monkeypatch, sqlite_session)
client = _download_client()
request_download = MagicMock(
return_value=SimpleNamespace(download_uri="/files/tools/report.txt?timestamp=1&sign=2")
)
monkeypatch.setattr(
sandbox_module,
"FileRequestService",
lambda: SimpleNamespace(request_download=request_download),
)
monkeypatch.setattr(sandbox_module.dify_config, "FILES_URL", "https://files.example")
service = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(cast(Client, client)))
result = service.download_file(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id="execution-valid",
account_id="authenticated-account",
path="report.txt",
)
assert result.url == "https://files.example/files/tools/report.txt?timestamp=1&sign=2&as_attachment=true"
request = client.download_binding_file_sync.call_args.args[0]
assert request.backend_binding_ref == "binding-workflow-valid-ref"
assert request.execution_context.user_id == "authenticated-account"
assert request.execution_context.user_from == "account"
request_download.assert_called_once_with(
tenant_id="tenant-1",
user_id="authenticated-account",
user_from="account",
invoke_from="debugger",
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:canonical"},
)
client.download_binding_file_sync.reset_mock()
for node_execution_id in (
"execution-cross-tenant",
"execution-wrong-app",
"execution-wrong-run",
"execution-wrong-node",
"execution-wrong-owner",
"execution-retired",
):
with pytest.raises(AgentSandboxInspectorError, match="active Workspace Binding"):
service.download_file(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id=node_execution_id,
account_id="authenticated-account",
path="report.txt",
)
client.download_binding_file_sync.assert_not_called()
assert request_download.call_count == 1
@pytest.mark.parametrize(
@@ -185,12 +709,14 @@ def test_agent_app_file_browsing_uses_build_draft_caller(
binding = SimpleNamespace(
agent_id="agent-1",
backend_binding_ref="binding-build-ref",
agent_config_version_id="config-1",
agent_config_version_kind=AgentConfigVersionKind.DRAFT,
)
get_binding = MagicMock(return_value=binding)
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_binding)
client = MagicMock()
response = WorkspaceListResponse(path=".", entries=[], truncated=False)
client.list_workspace_files_sync.return_value = response
response = BindingFileListResponse(path=".", entries=[], truncated=False)
client.list_binding_files_sync.return_value = response
result = AgentAppSandboxService(client_factory=lambda: nullcontext(client)).list_files(
tenant_id="tenant-1",
@@ -207,7 +733,7 @@ def test_agent_app_file_browsing_uses_build_draft_caller(
assert owner_scope.app_id == runtime_app_id
assert owner_scope.owner_type is AgentWorkspaceOwnerType.BUILD_DRAFT
assert owner_scope.owner_id == "build-1"
client.list_workspace_files_sync.assert_called_once_with("binding-build-ref", ".")
client.list_binding_files_sync.assert_called_once_with("binding-build-ref", ".")
def test_workflow_file_access_uses_node_execution_pointer(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -217,12 +743,17 @@ def test_workflow_file_access_uses_node_execution_pointer(monkeypatch: pytest.Mo
)
session = MagicMock()
session.scalar.return_value = execution
binding = SimpleNamespace(backend_binding_ref="binding-workflow-ref")
binding = SimpleNamespace(
agent_id="agent-1",
backend_binding_ref="binding-workflow-ref",
agent_config_version_id="config-1",
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
)
get_binding = MagicMock(return_value=binding)
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_binding)
client = MagicMock()
response = WorkspaceReadResponse(path="report.txt", size=2, truncated=False, binary=False, text="ok")
client.read_workspace_file_sync.return_value = response
response = BindingFileReadResponse(path="report.txt", size=2, truncated=False, binary=False, text="ok")
client.read_binding_file_sync.return_value = response
result = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).read_file(
tenant_id="tenant-1",
@@ -236,4 +767,238 @@ def test_workflow_file_access_uses_node_execution_pointer(monkeypatch: pytest.Mo
assert result is response
assert get_binding.call_args.kwargs["binding_id"] == "binding-workflow"
client.read_workspace_file_sync.assert_called_once_with("binding-workflow-ref", "report.txt")
client.read_binding_file_sync.assert_called_once_with("binding-workflow-ref", "report.txt")
session.rollback.assert_called_once_with()
def test_workflow_download_uses_authenticated_account_and_trusted_file_request(
monkeypatch: pytest.MonkeyPatch,
) -> None:
execution = SimpleNamespace(
agent_workspace_binding_id="binding-workflow",
process_data_dict={"workflow_agent_binding_id": "workflow-binding-1"},
)
session = MagicMock()
session.scalar.return_value = execution
binding = SimpleNamespace(
agent_id="agent-1",
backend_binding_ref="binding-workflow-ref",
agent_config_version_id="config-1",
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
)
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=binding))
events: list[str] = []
@contextmanager
def session_scope():
try:
yield session
finally:
events.append("session-exit")
monkeypatch.setattr(sandbox_module.session_factory, "create_session", session_scope)
client = MagicMock()
client.download_binding_file_sync.side_effect = lambda _request: (
events.append("client-download") or BindingFileDownloadResponse(reference="dify-file-ref:canonical")
)
request_download = MagicMock(
side_effect=lambda **_kwargs: (
events.append("file-request") or SimpleNamespace(download_uri="/files/tools/report.txt?timestamp=1&sign=2")
)
)
monkeypatch.setattr(
"services.agent_app_sandbox_service.FileRequestService",
lambda: SimpleNamespace(request_download=request_download),
)
monkeypatch.setattr("services.agent_app_sandbox_service.dify_config.FILES_URL", "https://files.example")
result = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).download_file(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id="execution-1",
account_id="account-1",
path="report.txt",
)
request = client.download_binding_file_sync.call_args.args[0]
assert request.execution_context.user_id == "account-1"
assert request.execution_context.user_from == "account"
assert request.execution_context.node_execution_id == "execution-1"
session.rollback.assert_called_once_with()
assert events == ["session-exit", "client-download", "file-request"]
request_download.assert_called_once_with(
tenant_id="tenant-1",
user_id="account-1",
user_from="account",
invoke_from="debugger",
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:canonical"},
)
assert result.url == "https://files.example/files/tools/report.txt?timestamp=1&sign=2&as_attachment=true"
def test_agent_app_download_uses_complete_account_context_after_session_exit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
events: list[str] = []
session = MagicMock()
session.scalar.side_effect = [
SimpleNamespace(app_id="app-1", backing_app_id=None),
SimpleNamespace(agent_workspace_binding_id="binding-build"),
]
@contextmanager
def session_scope():
try:
yield session
finally:
events.append("session-exit")
monkeypatch.setattr(sandbox_module.session_factory, "create_session", session_scope)
binding = SimpleNamespace(
agent_id="agent-1",
backend_binding_ref="binding-build-ref",
agent_config_version_id="config-1",
agent_config_version_kind=AgentConfigVersionKind.DRAFT,
)
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=binding))
client = MagicMock()
client.download_binding_file_sync.side_effect = lambda _request: (
events.append("client-download") or BindingFileDownloadResponse(reference="dify-file-ref:canonical")
)
request_download = MagicMock(
side_effect=lambda **_kwargs: (
events.append("file-request") or SimpleNamespace(download_uri="/files/tools/report.txt?timestamp=1&sign=2")
)
)
monkeypatch.setattr(
sandbox_module,
"FileRequestService",
lambda: SimpleNamespace(request_download=request_download),
)
monkeypatch.setattr(sandbox_module.dify_config, "FILES_URL", "https://files.example")
result = AgentAppSandboxService(client_factory=lambda: nullcontext(client)).download_file(
tenant_id="tenant-1",
app_id="app-1",
agent_id="agent-1",
caller_type="build_draft",
caller_id="build-1",
account_id="account-1",
path="report.txt",
)
request = client.download_binding_file_sync.call_args.args[0]
assert request.backend_binding_ref == "binding-build-ref"
assert request.path == "report.txt"
assert request.execution_context.model_dump(exclude_none=True) == {
"tenant_id": "tenant-1",
"user_id": "account-1",
"user_from": "account",
"app_id": "app-1",
"agent_id": "agent-1",
"agent_config_version_id": "config-1",
"agent_config_version_kind": "draft",
"agent_mode": "agent_app",
"invoke_from": "debugger",
}
assert events == ["session-exit", "client-download", "file-request"]
request_download.assert_called_once_with(
tenant_id="tenant-1",
user_id="account-1",
user_from="account",
invoke_from="debugger",
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:canonical"},
)
assert result.url == "https://files.example/files/tools/report.txt?timestamp=1&sign=2&as_attachment=true"
def test_file_request_rejection_maps_to_download_unavailable(monkeypatch: pytest.MonkeyPatch) -> None:
request_download = MagicMock(side_effect=ValueError("reference is not accessible"))
monkeypatch.setattr(
sandbox_module,
"FileRequestService",
lambda: SimpleNamespace(request_download=request_download),
)
with pytest.raises(AgentSandboxInspectorError) as exc_info:
sandbox_module._download_response(
tenant_id="tenant-1",
account_id="account-1",
reference="dify-file-ref:untrusted",
)
assert exc_info.value.code == "binding_file_download_unavailable"
assert exc_info.value.status_code == 502
@pytest.mark.parametrize(
"execution",
[
pytest.param(
SimpleNamespace(
agent_workspace_binding_id=None,
process_data_dict={"workflow_agent_binding_id": "workflow-binding-1"},
),
id="missing-binding-pointer",
),
pytest.param(
SimpleNamespace(
agent_workspace_binding_id="binding-workflow",
process_data_dict={},
),
id="missing-process-data",
),
],
)
def test_workflow_download_rejects_missing_binding_metadata_before_network(
monkeypatch: pytest.MonkeyPatch,
execution: SimpleNamespace,
) -> None:
session = MagicMock()
session.scalar.return_value = execution
_use_session(monkeypatch, session)
client = MagicMock()
with pytest.raises(AgentSandboxInspectorError) as exc_info:
WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).download_file(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id="execution-1",
account_id="account-1",
path="report.txt",
)
assert exc_info.value.code == "no_active_binding"
client.download_binding_file_sync.assert_not_called()
def test_workflow_download_rejects_non_active_or_mismatched_binding_before_network(
monkeypatch: pytest.MonkeyPatch,
) -> None:
execution = SimpleNamespace(
agent_workspace_binding_id="binding-workflow",
process_data_dict={"workflow_agent_binding_id": "workflow-binding-1"},
)
session = MagicMock()
session.scalar.return_value = execution
_use_session(monkeypatch, session)
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=None))
client = MagicMock()
with pytest.raises(AgentSandboxInspectorError) as exc_info:
WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).download_file(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="run-1",
node_id="node-1",
node_execution_id="execution-1",
account_id="account-1",
path="report.txt",
)
assert exc_info.value.code == "no_active_binding"
client.download_binding_file_sync.assert_not_called()