mirror of
https://github.com/langgenius/dify.git
synced 2026-06-11 02:31:13 +08:00
feat(dify-agent): sync shell and back proxy updates (#37159)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
629e046303
commit
ba9975a083
@ -9,6 +9,7 @@ from werkzeug.exceptions import Forbidden
|
||||
import services
|
||||
from core.tools.signature import verify_plugin_file_signature
|
||||
from core.tools.tool_file_manager import ToolFileManager
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from fields.file_fields import FileResponse
|
||||
|
||||
from ..common.errors import (
|
||||
@ -58,7 +59,8 @@ class PluginUploadFileApi(Resource):
|
||||
The file must be accompanied by valid timestamp, nonce, and signature parameters.
|
||||
|
||||
Returns:
|
||||
dict: File metadata including ID, URLs, and properties
|
||||
dict: File metadata including ID, canonical ``reference`` for
|
||||
output-file reconstruction, URLs, and properties
|
||||
int: HTTP status code (201 for success)
|
||||
|
||||
Raises:
|
||||
@ -112,6 +114,7 @@ class PluginUploadFileApi(Resource):
|
||||
# Create a dictionary with all the necessary attributes
|
||||
result = FileResponse(
|
||||
id=tool_file.id,
|
||||
reference=build_file_reference(record_id=tool_file.id),
|
||||
name=tool_file.name,
|
||||
size=tool_file.size,
|
||||
extension=extension,
|
||||
|
||||
@ -30,11 +30,12 @@ from core.plugin.entities.request import (
|
||||
)
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from core.tools.signature import get_signed_file_url_for_plugin
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||
from libs.helper import length_prefixed_response
|
||||
from models import Account, Tenant
|
||||
from models.model import EndUser
|
||||
from services.agent_file_request_service import AgentFileDownloadRequestService, FileDownloadRequestError
|
||||
from services.file_request_service import FileRequestService
|
||||
|
||||
|
||||
@inner_api_ns.route("/invoke/llm")
|
||||
@ -433,32 +434,50 @@ class PluginUploadFileRequestApi(Resource):
|
||||
|
||||
@inner_api_ns.route("/download/file/request")
|
||||
class PluginDownloadFileRequestApi(Resource):
|
||||
@get_user_tenant
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@plugin_data(payload_type=RequestRequestDownloadFile)
|
||||
@inner_api_ns.doc("plugin_download_file_request")
|
||||
@inner_api_ns.doc(description="Request a signed download URL for a workflow file ref")
|
||||
@inner_api_ns.doc(description="Request signed URL for file download through plugin interface")
|
||||
@inner_api_ns.doc(
|
||||
responses={
|
||||
200: "Signed download URL generated successfully",
|
||||
400: "Invalid access context or file mapping",
|
||||
200: "Signed URL generated successfully",
|
||||
401: "Unauthorized - invalid API key",
|
||||
404: "File not accessible to the tenant/user",
|
||||
404: "Service not available",
|
||||
}
|
||||
)
|
||||
def post(self, user_model: Account | EndUser, tenant_model: Tenant, payload: RequestRequestDownloadFile):
|
||||
try:
|
||||
data = AgentFileDownloadRequestService.resolve(
|
||||
tenant_id=tenant_model.id,
|
||||
user_id=user_model.id,
|
||||
user_from=payload.user_from,
|
||||
invoke_from=payload.invoke_from,
|
||||
file_mapping=payload.file,
|
||||
)
|
||||
except FileDownloadRequestError as exc:
|
||||
return BaseBackwardsInvocationResponse(error=exc.message).model_dump(), exc.status_code
|
||||
return BaseBackwardsInvocationResponse(data=data).model_dump()
|
||||
def post(self, payload: RequestRequestDownloadFile):
|
||||
"""Resolve signed download metadata for trusted external runtimes.
|
||||
|
||||
Unlike end-user-facing upload/download APIs, this inner endpoint serves
|
||||
trusted callers such as the ``dify-agent`` back proxy. The caller sends
|
||||
flattened ``tenant_id`` / ``user_id`` / ``user_from`` / ``invoke_from``
|
||||
context explicitly in the body, and ``FileRequestService`` rebuilds the
|
||||
corresponding ``FileAccessScope`` before resolving the signed URL.
|
||||
|
||||
The response is control-plane metadata only: filename, mime type, size,
|
||||
and the signed download URL. File bytes still flow through the existing
|
||||
signed file endpoints rather than through this inner API.
|
||||
"""
|
||||
tenant_model = db.session.get(Tenant, payload.tenant_id)
|
||||
if tenant_model is None:
|
||||
raise ValueError("tenant not found")
|
||||
|
||||
result = FileRequestService().request_download_url(
|
||||
tenant_id=tenant_model.id,
|
||||
user_id=payload.user_id,
|
||||
user_from=payload.user_from,
|
||||
invoke_from=payload.invoke_from,
|
||||
file_mapping=payload.file.model_dump(mode="python", exclude_none=True),
|
||||
)
|
||||
return BaseBackwardsInvocationResponse(
|
||||
data={
|
||||
"filename": result.filename,
|
||||
"mime_type": result.mime_type,
|
||||
"size": result.size,
|
||||
"download_url": result.download_url,
|
||||
}
|
||||
).model_dump()
|
||||
|
||||
|
||||
@inner_api_ns.route("/fetch/app/info")
|
||||
|
||||
@ -4,10 +4,11 @@ from collections.abc import Mapping
|
||||
from typing import Any, Literal
|
||||
|
||||
from flask import Response
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from core.entities.provider_entities import BasicProviderConfig
|
||||
from core.plugin.utils.http_parser import deserialize_response
|
||||
from core.workflow.file_reference import is_canonical_file_reference
|
||||
from graphon.model_runtime.entities.message_entities import (
|
||||
AssistantPromptMessage,
|
||||
PromptMessage,
|
||||
@ -231,18 +232,51 @@ class RequestRequestUploadFile(BaseModel):
|
||||
mimetype: str
|
||||
|
||||
|
||||
class RequestDownloadFileMapping(BaseModel):
|
||||
"""File mapping accepted by trusted download-request control-plane APIs."""
|
||||
|
||||
transfer_method: Literal["local_file", "tool_file", "datasource_file", "remote_url"]
|
||||
reference: str | None = None
|
||||
url: str | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_locator(self) -> "RequestDownloadFileMapping":
|
||||
if self.transfer_method == "remote_url":
|
||||
if not self.url:
|
||||
raise ValueError("url is required when transfer_method is remote_url")
|
||||
if self.reference is not None:
|
||||
raise ValueError("reference is not allowed when transfer_method is remote_url")
|
||||
return self
|
||||
if not self.reference:
|
||||
raise ValueError("reference is required for non-remote file mappings")
|
||||
if not is_canonical_file_reference(self.reference):
|
||||
raise ValueError("reference must be a canonical Dify file reference")
|
||||
if self.url is not None:
|
||||
raise ValueError("url is not allowed for non-remote file mappings")
|
||||
return self
|
||||
|
||||
|
||||
class RequestRequestDownloadFile(BaseModel):
|
||||
"""Request a signed download URL for a workflow file ref (Agent Files §3.1.1).
|
||||
"""Request to resolve a signed download URL for one runtime file mapping."""
|
||||
|
||||
``user_from`` / ``invoke_from`` are the flattened Dify file-access context (the
|
||||
dify-agent server reads them from the execution context). ``file`` is a standard
|
||||
file mapping: ``transfer_method`` plus ``reference`` (local_file / tool_file /
|
||||
datasource_file) or ``url`` (remote_url).
|
||||
"""
|
||||
tenant_id: str
|
||||
user_id: str
|
||||
user_from: Literal["account", "end-user"]
|
||||
invoke_from: Literal[
|
||||
"service-api",
|
||||
"openapi",
|
||||
"web-app",
|
||||
"trigger",
|
||||
"explore",
|
||||
"debugger",
|
||||
"published",
|
||||
"validation",
|
||||
]
|
||||
file: RequestDownloadFileMapping
|
||||
|
||||
user_from: str
|
||||
invoke_from: str
|
||||
file: Mapping[str, Any]
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RequestFetchAppInfo(BaseModel):
|
||||
|
||||
@ -1,3 +1,23 @@
|
||||
"""Opaque file reference helpers for workflow/runtime-facing file identities.
|
||||
|
||||
The canonical Dify file reference format is ``dify-file-ref:<base64url-json>``
|
||||
where the JSON payload always contains ``record_id`` and may optionally carry
|
||||
``storage_key`` for older compatibility paths. New agent-v2 file output and
|
||||
download contracts require this opaque canonical format instead of raw record
|
||||
ids.
|
||||
|
||||
This module intentionally exposes both strict and permissive helpers:
|
||||
|
||||
- :func:`is_canonical_file_reference` is the strict validator for new
|
||||
canonical-only contracts.
|
||||
- :func:`parse_file_reference` and :func:`resolve_file_record_id` remain lenient
|
||||
so historical rows and legacy payloads that stored raw ids or malformed
|
||||
values can still be read.
|
||||
|
||||
Callers enforcing canonical-only behavior must not use the permissive helpers as
|
||||
validators.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
@ -14,6 +34,11 @@ class FileReference:
|
||||
|
||||
|
||||
def build_file_reference(*, record_id: str, storage_key: str | None = None) -> str:
|
||||
"""Build one canonical opaque ``dify-file-ref:...`` string.
|
||||
|
||||
New external/runtime contracts should emit this value instead of exposing
|
||||
the underlying DB record id directly.
|
||||
"""
|
||||
payload = {"record_id": record_id}
|
||||
if storage_key is not None:
|
||||
payload["storage_key"] = storage_key
|
||||
@ -22,6 +47,12 @@ def build_file_reference(*, record_id: str, storage_key: str | None = None) -> s
|
||||
|
||||
|
||||
def parse_file_reference(reference: str | None) -> FileReference | None:
|
||||
"""Best-effort parse for canonical and historical file references.
|
||||
|
||||
This helper is intentionally lenient: when the input is a raw id or a
|
||||
malformed canonical string, it falls back to treating the whole input as the
|
||||
``record_id`` so older persisted payloads remain readable.
|
||||
"""
|
||||
if not reference:
|
||||
return None
|
||||
|
||||
@ -45,7 +76,25 @@ def parse_file_reference(reference: str | None) -> FileReference | None:
|
||||
return FileReference(record_id=record_id, storage_key=storage_key)
|
||||
|
||||
|
||||
def is_canonical_file_reference(reference: str | None) -> bool:
|
||||
"""Return whether one value matches the strict canonical file format.
|
||||
|
||||
Use this when new contracts require ``dify-file-ref:...`` and raw record ids
|
||||
must be rejected.
|
||||
"""
|
||||
parsed_reference = parse_file_reference(reference)
|
||||
if parsed_reference is None or reference is None:
|
||||
return False
|
||||
return reference.startswith(_FILE_REFERENCE_PREFIX) and parsed_reference.record_id != reference
|
||||
|
||||
|
||||
def resolve_file_record_id(reference: str | None) -> str | None:
|
||||
"""Resolve one file reference back to a record id permissively.
|
||||
|
||||
This is a compatibility helper, not a canonical-format validator.
|
||||
Canonical-only call sites should validate with
|
||||
:func:`is_canonical_file_reference` first.
|
||||
"""
|
||||
parsed_reference = parse_file_reference(reference)
|
||||
if parsed_reference is None:
|
||||
return None
|
||||
|
||||
@ -312,7 +312,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
tenant_id=dify_ctx.tenant_id,
|
||||
declared_outputs=effective_outputs,
|
||||
)
|
||||
)
|
||||
return
|
||||
@ -343,7 +343,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
tenant_id=dify_ctx.tenant_id,
|
||||
declared_outputs=effective_outputs,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
"""Tenant-scope validator for file refs produced by Agent backend outputs.
|
||||
|
||||
Stage 4 §5.3 / Agent Files §4.6: every file output the Agent backend produces
|
||||
must resolve to a file record owned by the current tenant; cross-tenant file
|
||||
references must never be plumbed downstream. Agent runtime output files are
|
||||
canonically ``ToolFile`` (referenced by a minimal ``{id}``), so this validator
|
||||
checks ``tool_files`` first and falls back to ``upload_files`` for compatibility
|
||||
with older/manual refs. ``PerOutputTypeChecker`` accepts a ``FileTenantValidator``
|
||||
Protocol so unit tests can stub the check without hitting Postgres.
|
||||
Agent outputs can point at tenant-owned ``upload_files`` or ``tool_files``
|
||||
records. Sandbox-originated output uploads become ``ToolFile`` rows, so the
|
||||
validator must accept both storage record families while still rejecting any
|
||||
cross-tenant or malformed identifier. ``PerOutputTypeChecker`` accepts a
|
||||
``FileTenantValidator`` Protocol so unit tests can stub the check without
|
||||
hitting Postgres.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -17,12 +16,13 @@ from sqlalchemy import select
|
||||
from sqlalchemy.exc import DataError, SQLAlchemyError
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from graphon.file import FileTransferMethod
|
||||
from models import ToolFile
|
||||
from models.model import UploadFile
|
||||
from models.tools import ToolFile
|
||||
|
||||
|
||||
class AgentOutputFileTenantValidator:
|
||||
"""Production ``FileTenantValidator`` backed by ``tool_files`` + ``upload_files``.
|
||||
class DatabaseFileTenantValidator:
|
||||
"""Production ``FileTenantValidator`` backed by file ownership tables.
|
||||
|
||||
Returns ``False`` (rejects the file) on any pathological input: empty
|
||||
file_id/tenant_id, non-UUID file_id format, DB errors. The Agent backend
|
||||
@ -31,24 +31,32 @@ class AgentOutputFileTenantValidator:
|
||||
workflow node from crashing on garbage backend output.
|
||||
"""
|
||||
|
||||
def is_owned_by_tenant(self, *, file_id: str, tenant_id: str) -> bool:
|
||||
def is_accessible_file_mapping(
|
||||
self,
|
||||
*,
|
||||
file_id: str,
|
||||
tenant_id: str,
|
||||
transfer_method: FileTransferMethod,
|
||||
) -> bool:
|
||||
if not file_id or not tenant_id:
|
||||
return False
|
||||
try:
|
||||
UUID(file_id)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
try:
|
||||
with session_factory.create_session() as session:
|
||||
# Agent output files are canonically ToolFile; check it first.
|
||||
tool_owner = session.scalar(select(ToolFile.tenant_id).where(ToolFile.id == file_id))
|
||||
if tool_owner is not None:
|
||||
return tool_owner == tenant_id
|
||||
upload_owner = session.scalar(select(UploadFile.tenant_id).where(UploadFile.id == file_id))
|
||||
if transfer_method in {FileTransferMethod.LOCAL_FILE, FileTransferMethod.DATASOURCE_FILE}:
|
||||
owner_tenant_id = session.scalar(select(UploadFile.tenant_id).where(UploadFile.id == file_id))
|
||||
elif transfer_method == FileTransferMethod.TOOL_FILE:
|
||||
owner_tenant_id = session.scalar(select(ToolFile.tenant_id).where(ToolFile.id == file_id))
|
||||
else:
|
||||
return False
|
||||
except (DataError, SQLAlchemyError):
|
||||
return False
|
||||
return upload_owner == tenant_id
|
||||
return owner_tenant_id == tenant_id
|
||||
|
||||
|
||||
# Back-compat alias for callers/tests that imported the upload-only name.
|
||||
UploadFileTenantValidator = AgentOutputFileTenantValidator
|
||||
AgentOutputFileTenantValidator = DatabaseFileTenantValidator
|
||||
UploadFileTenantValidator = DatabaseFileTenantValidator
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, Protocol
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendInternalEvent,
|
||||
@ -11,21 +11,37 @@ from clients.agent_backend import (
|
||||
AgentBackendRunPausedInternalEvent,
|
||||
AgentBackendRunSucceededInternalEvent,
|
||||
)
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.workflow.file_reference import is_canonical_file_reference
|
||||
from factories.file_factory.builders import build_from_mapping
|
||||
from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.model_runtime.entities.llm_entities import LLMUsage
|
||||
from graphon.node_events import NodeRunResult
|
||||
from graphon.variables.segments import ArrayFileSegment, FileSegment
|
||||
from models.agent_config_entities import DeclaredOutputConfig, DeclaredOutputType
|
||||
|
||||
|
||||
class ToolFileRebacker(Protocol):
|
||||
def __call__(self, *, tenant_id: str, tool_file_id: str) -> File | None: ...
|
||||
|
||||
|
||||
class WorkflowAgentOutputAdapter:
|
||||
"""Convert terminal Agent backend events into workflow node run results."""
|
||||
"""Convert terminal Agent backend events into workflow node run results.
|
||||
|
||||
def __init__(self, *, tool_file_rebacker: Callable[..., File | None] | None = None) -> None:
|
||||
# Agent Files §4.6: resolve a bare ToolFile id into a graphon File whose
|
||||
# metadata comes from the ToolFile row (not the untrusted sandbox payload).
|
||||
# Injected so unit tests can stub it without DB access; None keeps the
|
||||
# legacy payload-only behaviour for non-file or rich-payload outputs.
|
||||
``DifyAgentNode`` relies on this after the earlier per-output type-check pass:
|
||||
once the backend payload has been validated against declared ``FILE`` or
|
||||
``ARRAY[FILE]`` outputs, this adapter can safely convert canonical file
|
||||
mappings into ``FileSegment`` values without reintroducing false positives
|
||||
for normal object outputs. Older Agent backend builds may still return bare
|
||||
ToolFile ids (``{"id": "..."}``); when a ``ToolFileRebacker`` is provided,
|
||||
those ids are treated as a backwards-compatible fallback and hydrated from
|
||||
the server-side ToolFile row instead of trusted from the sandbox payload.
|
||||
"""
|
||||
|
||||
_tool_file_rebacker: ToolFileRebacker | None
|
||||
|
||||
def __init__(self, *, tool_file_rebacker: ToolFileRebacker | None = None) -> None:
|
||||
self._tool_file_rebacker = tool_file_rebacker
|
||||
|
||||
def build_success_result(
|
||||
@ -35,15 +51,33 @@ class WorkflowAgentOutputAdapter:
|
||||
inputs: dict[str, Any],
|
||||
process_data: dict[str, Any],
|
||||
metadata: dict[str, Any],
|
||||
declared_outputs: Sequence[DeclaredOutputConfig] | None = None,
|
||||
tenant_id: str | None = None,
|
||||
) -> NodeRunResult:
|
||||
"""Build the successful node result from one backend terminal event.
|
||||
|
||||
``declared_outputs`` is optional for generic normalization, but callers
|
||||
should pass it from the earlier type-checking stage so canonical file
|
||||
mappings are normalized on the correct declared fields only.
|
||||
|
||||
Canonical persisted-file mappings (``local_file`` / ``tool_file`` /
|
||||
``datasource_file``) also require ``metadata["tenant_id"]`` so the
|
||||
adapter can hydrate filename / extension / mime metadata through the
|
||||
server-side file factory before producing ``FileSegment`` values.
|
||||
"""
|
||||
metadata = self._with_terminal_metadata(metadata, event, "succeeded")
|
||||
usage = self._usage_from_metadata(metadata)
|
||||
metadata_tenant_id = metadata.get("tenant_id")
|
||||
resolved_tenant_id = tenant_id or (metadata_tenant_id if isinstance(metadata_tenant_id, str) else None)
|
||||
return NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
outputs=self._normalize_outputs(event.output, tenant_id=tenant_id),
|
||||
outputs=self._normalize_outputs(
|
||||
event.output,
|
||||
declared_outputs=declared_outputs,
|
||||
tenant_id=resolved_tenant_id,
|
||||
),
|
||||
metadata=self._build_node_metadata(metadata=metadata, usage=usage),
|
||||
llm_usage=usage or LLMUsage.empty_usage(),
|
||||
)
|
||||
@ -109,149 +143,158 @@ class WorkflowAgentOutputAdapter:
|
||||
error_type="agent_backend_stream_error",
|
||||
)
|
||||
|
||||
def _normalize_outputs(self, output: Any, *, tenant_id: str | None) -> dict[str, Any]:
|
||||
def _normalize_outputs(
|
||||
self,
|
||||
output: Any,
|
||||
*,
|
||||
declared_outputs: Sequence[DeclaredOutputConfig] | None = None,
|
||||
tenant_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Normalize backend output payloads into workflow-facing values.
|
||||
|
||||
Field values remain untouched unless the declared output type says they
|
||||
should be interpreted as files. Non-remote canonical mappings depend on
|
||||
``tenant_id`` so persisted file metadata can be reconstructed through the
|
||||
DB-backed file factory.
|
||||
"""
|
||||
if isinstance(output, dict):
|
||||
if self._is_file_payload(output):
|
||||
file = self._file_from_payload(output, tenant_id=tenant_id)
|
||||
if file is not None:
|
||||
return {"file": FileSegment(value=file)}
|
||||
return {key: self._normalize_output_value(value, tenant_id=tenant_id) for key, value in output.items()}
|
||||
declared_outputs_by_name = {declared.name: declared for declared in declared_outputs or ()}
|
||||
return {
|
||||
key: self._normalize_output_value(
|
||||
value,
|
||||
declared_output=declared_outputs_by_name.get(key),
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
for key, value in output.items()
|
||||
}
|
||||
if isinstance(output, str):
|
||||
return {"text": output}
|
||||
return {"result": output}
|
||||
|
||||
def _normalize_output_value(self, value: Any, *, tenant_id: str | None) -> Any:
|
||||
def _normalize_output_value(
|
||||
self,
|
||||
value: Any,
|
||||
*,
|
||||
declared_output: DeclaredOutputConfig | None = None,
|
||||
tenant_id: str | None = None,
|
||||
) -> Any:
|
||||
if isinstance(value, File | FileSegment | ArrayFileSegment):
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
if self._is_file_payload(value):
|
||||
file = self._file_from_payload(value, tenant_id=tenant_id)
|
||||
if file is not None:
|
||||
return FileSegment(value=file)
|
||||
# A bare ref that did not resolve to a tenant file: treat as a plain object.
|
||||
return {key: self._normalize_output_value(item, tenant_id=tenant_id) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
if value and all(isinstance(item, Mapping) and self._is_file_payload(item) for item in value):
|
||||
files = [self._file_from_payload(item, tenant_id=tenant_id) for item in value]
|
||||
if all(file is not None for file in files):
|
||||
return ArrayFileSegment(value=[file for file in files if file is not None])
|
||||
return [self._normalize_output_value(item, tenant_id=tenant_id) for item in value]
|
||||
legacy_tool_file_value = self._normalize_legacy_tool_file_value(value, tenant_id=tenant_id)
|
||||
if legacy_tool_file_value is not None:
|
||||
return legacy_tool_file_value
|
||||
if declared_output is not None:
|
||||
normalized_declared_value = self._normalize_declared_output_value(
|
||||
value,
|
||||
declared_output=declared_output,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
if normalized_declared_value is not None:
|
||||
return normalized_declared_value
|
||||
return value
|
||||
|
||||
# Keys a file-output ref may legitimately carry. A dict is treated as a file
|
||||
# ref only if it has an id/url AND every key is one of these — so a bare
|
||||
# ``{"id": "..."}`` (Agent Files §4.6 canonical) is recognized while ordinary
|
||||
# business objects that merely contain an ``id`` field are not.
|
||||
_FILE_FIELD_KEYS: frozenset[str] = frozenset(
|
||||
{
|
||||
"id",
|
||||
"file_id",
|
||||
"upload_file_id",
|
||||
"tool_file_id",
|
||||
"url",
|
||||
"remote_url",
|
||||
"filename",
|
||||
"name",
|
||||
"mime_type",
|
||||
"mimetype",
|
||||
"extension",
|
||||
"size",
|
||||
"type",
|
||||
"file_type",
|
||||
}
|
||||
)
|
||||
def _normalize_declared_output_value(
|
||||
self,
|
||||
value: Any,
|
||||
*,
|
||||
declared_output: DeclaredOutputConfig,
|
||||
tenant_id: str | None = None,
|
||||
) -> Any | None:
|
||||
if declared_output.type == DeclaredOutputType.FILE and isinstance(value, Mapping):
|
||||
return self._file_segment_from_payload(value, tenant_id=tenant_id)
|
||||
if (
|
||||
declared_output.type == DeclaredOutputType.ARRAY
|
||||
and declared_output.array_item is not None
|
||||
and declared_output.array_item.type == DeclaredOutputType.FILE
|
||||
and isinstance(value, list)
|
||||
and all(isinstance(item, Mapping) for item in value)
|
||||
):
|
||||
return ArrayFileSegment(value=[self._file_from_payload(item, tenant_id=tenant_id) for item in value])
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _is_file_payload(cls, value: Mapping[str, Any]) -> bool:
|
||||
has_ref = any(
|
||||
isinstance(value.get(key), str) and value.get(key)
|
||||
for key in ("id", "file_id", "upload_file_id", "tool_file_id", "url", "remote_url")
|
||||
def _normalize_legacy_tool_file_value(
|
||||
self,
|
||||
value: Any,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
) -> FileSegment | ArrayFileSegment | None:
|
||||
if isinstance(value, Mapping):
|
||||
file = self._legacy_tool_file_from_payload(value, tenant_id=tenant_id)
|
||||
return FileSegment(value=file) if file is not None else None
|
||||
if isinstance(value, list) and value and all(isinstance(item, Mapping) for item in value):
|
||||
files = [self._legacy_tool_file_from_payload(item, tenant_id=tenant_id) for item in value]
|
||||
if all(file is not None for file in files):
|
||||
return ArrayFileSegment(value=[file for file in files if file is not None])
|
||||
return None
|
||||
|
||||
def _legacy_tool_file_from_payload(self, value: Mapping[str, Any], *, tenant_id: str | None) -> File | None:
|
||||
if self._tool_file_rebacker is None or tenant_id is None or set(value) != {"id"}:
|
||||
return None
|
||||
tool_file_id = value.get("id")
|
||||
if not isinstance(tool_file_id, str) or not tool_file_id:
|
||||
return None
|
||||
return self._tool_file_rebacker(tenant_id=tenant_id, tool_file_id=tool_file_id)
|
||||
|
||||
def _file_segment_from_payload(self, value: Mapping[str, Any], *, tenant_id: str | None) -> FileSegment:
|
||||
return FileSegment(value=self._file_from_payload(value, tenant_id=tenant_id))
|
||||
|
||||
def _file_from_payload(self, value: Mapping[str, Any], *, tenant_id: str | None) -> File:
|
||||
transfer_method_raw = value.get("transfer_method")
|
||||
if not isinstance(transfer_method_raw, str):
|
||||
raise ValueError("file mapping missing transfer_method")
|
||||
transfer_method = FileTransferMethod.value_of(transfer_method_raw)
|
||||
|
||||
expected_keys = (
|
||||
{"transfer_method", "url"}
|
||||
if transfer_method == FileTransferMethod.REMOTE_URL
|
||||
else {
|
||||
"transfer_method",
|
||||
"reference",
|
||||
}
|
||||
)
|
||||
if set(value) != expected_keys:
|
||||
raise ValueError(f"{transfer_method.value} file mapping must contain exactly {sorted(expected_keys)}")
|
||||
|
||||
remote_url = self._string_value(value.get("url"))
|
||||
reference = self._string_value(value.get("reference"))
|
||||
|
||||
if transfer_method == FileTransferMethod.REMOTE_URL:
|
||||
if remote_url is None:
|
||||
raise ValueError("remote_url file mapping missing url")
|
||||
return File(
|
||||
type=FileType.CUSTOM,
|
||||
transfer_method=transfer_method,
|
||||
remote_url=remote_url,
|
||||
reference=None,
|
||||
filename=None,
|
||||
extension=None,
|
||||
mime_type=None,
|
||||
size=-1,
|
||||
)
|
||||
elif reference is None:
|
||||
raise ValueError(f"{transfer_method.value} file mapping missing reference")
|
||||
elif not is_canonical_file_reference(reference):
|
||||
raise ValueError(f"{transfer_method.value} file mapping has invalid canonical reference")
|
||||
if tenant_id is None:
|
||||
raise ValueError("tenant_id is required to reconstruct persisted file mappings")
|
||||
|
||||
return self._restore_file_from_canonical_mapping(
|
||||
mapping=value,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
return has_ref and all(key in cls._FILE_FIELD_KEYS for key in value)
|
||||
|
||||
@staticmethod
|
||||
def _is_rich_payload(value: Mapping[str, Any]) -> bool:
|
||||
"""The payload carries its own metadata, so it can build a File without DB reback."""
|
||||
return any(value.get(key) for key in ("filename", "name", "mime_type", "mimetype", "url", "remote_url"))
|
||||
|
||||
def _file_from_payload(self, value: Mapping[str, Any], *, tenant_id: str | None) -> File | None:
|
||||
# Canonical Agent output file is a ToolFile referenced by ``id`` (or the
|
||||
# ``tool_file_id`` alias). Reback its metadata authoritatively from the
|
||||
# ToolFile row instead of trusting the sandbox payload.
|
||||
tool_file_id = self._string_value(value.get("tool_file_id") or value.get("id"))
|
||||
remote_url = self._string_value(value.get("remote_url") or value.get("url"))
|
||||
upload_file_id = self._string_value(value.get("upload_file_id") or value.get("file_id"))
|
||||
|
||||
if tool_file_id and self._tool_file_rebacker is not None and tenant_id:
|
||||
rebacked = self._tool_file_rebacker(tenant_id=tenant_id, tool_file_id=tool_file_id)
|
||||
if rebacked is not None:
|
||||
return rebacked
|
||||
|
||||
# No authoritative reback: only build a File from the payload when it
|
||||
# actually carries file metadata; a bare unresolved id is not a file.
|
||||
if not self._is_rich_payload(value):
|
||||
return None
|
||||
|
||||
filename = self._string_value(value.get("filename") or value.get("name"))
|
||||
mime_type = self._string_value(value.get("mime_type") or value.get("mimetype"))
|
||||
extension = self._extension_from_payload(value, filename)
|
||||
file_type = self._file_type_from_payload(value, mime_type)
|
||||
size = value.get("size")
|
||||
if not isinstance(size, int):
|
||||
size = -1
|
||||
|
||||
if tool_file_id:
|
||||
transfer_method = FileTransferMethod.TOOL_FILE
|
||||
related_id = tool_file_id
|
||||
elif remote_url:
|
||||
transfer_method = FileTransferMethod.REMOTE_URL
|
||||
related_id = None
|
||||
else:
|
||||
transfer_method = FileTransferMethod.LOCAL_FILE
|
||||
related_id = upload_file_id
|
||||
|
||||
return File(
|
||||
type=file_type,
|
||||
transfer_method=transfer_method,
|
||||
remote_url=remote_url if transfer_method == FileTransferMethod.REMOTE_URL else None,
|
||||
related_id=related_id,
|
||||
filename=filename,
|
||||
extension=extension,
|
||||
mime_type=mime_type,
|
||||
size=size,
|
||||
def _restore_file_from_canonical_mapping(*, mapping: Mapping[str, Any], tenant_id: str) -> File:
|
||||
return build_from_mapping(
|
||||
mapping=mapping,
|
||||
tenant_id=tenant_id,
|
||||
access_controller=DatabaseFileAccessController(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _string_value(value: Any) -> str | None:
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
@classmethod
|
||||
def _extension_from_payload(cls, value: Mapping[str, Any], filename: str | None) -> str | None:
|
||||
extension = cls._string_value(value.get("extension"))
|
||||
if extension:
|
||||
return extension if extension.startswith(".") else f".{extension}"
|
||||
if filename and "." in filename:
|
||||
return f".{filename.rsplit('.', 1)[1]}"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _file_type_from_payload(value: Mapping[str, Any], mime_type: str | None) -> FileType:
|
||||
explicit_type = value.get("type") or value.get("file_type")
|
||||
if isinstance(explicit_type, str):
|
||||
try:
|
||||
return FileType(explicit_type)
|
||||
except ValueError:
|
||||
pass
|
||||
if mime_type:
|
||||
if mime_type.startswith("image/"):
|
||||
return FileType.IMAGE
|
||||
if mime_type.startswith("audio/"):
|
||||
return FileType.AUDIO
|
||||
if mime_type.startswith("video/"):
|
||||
return FileType.VIDEO
|
||||
return FileType.DOCUMENT
|
||||
return FileType.CUSTOM
|
||||
|
||||
@staticmethod
|
||||
def _usage_from_metadata(metadata: Mapping[str, Any]) -> LLMUsage | None:
|
||||
agent_backend = metadata.get("agent_backend")
|
||||
|
||||
@ -7,8 +7,9 @@ inside pydantic-ai), the API side runs a *second* pass that:
|
||||
1. Locates each declared output by name in the backend payload.
|
||||
2. Asserts the value's shape against the declared ``DeclaredOutputType``
|
||||
(including array items and file ref objects).
|
||||
3. For file outputs, verifies the referenced ``file_id`` resolves to a file
|
||||
owned by the current tenant (PRD §5.3 file output reference safety).
|
||||
3. For file outputs, validates the canonical file mapping contract and verifies
|
||||
any referenced file record resolves to a file owned by the current tenant
|
||||
(PRD §5.3 file output reference safety).
|
||||
|
||||
The checker is intentionally pure: it takes data in and returns a structured
|
||||
outcome out. ``FileTenantValidator`` is injected as a Protocol so unit tests
|
||||
@ -22,6 +23,8 @@ from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any, Protocol
|
||||
|
||||
from core.workflow.file_reference import is_canonical_file_reference, parse_file_reference
|
||||
from graphon.file import FileTransferMethod
|
||||
from models.agent_config_entities import (
|
||||
DeclaredArrayItem,
|
||||
DeclaredOutputConfig,
|
||||
@ -73,22 +76,23 @@ class OutputTypeCheckOutcome:
|
||||
|
||||
|
||||
class FileTenantValidator(Protocol):
|
||||
"""Verify a file ref resolves to a file owned by the given tenant."""
|
||||
"""Verify one canonical file mapping resolves to an accessible tenant file."""
|
||||
|
||||
def is_owned_by_tenant(self, *, file_id: str, tenant_id: str) -> bool: ...
|
||||
|
||||
|
||||
# Recognized id fields in a file-output ref. Agent Files §4.6: the canonical
|
||||
# minimal form is ``{"id": "<tool_file_id>"}``; the rest are accepted aliases.
|
||||
_FILE_ID_KEYS: tuple[str, ...] = ("id", "file_id", "upload_file_id", "tool_file_id")
|
||||
def is_accessible_file_mapping(
|
||||
self,
|
||||
*,
|
||||
file_id: str,
|
||||
tenant_id: str,
|
||||
transfer_method: FileTransferMethod,
|
||||
) -> bool: ...
|
||||
|
||||
|
||||
class PerOutputTypeChecker:
|
||||
"""Validate that each declared output is present and shaped correctly.
|
||||
|
||||
The checker handles array items recursively and is opinionated about file
|
||||
refs: only dicts with at least one recognized id key plus a tenant-scope
|
||||
match pass. Stage 4 §5.2 + §5.3.
|
||||
refs: only canonical file mappings are accepted for Agent v2 output files.
|
||||
Stage 4 §5.2 + §5.3.
|
||||
"""
|
||||
|
||||
def __init__(self, file_validator: FileTenantValidator) -> None:
|
||||
@ -227,18 +231,59 @@ class PerOutputTypeChecker:
|
||||
|
||||
def _validate_file_value(self, *, value: Any, tenant_id: str) -> str | None:
|
||||
if not isinstance(value, Mapping):
|
||||
return f"expected file ref object, got {type(value).__name__}"
|
||||
file_id = self._extract_file_id(value)
|
||||
if file_id is None:
|
||||
return "file ref missing a recognized file_id field"
|
||||
if not self._file_validator.is_owned_by_tenant(file_id=file_id, tenant_id=tenant_id):
|
||||
return f"file_id {file_id!r} is not accessible to tenant {tenant_id!r}"
|
||||
return None
|
||||
return f"expected canonical file mapping object, got {type(value).__name__}"
|
||||
|
||||
@staticmethod
|
||||
def _extract_file_id(value: Mapping[str, Any]) -> str | None:
|
||||
for key in _FILE_ID_KEYS:
|
||||
candidate = value.get(key)
|
||||
if isinstance(candidate, str) and candidate:
|
||||
return candidate
|
||||
transfer_method_raw = value.get("transfer_method")
|
||||
if not isinstance(transfer_method_raw, str):
|
||||
return "file mapping missing transfer_method"
|
||||
|
||||
try:
|
||||
transfer_method = FileTransferMethod.value_of(transfer_method_raw)
|
||||
except ValueError:
|
||||
return f"unsupported file transfer_method {transfer_method_raw!r}"
|
||||
|
||||
expected_keys = (
|
||||
{"transfer_method", "url"}
|
||||
if transfer_method == FileTransferMethod.REMOTE_URL
|
||||
else {
|
||||
"transfer_method",
|
||||
"reference",
|
||||
}
|
||||
)
|
||||
actual_keys = set(value)
|
||||
if actual_keys != expected_keys:
|
||||
unexpected_keys = sorted(actual_keys - expected_keys)
|
||||
missing_keys = sorted(expected_keys - actual_keys)
|
||||
details: list[str] = []
|
||||
if missing_keys:
|
||||
details.append(f"missing {', '.join(missing_keys)}")
|
||||
if unexpected_keys:
|
||||
details.append(f"unexpected {', '.join(unexpected_keys)}")
|
||||
return (
|
||||
f"{transfer_method.value} file mapping must contain exactly "
|
||||
f"{sorted(expected_keys)} ({'; '.join(details)})"
|
||||
)
|
||||
|
||||
if transfer_method == FileTransferMethod.REMOTE_URL:
|
||||
url = value.get("url")
|
||||
if not isinstance(url, str) or not url:
|
||||
return "remote_url file mapping missing url"
|
||||
return None
|
||||
|
||||
reference = value.get("reference")
|
||||
if not isinstance(reference, str) or not reference:
|
||||
return f"{transfer_method.value} file mapping missing reference"
|
||||
if not is_canonical_file_reference(reference):
|
||||
return f"{transfer_method.value} file mapping has invalid canonical reference"
|
||||
|
||||
parsed_reference = parse_file_reference(reference)
|
||||
if parsed_reference is None:
|
||||
return f"{transfer_method.value} file mapping has invalid canonical reference"
|
||||
file_id = parsed_reference.record_id
|
||||
if not self._file_validator.is_accessible_file_mapping(
|
||||
file_id=file_id,
|
||||
tenant_id=tenant_id,
|
||||
transfer_method=transfer_method,
|
||||
):
|
||||
return f"file reference {reference!r} is not accessible to tenant {tenant_id!r}"
|
||||
return None
|
||||
|
||||
@ -30,6 +30,7 @@ from clients.agent_backend import (
|
||||
from configs import dify_config
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text
|
||||
from graphon.file import FileTransferMethod
|
||||
from graphon.variables.segments import Segment
|
||||
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
|
||||
from models.agent_config_entities import (
|
||||
@ -174,6 +175,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
execution_context=DifyExecutionContextLayerConfig(
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
user_id=context.dify_context.user_id,
|
||||
user_from=cast(DifyExecutionContextUserFrom, context.dify_context.user_from.value),
|
||||
app_id=context.dify_context.app_id,
|
||||
workflow_id=context.workflow_id,
|
||||
workflow_run_id=context.workflow_run_id,
|
||||
@ -182,12 +184,8 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
conversation_id=get_system_text(context.variable_pool, SystemVariableKey.CONVERSATION_ID),
|
||||
agent_id=context.agent.id,
|
||||
agent_config_version_id=context.snapshot.id,
|
||||
# Agent Files §1.3: forward the real Dify access context
|
||||
# (user_from + invoke_from) so downstream file/drive inner APIs
|
||||
# can rebuild it; the agent run mode moves to agent_mode.
|
||||
user_from=cast(DifyExecutionContextUserFrom, context.dify_context.user_from.value),
|
||||
agent_mode=self._agent_backend_agent_mode(context.dify_context.invoke_from),
|
||||
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
|
||||
agent_mode=self._agent_mode(context.dify_context.invoke_from),
|
||||
),
|
||||
agent_soul_prompt=agent_soul.prompt.system_prompt or None,
|
||||
workflow_node_job_prompt=workflow_job_prompt,
|
||||
@ -211,7 +209,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _agent_mode(invoke_from: InvokeFrom) -> Literal["workflow_run", "single_step"]:
|
||||
def _agent_backend_agent_mode(invoke_from: InvokeFrom) -> Literal["workflow_run", "single_step"]:
|
||||
if invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.VALIDATION}:
|
||||
return "single_step"
|
||||
return "workflow_run"
|
||||
@ -396,14 +394,44 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
return schema
|
||||
case DeclaredOutputType.FILE:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_id": {"type": "string"},
|
||||
"filename": {"type": "string"},
|
||||
"mime_type": {"type": "string"},
|
||||
"url": {"type": "string"},
|
||||
},
|
||||
"required": ["file_id"],
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"transfer_method": {"const": FileTransferMethod.LOCAL_FILE.value},
|
||||
"reference": {"type": "string"},
|
||||
},
|
||||
"required": ["transfer_method", "reference"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"transfer_method": {"const": FileTransferMethod.TOOL_FILE.value},
|
||||
"reference": {"type": "string"},
|
||||
},
|
||||
"required": ["transfer_method", "reference"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"transfer_method": {"const": FileTransferMethod.DATASOURCE_FILE.value},
|
||||
"reference": {"type": "string"},
|
||||
},
|
||||
"required": ["transfer_method", "reference"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"transfer_method": {"const": FileTransferMethod.REMOTE_URL.value},
|
||||
"url": {"type": "string"},
|
||||
},
|
||||
"required": ["transfer_method", "url"],
|
||||
},
|
||||
],
|
||||
}
|
||||
assert_never(output_type)
|
||||
|
||||
|
||||
@ -381,7 +381,7 @@ def _build_from_datasource_file(
|
||||
file_id=mapping.get("datasource_file_id"),
|
||||
filename=datasource_file.name,
|
||||
file_type=file_type,
|
||||
transfer_method=FileTransferMethod.TOOL_FILE,
|
||||
transfer_method=transfer_method,
|
||||
remote_url=datasource_file.source_url,
|
||||
reference=build_file_reference(record_id=str(datasource_file.id)),
|
||||
extension=extension,
|
||||
|
||||
@ -23,6 +23,7 @@ class UploadConfig(ResponseModel):
|
||||
|
||||
class FileResponse(ResponseModel):
|
||||
id: str
|
||||
reference: str | None = None
|
||||
name: str
|
||||
size: int
|
||||
extension: str | None = None
|
||||
|
||||
@ -6,6 +6,9 @@ from typing import Any, Final, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from core.workflow.file_reference import is_canonical_file_reference
|
||||
from graphon.file import FileTransferMethod
|
||||
|
||||
|
||||
class AgentKnowledgeQueryMode(StrEnum):
|
||||
USER_QUERY = "user_query"
|
||||
@ -615,8 +618,10 @@ class DeclaredOutputConfig(BaseModel):
|
||||
ok = isinstance(value, dict)
|
||||
elif type_ == DeclaredOutputType.ARRAY:
|
||||
ok = isinstance(value, list)
|
||||
if ok and self.array_item is not None and self.array_item.type == DeclaredOutputType.FILE:
|
||||
ok = all(self._is_valid_file_default_value(item) for item in value)
|
||||
elif type_ == DeclaredOutputType.FILE:
|
||||
ok = isinstance(value, dict) and "file_id" in value
|
||||
ok = self._is_valid_file_default_value(value)
|
||||
else:
|
||||
ok = False
|
||||
if not ok:
|
||||
@ -624,6 +629,32 @@ class DeclaredOutputConfig(BaseModel):
|
||||
f"default_value shape does not match output type {type_.value!r}: got {type(value).__name__}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_file_default_value(value: Any) -> bool:
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
transfer_method_raw = value.get("transfer_method")
|
||||
if not isinstance(transfer_method_raw, str):
|
||||
return False
|
||||
try:
|
||||
transfer_method = FileTransferMethod.value_of(transfer_method_raw)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if transfer_method == FileTransferMethod.REMOTE_URL:
|
||||
return (
|
||||
set(value) == {"transfer_method", "url"}
|
||||
and isinstance(value.get("url"), str)
|
||||
and bool(value.get("url"))
|
||||
)
|
||||
|
||||
reference = value.get("reference")
|
||||
return (
|
||||
set(value) == {"transfer_method", "reference"}
|
||||
and isinstance(reference, str)
|
||||
and is_canonical_file_reference(reference)
|
||||
)
|
||||
|
||||
|
||||
# PRD §OUTPUT 配置框 0522 共识: "Output 如果没有配置,则 text, files, json"
|
||||
# The runtime injects these when ``declared_outputs`` is empty (stage 4 §4.1, D-3).
|
||||
|
||||
@ -14744,6 +14744,7 @@ Request payload for bulk downloading documents as a zip archive.
|
||||
| name | string | | Yes |
|
||||
| original_url | string | | No |
|
||||
| preview_url | string | | No |
|
||||
| reference | string | | No |
|
||||
| size | integer | | Yes |
|
||||
| source_url | string | | No |
|
||||
| tenant_id | string | | No |
|
||||
|
||||
@ -601,6 +601,7 @@ mode is a closed enum.
|
||||
| name | string | | Yes |
|
||||
| original_url | string | | No |
|
||||
| preview_url | string | | No |
|
||||
| reference | string | | No |
|
||||
| size | integer | | Yes |
|
||||
| source_url | string | | No |
|
||||
| tenant_id | string | | No |
|
||||
|
||||
@ -2824,6 +2824,7 @@ Note: The SQLAlchemy model defines an `is_anonymous` property for Flask-Login se
|
||||
| name | string | | Yes |
|
||||
| original_url | string | | No |
|
||||
| preview_url | string | | No |
|
||||
| reference | string | | No |
|
||||
| size | integer | | Yes |
|
||||
| source_url | string | | No |
|
||||
| tenant_id | string | | No |
|
||||
|
||||
@ -1183,6 +1183,7 @@ Returns Server-Sent Events stream.
|
||||
| name | string | | Yes |
|
||||
| original_url | string | | No |
|
||||
| preview_url | string | | No |
|
||||
| reference | string | | No |
|
||||
| size | integer | | Yes |
|
||||
| source_url | string | | No |
|
||||
| tenant_id | string | | No |
|
||||
|
||||
80
api/services/file_request_service.py
Normal file
80
api/services/file_request_service.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""Service helpers for trusted file request control-plane endpoints.
|
||||
|
||||
These helpers are used by inner APIs that return signed upload/download URLs to
|
||||
trusted external runtimes such as ``dify-agent``. They do not transfer file
|
||||
bytes themselves; they only rebuild access-scoped ``graphon.file.File`` values
|
||||
and resolve the signed URL that the caller should use directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from core.app.file_access import DatabaseFileAccessController, FileAccessScope, bind_file_access_scope
|
||||
from factories.file_factory.builders import build_from_mapping
|
||||
from graphon.file import File
|
||||
from graphon.file import helpers as file_helpers
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DownloadFileRequestResult:
|
||||
"""Resolved metadata and signed URL returned to trusted download callers."""
|
||||
|
||||
filename: str
|
||||
mime_type: str | None
|
||||
size: int
|
||||
download_url: str
|
||||
|
||||
|
||||
class FileRequestService:
|
||||
"""Resolve signed download URLs for trusted external file consumers."""
|
||||
|
||||
_access_controller: DatabaseFileAccessController
|
||||
|
||||
def __init__(self, access_controller: DatabaseFileAccessController | None = None) -> None:
|
||||
self._access_controller = access_controller or DatabaseFileAccessController()
|
||||
|
||||
def request_download_url(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
user_from: UserFrom | str,
|
||||
invoke_from: InvokeFrom | str,
|
||||
file_mapping: Mapping[str, Any],
|
||||
) -> DownloadFileRequestResult:
|
||||
"""Resolve one file mapping into signed download metadata.
|
||||
|
||||
The request is evaluated under a request-local :class:`FileAccessScope`
|
||||
so file-factory reconstruction and URL resolution enforce the same
|
||||
tenant/user authorization rules used by workflow runtime execution.
|
||||
"""
|
||||
|
||||
scope = FileAccessScope(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
user_from=user_from if isinstance(user_from, UserFrom) else UserFrom(user_from),
|
||||
invoke_from=invoke_from if isinstance(invoke_from, InvokeFrom) else InvokeFrom(invoke_from),
|
||||
)
|
||||
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)
|
||||
|
||||
if not download_url:
|
||||
raise ValueError("file does not support signed download")
|
||||
return DownloadFileRequestResult(
|
||||
filename=file.filename or "download.bin",
|
||||
mime_type=file.mime_type,
|
||||
size=file.size,
|
||||
download_url=download_url,
|
||||
)
|
||||
|
||||
def _build_file(self, *, mapping: Mapping[str, Any], tenant_id: str) -> File:
|
||||
return build_from_mapping(
|
||||
mapping=mapping,
|
||||
tenant_id=tenant_id,
|
||||
access_controller=self._access_controller,
|
||||
)
|
||||
@ -9,8 +9,8 @@ read-only views:
|
||||
* :meth:`snapshot_workflow_run` — every node + its declared outputs + per-output
|
||||
status, for one debug workflow run.
|
||||
* :meth:`node_detail` — the same shape filtered down to one node.
|
||||
* :meth:`output_preview` — full payload for one output, with signed download
|
||||
URL when the output references an upload file.
|
||||
* :meth:`output_preview` — full payload for one output, with signed download
|
||||
URL when the output is a canonical Agent v2 file mapping.
|
||||
|
||||
Design constraints baked into this version:
|
||||
|
||||
@ -53,6 +53,7 @@ from typing import Any
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.db.session_factory import session_factory
|
||||
from core.workflow.nodes.agent_v2.binding_resolver import (
|
||||
WorkflowAgentBindingError,
|
||||
@ -61,6 +62,7 @@ from core.workflow.nodes.agent_v2.binding_resolver import (
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import (
|
||||
WorkflowAgentRuntimeRequestBuilder,
|
||||
)
|
||||
from factories.file_factory.builders import build_from_mapping
|
||||
from graphon.enums import (
|
||||
BuiltinNodeTypes,
|
||||
WorkflowExecutionStatus,
|
||||
@ -175,6 +177,7 @@ class _ResolvedDeclaration:
|
||||
|
||||
name: str
|
||||
declared_type: DeclaredOutputType | None
|
||||
array_item_type: DeclaredOutputType | None
|
||||
inferred: bool
|
||||
|
||||
|
||||
@ -294,53 +297,105 @@ def _retried_attempt_count(metadata: Mapping[str, Any] | None) -> int:
|
||||
|
||||
|
||||
_PREVIEW_TEXT_LIMIT = 500
|
||||
_FILE_ID_KEYS: tuple[str, ...] = ("file_id", "upload_file_id", "tool_file_id")
|
||||
|
||||
|
||||
def _looks_like_file_ref(value: Any) -> str | None:
|
||||
"""Return the resolved ``file_id`` when ``value`` is a file-shaped dict."""
|
||||
def _looks_like_file_ref(value: Any) -> bool:
|
||||
"""Return whether ``value`` looks like a canonical Agent v2 file mapping."""
|
||||
if not isinstance(value, Mapping):
|
||||
return None
|
||||
for key in _FILE_ID_KEYS:
|
||||
candidate = value.get(key)
|
||||
if isinstance(candidate, str) and candidate:
|
||||
return candidate
|
||||
return None
|
||||
return False
|
||||
transfer_method = value.get("transfer_method")
|
||||
if transfer_method == "remote_url":
|
||||
return isinstance(value.get("url"), str) and bool(value.get("url"))
|
||||
return isinstance(transfer_method, str) and isinstance(value.get("reference"), str) and bool(value.get("reference"))
|
||||
|
||||
|
||||
def _value_preview(value: Any) -> Any:
|
||||
def _resolve_preview_url(value: Mapping[str, Any], *, tenant_id: str) -> str | None:
|
||||
"""Resolve one canonical file mapping into its preview/download URL.
|
||||
|
||||
Agent v2 output files now use the same ``transfer_method`` +
|
||||
``reference``/``url`` mapping contract as the back-proxy download request,
|
||||
so the Inspector rebuilds a graphon ``File`` through the standard factory
|
||||
path instead of trying to special-case upload/tool file ids itself.
|
||||
"""
|
||||
file = build_from_mapping(
|
||||
mapping=value,
|
||||
tenant_id=tenant_id,
|
||||
access_controller=DatabaseFileAccessController(),
|
||||
)
|
||||
return file_helpers.resolve_file_url(file)
|
||||
|
||||
|
||||
def _value_preview(value: Any, *, tenant_id: str, declaration: _ResolvedDeclaration) -> Any:
|
||||
"""Compact preview suitable for the snapshot endpoint.
|
||||
|
||||
File refs are augmented with a signed download URL so the panel can render
|
||||
a thumbnail / link without a second round-trip; long strings are truncated;
|
||||
other scalar / dict / list shapes are returned as-is (the Pydantic layer
|
||||
enforces JSON-safety on serialization).
|
||||
Canonical file mappings are augmented with a signed download URL only when
|
||||
the declared output is ``FILE`` or ``ARRAY[FILE]``. Canonical-looking
|
||||
mappings nested inside ``OBJECT`` or non-file arrays remain plain JSON so
|
||||
the Inspector does not introduce a nested file protocol the runtime itself
|
||||
does not promise. Long strings are truncated; other scalar / dict / list
|
||||
shapes are returned as-is (the Pydantic layer enforces JSON-safety on
|
||||
serialization).
|
||||
"""
|
||||
file_id = _looks_like_file_ref(value)
|
||||
if file_id:
|
||||
if declaration.declared_type == DeclaredOutputType.FILE and _looks_like_file_ref(value):
|
||||
assert isinstance(value, Mapping)
|
||||
try:
|
||||
preview_url = file_helpers.get_signed_file_url(upload_file_id=file_id)
|
||||
preview_url = _resolve_preview_url(value, tenant_id=tenant_id)
|
||||
except Exception:
|
||||
logger.warning("NodeOutputInspector: signed URL failed for file_id=%s", file_id, exc_info=True)
|
||||
logger.warning("NodeOutputInspector: signed URL failed for file mapping=%s", value, exc_info=True)
|
||||
preview_url = None
|
||||
return {**dict(value), "preview_url": preview_url}
|
||||
if (
|
||||
declaration.declared_type == DeclaredOutputType.ARRAY
|
||||
and declaration.array_item_type == DeclaredOutputType.FILE
|
||||
and isinstance(value, list)
|
||||
and all(_looks_like_file_ref(item) for item in value)
|
||||
):
|
||||
resolved_items: list[Any] = []
|
||||
for item in value:
|
||||
assert isinstance(item, Mapping)
|
||||
try:
|
||||
preview_url = _resolve_preview_url(item, tenant_id=tenant_id)
|
||||
except Exception:
|
||||
logger.warning("NodeOutputInspector: signed URL failed for file mapping=%s", item, exc_info=True)
|
||||
preview_url = None
|
||||
resolved_items.append({**dict(item), "preview_url": preview_url})
|
||||
return resolved_items
|
||||
if isinstance(value, str) and len(value) > _PREVIEW_TEXT_LIMIT:
|
||||
return value[:_PREVIEW_TEXT_LIMIT] + "…"
|
||||
return value
|
||||
|
||||
|
||||
def _full_value(value: Any) -> Any:
|
||||
"""Same shape as :func:`_value_preview` minus the truncation."""
|
||||
file_id = _looks_like_file_ref(value)
|
||||
if file_id:
|
||||
def _full_value(value: Any, *, tenant_id: str, declaration: _ResolvedDeclaration) -> Any:
|
||||
"""Same shape as :func:`_value_preview` minus the truncation.
|
||||
|
||||
As with preview values, only outputs declared as ``FILE`` or
|
||||
``ARRAY[FILE]`` get signed URL augmentation; non-file outputs keep their raw
|
||||
JSON payload unchanged even if it resembles a canonical file mapping.
|
||||
"""
|
||||
if declaration.declared_type == DeclaredOutputType.FILE and _looks_like_file_ref(value):
|
||||
assert isinstance(value, Mapping)
|
||||
try:
|
||||
preview_url = file_helpers.get_signed_file_url(upload_file_id=file_id)
|
||||
preview_url = _resolve_preview_url(value, tenant_id=tenant_id)
|
||||
except Exception:
|
||||
logger.warning("NodeOutputInspector: signed URL failed for file_id=%s", file_id, exc_info=True)
|
||||
logger.warning("NodeOutputInspector: signed URL failed for file mapping=%s", value, exc_info=True)
|
||||
preview_url = None
|
||||
return {**dict(value), "preview_url": preview_url}
|
||||
if (
|
||||
declaration.declared_type == DeclaredOutputType.ARRAY
|
||||
and declaration.array_item_type == DeclaredOutputType.FILE
|
||||
and isinstance(value, list)
|
||||
and all(_looks_like_file_ref(item) for item in value)
|
||||
):
|
||||
resolved_items: list[Any] = []
|
||||
for item in value:
|
||||
assert isinstance(item, Mapping)
|
||||
try:
|
||||
preview_url = _resolve_preview_url(item, tenant_id=tenant_id)
|
||||
except Exception:
|
||||
logger.warning("NodeOutputInspector: signed URL failed for file mapping=%s", item, exc_info=True)
|
||||
preview_url = None
|
||||
resolved_items.append({**dict(item), "preview_url": preview_url})
|
||||
return resolved_items
|
||||
return value
|
||||
|
||||
|
||||
@ -421,10 +476,22 @@ class NodeOutputInspectorService:
|
||||
output_name: str,
|
||||
) -> OutputPreviewView:
|
||||
"""Full payload for one declared output (with signed file URL)."""
|
||||
detail = self.node_detail(
|
||||
app_model=app_model,
|
||||
workflow_run_id=workflow_run_id,
|
||||
node_id=node_id,
|
||||
workflow_run, executions = self._load_run_and_executions(app_model=app_model, workflow_run_id=workflow_run_id)
|
||||
graph_nodes = _graph_nodes(workflow_run)
|
||||
raw_node = next((n for n in graph_nodes if str(n.get("id")) == node_id), None)
|
||||
if raw_node is None:
|
||||
raise NodeOutputInspectorError(
|
||||
"node_not_in_workflow_run",
|
||||
f"Node {node_id!r} does not appear in workflow run {workflow_run_id!r}.",
|
||||
)
|
||||
|
||||
execution = self._index_executions_by_node(executions).get(node_id)
|
||||
detail = self._build_node_view(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
workflow_id=workflow_run.workflow_id,
|
||||
raw_node=raw_node,
|
||||
execution=execution,
|
||||
)
|
||||
view = next((o for o in detail.outputs if o.name == output_name), None)
|
||||
if view is None:
|
||||
@ -432,19 +499,31 @@ class NodeOutputInspectorService:
|
||||
"node_output_not_declared",
|
||||
f"Output {output_name!r} is not declared on node {node_id!r}.",
|
||||
)
|
||||
declaration = next(
|
||||
(
|
||||
d
|
||||
for d in self._resolve_declared_outputs(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
workflow_id=workflow_run.workflow_id,
|
||||
node_id=node_id,
|
||||
raw_node=raw_node,
|
||||
execution=execution,
|
||||
)
|
||||
if d.name == output_name
|
||||
),
|
||||
_ResolvedDeclaration(name=output_name, declared_type=view.type, array_item_type=None, inferred=True),
|
||||
)
|
||||
|
||||
# ``node_detail`` already produced a truncated value_preview; reload
|
||||
# the raw value from the execution payload so the preview endpoint can
|
||||
# return the full thing (still wrapped through ``_full_value`` for
|
||||
# signed file URLs).
|
||||
execution = self._index_executions_by_node(
|
||||
self._load_run_and_executions(app_model=app_model, workflow_run_id=workflow_run_id)[1]
|
||||
).get(node_id)
|
||||
full_value: Any = None
|
||||
if execution is not None:
|
||||
outputs = _decode_json_blob(execution.outputs) or {}
|
||||
if output_name in outputs:
|
||||
full_value = _full_value(outputs[output_name])
|
||||
full_value = _full_value(outputs[output_name], tenant_id=app_model.tenant_id, declaration=declaration)
|
||||
|
||||
return OutputPreviewView(
|
||||
node_id=node_id,
|
||||
@ -546,6 +625,7 @@ class NodeOutputInspectorService:
|
||||
for declaration in declarations:
|
||||
output_views.append(
|
||||
self._build_output_view(
|
||||
tenant_id=tenant_id,
|
||||
declaration=declaration,
|
||||
node_status=node_status,
|
||||
outputs_dict=outputs_dict,
|
||||
@ -568,6 +648,7 @@ class NodeOutputInspectorService:
|
||||
def _build_output_view(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
declaration: _ResolvedDeclaration,
|
||||
node_status: NodeStatus,
|
||||
outputs_dict: Mapping[str, Any] | None,
|
||||
@ -617,7 +698,11 @@ class NodeOutputInspectorService:
|
||||
else:
|
||||
status = NodeOutputStatus.READY
|
||||
|
||||
value_preview = _value_preview(outputs_dict.get(name)) if outputs_dict and name in outputs_dict else None
|
||||
value_preview = (
|
||||
_value_preview(outputs_dict.get(name), tenant_id=tenant_id, declaration=declaration)
|
||||
if outputs_dict and name in outputs_dict
|
||||
else None
|
||||
)
|
||||
|
||||
return NodeOutputView(
|
||||
name=name,
|
||||
@ -659,7 +744,15 @@ class NodeOutputInspectorService:
|
||||
node_id=node_id,
|
||||
)
|
||||
if agent_decl is not None:
|
||||
return [_ResolvedDeclaration(name=o.name, declared_type=o.type, inferred=False) for o in agent_decl]
|
||||
return [
|
||||
_ResolvedDeclaration(
|
||||
name=o.name,
|
||||
declared_type=o.type,
|
||||
array_item_type=o.array_item.type if o.array_item is not None else None,
|
||||
inferred=False,
|
||||
)
|
||||
for o in agent_decl
|
||||
]
|
||||
|
||||
# Non-agent (or agent-binding-missing) fall back to inferring from the
|
||||
# produced payload so the Inspector still has something to show.
|
||||
@ -700,7 +793,9 @@ class NodeOutputInspectorService:
|
||||
outputs = _decode_json_blob(execution.outputs)
|
||||
if not outputs:
|
||||
return []
|
||||
return [_ResolvedDeclaration(name=name, declared_type=None, inferred=True) for name in outputs]
|
||||
return [
|
||||
_ResolvedDeclaration(name=name, declared_type=None, array_item_type=None, inferred=True) for name in outputs
|
||||
]
|
||||
|
||||
|
||||
def _is_passing(result: Mapping[str, Any]) -> bool:
|
||||
|
||||
@ -34,7 +34,12 @@ def _request():
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
execution_context=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
execution_context=DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_from="account",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="debugger",
|
||||
),
|
||||
workflow_node_job_prompt="Do the task.",
|
||||
user_prompt="hello",
|
||||
)
|
||||
|
||||
@ -17,7 +17,12 @@ def _request():
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
execution_context=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
execution_context=DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_from="account",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="debugger",
|
||||
),
|
||||
workflow_node_job_prompt="Do the task.",
|
||||
user_prompt="hello",
|
||||
)
|
||||
|
||||
@ -53,11 +53,13 @@ def _run_input() -> AgentBackendWorkflowNodeRunInput:
|
||||
execution_context=DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
workflow_id="workflow-1",
|
||||
workflow_run_id="workflow-run-1",
|
||||
node_id="node-1",
|
||||
node_execution_id="node-execution-1",
|
||||
invoke_from="workflow_run",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="debugger",
|
||||
),
|
||||
idempotency_key="workflow-run-1:node-execution-1",
|
||||
agent_soul_prompt="You are a careful reviewer.",
|
||||
@ -112,7 +114,11 @@ def test_request_builder_sets_model_and_output_layer_contract_ids():
|
||||
layers = {layer.name: layer for layer in request.composition.layers}
|
||||
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID].type == DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID
|
||||
assert cast(DifyExecutionContextLayerConfig, layers[DIFY_EXECUTION_CONTEXT_LAYER_ID].config).user_id == "user-1"
|
||||
execution_context_config = cast(DifyExecutionContextLayerConfig, layers[DIFY_EXECUTION_CONTEXT_LAYER_ID].config)
|
||||
assert execution_context_config.user_id == "user-1"
|
||||
assert execution_context_config.user_from == "account"
|
||||
assert execution_context_config.agent_mode == "workflow_run"
|
||||
assert execution_context_config.invoke_from == "debugger"
|
||||
assert layers[DIFY_AGENT_HISTORY_LAYER_ID].type == PYDANTIC_AI_HISTORY_LAYER_TYPE_ID
|
||||
assert layers[DIFY_AGENT_MODEL_LAYER_ID].type == DIFY_PLUGIN_LLM_LAYER_TYPE_ID
|
||||
assert cast(DifyPluginLLMLayerConfig, layers[DIFY_AGENT_MODEL_LAYER_ID].config).plugin_id == "langgenius/openai"
|
||||
@ -240,7 +246,12 @@ def test_request_builder_rejects_blank_prompts():
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
execution_context=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
execution_context=DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_from="account",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="debugger",
|
||||
),
|
||||
workflow_node_job_prompt=" ",
|
||||
user_prompt="hello",
|
||||
)
|
||||
@ -265,8 +276,10 @@ def _agent_app_input(*, include_shell: bool = False) -> AgentBackendAgentAppRunI
|
||||
execution_context=DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="end-user",
|
||||
conversation_id="conv-1",
|
||||
invoke_from="agent_app",
|
||||
agent_mode="agent_app",
|
||||
invoke_from="web-app",
|
||||
),
|
||||
agent_soul_prompt="You are Iris.",
|
||||
user_prompt="List files.",
|
||||
|
||||
@ -162,6 +162,7 @@ class TestFileApiPost:
|
||||
mock_file.preview_url = "http://example.com/preview/file-id-123"
|
||||
mock_file.source_url = "http://example.com/source/file-id-123"
|
||||
mock_file.original_url = None
|
||||
mock_file.reference = None
|
||||
mock_file.user_id = "user-123"
|
||||
mock_file.tenant_id = "tenant-123"
|
||||
mock_file.conversation_id = None
|
||||
@ -198,6 +199,7 @@ class TestFileApiPost:
|
||||
mock_file.preview_url = None
|
||||
mock_file.source_url = None
|
||||
mock_file.original_url = None
|
||||
mock_file.reference = None
|
||||
mock_file.user_id = "user-456"
|
||||
mock_file.tenant_id = "tenant-456"
|
||||
mock_file.conversation_id = None
|
||||
|
||||
@ -6,6 +6,7 @@ import pytest
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
import controllers.files.upload as module
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
|
||||
|
||||
def unwrap(func):
|
||||
@ -85,6 +86,7 @@ class TestPluginUploadFileApi:
|
||||
|
||||
assert status_code == 201
|
||||
assert result["id"] == "file-id"
|
||||
assert result["reference"] == build_file_reference(record_id="file-id")
|
||||
assert result["preview_url"] == "signed-url"
|
||||
|
||||
def test_missing_file(self):
|
||||
|
||||
@ -14,6 +14,7 @@ import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.inner_api.plugin.plugin import (
|
||||
PluginDownloadFileRequestApi,
|
||||
PluginFetchAppInfoApi,
|
||||
PluginInvokeAppApi,
|
||||
PluginInvokeEncryptApi,
|
||||
@ -30,6 +31,7 @@ from controllers.inner_api.plugin.plugin import (
|
||||
PluginInvokeTTSApi,
|
||||
PluginUploadFileRequestApi,
|
||||
)
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
|
||||
|
||||
def _extract_raw_post(cls):
|
||||
@ -282,6 +284,59 @@ class TestPluginUploadFileRequestApi:
|
||||
assert result["data"]["url"] == "https://storage.example.com/signed-upload-url"
|
||||
|
||||
|
||||
class TestPluginDownloadFileRequestApi:
|
||||
"""Test PluginDownloadFileRequestApi endpoint structure and handler logic"""
|
||||
|
||||
@pytest.fixture
|
||||
def api_instance(self):
|
||||
return PluginDownloadFileRequestApi()
|
||||
|
||||
def test_has_post_method(self, api_instance):
|
||||
assert hasattr(api_instance, "post")
|
||||
assert callable(api_instance.post)
|
||||
|
||||
@patch("controllers.inner_api.plugin.plugin.FileRequestService")
|
||||
@patch("controllers.inner_api.plugin.plugin.db")
|
||||
def test_post_returns_signed_download_url(self, mock_db, mock_service_cls, api_instance, app: Flask):
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-id"
|
||||
mock_db.session.get.return_value = mock_tenant
|
||||
mock_service = mock_service_cls.return_value
|
||||
mock_service.request_download_url.return_value = MagicMock(
|
||||
filename="report.pdf",
|
||||
mime_type="application/pdf",
|
||||
size=123,
|
||||
download_url="https://files.example.com/download",
|
||||
)
|
||||
mock_payload = MagicMock()
|
||||
mock_payload.tenant_id = "tenant-id"
|
||||
mock_payload.user_id = "user-id"
|
||||
mock_payload.user_from = "account"
|
||||
mock_payload.invoke_from = "debugger"
|
||||
reference = build_file_reference(record_id="tool-file-1")
|
||||
mock_payload.file.model_dump.return_value = {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
raw_post = _extract_raw_post(PluginDownloadFileRequestApi)
|
||||
result = raw_post(api_instance, payload=mock_payload)
|
||||
|
||||
mock_service.request_download_url.assert_called_once_with(
|
||||
tenant_id="tenant-id",
|
||||
user_id="user-id",
|
||||
user_from="account",
|
||||
invoke_from="debugger",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": reference},
|
||||
)
|
||||
assert result["data"] == {
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 123,
|
||||
"download_url": "https://files.example.com/download",
|
||||
}
|
||||
|
||||
|
||||
class TestPluginFetchAppInfoApi:
|
||||
"""Test PluginFetchAppInfoApi endpoint structure and handler logic"""
|
||||
|
||||
|
||||
@ -259,6 +259,7 @@ class TestFileApiPost:
|
||||
mock_upload.preview_url = None
|
||||
mock_upload.source_url = None
|
||||
mock_upload.original_url = None
|
||||
mock_upload.reference = None
|
||||
mock_upload.user_id = None
|
||||
mock_upload.tenant_id = None
|
||||
mock_upload.conversation_id = None
|
||||
|
||||
@ -25,7 +25,12 @@ from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
def _exec_ctx() -> DifyExecutionContextLayerConfig:
|
||||
return DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="agent_app")
|
||||
return DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_from="end-user",
|
||||
invoke_from="web-app",
|
||||
agent_mode="agent_app",
|
||||
)
|
||||
|
||||
|
||||
class TestBuildForAgentApp:
|
||||
|
||||
126
api/tests/unit_tests/core/plugin/entities/test_request.py
Normal file
126
api/tests/unit_tests/core/plugin/entities/test_request.py
Normal file
@ -0,0 +1,126 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from core.plugin.entities.request import RequestRequestDownloadFile
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
|
||||
|
||||
def test_request_download_file_accepts_tool_file_reference() -> 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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert payload.file.transfer_method == "tool_file"
|
||||
assert payload.file.reference == reference
|
||||
|
||||
|
||||
def test_request_download_file_accepts_remote_url() -> None:
|
||||
payload = RequestRequestDownloadFile.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"user_from": "end-user",
|
||||
"invoke_from": "service-api",
|
||||
"file": {
|
||||
"transfer_method": "remote_url",
|
||||
"url": "https://example.com/report.pdf",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert payload.file.transfer_method == "remote_url"
|
||||
assert payload.file.url == "https://example.com/report.pdf"
|
||||
|
||||
|
||||
def test_request_download_file_rejects_remote_url_without_url() -> None:
|
||||
with pytest.raises(ValidationError, match="url is required"):
|
||||
_ = RequestRequestDownloadFile.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"user_from": "account",
|
||||
"invoke_from": "debugger",
|
||||
"file": {
|
||||
"transfer_method": "remote_url",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_request_download_file_rejects_remote_url_with_reference() -> None:
|
||||
reference = build_file_reference(record_id="tool-file-1")
|
||||
with pytest.raises(ValidationError, match="reference is not allowed"):
|
||||
_ = RequestRequestDownloadFile.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"user_from": "account",
|
||||
"invoke_from": "debugger",
|
||||
"file": {
|
||||
"transfer_method": "remote_url",
|
||||
"url": "https://example.com/report.pdf",
|
||||
"reference": reference,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("transfer_method", ["tool_file", "local_file"])
|
||||
def test_request_download_file_rejects_non_remote_without_reference(transfer_method: str) -> None:
|
||||
with pytest.raises(ValidationError, match="reference is required"):
|
||||
_ = RequestRequestDownloadFile.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"user_from": "account",
|
||||
"invoke_from": "debugger",
|
||||
"file": {
|
||||
"transfer_method": transfer_method,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_request_download_file_rejects_non_canonical_reference() -> None:
|
||||
with pytest.raises(ValidationError, match="canonical Dify file reference"):
|
||||
_ = RequestRequestDownloadFile.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"user_from": "account",
|
||||
"invoke_from": "debugger",
|
||||
"file": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": "raw-tool-file-uuid",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("transfer_method", ["tool_file", "local_file", "datasource_file"])
|
||||
def test_request_download_file_rejects_non_remote_with_url(transfer_method: str) -> None:
|
||||
reference = build_file_reference(record_id="tool-file-1")
|
||||
with pytest.raises(ValidationError, match="url is not allowed"):
|
||||
_ = RequestRequestDownloadFile.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"user_from": "account",
|
||||
"invoke_from": "debugger",
|
||||
"file": {
|
||||
"transfer_method": transfer_method,
|
||||
"reference": reference,
|
||||
"url": "https://example.com/report.pdf",
|
||||
},
|
||||
}
|
||||
)
|
||||
@ -1,7 +1,9 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import patch
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.protocol import RunStartedEvent, RunSucceededEvent, RunSucceededEventData
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendRunEventAdapter,
|
||||
@ -11,6 +13,7 @@ from clients.agent_backend import (
|
||||
FakeAgentBackendScenario,
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from core.workflow.nodes.agent_v2 import DifyAgentNode
|
||||
from core.workflow.nodes.agent_v2.binding_resolver import WorkflowAgentBindingBundle, WorkflowAgentBindingResolver
|
||||
from core.workflow.nodes.agent_v2.entities import DifyAgentNodeData
|
||||
@ -19,11 +22,17 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import WorkflowAgentRu
|
||||
from core.workflow.nodes.agent_v2.session_store import WorkflowAgentRuntimeSessionStore, WorkflowAgentSessionScope
|
||||
from graphon.entities import GraphInitParams
|
||||
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.node_events import PauseRequestedEvent, StreamCompletedEvent
|
||||
from graphon.runtime import GraphRuntimeState
|
||||
from graphon.variables.segments import StringSegment
|
||||
from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment
|
||||
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
|
||||
from models.agent_config_entities import AgentSoulConfig, AgentSoulModelConfig, WorkflowNodeJobConfig
|
||||
from models.agent_config_entities import (
|
||||
AgentSoulConfig,
|
||||
AgentSoulModelConfig,
|
||||
DeclaredOutputType,
|
||||
WorkflowNodeJobConfig,
|
||||
)
|
||||
|
||||
|
||||
class FakeCredentialsProvider:
|
||||
@ -33,6 +42,19 @@ class FakeCredentialsProvider:
|
||||
return {"api_key": "secret-key"}
|
||||
|
||||
|
||||
def _restored_file(*, transfer_method: FileTransferMethod, reference: str) -> File:
|
||||
return File(
|
||||
type=FileType.DOCUMENT,
|
||||
transfer_method=transfer_method,
|
||||
remote_url=None,
|
||||
reference=reference,
|
||||
filename="report.pdf",
|
||||
extension=".pdf",
|
||||
mime_type="application/pdf",
|
||||
size=12,
|
||||
)
|
||||
|
||||
|
||||
class FakeVariablePool:
|
||||
def get(self, selector):
|
||||
values = {
|
||||
@ -123,11 +145,38 @@ class FakeSessionStore:
|
||||
self.cleaned.append((scope, backend_run_id))
|
||||
|
||||
|
||||
class FileOutputBackendClient(FakeAgentBackendRunClient):
|
||||
output_payload: dict[str, object]
|
||||
|
||||
def __init__(self, *, output_payload: dict[str, object]) -> None:
|
||||
super().__init__(scenario=FakeAgentBackendScenario.SUCCESS)
|
||||
self.output_payload = output_payload
|
||||
|
||||
def _events(self, run_id: str):
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
|
||||
from clients.agent_backend.fake_client import _FIXED_TIME
|
||||
|
||||
return (
|
||||
RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME),
|
||||
RunSucceededEvent(
|
||||
id="2-0",
|
||||
run_id=run_id,
|
||||
created_at=_FIXED_TIME,
|
||||
data=RunSucceededEventData(
|
||||
output=self.output_payload,
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _node(
|
||||
*,
|
||||
scenario: FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
|
||||
agent_backend_client: FakeAgentBackendRunClient | None = None,
|
||||
session_store: FakeSessionStore | None = None,
|
||||
declared_outputs: list[dict[str, object]] | None = None,
|
||||
agent_backend_client: FakeAgentBackendRunClient | None = None,
|
||||
) -> DifyAgentNode:
|
||||
graph_init_params = GraphInitParams(
|
||||
workflow_id="workflow-1",
|
||||
@ -147,16 +196,26 @@ def _node(
|
||||
from core.workflow.nodes.agent_v2.output_type_checker import PerOutputTypeChecker
|
||||
|
||||
class _AlwaysAllowFileValidator:
|
||||
def is_owned_by_tenant(self, *, file_id: str, tenant_id: str) -> bool:
|
||||
def is_accessible_file_mapping(self, *, file_id: str, tenant_id: str, transfer_method) -> bool:
|
||||
return True
|
||||
|
||||
client = agent_backend_client or FakeAgentBackendRunClient(scenario=scenario)
|
||||
binding_resolver = FakeBindingResolver()
|
||||
if declared_outputs is not None:
|
||||
binding_resolver.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"workflow_prompt": "Use the previous output.",
|
||||
"previous_node_output_refs": [{"node_id": "previous-node", "output": "text"}],
|
||||
"declared_outputs": declared_outputs,
|
||||
}
|
||||
)
|
||||
|
||||
return DifyAgentNode(
|
||||
node_id="agent-node",
|
||||
data=DifyAgentNodeData.model_validate({"type": BuiltinNodeTypes.AGENT, "version": "2"}),
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=cast(GraphRuntimeState, SimpleNamespace(variable_pool=FakeVariablePool())),
|
||||
binding_resolver=FakeBindingResolver(),
|
||||
binding_resolver=binding_resolver,
|
||||
runtime_request_builder=WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()),
|
||||
agent_backend_client=client,
|
||||
event_adapter=AgentBackendRunEventAdapter(),
|
||||
@ -181,6 +240,107 @@ def test_agent_node_run_maps_successful_agent_backend_run_to_node_result():
|
||||
assert result.inputs["agent_backend_request"]["composition"]["layers"][5]["config"]["credentials"] == "[REDACTED]"
|
||||
|
||||
|
||||
def test_agent_node_run_normalizes_declared_file_output_with_canonical_mapping():
|
||||
tool_reference = build_file_reference(record_id="tool-file-1")
|
||||
with patch(
|
||||
"core.workflow.nodes.agent_v2.output_adapter.build_from_mapping",
|
||||
return_value=_restored_file(transfer_method=FileTransferMethod.TOOL_FILE, reference=tool_reference),
|
||||
):
|
||||
events = list(
|
||||
_node(
|
||||
declared_outputs=[{"name": "report", "type": DeclaredOutputType.FILE}],
|
||||
agent_backend_client=FileOutputBackendClient(
|
||||
output_payload={"report": {"transfer_method": "tool_file", "reference": tool_reference}}
|
||||
),
|
||||
)._run()
|
||||
)
|
||||
|
||||
result = cast(StreamCompletedEvent, events[0]).node_run_result
|
||||
report = result.outputs["report"]
|
||||
assert isinstance(report, FileSegment)
|
||||
assert report.value.reference == tool_reference
|
||||
|
||||
|
||||
def test_agent_node_run_normalizes_declared_datasource_file_output_with_canonical_mapping():
|
||||
datasource_reference = build_file_reference(record_id="datasource-file-1")
|
||||
with patch(
|
||||
"core.workflow.nodes.agent_v2.output_adapter.build_from_mapping",
|
||||
return_value=_restored_file(
|
||||
transfer_method=FileTransferMethod.DATASOURCE_FILE,
|
||||
reference=datasource_reference,
|
||||
),
|
||||
):
|
||||
events = list(
|
||||
_node(
|
||||
declared_outputs=[{"name": "report", "type": DeclaredOutputType.FILE}],
|
||||
agent_backend_client=FileOutputBackendClient(
|
||||
output_payload={"report": {"transfer_method": "datasource_file", "reference": datasource_reference}}
|
||||
),
|
||||
)._run()
|
||||
)
|
||||
|
||||
result = cast(StreamCompletedEvent, events[0]).node_run_result
|
||||
report = result.outputs["report"]
|
||||
assert isinstance(report, FileSegment)
|
||||
assert report.value.transfer_method == FileTransferMethod.DATASOURCE_FILE
|
||||
assert report.value.reference == datasource_reference
|
||||
|
||||
|
||||
def test_agent_node_run_normalizes_declared_remote_url_file_output_with_canonical_mapping():
|
||||
remote_url = "https://example.com/report.pdf"
|
||||
|
||||
events = list(
|
||||
_node(
|
||||
declared_outputs=[{"name": "report", "type": DeclaredOutputType.FILE}],
|
||||
agent_backend_client=FileOutputBackendClient(
|
||||
output_payload={"report": {"transfer_method": "remote_url", "url": remote_url}}
|
||||
),
|
||||
)._run()
|
||||
)
|
||||
|
||||
result = cast(StreamCompletedEvent, events[0]).node_run_result
|
||||
report = result.outputs["report"]
|
||||
assert isinstance(report, FileSegment)
|
||||
assert report.value.transfer_method == FileTransferMethod.REMOTE_URL
|
||||
assert report.value.remote_url == remote_url
|
||||
|
||||
|
||||
def test_agent_node_run_normalizes_declared_array_file_output_with_canonical_mappings():
|
||||
first_reference = build_file_reference(record_id="tool-file-1")
|
||||
second_reference = build_file_reference(record_id="tool-file-2")
|
||||
with patch(
|
||||
"core.workflow.nodes.agent_v2.output_adapter.build_from_mapping",
|
||||
side_effect=[
|
||||
_restored_file(transfer_method=FileTransferMethod.TOOL_FILE, reference=first_reference),
|
||||
_restored_file(transfer_method=FileTransferMethod.TOOL_FILE, reference=second_reference),
|
||||
],
|
||||
):
|
||||
events = list(
|
||||
_node(
|
||||
declared_outputs=[
|
||||
{
|
||||
"name": "attachments",
|
||||
"type": DeclaredOutputType.ARRAY,
|
||||
"array_item": {"type": DeclaredOutputType.FILE},
|
||||
}
|
||||
],
|
||||
agent_backend_client=FileOutputBackendClient(
|
||||
output_payload={
|
||||
"attachments": [
|
||||
{"transfer_method": "tool_file", "reference": first_reference},
|
||||
{"transfer_method": "tool_file", "reference": second_reference},
|
||||
]
|
||||
}
|
||||
),
|
||||
)._run()
|
||||
)
|
||||
|
||||
result = cast(StreamCompletedEvent, events[0]).node_run_result
|
||||
attachments = result.outputs["attachments"]
|
||||
assert isinstance(attachments, ArrayFileSegment)
|
||||
assert [item.reference for item in attachments.value] == [first_reference, second_reference]
|
||||
|
||||
|
||||
def test_agent_node_run_maps_failed_agent_backend_run_to_node_result():
|
||||
events = list(_node(scenario=FakeAgentBackendScenario.FAILED)._run())
|
||||
|
||||
|
||||
@ -12,17 +12,29 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.workflow.nodes.agent_v2.file_tenant_validator import (
|
||||
AgentOutputFileTenantValidator,
|
||||
UploadFileTenantValidator,
|
||||
)
|
||||
from core.workflow.nodes.agent_v2.file_tenant_validator import UploadFileTenantValidator
|
||||
from graphon.file import FileTransferMethod
|
||||
|
||||
|
||||
def test_empty_inputs_return_false_without_db_hit():
|
||||
validator = UploadFileTenantValidator()
|
||||
with patch("core.workflow.nodes.agent_v2.file_tenant_validator.session_factory") as factory:
|
||||
assert validator.is_owned_by_tenant(file_id="", tenant_id="tenant-1") is False
|
||||
assert validator.is_owned_by_tenant(file_id="abc", tenant_id="") is False
|
||||
assert (
|
||||
validator.is_accessible_file_mapping(
|
||||
file_id="",
|
||||
tenant_id="tenant-1",
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
validator.is_accessible_file_mapping(
|
||||
file_id="abc",
|
||||
tenant_id="",
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
)
|
||||
is False
|
||||
)
|
||||
factory.create_session.assert_not_called()
|
||||
|
||||
|
||||
@ -40,7 +52,14 @@ def test_empty_inputs_return_false_without_db_hit():
|
||||
def test_non_uuid_file_ids_return_false_without_db_hit(bad_file_id: str):
|
||||
validator = UploadFileTenantValidator()
|
||||
with patch("core.workflow.nodes.agent_v2.file_tenant_validator.session_factory") as factory:
|
||||
assert validator.is_owned_by_tenant(file_id=bad_file_id, tenant_id="tenant-1") is False
|
||||
assert (
|
||||
validator.is_accessible_file_mapping(
|
||||
file_id=bad_file_id,
|
||||
tenant_id="tenant-1",
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
)
|
||||
is False
|
||||
)
|
||||
factory.create_session.assert_not_called()
|
||||
|
||||
|
||||
@ -53,32 +72,37 @@ def test_db_error_swallowed_and_returns_false():
|
||||
valid_uuid = "550e8400-e29b-41d4-a716-446655440000"
|
||||
with patch("core.workflow.nodes.agent_v2.file_tenant_validator.session_factory") as factory:
|
||||
factory.create_session.return_value.__enter__.return_value.scalar.side_effect = SQLAlchemyError("boom")
|
||||
assert validator.is_owned_by_tenant(file_id=valid_uuid, tenant_id="tenant-1") is False
|
||||
assert (
|
||||
validator.is_accessible_file_mapping(
|
||||
file_id=valid_uuid,
|
||||
tenant_id="tenant-1",
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
_VALID_UUID = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
|
||||
def test_tool_file_owned_by_tenant_returns_true():
|
||||
"""Agent Files §4.6: agent output files are canonically ToolFile."""
|
||||
validator = AgentOutputFileTenantValidator()
|
||||
def test_accessible_file_mapping_checks_transfer_method_family():
|
||||
validator = UploadFileTenantValidator()
|
||||
valid_uuid = "550e8400-e29b-41d4-a716-446655440000"
|
||||
with patch("core.workflow.nodes.agent_v2.file_tenant_validator.session_factory") as factory:
|
||||
factory.create_session.return_value.__enter__.return_value.scalar.return_value = None
|
||||
assert (
|
||||
validator.is_accessible_file_mapping(
|
||||
file_id=valid_uuid,
|
||||
tenant_id="tenant-1",
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
with patch("core.workflow.nodes.agent_v2.file_tenant_validator.session_factory") as factory:
|
||||
# First scalar() = tool_files lookup -> tenant owns it.
|
||||
factory.create_session.return_value.__enter__.return_value.scalar.return_value = "tenant-1"
|
||||
assert validator.is_owned_by_tenant(file_id=_VALID_UUID, tenant_id="tenant-1") is True
|
||||
|
||||
|
||||
def test_tool_file_owned_by_other_tenant_rejected():
|
||||
validator = AgentOutputFileTenantValidator()
|
||||
with patch("core.workflow.nodes.agent_v2.file_tenant_validator.session_factory") as factory:
|
||||
factory.create_session.return_value.__enter__.return_value.scalar.return_value = "tenant-OTHER"
|
||||
assert validator.is_owned_by_tenant(file_id=_VALID_UUID, tenant_id="tenant-1") is False
|
||||
|
||||
|
||||
def test_falls_back_to_upload_file_when_not_a_tool_file():
|
||||
validator = AgentOutputFileTenantValidator()
|
||||
with patch("core.workflow.nodes.agent_v2.file_tenant_validator.session_factory") as factory:
|
||||
scalar = factory.create_session.return_value.__enter__.return_value.scalar
|
||||
# tool_files miss -> upload_files hit for this tenant.
|
||||
scalar.side_effect = [None, "tenant-1"]
|
||||
assert validator.is_owned_by_tenant(file_id=_VALID_UUID, tenant_id="tenant-1") is True
|
||||
assert (
|
||||
validator.is_accessible_file_mapping(
|
||||
file_id=valid_uuid,
|
||||
tenant_id="tenant-1",
|
||||
transfer_method=FileTransferMethod.TOOL_FILE,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
|
||||
from clients.agent_backend import (
|
||||
@ -6,10 +9,25 @@ from clients.agent_backend import (
|
||||
AgentBackendRunPausedInternalEvent,
|
||||
AgentBackendRunSucceededInternalEvent,
|
||||
)
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from core.workflow.nodes.agent_v2.output_adapter import WorkflowAgentOutputAdapter
|
||||
from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.variables.segments import ArrayFileSegment, FileSegment
|
||||
from models.agent_config_entities import DeclaredArrayItem, DeclaredOutputConfig, DeclaredOutputType
|
||||
|
||||
|
||||
def _restored_file(*, transfer_method: FileTransferMethod, reference: str) -> File:
|
||||
return File(
|
||||
type=FileType.DOCUMENT,
|
||||
transfer_method=transfer_method,
|
||||
remote_url=None,
|
||||
reference=reference,
|
||||
filename="report.pdf",
|
||||
extension=".pdf",
|
||||
mime_type="application/pdf",
|
||||
size=12,
|
||||
)
|
||||
|
||||
|
||||
def _rebacked_tool_file(tool_file_id: str) -> File:
|
||||
@ -71,6 +89,24 @@ def test_unresolved_minimal_id_stays_a_plain_object():
|
||||
assert result.outputs["thing"] == {"id": "not-a-file"}
|
||||
|
||||
|
||||
def test_invalid_minimal_id_stays_a_plain_object_without_reback():
|
||||
adapter = WorkflowAgentOutputAdapter(tool_file_rebacker=lambda **_: _rebacked_tool_file("unexpected"))
|
||||
result = adapter.build_success_result(
|
||||
event=_succeeded({"thing": {"id": 123}}),
|
||||
inputs={},
|
||||
process_data={},
|
||||
metadata={},
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
assert result.outputs["thing"] == {"id": 123}
|
||||
|
||||
|
||||
def test_success_output_adapter_preserves_existing_file_segment():
|
||||
file = _rebacked_tool_file("tool-file-1")
|
||||
segment = FileSegment(value=file)
|
||||
assert WorkflowAgentOutputAdapter()._normalize_output_value(segment) is segment
|
||||
|
||||
|
||||
def test_array_of_minimal_id_file_outputs_rebacked():
|
||||
adapter = WorkflowAgentOutputAdapter(
|
||||
tool_file_rebacker=lambda *, tenant_id, tool_file_id: _rebacked_tool_file(tool_file_id)
|
||||
@ -174,25 +210,211 @@ def test_success_output_adapter_normalizes_string_and_scalar_outputs():
|
||||
|
||||
|
||||
def test_success_output_adapter_normalizes_file_output_to_file_segments():
|
||||
upload_reference = build_file_reference(record_id="upload-file-1")
|
||||
tool_reference = build_file_reference(record_id="tool-file-1")
|
||||
with patch(
|
||||
"core.workflow.nodes.agent_v2.output_adapter.build_from_mapping",
|
||||
side_effect=[
|
||||
_restored_file(transfer_method=FileTransferMethod.LOCAL_FILE, reference=upload_reference),
|
||||
_restored_file(transfer_method=FileTransferMethod.TOOL_FILE, reference=tool_reference),
|
||||
],
|
||||
):
|
||||
result = WorkflowAgentOutputAdapter().build_success_result(
|
||||
event=AgentBackendRunSucceededInternalEvent(
|
||||
run_id="run-1",
|
||||
source_event_id="2-0",
|
||||
output={
|
||||
"report": {
|
||||
"transfer_method": "local_file",
|
||||
"reference": upload_reference,
|
||||
},
|
||||
"attachments": [
|
||||
{
|
||||
"transfer_method": "tool_file",
|
||||
"reference": tool_reference,
|
||||
}
|
||||
],
|
||||
},
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
inputs={},
|
||||
process_data={},
|
||||
metadata={"tenant_id": "tenant-1"},
|
||||
declared_outputs=[
|
||||
DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE),
|
||||
DeclaredOutputConfig(
|
||||
name="attachments",
|
||||
type=DeclaredOutputType.ARRAY,
|
||||
array_item=DeclaredArrayItem(type=DeclaredOutputType.FILE),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
report = result.outputs["report"]
|
||||
assert isinstance(report, FileSegment)
|
||||
assert report.value.transfer_method == FileTransferMethod.LOCAL_FILE
|
||||
assert report.value.reference == upload_reference
|
||||
|
||||
attachments = result.outputs["attachments"]
|
||||
assert isinstance(attachments, ArrayFileSegment)
|
||||
assert attachments.value[0].transfer_method == FileTransferMethod.TOOL_FILE
|
||||
assert attachments.value[0].reference == tool_reference
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "expected_error"),
|
||||
[
|
||||
({}, "file mapping missing transfer_method"),
|
||||
(
|
||||
{"transfer_method": "remote_url", "url": "https://example.com/report.pdf", "extra": "nope"},
|
||||
"remote_url file mapping must contain exactly",
|
||||
),
|
||||
({"transfer_method": "remote_url", "url": ""}, "remote_url file mapping missing url"),
|
||||
({"transfer_method": "tool_file", "reference": ""}, "tool_file file mapping missing reference"),
|
||||
(
|
||||
{"transfer_method": "tool_file", "reference": "raw-tool-file-id"},
|
||||
"tool_file file mapping has invalid canonical reference",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_success_output_adapter_rejects_invalid_declared_file_mappings(
|
||||
payload: dict[str, object],
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
with pytest.raises(ValueError, match=expected_error):
|
||||
WorkflowAgentOutputAdapter().build_success_result(
|
||||
event=_succeeded({"report": payload}),
|
||||
inputs={},
|
||||
process_data={},
|
||||
metadata={"tenant_id": "tenant-1"},
|
||||
declared_outputs=[DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE)],
|
||||
)
|
||||
|
||||
|
||||
def test_success_output_adapter_requires_tenant_for_canonical_file_mapping():
|
||||
reference = build_file_reference(record_id="tool-file-1")
|
||||
with pytest.raises(ValueError, match="tenant_id is required"):
|
||||
WorkflowAgentOutputAdapter().build_success_result(
|
||||
event=_succeeded({"report": {"transfer_method": "tool_file", "reference": reference}}),
|
||||
inputs={},
|
||||
process_data={},
|
||||
metadata={},
|
||||
declared_outputs=[DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE)],
|
||||
)
|
||||
|
||||
|
||||
def test_success_output_adapter_accepts_canonical_file_mapping_for_declared_file_output():
|
||||
tool_reference = build_file_reference(record_id="tool-file-1")
|
||||
with patch(
|
||||
"core.workflow.nodes.agent_v2.output_adapter.build_from_mapping",
|
||||
return_value=_restored_file(transfer_method=FileTransferMethod.TOOL_FILE, reference=tool_reference),
|
||||
):
|
||||
result = WorkflowAgentOutputAdapter().build_success_result(
|
||||
event=AgentBackendRunSucceededInternalEvent(
|
||||
run_id="run-1",
|
||||
source_event_id="2-0",
|
||||
output={"report": {"transfer_method": "tool_file", "reference": tool_reference}},
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
inputs={},
|
||||
process_data={},
|
||||
metadata={"tenant_id": "tenant-1"},
|
||||
declared_outputs=[DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE)],
|
||||
)
|
||||
|
||||
report = result.outputs["report"]
|
||||
assert isinstance(report, FileSegment)
|
||||
assert report.value.transfer_method == FileTransferMethod.TOOL_FILE
|
||||
assert report.value.reference == tool_reference
|
||||
|
||||
|
||||
def test_success_output_adapter_accepts_canonical_datasource_file_mapping_for_declared_file_output():
|
||||
datasource_reference = build_file_reference(record_id="datasource-file-1")
|
||||
with patch(
|
||||
"core.workflow.nodes.agent_v2.output_adapter.build_from_mapping",
|
||||
return_value=_restored_file(
|
||||
transfer_method=FileTransferMethod.DATASOURCE_FILE,
|
||||
reference=datasource_reference,
|
||||
),
|
||||
):
|
||||
result = WorkflowAgentOutputAdapter().build_success_result(
|
||||
event=AgentBackendRunSucceededInternalEvent(
|
||||
run_id="run-1",
|
||||
source_event_id="2-0",
|
||||
output={"report": {"transfer_method": "datasource_file", "reference": datasource_reference}},
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
inputs={},
|
||||
process_data={},
|
||||
metadata={"tenant_id": "tenant-1"},
|
||||
declared_outputs=[DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE)],
|
||||
)
|
||||
|
||||
report = result.outputs["report"]
|
||||
assert isinstance(report, FileSegment)
|
||||
assert report.value.transfer_method == FileTransferMethod.DATASOURCE_FILE
|
||||
assert report.value.reference == datasource_reference
|
||||
|
||||
|
||||
def test_success_output_adapter_accepts_canonical_remote_url_mapping_for_declared_file_output():
|
||||
remote_url = "https://example.com/report.pdf"
|
||||
|
||||
result = WorkflowAgentOutputAdapter().build_success_result(
|
||||
event=AgentBackendRunSucceededInternalEvent(
|
||||
run_id="run-1",
|
||||
source_event_id="2-0",
|
||||
output={
|
||||
"report": {
|
||||
"file_id": "upload-file-1",
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 12,
|
||||
},
|
||||
"attachments": [
|
||||
{
|
||||
"tool_file_id": "tool-file-1",
|
||||
"filename": "chart.png",
|
||||
"mime_type": "image/png",
|
||||
}
|
||||
],
|
||||
},
|
||||
output={"report": {"transfer_method": "remote_url", "url": remote_url}},
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
inputs={},
|
||||
process_data={},
|
||||
metadata={"tenant_id": "tenant-1"},
|
||||
declared_outputs=[DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE)],
|
||||
)
|
||||
|
||||
report = result.outputs["report"]
|
||||
assert isinstance(report, FileSegment)
|
||||
assert report.value.transfer_method == FileTransferMethod.REMOTE_URL
|
||||
assert report.value.remote_url == remote_url
|
||||
|
||||
|
||||
def test_success_output_adapter_accepts_canonical_file_mapping_for_declared_array_file_output():
|
||||
tool_reference = build_file_reference(record_id="tool-file-1")
|
||||
with patch(
|
||||
"core.workflow.nodes.agent_v2.output_adapter.build_from_mapping",
|
||||
return_value=_restored_file(transfer_method=FileTransferMethod.TOOL_FILE, reference=tool_reference),
|
||||
):
|
||||
result = WorkflowAgentOutputAdapter().build_success_result(
|
||||
event=AgentBackendRunSucceededInternalEvent(
|
||||
run_id="run-1",
|
||||
source_event_id="2-0",
|
||||
output={"attachments": [{"transfer_method": "tool_file", "reference": tool_reference}]},
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
inputs={},
|
||||
process_data={},
|
||||
metadata={"tenant_id": "tenant-1"},
|
||||
declared_outputs=[
|
||||
DeclaredOutputConfig(
|
||||
name="attachments",
|
||||
type=DeclaredOutputType.ARRAY,
|
||||
array_item=DeclaredArrayItem(type=DeclaredOutputType.FILE),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
attachments = result.outputs["attachments"]
|
||||
assert isinstance(attachments, ArrayFileSegment)
|
||||
assert attachments.value[0].transfer_method == FileTransferMethod.TOOL_FILE
|
||||
assert attachments.value[0].reference == tool_reference
|
||||
|
||||
|
||||
def test_success_output_adapter_does_not_treat_generic_object_with_string_id_as_file():
|
||||
result = WorkflowAgentOutputAdapter().build_success_result(
|
||||
event=AgentBackendRunSucceededInternalEvent(
|
||||
run_id="run-1",
|
||||
source_event_id="2-0",
|
||||
output={"meta": {"id": "123", "type": "summary"}},
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
inputs={},
|
||||
@ -200,17 +422,117 @@ def test_success_output_adapter_normalizes_file_output_to_file_segments():
|
||||
metadata={},
|
||||
)
|
||||
|
||||
report = result.outputs["report"]
|
||||
assert isinstance(report, FileSegment)
|
||||
assert report.value.type == FileType.DOCUMENT
|
||||
assert report.value.transfer_method == FileTransferMethod.LOCAL_FILE
|
||||
assert report.value.reference == "upload-file-1"
|
||||
assert result.outputs["meta"] == {"id": "123", "type": "summary"}
|
||||
|
||||
attachments = result.outputs["attachments"]
|
||||
assert isinstance(attachments, ArrayFileSegment)
|
||||
assert attachments.value[0].type == FileType.IMAGE
|
||||
assert attachments.value[0].transfer_method == FileTransferMethod.TOOL_FILE
|
||||
assert attachments.value[0].reference == "tool-file-1"
|
||||
|
||||
def test_success_output_adapter_does_not_crash_on_generic_object_with_non_string_id():
|
||||
result = WorkflowAgentOutputAdapter().build_success_result(
|
||||
event=AgentBackendRunSucceededInternalEvent(
|
||||
run_id="run-1",
|
||||
source_event_id="2-0",
|
||||
output={"meta": {"id": 1, "name": "foo"}},
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
inputs={},
|
||||
process_data={},
|
||||
metadata={},
|
||||
)
|
||||
|
||||
assert result.outputs["meta"] == {"id": 1, "name": "foo"}
|
||||
|
||||
|
||||
def test_success_output_adapter_preserves_nested_canonical_file_mapping_inside_object_output():
|
||||
tool_reference = build_file_reference(record_id="tool-file-1")
|
||||
result = WorkflowAgentOutputAdapter().build_success_result(
|
||||
event=AgentBackendRunSucceededInternalEvent(
|
||||
run_id="run-1",
|
||||
source_event_id="2-0",
|
||||
output={
|
||||
"meta": {
|
||||
"attachment": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": tool_reference,
|
||||
}
|
||||
}
|
||||
},
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
inputs={},
|
||||
process_data={},
|
||||
metadata={},
|
||||
declared_outputs=[DeclaredOutputConfig(name="meta", type=DeclaredOutputType.OBJECT)],
|
||||
)
|
||||
|
||||
assert result.outputs["meta"] == {
|
||||
"attachment": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": tool_reference,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_success_output_adapter_preserves_nested_canonical_file_mapping_inside_generic_array_output():
|
||||
tool_reference = build_file_reference(record_id="tool-file-1")
|
||||
result = WorkflowAgentOutputAdapter().build_success_result(
|
||||
event=AgentBackendRunSucceededInternalEvent(
|
||||
run_id="run-1",
|
||||
source_event_id="2-0",
|
||||
output={
|
||||
"items": [
|
||||
{
|
||||
"attachment": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": tool_reference,
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
inputs={},
|
||||
process_data={},
|
||||
metadata={},
|
||||
declared_outputs=[
|
||||
DeclaredOutputConfig(
|
||||
name="items",
|
||||
type=DeclaredOutputType.ARRAY,
|
||||
array_item=DeclaredArrayItem(type=DeclaredOutputType.OBJECT),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert result.outputs["items"] == [
|
||||
{
|
||||
"attachment": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": tool_reference,
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_success_output_adapter_does_not_normalize_top_level_canonical_file_mapping_without_declared_file_field():
|
||||
tool_reference = build_file_reference(record_id="tool-file-1")
|
||||
result = WorkflowAgentOutputAdapter().build_success_result(
|
||||
event=AgentBackendRunSucceededInternalEvent(
|
||||
run_id="run-1",
|
||||
source_event_id="2-0",
|
||||
output={
|
||||
"transfer_method": "tool_file",
|
||||
"reference": tool_reference,
|
||||
},
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
inputs={},
|
||||
process_data={},
|
||||
metadata={"tenant_id": "tenant-1"},
|
||||
declared_outputs=[DeclaredOutputConfig(name="text", type=DeclaredOutputType.STRING, required=False)],
|
||||
)
|
||||
|
||||
assert result.outputs == {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": tool_reference,
|
||||
}
|
||||
|
||||
|
||||
def test_success_output_adapter_maps_backend_usage_to_llm_usage_and_metadata():
|
||||
|
||||
@ -9,10 +9,12 @@ from collections.abc import Mapping
|
||||
|
||||
import pytest
|
||||
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from core.workflow.nodes.agent_v2.output_type_checker import (
|
||||
OutputTypeCheckStatus,
|
||||
PerOutputTypeChecker,
|
||||
)
|
||||
from graphon.file import FileTransferMethod
|
||||
from models.agent_config_entities import (
|
||||
DeclaredArrayItem,
|
||||
DeclaredOutputConfig,
|
||||
@ -21,13 +23,30 @@ from models.agent_config_entities import (
|
||||
|
||||
|
||||
class StubFileValidator:
|
||||
"""Trivially records the set of file_ids that pass tenant scope."""
|
||||
"""Trivially records the set of file mappings that pass tenant scope."""
|
||||
|
||||
def __init__(self, *, allowed: Mapping[str, set[str]] | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
allowed: Mapping[str, set[str]] | None = None,
|
||||
allowed_by_method: Mapping[tuple[str, str], set[str]] | None = None,
|
||||
) -> None:
|
||||
# Mapping: tenant_id -> {file_id, ...}
|
||||
self._allowed = {tenant: set(ids) for tenant, ids in (allowed or {}).items()}
|
||||
self._allowed_by_method = {
|
||||
(tenant, transfer_method): set(ids) for (tenant, transfer_method), ids in (allowed_by_method or {}).items()
|
||||
}
|
||||
|
||||
def is_owned_by_tenant(self, *, file_id: str, tenant_id: str) -> bool:
|
||||
def is_accessible_file_mapping(
|
||||
self,
|
||||
*,
|
||||
file_id: str,
|
||||
tenant_id: str,
|
||||
transfer_method: FileTransferMethod,
|
||||
) -> bool:
|
||||
scoped_ids = self._allowed_by_method.get((tenant_id, transfer_method.value))
|
||||
if self._allowed_by_method:
|
||||
return file_id in (scoped_ids or set())
|
||||
return file_id in self._allowed.get(tenant_id, set())
|
||||
|
||||
|
||||
@ -35,8 +54,12 @@ def _str_output(name: str = "summary", required: bool = True) -> DeclaredOutputC
|
||||
return DeclaredOutputConfig(name=name, type=DeclaredOutputType.STRING, required=required)
|
||||
|
||||
|
||||
def _make_checker(*, allowed: Mapping[str, set[str]] | None = None) -> PerOutputTypeChecker:
|
||||
return PerOutputTypeChecker(file_validator=StubFileValidator(allowed=allowed))
|
||||
def _make_checker(
|
||||
*,
|
||||
allowed: Mapping[str, set[str]] | None = None,
|
||||
allowed_by_method: Mapping[tuple[str, str], set[str]] | None = None,
|
||||
) -> PerOutputTypeChecker:
|
||||
return PerOutputTypeChecker(file_validator=StubFileValidator(allowed=allowed, allowed_by_method=allowed_by_method))
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
@ -127,15 +150,17 @@ def test_array_of_files_validates_per_item_file_ref():
|
||||
type=DeclaredOutputType.ARRAY,
|
||||
array_item=DeclaredArrayItem(type=DeclaredOutputType.FILE),
|
||||
)
|
||||
allowed_reference = build_file_reference(record_id="file-A")
|
||||
denied_reference = build_file_reference(record_id="other-tenant-file")
|
||||
|
||||
ok = checker.check(
|
||||
declared_outputs=[declared],
|
||||
raw_output={"docs": [{"file_id": "file-A", "filename": "a.pdf"}]},
|
||||
raw_output={"docs": [{"transfer_method": "tool_file", "reference": allowed_reference}]},
|
||||
tenant_id="t-1",
|
||||
)
|
||||
cross_tenant = checker.check(
|
||||
declared_outputs=[declared],
|
||||
raw_output={"docs": [{"file_id": "other-tenant-file", "filename": "x.pdf"}]},
|
||||
raw_output={"docs": [{"transfer_method": "tool_file", "reference": denied_reference}]},
|
||||
tenant_id="t-1",
|
||||
)
|
||||
|
||||
@ -152,62 +177,191 @@ def test_array_of_files_validates_per_item_file_ref():
|
||||
def test_file_ref_must_be_tenant_owned():
|
||||
checker = _make_checker(allowed={"t-1": {"my-file"}})
|
||||
declared = DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE)
|
||||
allowed_reference = build_file_reference(record_id="my-file")
|
||||
denied_reference = build_file_reference(record_id="other")
|
||||
|
||||
outcome = checker.check(
|
||||
declared_outputs=[declared],
|
||||
raw_output={"report": {"file_id": "my-file", "filename": "r.pdf"}},
|
||||
raw_output={"report": {"transfer_method": "local_file", "reference": allowed_reference}},
|
||||
tenant_id="t-1",
|
||||
)
|
||||
assert not outcome.has_failures
|
||||
|
||||
outcome = checker.check(
|
||||
declared_outputs=[declared],
|
||||
raw_output={"report": {"file_id": "other", "filename": "r.pdf"}},
|
||||
raw_output={"report": {"transfer_method": "local_file", "reference": denied_reference}},
|
||||
tenant_id="t-1",
|
||||
)
|
||||
assert outcome.has_failures
|
||||
|
||||
|
||||
def test_file_ref_accepts_canonical_id_alias():
|
||||
"""Agent Files §4.6: the canonical minimal file ref is ``{"id": "<tool_file_id>"}``."""
|
||||
checker = _make_checker(allowed={"t-1": {"tool-file-1"}})
|
||||
def test_file_ref_must_match_transfer_method_family():
|
||||
tool_reference = build_file_reference(record_id="tool-file-1")
|
||||
checker = _make_checker(
|
||||
allowed={"t-1": {"tool-file-1"}},
|
||||
allowed_by_method={
|
||||
("t-1", FileTransferMethod.TOOL_FILE.value): {"tool-file-1"},
|
||||
},
|
||||
)
|
||||
declared = DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE)
|
||||
|
||||
outcome = checker.check(
|
||||
declared_outputs=[declared],
|
||||
raw_output={"report": {"id": "tool-file-1"}},
|
||||
raw_output={"report": {"transfer_method": "local_file", "reference": tool_reference}},
|
||||
tenant_id="t-1",
|
||||
)
|
||||
|
||||
assert outcome.has_failures
|
||||
assert "not accessible" in (outcome.failures[0].reason or "")
|
||||
|
||||
|
||||
def test_datasource_file_ref_is_accepted_for_matching_transfer_method_family():
|
||||
datasource_reference = build_file_reference(record_id="datasource-file-1")
|
||||
checker = _make_checker(
|
||||
allowed={"t-1": {"datasource-file-1"}},
|
||||
allowed_by_method={
|
||||
("t-1", FileTransferMethod.DATASOURCE_FILE.value): {"datasource-file-1"},
|
||||
},
|
||||
)
|
||||
declared = DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE)
|
||||
|
||||
outcome = checker.check(
|
||||
declared_outputs=[declared],
|
||||
raw_output={"report": {"transfer_method": "datasource_file", "reference": datasource_reference}},
|
||||
tenant_id="t-1",
|
||||
)
|
||||
|
||||
assert not outcome.has_failures
|
||||
assert outcome.results[0].status == OutputTypeCheckStatus.READY
|
||||
|
||||
|
||||
def test_file_ref_missing_id_field_fails():
|
||||
def test_datasource_file_ref_rejects_transfer_method_family_mismatch():
|
||||
tool_reference = build_file_reference(record_id="tool-file-1")
|
||||
checker = _make_checker(
|
||||
allowed={"t-1": {"tool-file-1"}},
|
||||
allowed_by_method={
|
||||
("t-1", FileTransferMethod.TOOL_FILE.value): {"tool-file-1"},
|
||||
},
|
||||
)
|
||||
declared = DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE)
|
||||
|
||||
outcome = checker.check(
|
||||
declared_outputs=[declared],
|
||||
raw_output={"report": {"transfer_method": "datasource_file", "reference": tool_reference}},
|
||||
tenant_id="t-1",
|
||||
)
|
||||
|
||||
assert outcome.has_failures
|
||||
assert "not accessible" in (outcome.failures[0].reason or "")
|
||||
|
||||
|
||||
def test_file_ref_missing_reference_or_url_fails():
|
||||
checker = _make_checker()
|
||||
declared = DeclaredOutputConfig(name="r", type=DeclaredOutputType.FILE)
|
||||
|
||||
outcome = checker.check(
|
||||
declared_outputs=[declared],
|
||||
raw_output={"r": {"filename": "x.pdf"}}, # no file_id / upload_file_id / tool_file_id
|
||||
raw_output={"r": {"transfer_method": "tool_file"}},
|
||||
tenant_id="t-1",
|
||||
)
|
||||
|
||||
assert outcome.failures[0].reason == "file ref missing a recognized file_id field"
|
||||
assert outcome.failures[0].reason == (
|
||||
"tool_file file mapping must contain exactly ['reference', 'transfer_method'] (missing reference)"
|
||||
)
|
||||
|
||||
|
||||
def test_file_ref_accepts_upload_file_id_alias():
|
||||
checker = _make_checker(allowed={"t-1": {"alt-file"}})
|
||||
def test_file_ref_accepts_remote_url_mapping_without_tenant_lookup():
|
||||
checker = _make_checker()
|
||||
declared = DeclaredOutputConfig(name="r", type=DeclaredOutputType.FILE)
|
||||
|
||||
outcome = checker.check(
|
||||
declared_outputs=[declared],
|
||||
raw_output={"r": {"upload_file_id": "alt-file"}},
|
||||
raw_output={"r": {"transfer_method": "remote_url", "url": "https://example.com/report.pdf"}},
|
||||
tenant_id="t-1",
|
||||
)
|
||||
|
||||
assert not outcome.has_failures
|
||||
|
||||
|
||||
def test_file_ref_rejects_legacy_id_only_shape():
|
||||
checker = _make_checker(allowed={"t-1": {"tool-file-1"}})
|
||||
declared = DeclaredOutputConfig(name="r", type=DeclaredOutputType.FILE)
|
||||
|
||||
outcome = checker.check(
|
||||
declared_outputs=[declared],
|
||||
raw_output={"r": {"id": "tool-file-1"}},
|
||||
tenant_id="t-1",
|
||||
)
|
||||
|
||||
assert outcome.has_failures
|
||||
assert outcome.failures[0].reason == "file mapping missing transfer_method"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_value", "expected_reason"),
|
||||
[
|
||||
("not-a-list", "expected array, got str"),
|
||||
(123, "expected canonical file mapping object, got int"),
|
||||
({"transfer_method": "unsupported", "reference": "x"}, "unsupported file transfer_method 'unsupported'"),
|
||||
({"transfer_method": "remote_url", "url": ""}, "remote_url file mapping missing url"),
|
||||
],
|
||||
)
|
||||
def test_rejects_additional_invalid_array_and_file_shapes(raw_value: object, expected_reason: str) -> None:
|
||||
checker = _make_checker()
|
||||
declared = (
|
||||
DeclaredOutputConfig(
|
||||
name="r",
|
||||
type=DeclaredOutputType.ARRAY,
|
||||
array_item=DeclaredArrayItem(type=DeclaredOutputType.STRING),
|
||||
)
|
||||
if raw_value == "not-a-list"
|
||||
else DeclaredOutputConfig(name="r", type=DeclaredOutputType.FILE)
|
||||
)
|
||||
|
||||
outcome = checker.check(
|
||||
declared_outputs=[declared],
|
||||
raw_output={"r": raw_value},
|
||||
tenant_id="t-1",
|
||||
)
|
||||
|
||||
assert outcome.has_failures
|
||||
assert outcome.failures[0].reason == expected_reason
|
||||
|
||||
|
||||
def test_file_ref_rejects_extra_rich_descriptor_fields() -> None:
|
||||
checker = _make_checker(allowed={"t-1": {"tool-file-1"}})
|
||||
declared = DeclaredOutputConfig(name="r", type=DeclaredOutputType.FILE)
|
||||
reference = build_file_reference(record_id="tool-file-1")
|
||||
|
||||
outcome = checker.check(
|
||||
declared_outputs=[declared],
|
||||
raw_output={
|
||||
"r": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": reference,
|
||||
"filename": "report.pdf",
|
||||
}
|
||||
},
|
||||
tenant_id="t-1",
|
||||
)
|
||||
|
||||
assert outcome.has_failures
|
||||
assert "unexpected filename" in (outcome.failures[0].reason or "")
|
||||
|
||||
|
||||
def test_file_ref_rejects_non_canonical_reference() -> None:
|
||||
checker = _make_checker(allowed={"t-1": {"tool-file-1"}})
|
||||
declared = DeclaredOutputConfig(name="r", type=DeclaredOutputType.FILE)
|
||||
|
||||
outcome = checker.check(
|
||||
declared_outputs=[declared],
|
||||
raw_output={"r": {"transfer_method": "tool_file", "reference": "raw-tool-file-uuid"}},
|
||||
tenant_id="t-1",
|
||||
)
|
||||
|
||||
assert outcome.has_failures
|
||||
assert outcome.failures[0].reason == "tool_file file mapping has invalid canonical reference"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Missing values + required flag
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -169,10 +169,9 @@ def test_builds_create_run_request_from_agent_soul_and_node_job():
|
||||
layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]}
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["agent_id"] == "agent-1"
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["agent_config_version_id"] == "snapshot-1"
|
||||
# Real Dify access context is forwarded; the agent run mode moves to agent_mode.
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["user_from"] == "account"
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["invoke_from"] == "debugger"
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["agent_mode"] == "single_step"
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["invoke_from"] == "debugger"
|
||||
assert dumped["idempotency_key"] == "run-1:node-exec-1"
|
||||
assert dumped["composition"]["layers"][0]["config"]["prefix"] == "You are careful."
|
||||
assert dumped["composition"]["layers"][1]["config"]["prefix"] == "Use the previous output."
|
||||
@ -247,12 +246,17 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada
|
||||
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]}
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["invoke_from"] == "service-api"
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["agent_mode"] == "workflow_run"
|
||||
assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["invoke_from"] == "service-api"
|
||||
assert dumped["idempotency_key"] == "node-exec-1"
|
||||
output_schema = dumped["composition"]["layers"][-1]["config"]["json_schema"]
|
||||
assert output_schema["properties"]["report"]["properties"]["file_id"]["type"] == "string"
|
||||
assert output_schema["properties"]["report"]["required"] == ["file_id"]
|
||||
report_schema = output_schema["properties"]["report"]
|
||||
assert len(report_schema["oneOf"]) == 4
|
||||
assert all(branch["additionalProperties"] is False for branch in report_schema["oneOf"])
|
||||
assert report_schema["oneOf"][0]["required"] == ["transfer_method", "reference"]
|
||||
assert report_schema["oneOf"][1]["required"] == ["transfer_method", "reference"]
|
||||
assert report_schema["oneOf"][2]["required"] == ["transfer_method", "reference"]
|
||||
assert report_schema["oneOf"][3]["required"] == ["transfer_method", "url"]
|
||||
assert output_schema["properties"]["confidence"]["type"] == "number"
|
||||
assert output_schema["required"] == ["report"]
|
||||
assert dumped["composition"]["layers"][5]["config"]["model_settings"] == {"temperature": 0.2}
|
||||
@ -545,8 +549,8 @@ def test_empty_declared_outputs_injects_prd_defaults_text_files_json():
|
||||
assert properties["text"]["type"] == "string"
|
||||
assert properties["files"]["type"] == "array"
|
||||
# `files` defaults to array<file> → items is a file ref object.
|
||||
assert properties["files"]["items"]["properties"]["file_id"]["type"] == "string"
|
||||
assert properties["files"]["items"]["required"] == ["file_id"]
|
||||
assert len(properties["files"]["items"]["oneOf"]) == 4
|
||||
assert all(branch["additionalProperties"] is False for branch in properties["files"]["items"]["oneOf"])
|
||||
assert properties["json"]["type"] == "object"
|
||||
# Defaults are all required=False so no `required:` key on the schema.
|
||||
assert "required" not in output_layer["json_schema"]
|
||||
|
||||
@ -357,6 +357,7 @@ class TestBuildFromDatasourceFile:
|
||||
)
|
||||
assert captured["mime_type"] == "text/csv"
|
||||
assert file.extension == ".csv"
|
||||
assert file.transfer_method == FileTransferMethod.DATASOURCE_FILE
|
||||
|
||||
def test_extension_falls_back_to_bin_when_key_has_no_dot(self, monkeypatch: pytest.MonkeyPatch):
|
||||
captured: dict = {}
|
||||
@ -384,3 +385,4 @@ class TestBuildFromDatasourceFile:
|
||||
|
||||
assert captured["extension"] == ".bin"
|
||||
assert file.extension == ".bin"
|
||||
assert file.transfer_method == FileTransferMethod.DATASOURCE_FILE
|
||||
|
||||
94
api/tests/unit_tests/models/test_agent_config_entities.py
Normal file
94
api/tests/unit_tests/models/test_agent_config_entities.py
Normal file
@ -0,0 +1,94 @@
|
||||
import pytest
|
||||
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from models.agent_config_entities import DeclaredOutputConfig, DeclaredOutputType
|
||||
|
||||
|
||||
def test_file_default_value_accepts_canonical_reference_mapping() -> None:
|
||||
reference = build_file_reference(record_id="tool-file-1")
|
||||
|
||||
config = DeclaredOutputConfig.model_validate(
|
||||
{
|
||||
"name": "report",
|
||||
"type": "file",
|
||||
"failure_strategy": {
|
||||
"on_failure": "default_value",
|
||||
"default_value": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": reference,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert config.type == DeclaredOutputType.FILE
|
||||
|
||||
|
||||
def test_file_default_value_rejects_legacy_file_id_shape() -> None:
|
||||
with pytest.raises(ValueError, match="default_value shape"):
|
||||
_ = DeclaredOutputConfig.model_validate(
|
||||
{
|
||||
"name": "report",
|
||||
"type": "file",
|
||||
"failure_strategy": {
|
||||
"on_failure": "default_value",
|
||||
"default_value": {
|
||||
"file_id": "legacy-file-id",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_file_default_value_rejects_non_canonical_reference() -> None:
|
||||
with pytest.raises(ValueError, match="default_value shape"):
|
||||
_ = DeclaredOutputConfig.model_validate(
|
||||
{
|
||||
"name": "report",
|
||||
"type": "file",
|
||||
"failure_strategy": {
|
||||
"on_failure": "default_value",
|
||||
"default_value": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": "raw-tool-file-uuid",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_array_file_default_value_accepts_canonical_mappings() -> None:
|
||||
first_reference = build_file_reference(record_id="tool-file-1")
|
||||
second_reference = build_file_reference(record_id="tool-file-2")
|
||||
|
||||
config = DeclaredOutputConfig.model_validate(
|
||||
{
|
||||
"name": "reports",
|
||||
"type": "array",
|
||||
"array_item": {"type": "file"},
|
||||
"failure_strategy": {
|
||||
"on_failure": "default_value",
|
||||
"default_value": [
|
||||
{"transfer_method": "tool_file", "reference": first_reference},
|
||||
{"transfer_method": "tool_file", "reference": second_reference},
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert config.type == DeclaredOutputType.ARRAY
|
||||
|
||||
|
||||
def test_array_file_default_value_rejects_legacy_item_shape() -> None:
|
||||
with pytest.raises(ValueError, match="default_value shape"):
|
||||
_ = DeclaredOutputConfig.model_validate(
|
||||
{
|
||||
"name": "reports",
|
||||
"type": "array",
|
||||
"array_item": {"type": "file"},
|
||||
"failure_strategy": {
|
||||
"on_failure": "default_value",
|
||||
"default_value": [{"file_id": "legacy-file-id"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
76
api/tests/unit_tests/services/test_file_request_service.py
Normal file
76
api/tests/unit_tests/services/test_file_request_service.py
Normal file
@ -0,0 +1,76 @@
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from core.app.file_access import FileAccessScope
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from services.file_request_service import FileRequestService
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("user_from", "invoke_from", "expected_user_from", "expected_invoke_from"),
|
||||
[
|
||||
(UserFrom.ACCOUNT, InvokeFrom.DEBUGGER, UserFrom.ACCOUNT, InvokeFrom.DEBUGGER),
|
||||
("end-user", "service-api", UserFrom.END_USER, InvokeFrom.SERVICE_API),
|
||||
],
|
||||
)
|
||||
def test_request_download_url_builds_file_under_bound_scope(
|
||||
user_from: UserFrom | str,
|
||||
invoke_from: InvokeFrom | str,
|
||||
expected_user_from: UserFrom,
|
||||
expected_invoke_from: InvokeFrom,
|
||||
) -> None:
|
||||
fake_file = MagicMock(filename="report.pdf", mime_type="application/pdf", size=123)
|
||||
access_controller = MagicMock()
|
||||
service = FileRequestService(access_controller=access_controller)
|
||||
reference = build_file_reference(record_id="tool-file-1")
|
||||
|
||||
with (
|
||||
patch("services.file_request_service.bind_file_access_scope", return_value=nullcontext()) as bind_scope,
|
||||
patch.object(service, "_build_file", return_value=fake_file) as build_file,
|
||||
patch(
|
||||
"services.file_request_service.file_helpers.resolve_file_url", return_value="https://files.example.com/x"
|
||||
),
|
||||
):
|
||||
result = service.request_download_url(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from=user_from,
|
||||
invoke_from=invoke_from,
|
||||
file_mapping={"transfer_method": "tool_file", "reference": reference},
|
||||
)
|
||||
|
||||
bind_scope.assert_called_once()
|
||||
bound_scope = bind_scope.call_args.args[0]
|
||||
assert isinstance(bound_scope, FileAccessScope)
|
||||
assert bound_scope.tenant_id == "tenant-1"
|
||||
assert bound_scope.user_id == "user-1"
|
||||
assert bound_scope.user_from == expected_user_from
|
||||
assert bound_scope.invoke_from == expected_invoke_from
|
||||
build_file.assert_called_once_with(
|
||||
mapping={"transfer_method": "tool_file", "reference": reference}, tenant_id="tenant-1"
|
||||
)
|
||||
assert result.filename == "report.pdf"
|
||||
assert result.mime_type == "application/pdf"
|
||||
assert result.size == 123
|
||||
assert result.download_url == "https://files.example.com/x"
|
||||
|
||||
|
||||
def test_request_download_url_rejects_unsupported_files() -> None:
|
||||
service = FileRequestService(access_controller=MagicMock())
|
||||
|
||||
with (
|
||||
patch("services.file_request_service.bind_file_access_scope", return_value=nullcontext()),
|
||||
patch.object(service, "_build_file", return_value=MagicMock(filename="report.pdf", mime_type=None, size=1)),
|
||||
patch("services.file_request_service.file_helpers.resolve_file_url", return_value=None),
|
||||
):
|
||||
with pytest.raises(ValueError, match="file does not support signed download"):
|
||||
service.request_download_url(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
invoke_from="debugger",
|
||||
file_mapping={"transfer_method": "unknown"},
|
||||
)
|
||||
@ -15,6 +15,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
|
||||
from models.agent_config_entities import (
|
||||
DeclaredArrayItem,
|
||||
@ -27,6 +28,7 @@ from services.workflow.node_output_inspector_service import (
|
||||
NodeOutputInspectorService,
|
||||
NodeOutputStatus,
|
||||
NodeStatus,
|
||||
_resolve_preview_url,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
@ -204,6 +206,20 @@ def test_output_preview_404_when_output_name_unknown():
|
||||
assert exc.value.code == "node_output_not_declared"
|
||||
|
||||
|
||||
def test_output_preview_404_when_node_id_absent_from_graph():
|
||||
service = _make_service()
|
||||
run = _workflow_run(nodes=[_agent_v2_node(node_id="agent-1")])
|
||||
with _patch_session(workflow_run=run, executions=[]):
|
||||
with pytest.raises(NodeOutputInspectorError) as exc:
|
||||
service.output_preview(
|
||||
app_model=_app_model(),
|
||||
workflow_run_id="run-1",
|
||||
node_id="ghost",
|
||||
output_name="report",
|
||||
)
|
||||
assert exc.value.code == "node_not_in_workflow_run"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Snapshot happy path
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
@ -372,12 +388,15 @@ def test_file_output_preview_includes_signed_url():
|
||||
],
|
||||
)
|
||||
run = _workflow_run(nodes=[_agent_v2_node(node_id="agent-1")])
|
||||
file_payload = {"file_id": "550e8400-e29b-41d4-a716-446655440000", "filename": "x.pdf"}
|
||||
file_payload = {
|
||||
"transfer_method": "local_file",
|
||||
"reference": build_file_reference(record_id="550e8400-e29b-41d4-a716-446655440000"),
|
||||
}
|
||||
ex = _execution(node_id="agent-1", outputs={"report": file_payload})
|
||||
with (
|
||||
_patch_session(workflow_run=run, executions=[ex]),
|
||||
patch(
|
||||
"services.workflow.node_output_inspector_service.file_helpers.get_signed_file_url",
|
||||
"services.workflow.node_output_inspector_service._resolve_preview_url",
|
||||
return_value="https://signed.example/x.pdf",
|
||||
),
|
||||
):
|
||||
@ -385,7 +404,7 @@ def test_file_output_preview_includes_signed_url():
|
||||
preview_value = snapshot.node_outputs[0].outputs[0].value_preview
|
||||
assert isinstance(preview_value, dict)
|
||||
assert preview_value["preview_url"] == "https://signed.example/x.pdf"
|
||||
assert preview_value["filename"] == "x.pdf"
|
||||
assert preview_value["reference"] == file_payload["reference"]
|
||||
|
||||
|
||||
def test_file_output_preview_endpoint_returns_full_value_with_signed_url():
|
||||
@ -395,12 +414,15 @@ def test_file_output_preview_endpoint_returns_full_value_with_signed_url():
|
||||
],
|
||||
)
|
||||
run = _workflow_run(nodes=[_agent_v2_node(node_id="agent-1")])
|
||||
file_payload = {"file_id": "550e8400-e29b-41d4-a716-446655440000", "filename": "x.pdf"}
|
||||
file_payload = {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": build_file_reference(record_id="550e8400-e29b-41d4-a716-446655440000"),
|
||||
}
|
||||
ex = _execution(node_id="agent-1", outputs={"report": file_payload})
|
||||
with (
|
||||
_patch_session(workflow_run=run, executions=[ex]),
|
||||
patch(
|
||||
"services.workflow.node_output_inspector_service.file_helpers.get_signed_file_url",
|
||||
"services.workflow.node_output_inspector_service._resolve_preview_url",
|
||||
return_value="https://signed.example/x.pdf",
|
||||
),
|
||||
):
|
||||
@ -416,6 +438,144 @@ def test_file_output_preview_endpoint_returns_full_value_with_signed_url():
|
||||
assert preview.value["preview_url"] == "https://signed.example/x.pdf"
|
||||
|
||||
|
||||
def test_resolve_preview_url_uses_standard_file_factory():
|
||||
file_payload = {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": build_file_reference(record_id="550e8400-e29b-41d4-a716-446655440000"),
|
||||
}
|
||||
file = MagicMock()
|
||||
with (
|
||||
patch("services.workflow.node_output_inspector_service.DatabaseFileAccessController") as controller_cls,
|
||||
patch("services.workflow.node_output_inspector_service.build_from_mapping", return_value=file) as build_file,
|
||||
patch(
|
||||
"services.workflow.node_output_inspector_service.file_helpers.resolve_file_url",
|
||||
return_value="https://signed.example/x.pdf",
|
||||
) as resolve_file_url,
|
||||
):
|
||||
assert _resolve_preview_url(file_payload, tenant_id="tenant-1") == "https://signed.example/x.pdf"
|
||||
|
||||
build_file.assert_called_once_with(
|
||||
mapping=file_payload,
|
||||
tenant_id="tenant-1",
|
||||
access_controller=controller_cls.return_value,
|
||||
)
|
||||
resolve_file_url.assert_called_once_with(file)
|
||||
|
||||
|
||||
def test_array_file_output_preview_includes_signed_urls_for_each_item():
|
||||
service = _make_service(
|
||||
declared_outputs=[
|
||||
DeclaredOutputConfig(
|
||||
name="files",
|
||||
type=DeclaredOutputType.ARRAY,
|
||||
array_item=DeclaredArrayItem(type=DeclaredOutputType.FILE),
|
||||
),
|
||||
],
|
||||
)
|
||||
run = _workflow_run(nodes=[_agent_v2_node(node_id="agent-1")])
|
||||
file_payloads = [
|
||||
{
|
||||
"transfer_method": "tool_file",
|
||||
"reference": build_file_reference(record_id="550e8400-e29b-41d4-a716-446655440001"),
|
||||
},
|
||||
{
|
||||
"transfer_method": "tool_file",
|
||||
"reference": build_file_reference(record_id="550e8400-e29b-41d4-a716-446655440002"),
|
||||
},
|
||||
]
|
||||
ex = _execution(node_id="agent-1", outputs={"files": file_payloads})
|
||||
with (
|
||||
_patch_session(workflow_run=run, executions=[ex]),
|
||||
patch(
|
||||
"services.workflow.node_output_inspector_service._resolve_preview_url",
|
||||
side_effect=[
|
||||
"https://signed.example/1.pdf",
|
||||
"https://signed.example/2.pdf",
|
||||
"https://signed.example/1-detail.pdf",
|
||||
"https://signed.example/2-detail.pdf",
|
||||
"https://signed.example/1-full.pdf",
|
||||
"https://signed.example/2-full.pdf",
|
||||
],
|
||||
),
|
||||
):
|
||||
snapshot = service.snapshot_workflow_run(app_model=_app_model(), workflow_run_id="run-1")
|
||||
preview = service.output_preview(
|
||||
app_model=_app_model(),
|
||||
workflow_run_id="run-1",
|
||||
node_id="agent-1",
|
||||
output_name="files",
|
||||
)
|
||||
|
||||
snapshot_value = snapshot.node_outputs[0].outputs[0].value_preview
|
||||
assert isinstance(snapshot_value, list)
|
||||
assert [item["preview_url"] for item in snapshot_value] == [
|
||||
"https://signed.example/1.pdf",
|
||||
"https://signed.example/2.pdf",
|
||||
]
|
||||
assert isinstance(preview.value, list)
|
||||
assert [item["preview_url"] for item in preview.value] == [
|
||||
"https://signed.example/1-full.pdf",
|
||||
"https://signed.example/2-full.pdf",
|
||||
]
|
||||
|
||||
|
||||
def test_file_output_preview_uses_none_when_signed_url_resolution_fails():
|
||||
service = _make_service(
|
||||
declared_outputs=[
|
||||
DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE),
|
||||
],
|
||||
)
|
||||
run = _workflow_run(nodes=[_agent_v2_node(node_id="agent-1")])
|
||||
file_payload = {
|
||||
"transfer_method": "local_file",
|
||||
"reference": build_file_reference(record_id="550e8400-e29b-41d4-a716-446655440000"),
|
||||
}
|
||||
ex = _execution(node_id="agent-1", outputs={"report": file_payload})
|
||||
with (
|
||||
_patch_session(workflow_run=run, executions=[ex]),
|
||||
patch(
|
||||
"services.workflow.node_output_inspector_service._resolve_preview_url",
|
||||
side_effect=RuntimeError("boom"),
|
||||
),
|
||||
):
|
||||
snapshot = service.snapshot_workflow_run(app_model=_app_model(), workflow_run_id="run-1")
|
||||
|
||||
preview_value = snapshot.node_outputs[0].outputs[0].value_preview
|
||||
assert isinstance(preview_value, dict)
|
||||
assert preview_value["preview_url"] is None
|
||||
|
||||
|
||||
def test_object_output_preview_does_not_augment_canonical_file_mapping_shape():
|
||||
service = _make_service(
|
||||
declared_outputs=[
|
||||
DeclaredOutputConfig(name="meta", type=DeclaredOutputType.OBJECT),
|
||||
],
|
||||
)
|
||||
run = _workflow_run(nodes=[_agent_v2_node(node_id="agent-1")])
|
||||
raw_value = {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": build_file_reference(record_id="550e8400-e29b-41d4-a716-446655440000"),
|
||||
}
|
||||
ex = _execution(node_id="agent-1", outputs={"meta": raw_value})
|
||||
with (
|
||||
_patch_session(workflow_run=run, executions=[ex]),
|
||||
patch(
|
||||
"services.workflow.node_output_inspector_service._resolve_preview_url",
|
||||
return_value="https://signed.example/x.pdf",
|
||||
),
|
||||
):
|
||||
snapshot = service.snapshot_workflow_run(app_model=_app_model(), workflow_run_id="run-1")
|
||||
preview = service.output_preview(
|
||||
app_model=_app_model(),
|
||||
workflow_run_id="run-1",
|
||||
node_id="agent-1",
|
||||
output_name="meta",
|
||||
)
|
||||
|
||||
assert snapshot.node_outputs[0].outputs[0].value_preview == raw_value
|
||||
assert preview.value == raw_value
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Retry / metadata
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
16
api/uv.lock
generated
16
api/uv.lock
generated
@ -1286,6 +1286,7 @@ dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-ai-slim" },
|
||||
{ name = "typer" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
|
||||
@ -1293,23 +1294,28 @@ dependencies = [
|
||||
requires-dist = [
|
||||
{ name = "fastapi", marker = "extra == 'server'", specifier = "==0.136.0" },
|
||||
{ name = "graphon", marker = "extra == 'server'", specifier = "==0.2.2" },
|
||||
{ name = "grpclib", extras = ["protobuf"], marker = "extra == 'grpc'", specifier = ">=0.4.9,<0.5.0" },
|
||||
{ name = "httpx", specifier = "==0.28.1" },
|
||||
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" },
|
||||
{ name = "jwcrypto", marker = "extra == 'server'", specifier = ">=1.5.6,<2" },
|
||||
{ name = "protobuf", marker = "extra == 'grpc'", specifier = ">=6.33.5,<7.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.12.5,<2.13" },
|
||||
{ name = "pydantic-ai-slim", specifier = ">=1.85.1,<2.0.0" },
|
||||
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" },
|
||||
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" },
|
||||
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.1.1" },
|
||||
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.2.0" },
|
||||
{ name = "typer", specifier = ">=0.16.1,<0.17" },
|
||||
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" },
|
||||
]
|
||||
provides-extras = ["server"]
|
||||
provides-extras = ["grpc", "server"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "basedpyright", specifier = ">=1.39.3" },
|
||||
{ name = "coverage", extras = ["toml"], specifier = ">=7.10.7" },
|
||||
{ name = "grpcio-tools", specifier = ">=1.81.0,<2.0.0" },
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
{ name = "pytest-examples", specifier = ">=0.0.18" },
|
||||
{ name = "pytest-mock", specifier = ">=3.14.0" },
|
||||
@ -6549,7 +6555,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.20.0"
|
||||
version = "0.16.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@ -6557,9 +6563,9 @@ dependencies = [
|
||||
{ name = "shellingham" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/78/d90f616bf5f88f8710ad067c1f8705bf7618059836ca084e5bb2a0855d75/typer-0.16.1.tar.gz", hash = "sha256:d358c65a464a7a90f338e3bb7ff0c74ac081449e53884b12ba658cbd72990614", size = 102836, upload-time = "2025-08-18T19:18:22.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/76/06dbe78f39b2203d2a47d5facc5df5102d0561e2807396471b5f7c5a30a1/typer-0.16.1-py3-none-any.whl", hash = "sha256:90ee01cb02d9b8395ae21ee3368421faf21fa138cb2a541ed369c08cec5237c9", size = 46397, upload-time = "2025-08-18T19:18:21.663Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -20,6 +20,23 @@ DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
|
||||
# API key sent to the Dify plugin daemon.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=
|
||||
|
||||
# Shell layer
|
||||
# Base URL for the shellctl server used by the dify.shell layer. Leave empty to disable shell layer use.
|
||||
DIFY_AGENT_SHELLCTL_ENTRYPOINT=
|
||||
# Optional bearer token sent to the shellctl server.
|
||||
DIFY_AGENT_SHELLCTL_AUTH_TOKEN=
|
||||
|
||||
# Agent Stub
|
||||
# Public Agent Stub URL reachable from shellctl-managed remote machines.
|
||||
# Use http(s)://.../agent-stub for HTTP or grpc://host:port for gRPC.
|
||||
# Leave empty to avoid injecting DIFY_AGENT_STUB_* into shell.run jobs.
|
||||
DIFY_AGENT_STUB_URL=
|
||||
# Optional bind override used only when DIFY_AGENT_STUB_URL uses grpc://.
|
||||
DIFY_AGENT_STUB_GRPC_BIND_ADDRESS=
|
||||
# Server-wide root secret used to derive Agent Stub JWE keys.
|
||||
# Required when DIFY_AGENT_STUB_URL is set; must be unpadded base64url for 32 bytes.
|
||||
DIFY_AGENT_SERVER_SECRET_KEY=
|
||||
|
||||
# Shared plugin-daemon HTTP client timeouts and limits.
|
||||
# Plugin-daemon HTTP connect timeout in seconds.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_CONNECT_TIMEOUT=10
|
||||
|
||||
@ -12,7 +12,7 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN python -m pip install --no-cache-dir \
|
||||
shell-session-manager==2.1.1 \
|
||||
shell-session-manager==2.2.0 \
|
||||
uv
|
||||
|
||||
RUN useradd --create-home --shell /bin/sh dify
|
||||
|
||||
@ -66,6 +66,11 @@ The minimum settings are:
|
||||
|
||||
See `.example.env` for the full server settings template.
|
||||
|
||||
If you plan to run `dify.shell`, also configure `DIFY_AGENT_SHELLCTL_ENTRYPOINT`
|
||||
and, when shell jobs need to call back with the `dify-agent` command, set
|
||||
`DIFY_AGENT_STUB_URL` plus a 32-byte base64url
|
||||
`DIFY_AGENT_SERVER_SECRET_KEY` as documented in `.example.env`.
|
||||
|
||||
## Start the Dify Agent server
|
||||
|
||||
For a normal local server process:
|
||||
|
||||
@ -36,6 +36,11 @@ also reads `.env` and `dify-agent/.env` when present.
|
||||
| `DIFY_AGENT_RUN_RETENTION_SECONDS` | `259200` | Seconds to retain Redis run records and per-run event streams; defaults to 3 days. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_URL` | `http://localhost:5002` | Base URL for the Dify plugin daemon. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_API_KEY` | empty | API key sent to the Dify plugin daemon. |
|
||||
| `DIFY_AGENT_SHELLCTL_ENTRYPOINT` | empty | Base URL for the shellctl server used by `dify.shell`; required when runs include the shell layer. |
|
||||
| `DIFY_AGENT_SHELLCTL_AUTH_TOKEN` | empty | Optional bearer token sent to the shellctl server. |
|
||||
| `DIFY_AGENT_STUB_URL` | empty | Public Agent Stub URL reachable from shellctl-managed remote machines. Use `http(s)://.../agent-stub` for HTTP or `grpc://host:port` for gRPC; enables `DIFY_AGENT_STUB_*` env injection for user `shell.run` jobs. |
|
||||
| `DIFY_AGENT_STUB_GRPC_BIND_ADDRESS` | empty | Optional `host:port` bind override used only when `DIFY_AGENT_STUB_URL` uses `grpc://`. |
|
||||
| `DIFY_AGENT_SERVER_SECRET_KEY` | empty | Server-wide root secret used to derive Agent Stub JWE keys; required when `DIFY_AGENT_STUB_URL` is set and must be unpadded base64url for 32 bytes. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_CONNECT_TIMEOUT` | `10` | Plugin-daemon HTTP connect timeout in seconds. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_READ_TIMEOUT` | `600` | Plugin-daemon HTTP read timeout in seconds. |
|
||||
| `DIFY_AGENT_PLUGIN_DAEMON_WRITE_TIMEOUT` | `30` | Plugin-daemon HTTP write timeout in seconds. |
|
||||
@ -53,6 +58,11 @@ DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30
|
||||
DIFY_AGENT_RUN_RETENTION_SECONDS=259200
|
||||
DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
|
||||
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=replace-with-daemon-key
|
||||
DIFY_AGENT_SHELLCTL_ENTRYPOINT=http://127.0.0.1:5004
|
||||
DIFY_AGENT_SHELLCTL_AUTH_TOKEN=replace-with-shellctl-token
|
||||
# Generate with: python -c 'import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode())'
|
||||
DIFY_AGENT_STUB_URL=https://agent.example.com/agent-stub
|
||||
DIFY_AGENT_SERVER_SECRET_KEY=replace-with-base64url-32-byte-secret
|
||||
```
|
||||
|
||||
Run records and event streams use the same retention. Status writes refresh the
|
||||
|
||||
@ -51,10 +51,28 @@ DIFY_AGENT_SHELLCTL_AUTH_TOKEN=replace-with-shellctl-token
|
||||
client on the no-token path. Set it only when the shellctl server is started with
|
||||
bearer authentication.
|
||||
|
||||
To let commands inside user-visible shell jobs call back to the Dify Agent server
|
||||
with `dify-agent ...`, also enable the Agent Stub:
|
||||
|
||||
```env
|
||||
DIFY_AGENT_STUB_URL=https://agent.example.com/agent-stub
|
||||
DIFY_AGENT_SERVER_SECRET_KEY=replace-with-base64url-32-byte-secret
|
||||
```
|
||||
|
||||
`DIFY_AGENT_SERVER_SECRET_KEY` must be unpadded base64url text for exactly 32
|
||||
decoded bytes. One way to generate it is:
|
||||
|
||||
```bash
|
||||
python -c 'import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode())'
|
||||
```
|
||||
|
||||
## Client request shape
|
||||
|
||||
A client adds the shell layer as an ordinary composition layer. The shell layer
|
||||
does not need dependencies. A typical run still also includes:
|
||||
A client adds the shell layer as an ordinary composition layer. Basic shell jobs
|
||||
do not need dependencies. To inject `DIFY_AGENT_STUB_URL` and
|
||||
`DIFY_AGENT_STUB_AUTH_JWE` into user-visible `shell.run` jobs, declare the
|
||||
execution-context layer as the shell layer's `execution_context` dependency. A
|
||||
typical run still also includes:
|
||||
|
||||
- a prompt layer that supplies the task;
|
||||
- an execution-context layer carrying tenant/user context;
|
||||
@ -109,6 +127,7 @@ product, and a SHA-256 hash of the CSV content.""",
|
||||
RunLayerSpec(
|
||||
name="shell",
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyShellLayerConfig(),
|
||||
),
|
||||
RunLayerSpec(
|
||||
@ -144,7 +163,12 @@ The same request serialized as JSON has these important layer entries:
|
||||
"schema_version": 1,
|
||||
"layers": [
|
||||
{"name": "prompt", "type": "plain.prompt"},
|
||||
{"name": "shell", "type": "dify.shell", "config": {}},
|
||||
{
|
||||
"name": "shell",
|
||||
"type": "dify.shell",
|
||||
"deps": {"execution_context": "execution_context"},
|
||||
"config": {}
|
||||
},
|
||||
{"name": "execution_context", "type": "dify.execution_context"},
|
||||
{
|
||||
"name": "llm",
|
||||
@ -196,7 +220,7 @@ defaults.
|
||||
The provided `docker/shellctl/Dockerfile` installs:
|
||||
|
||||
- `tmux`, required by `shellctl` to manage shell jobs;
|
||||
- `shell-session-manager==2.1.1`, which provides the `shellctl` CLI/server;
|
||||
- `shell-session-manager==2.2.0`, which provides the `shellctl` CLI/server;
|
||||
- `uv`, so uv shebang scripts with PEP 723 metadata can run inside the shell
|
||||
workspace;
|
||||
- a non-root default user named `dify`.
|
||||
|
||||
@ -53,7 +53,9 @@ async def main() -> None:
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(
|
||||
tenant_id=TENANT_ID,
|
||||
invoke_from="workflow_run",
|
||||
user_from="account",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="service-api",
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
|
||||
@ -46,7 +46,9 @@ def main() -> None:
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(
|
||||
tenant_id=TENANT_ID,
|
||||
invoke_from="workflow_run",
|
||||
user_from="account",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="service-api",
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
|
||||
46
dify-agent/proto/dify/agent/stub/v1/agent_stub.proto
Normal file
46
dify-agent/proto/dify/agent/stub/v1/agent_stub.proto
Normal file
@ -0,0 +1,46 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package dify.agent.stub.v1;
|
||||
|
||||
service AgentStubService {
|
||||
rpc Connect(ConnectRequest) returns (ConnectResponse);
|
||||
rpc CreateFileUploadRequest(FileUploadRequest) returns (FileUploadResponse);
|
||||
rpc CreateFileDownloadRequest(FileDownloadRequest) returns (FileDownloadResponse);
|
||||
}
|
||||
|
||||
message ConnectRequest {
|
||||
int32 protocol_version = 1;
|
||||
repeated string argv = 2;
|
||||
string metadata_json = 3;
|
||||
}
|
||||
|
||||
message ConnectResponse {
|
||||
string connection_id = 1;
|
||||
string status = 2;
|
||||
}
|
||||
|
||||
message FileUploadRequest {
|
||||
string filename = 1;
|
||||
string mimetype = 2;
|
||||
}
|
||||
|
||||
message FileUploadResponse {
|
||||
string upload_url = 1;
|
||||
}
|
||||
|
||||
message FileMapping {
|
||||
string transfer_method = 1;
|
||||
optional string reference = 2;
|
||||
optional string url = 3;
|
||||
}
|
||||
|
||||
message FileDownloadRequest {
|
||||
FileMapping file = 1;
|
||||
}
|
||||
|
||||
message FileDownloadResponse {
|
||||
string filename = 1;
|
||||
optional string mime_type = 2;
|
||||
int64 size = 3;
|
||||
string download_url = 4;
|
||||
}
|
||||
@ -8,18 +8,28 @@ dependencies = [
|
||||
"httpx==0.28.1",
|
||||
"pydantic>=2.12.5,<2.13",
|
||||
"pydantic-ai-slim>=1.85.1,<2.0.0",
|
||||
"typer>=0.16.1,<0.17",
|
||||
"typing-extensions>=4.12.2,<5.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
dify-agent = "dify_agent.agent_stub.cli.main:main"
|
||||
dify-agent-stub-server = "dify_agent.agent_stub.server.cli:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
grpc = [
|
||||
"grpclib[protobuf]>=0.4.9,<0.5.0",
|
||||
"protobuf>=6.33.5,<7.0.0",
|
||||
]
|
||||
server = [
|
||||
"fastapi==0.136.0",
|
||||
"graphon==0.2.2",
|
||||
"jsonschema>=4.23.0,<5.0.0",
|
||||
"jwcrypto>=1.5.6,<2",
|
||||
"pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0",
|
||||
"pydantic-settings>=2.12.0,<3.0.0",
|
||||
"redis>=7.4.0,<8.0.0",
|
||||
"shell-session-manager==2.1.1",
|
||||
"shell-session-manager==2.2.0",
|
||||
"uvicorn[standard]==0.46.0",
|
||||
]
|
||||
|
||||
@ -60,6 +70,7 @@ include = [
|
||||
dev = [
|
||||
"basedpyright>=1.39.3",
|
||||
"coverage[toml]>=7.10.7",
|
||||
"grpcio-tools>=1.81.0,<2.0.0",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-examples>=0.0.18",
|
||||
"pytest-mock>=3.14.0",
|
||||
|
||||
9
dify-agent/src/dify_agent/agent_stub/__init__.py
Normal file
9
dify-agent/src/dify_agent/agent_stub/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""Client-safe import root for Dify Agent Stub code.
|
||||
|
||||
The package intentionally avoids eager imports so sandbox CLI users can import
|
||||
``dify_agent.agent_stub`` without pulling in FastAPI, Redis, JWE, or other
|
||||
server-only dependencies. Import server helpers from ``dify_agent.agent_stub.server``
|
||||
explicitly when running or embedding the stub server.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
3
dify-agent/src/dify_agent/agent_stub/cli/__init__.py
Normal file
3
dify-agent/src/dify_agent/agent_stub/cli/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""Client-safe CLI package for the ``dify-agent`` sandbox command."""
|
||||
|
||||
__all__: list[str] = []
|
||||
20
dify-agent/src/dify_agent/agent_stub/cli/_agent_stub.py
Normal file
20
dify-agent/src/dify_agent/agent_stub/cli/_agent_stub.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""CLI-facing wrapper around the client-safe Agent Stub transport facade."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dify_agent.agent_stub.cli._env import read_agent_stub_environment
|
||||
from dify_agent.agent_stub.client._agent_stub import connect_agent_stub_sync
|
||||
from dify_agent.agent_stub.protocol.agent_stub import AgentStubConnectResponse
|
||||
|
||||
|
||||
def connect_from_environment(*, argv: list[str]) -> AgentStubConnectResponse:
|
||||
"""Connect to the configured Agent Stub using the current environment."""
|
||||
environment = read_agent_stub_environment()
|
||||
return connect_agent_stub_sync(
|
||||
url=environment.url,
|
||||
auth_jwe=environment.auth_jwe,
|
||||
argv=argv,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["connect_from_environment"]
|
||||
59
dify-agent/src/dify_agent/agent_stub/cli/_env.py
Normal file
59
dify-agent/src/dify_agent/agent_stub/cli/_env.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""Environment-variable helpers for the client-safe Agent Stub CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR,
|
||||
AGENT_STUB_URL_ENV_VAR,
|
||||
normalize_agent_stub_url,
|
||||
)
|
||||
|
||||
|
||||
class MissingAgentStubEnvironmentError(RuntimeError):
|
||||
"""Raised when the Agent Stub CLI environment is incomplete."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AgentStubEnvironment:
|
||||
"""Validated environment values needed for one CLI forwarding request."""
|
||||
|
||||
url: str
|
||||
auth_jwe: str
|
||||
|
||||
|
||||
def has_agent_stub_environment(env: Mapping[str, str] | None = None) -> bool:
|
||||
"""Return whether both required Agent Stub environment variables exist."""
|
||||
values = env or os.environ
|
||||
return bool(values.get(AGENT_STUB_URL_ENV_VAR) and values.get(AGENT_STUB_AUTH_JWE_ENV_VAR))
|
||||
|
||||
|
||||
def read_agent_stub_environment(env: Mapping[str, str] | None = None) -> AgentStubEnvironment:
|
||||
"""Read and validate the Agent Stub environment variables."""
|
||||
values = env or os.environ
|
||||
url = (values.get(AGENT_STUB_URL_ENV_VAR) or "").strip()
|
||||
auth_jwe = (values.get(AGENT_STUB_AUTH_JWE_ENV_VAR) or "").strip()
|
||||
missing: list[str] = []
|
||||
if not url:
|
||||
missing.append(AGENT_STUB_URL_ENV_VAR)
|
||||
if not auth_jwe:
|
||||
missing.append(AGENT_STUB_AUTH_JWE_ENV_VAR)
|
||||
if missing:
|
||||
names = ", ".join(missing)
|
||||
raise MissingAgentStubEnvironmentError(f"missing required Agent Stub environment variables: {names}")
|
||||
try:
|
||||
normalized_url = normalize_agent_stub_url(url)
|
||||
except ValueError as exc:
|
||||
raise MissingAgentStubEnvironmentError(f"invalid {AGENT_STUB_URL_ENV_VAR}: {exc}") from exc
|
||||
return AgentStubEnvironment(url=normalized_url, auth_jwe=auth_jwe)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentStubEnvironment",
|
||||
"MissingAgentStubEnvironmentError",
|
||||
"has_agent_stub_environment",
|
||||
"read_agent_stub_environment",
|
||||
]
|
||||
139
dify-agent/src/dify_agent/agent_stub/cli/_files.py
Normal file
139
dify-agent/src/dify_agent/agent_stub/cli/_files.py
Normal file
@ -0,0 +1,139 @@
|
||||
"""CLI helpers for sandbox-visible Agent Stub file commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
|
||||
from dify_agent.agent_stub.cli._env import read_agent_stub_environment
|
||||
from dify_agent.agent_stub.client._agent_stub import (
|
||||
download_file_bytes_from_signed_url_sync,
|
||||
request_agent_stub_file_download_sync,
|
||||
request_agent_stub_file_upload_sync,
|
||||
upload_file_to_signed_url_sync,
|
||||
)
|
||||
from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError
|
||||
from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileMapping, is_canonical_dify_file_reference
|
||||
|
||||
|
||||
class UploadedToolFileMapping(BaseModel):
|
||||
"""Canonical Agent output mapping returned by ``dify-agent file upload``."""
|
||||
|
||||
transfer_method: Literal["tool_file"] = "tool_file"
|
||||
reference: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DownloadedFileResult:
|
||||
"""Local filesystem result for one CLI download command."""
|
||||
|
||||
path: Path
|
||||
|
||||
|
||||
def upload_file_from_environment(*, path: str) -> UploadedToolFileMapping:
|
||||
"""Upload one sandbox-local file through the Agent Stub control plane.
|
||||
|
||||
The signed upload data-plane response must carry the Dify-generated
|
||||
``reference`` for the new ``ToolFile`` so the sandbox can return the
|
||||
canonical Agent output file mapping without synthesizing reference format.
|
||||
"""
|
||||
|
||||
source_path = Path(path).expanduser().resolve()
|
||||
if not source_path.is_file():
|
||||
raise AgentStubValidationError(f"local file not found: {source_path}")
|
||||
|
||||
environment = read_agent_stub_environment()
|
||||
filename = source_path.name
|
||||
mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
upload_request = request_agent_stub_file_upload_sync(
|
||||
url=environment.url,
|
||||
auth_jwe=environment.auth_jwe,
|
||||
filename=filename,
|
||||
mimetype=mime_type,
|
||||
)
|
||||
with source_path.open("rb") as file_obj:
|
||||
payload = upload_file_to_signed_url_sync(
|
||||
upload_url=upload_request.upload_url,
|
||||
filename=filename,
|
||||
file_obj=file_obj,
|
||||
mimetype=mime_type,
|
||||
)
|
||||
return _normalize_uploaded_tool_file(payload)
|
||||
|
||||
|
||||
def download_file_from_environment(
|
||||
*,
|
||||
transfer_method: str,
|
||||
reference_or_url: str,
|
||||
directory: str | None = None,
|
||||
) -> DownloadedFileResult:
|
||||
"""Download one workflow file mapping into the sandbox filesystem."""
|
||||
|
||||
environment = read_agent_stub_environment()
|
||||
normalized_transfer_method = cast(
|
||||
Literal["local_file", "tool_file", "datasource_file", "remote_url"],
|
||||
transfer_method,
|
||||
)
|
||||
try:
|
||||
file_mapping = AgentStubFileMapping(
|
||||
transfer_method=normalized_transfer_method,
|
||||
url=reference_or_url if normalized_transfer_method == "remote_url" else None,
|
||||
reference=reference_or_url if normalized_transfer_method != "remote_url" else None,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise AgentStubValidationError("invalid file download arguments") from exc
|
||||
|
||||
download_request = request_agent_stub_file_download_sync(
|
||||
url=environment.url,
|
||||
auth_jwe=environment.auth_jwe,
|
||||
file=file_mapping,
|
||||
)
|
||||
target_dir = Path(directory).expanduser().resolve() if directory else Path.cwd()
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
destination = _deduplicate_destination_path(target_dir / _sanitize_download_filename(download_request.filename))
|
||||
destination.write_bytes(download_file_bytes_from_signed_url_sync(download_url=download_request.download_url))
|
||||
return DownloadedFileResult(path=destination)
|
||||
|
||||
|
||||
def _normalize_uploaded_tool_file(payload: dict[str, object]) -> UploadedToolFileMapping:
|
||||
reference = payload.get("reference")
|
||||
if not isinstance(reference, str) or not reference:
|
||||
raise AgentStubTransferError("signed file upload response is missing reference")
|
||||
if not is_canonical_dify_file_reference(reference):
|
||||
raise AgentStubTransferError("signed file upload response has invalid canonical reference")
|
||||
return UploadedToolFileMapping(reference=reference)
|
||||
|
||||
|
||||
def _deduplicate_destination_path(path: Path) -> Path:
|
||||
if not path.exists():
|
||||
return path
|
||||
|
||||
suffix = "".join(path.suffixes)
|
||||
stem = path.name[: -len(suffix)] if suffix else path.name
|
||||
counter = 1
|
||||
while True:
|
||||
candidate = path.with_name(f"{stem} ({counter}){suffix}")
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
counter += 1
|
||||
|
||||
|
||||
def _sanitize_download_filename(filename: str) -> str:
|
||||
sanitized = Path(filename).name
|
||||
if sanitized in {"", ".", ".."}:
|
||||
raise AgentStubTransferError("signed file download response has invalid filename")
|
||||
return sanitized
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DownloadedFileResult",
|
||||
"UploadedToolFileMapping",
|
||||
"download_file_from_environment",
|
||||
"upload_file_from_environment",
|
||||
]
|
||||
158
dify-agent/src/dify_agent/agent_stub/cli/main.py
Normal file
158
dify-agent/src/dify_agent/agent_stub/cli/main.py
Normal file
@ -0,0 +1,158 @@
|
||||
"""Typer entry point for the client-safe ``dify-agent`` console script.
|
||||
|
||||
The CLI supports an explicit ``connect`` command and treats unknown bare
|
||||
commands as Agent Stub forwards. When the injected Agent Stub environment
|
||||
variables are missing, that path intentionally surfaces a clear missing-env
|
||||
error instead of Typer's generic unknown-command message. The module depends
|
||||
only on client-safe code so importing the console entry point does not pull in
|
||||
FastAPI, Redis, shellctl, or JWE runtime dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import typer
|
||||
from typer.main import get_command
|
||||
|
||||
from dify_agent.agent_stub.cli._agent_stub import connect_from_environment
|
||||
from dify_agent.agent_stub.cli._env import MissingAgentStubEnvironmentError, has_agent_stub_environment
|
||||
from dify_agent.agent_stub.cli._files import download_file_from_environment, upload_file_from_environment
|
||||
from dify_agent.agent_stub.client._errors import AgentStubClientError
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
add_completion=False,
|
||||
help="Forward shell-visible dify-agent commands to the Dify Agent Stub server.",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
file_app = typer.Typer(help="Upload or download workflow files through the Agent Stub.")
|
||||
app.add_typer(file_app, name="file")
|
||||
_KNOWN_ROOT_COMMANDS = frozenset({"connect", "file"})
|
||||
|
||||
|
||||
@app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
|
||||
def connect(
|
||||
json_output: bool = typer.Option(False, "--json", help="Emit the connection response as JSON."),
|
||||
argv: list[str] = typer.Argument(default_factory=list, metavar="ARGV"),
|
||||
) -> None:
|
||||
"""Establish one Agent Stub connection using the current environment."""
|
||||
_run_connect(argv=list(argv), json_output=json_output)
|
||||
|
||||
|
||||
@file_app.command("upload")
|
||||
def upload(path: str = typer.Argument(..., metavar="PATH")) -> None:
|
||||
"""Upload one sandbox-local file as a ToolFile output reference."""
|
||||
_run_file_upload(path=path)
|
||||
|
||||
|
||||
@file_app.command("download")
|
||||
def download(
|
||||
transfer_method: str = typer.Argument(..., metavar="TRANSFER_METHOD"),
|
||||
reference_or_url: str = typer.Argument(..., metavar="REFERENCE_OR_URL"),
|
||||
directory: str | None = typer.Argument(default=None, metavar="DIR"),
|
||||
) -> None:
|
||||
"""Download one workflow file mapping into the local sandbox directory."""
|
||||
_run_file_download(
|
||||
transfer_method=transfer_method,
|
||||
reference_or_url=reference_or_url,
|
||||
directory=directory,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
"""Run the ``dify-agent`` CLI with optional argv injection for tests."""
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if args[:1] == ["connect"] and not _is_help_request(args[1:]):
|
||||
json_output, forwarded_args = _parse_connect_args(args[1:])
|
||||
_run_connect(argv=forwarded_args, json_output=json_output)
|
||||
return
|
||||
json_output, forwarded_args = _extract_root_json_flag(args)
|
||||
if _is_unknown_bare_command(forwarded_args):
|
||||
if not has_agent_stub_environment():
|
||||
_show_root_help()
|
||||
_run_connect(argv=forwarded_args, json_output=json_output)
|
||||
return
|
||||
app(prog_name="dify-agent", args=args)
|
||||
|
||||
|
||||
def _extract_root_json_flag(argv: list[str]) -> tuple[bool, list[str]]:
|
||||
if len(argv) >= 2 and argv[0] == "--json" and argv[1] not in _KNOWN_ROOT_COMMANDS:
|
||||
return True, argv[1:]
|
||||
return False, argv
|
||||
|
||||
|
||||
def _is_unknown_bare_command(argv: list[str]) -> bool:
|
||||
if not argv:
|
||||
return False
|
||||
first = argv[0]
|
||||
return first not in _KNOWN_ROOT_COMMANDS and not first.startswith("-")
|
||||
|
||||
|
||||
def _parse_connect_args(argv: list[str]) -> tuple[bool, list[str]]:
|
||||
json_output = False
|
||||
remaining = list(argv)
|
||||
if remaining[:1] == ["--json"]:
|
||||
json_output = True
|
||||
remaining = remaining[1:]
|
||||
if remaining[:1] == ["--"]:
|
||||
remaining = remaining[1:]
|
||||
return json_output, remaining
|
||||
|
||||
|
||||
def _is_help_request(argv: list[str]) -> bool:
|
||||
return any(value in {"--help", "-h"} for value in argv)
|
||||
|
||||
|
||||
def _show_root_help() -> None:
|
||||
"""Render root CLI guidance before reporting unknown-command env errors."""
|
||||
command = get_command(app)
|
||||
context = command.make_context("dify-agent", [], resilient_parsing=True)
|
||||
typer.echo(command.get_help(context))
|
||||
|
||||
|
||||
def _run_connect(*, argv: list[str], json_output: bool) -> None:
|
||||
try:
|
||||
response = connect_from_environment(argv=argv)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
if json_output:
|
||||
typer.echo(response.model_dump_json())
|
||||
return
|
||||
typer.echo(f"connected {response.connection_id}")
|
||||
|
||||
|
||||
def _run_file_upload(*, path: str) -> None:
|
||||
try:
|
||||
response = upload_file_from_environment(path=path)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
typer.echo(response.model_dump_json())
|
||||
|
||||
|
||||
def _run_file_download(*, transfer_method: str, reference_or_url: str, directory: str | None) -> None:
|
||||
try:
|
||||
response = download_file_from_environment(
|
||||
transfer_method=transfer_method,
|
||||
reference_or_url=reference_or_url,
|
||||
directory=directory,
|
||||
)
|
||||
except MissingAgentStubEnvironmentError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(2) from exc
|
||||
except AgentStubClientError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise SystemExit(1) from exc
|
||||
typer.echo(str(response.path))
|
||||
|
||||
|
||||
__all__ = ["app", "main"]
|
||||
31
dify-agent/src/dify_agent/agent_stub/client/__init__.py
Normal file
31
dify-agent/src/dify_agent/agent_stub/client/__init__.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""Client-safe helpers for the Dify Agent Stub control plane."""
|
||||
|
||||
from ._agent_stub import (
|
||||
connect_agent_stub_sync,
|
||||
download_file_bytes_from_signed_url_sync,
|
||||
request_agent_stub_file_download_sync,
|
||||
request_agent_stub_file_upload_sync,
|
||||
upload_file_to_signed_url_sync,
|
||||
)
|
||||
from ._errors import (
|
||||
AgentStubClientError,
|
||||
AgentStubGRPCError,
|
||||
AgentStubHTTPError,
|
||||
AgentStubMissingGRPCDependencyError,
|
||||
AgentStubTransferError,
|
||||
AgentStubValidationError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentStubClientError",
|
||||
"AgentStubGRPCError",
|
||||
"AgentStubHTTPError",
|
||||
"AgentStubMissingGRPCDependencyError",
|
||||
"AgentStubTransferError",
|
||||
"AgentStubValidationError",
|
||||
"connect_agent_stub_sync",
|
||||
"download_file_bytes_from_signed_url_sync",
|
||||
"request_agent_stub_file_download_sync",
|
||||
"request_agent_stub_file_upload_sync",
|
||||
"upload_file_to_signed_url_sync",
|
||||
]
|
||||
122
dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py
Normal file
122
dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""Transport-dispatch facade for Agent Stub control-plane calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from pydantic import JsonValue
|
||||
|
||||
from dify_agent.agent_stub.client._agent_stub_http import (
|
||||
connect_agent_stub_http_sync,
|
||||
download_file_bytes_from_signed_url_sync,
|
||||
request_agent_stub_file_download_http_sync,
|
||||
request_agent_stub_file_upload_http_sync,
|
||||
upload_file_to_signed_url_sync,
|
||||
)
|
||||
from dify_agent.agent_stub.client._errors import AgentStubValidationError
|
||||
from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileMapping, parse_agent_stub_endpoint
|
||||
|
||||
|
||||
def connect_agent_stub_sync(
|
||||
*,
|
||||
url: str,
|
||||
auth_jwe: str,
|
||||
argv: list[str],
|
||||
metadata: dict[str, JsonValue] | None = None,
|
||||
timeout: float | httpx.Timeout = 30.0,
|
||||
sync_http_client: httpx.Client | None = None,
|
||||
):
|
||||
"""Connect through HTTP or gRPC based on the configured Agent Stub URL."""
|
||||
endpoint = _parse_endpoint(url)
|
||||
if endpoint.is_grpc:
|
||||
from dify_agent.agent_stub.client._agent_stub_grpc import connect_agent_stub_grpc_sync
|
||||
|
||||
return connect_agent_stub_grpc_sync(
|
||||
url=endpoint.url,
|
||||
auth_jwe=auth_jwe,
|
||||
argv=argv,
|
||||
metadata=metadata,
|
||||
timeout=timeout,
|
||||
)
|
||||
return connect_agent_stub_http_sync(
|
||||
base_url=endpoint.url,
|
||||
auth_jwe=auth_jwe,
|
||||
argv=argv,
|
||||
metadata=metadata,
|
||||
timeout=timeout,
|
||||
sync_http_client=sync_http_client,
|
||||
)
|
||||
|
||||
|
||||
def request_agent_stub_file_upload_sync(
|
||||
*,
|
||||
url: str,
|
||||
auth_jwe: str,
|
||||
filename: str,
|
||||
mimetype: str,
|
||||
timeout: float | httpx.Timeout = 30.0,
|
||||
sync_http_client: httpx.Client | None = None,
|
||||
):
|
||||
"""Request a signed upload URL through the selected Agent Stub transport."""
|
||||
endpoint = _parse_endpoint(url)
|
||||
if endpoint.is_grpc:
|
||||
from dify_agent.agent_stub.client._agent_stub_grpc import request_agent_stub_file_upload_grpc_sync
|
||||
|
||||
return request_agent_stub_file_upload_grpc_sync(
|
||||
url=endpoint.url,
|
||||
auth_jwe=auth_jwe,
|
||||
filename=filename,
|
||||
mimetype=mimetype,
|
||||
timeout=timeout,
|
||||
)
|
||||
return request_agent_stub_file_upload_http_sync(
|
||||
base_url=endpoint.url,
|
||||
auth_jwe=auth_jwe,
|
||||
filename=filename,
|
||||
mimetype=mimetype,
|
||||
timeout=timeout,
|
||||
sync_http_client=sync_http_client,
|
||||
)
|
||||
|
||||
|
||||
def request_agent_stub_file_download_sync(
|
||||
*,
|
||||
url: str,
|
||||
auth_jwe: str,
|
||||
file: AgentStubFileMapping,
|
||||
timeout: float | httpx.Timeout = 30.0,
|
||||
sync_http_client: httpx.Client | None = None,
|
||||
):
|
||||
"""Request a signed download URL through the selected Agent Stub transport."""
|
||||
endpoint = _parse_endpoint(url)
|
||||
if endpoint.is_grpc:
|
||||
from dify_agent.agent_stub.client._agent_stub_grpc import request_agent_stub_file_download_grpc_sync
|
||||
|
||||
return request_agent_stub_file_download_grpc_sync(
|
||||
url=endpoint.url,
|
||||
auth_jwe=auth_jwe,
|
||||
file=file,
|
||||
timeout=timeout,
|
||||
)
|
||||
return request_agent_stub_file_download_http_sync(
|
||||
base_url=endpoint.url,
|
||||
auth_jwe=auth_jwe,
|
||||
file=file,
|
||||
timeout=timeout,
|
||||
sync_http_client=sync_http_client,
|
||||
)
|
||||
|
||||
|
||||
def _parse_endpoint(url: str):
|
||||
try:
|
||||
return parse_agent_stub_endpoint(url)
|
||||
except ValueError as exc:
|
||||
raise AgentStubValidationError("invalid Agent Stub base URL") from exc
|
||||
|
||||
|
||||
__all__ = [
|
||||
"connect_agent_stub_sync",
|
||||
"download_file_bytes_from_signed_url_sync",
|
||||
"request_agent_stub_file_download_sync",
|
||||
"request_agent_stub_file_upload_sync",
|
||||
"upload_file_to_signed_url_sync",
|
||||
]
|
||||
242
dify-agent/src/dify_agent/agent_stub/client/_agent_stub_grpc.py
Normal file
242
dify-agent/src/dify_agent/agent_stub/client/_agent_stub_grpc.py
Normal file
@ -0,0 +1,242 @@
|
||||
"""Client-safe gRPC helpers for Agent Stub control-plane endpoints.
|
||||
|
||||
These entrypoints mirror the HTTP helpers in ``_agent_stub_http.py`` but keep
|
||||
the gRPC dependency chain lazy so default installs remain import-safe. Callers
|
||||
only reach this module after choosing a ``grpc://`` Agent Stub URL. At that
|
||||
point the public failure contract is:
|
||||
|
||||
- missing optional gRPC/protobuf dependencies raise
|
||||
``AgentStubMissingGRPCDependencyError``;
|
||||
- non-OK server statuses raise ``AgentStubGRPCError`` with the surfaced gRPC
|
||||
status name and detail text;
|
||||
- transport/runtime failures such as bad targets, terminated streams, or socket
|
||||
errors raise ``AgentStubClientError``.
|
||||
|
||||
Authentication follows gRPC conventions rather than HTTP headers: the compact
|
||||
JWE bearer token is sent as ``authorization: Bearer <token>`` metadata on each
|
||||
unary RPC.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from types import ModuleType
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from pydantic import JsonValue
|
||||
|
||||
from dify_agent.agent_stub.client._errors import (
|
||||
AgentStubClientError,
|
||||
AgentStubGRPCError,
|
||||
AgentStubMissingGRPCDependencyError,
|
||||
)
|
||||
from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileMapping, parse_agent_stub_endpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from grpclib.client import Channel
|
||||
from grpclib.exceptions import GRPCError, StreamTerminatedError
|
||||
|
||||
from dify_agent.agent_stub.grpc._generated.agent_stub_grpc import AgentStubServiceStub
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _GRPCRuntime:
|
||||
Channel: type[Channel]
|
||||
AgentStubServiceStub: type[AgentStubServiceStub]
|
||||
agent_stub_pb2: ModuleType
|
||||
GRPCError: type[GRPCError]
|
||||
StreamTerminatedError: type[StreamTerminatedError]
|
||||
|
||||
|
||||
def connect_agent_stub_grpc_sync(
|
||||
*,
|
||||
url: str,
|
||||
auth_jwe: str,
|
||||
argv: list[str],
|
||||
metadata: dict[str, JsonValue] | None = None,
|
||||
timeout: float | httpx.Timeout = 30.0,
|
||||
):
|
||||
"""Create one gRPC Agent Stub connection using the provided bearer JWE.
|
||||
|
||||
Raises:
|
||||
AgentStubMissingGRPCDependencyError: if the optional gRPC runtime or
|
||||
generated protobuf support is not installed.
|
||||
AgentStubGRPCError: if the server returns a non-OK gRPC status.
|
||||
AgentStubClientError: if the target URL is invalid for gRPC or the
|
||||
runtime/transport fails before a valid gRPC response is received.
|
||||
"""
|
||||
return asyncio.run(
|
||||
_call_grpc(
|
||||
url=url,
|
||||
auth_jwe=auth_jwe,
|
||||
method_name="Connect",
|
||||
request_factory=lambda runtime: _require_conversions().proto_connect_request(
|
||||
runtime.agent_stub_pb2,
|
||||
argv=argv,
|
||||
metadata=metadata,
|
||||
),
|
||||
response_parser=lambda response: _require_conversions().connect_response_from_proto(response),
|
||||
timeout=timeout,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def request_agent_stub_file_upload_grpc_sync(
|
||||
*,
|
||||
url: str,
|
||||
auth_jwe: str,
|
||||
filename: str,
|
||||
mimetype: str,
|
||||
timeout: float | httpx.Timeout = 30.0,
|
||||
):
|
||||
"""Request one signed upload URL through the gRPC Agent Stub endpoint.
|
||||
|
||||
The compact-JWE bearer token is sent via ``authorization`` metadata on the
|
||||
unary RPC rather than an HTTP header.
|
||||
|
||||
Raises:
|
||||
AgentStubMissingGRPCDependencyError: if the optional gRPC runtime or
|
||||
generated protobuf support is not installed.
|
||||
AgentStubGRPCError: if the server returns a non-OK gRPC status.
|
||||
AgentStubClientError: if the target URL is invalid for gRPC or the
|
||||
runtime/transport fails before a valid gRPC response is received.
|
||||
"""
|
||||
return asyncio.run(
|
||||
_call_grpc(
|
||||
url=url,
|
||||
auth_jwe=auth_jwe,
|
||||
method_name="CreateFileUploadRequest",
|
||||
request_factory=lambda runtime: _require_conversions().proto_file_upload_request(
|
||||
runtime.agent_stub_pb2,
|
||||
filename=filename,
|
||||
mimetype=mimetype,
|
||||
),
|
||||
response_parser=lambda response: _require_conversions().file_upload_response_from_proto(response),
|
||||
timeout=timeout,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def request_agent_stub_file_download_grpc_sync(
|
||||
*,
|
||||
url: str,
|
||||
auth_jwe: str,
|
||||
file: AgentStubFileMapping,
|
||||
timeout: float | httpx.Timeout = 30.0,
|
||||
):
|
||||
"""Request one signed download URL through the gRPC Agent Stub endpoint.
|
||||
|
||||
The compact-JWE bearer token is sent via ``authorization`` metadata on the
|
||||
unary RPC rather than an HTTP header.
|
||||
|
||||
Raises:
|
||||
AgentStubMissingGRPCDependencyError: if the optional gRPC runtime or
|
||||
generated protobuf support is not installed.
|
||||
AgentStubGRPCError: if the server returns a non-OK gRPC status.
|
||||
AgentStubClientError: if the target URL is invalid for gRPC or the
|
||||
runtime/transport fails before a valid gRPC response is received.
|
||||
"""
|
||||
return asyncio.run(
|
||||
_call_grpc(
|
||||
url=url,
|
||||
auth_jwe=auth_jwe,
|
||||
method_name="CreateFileDownloadRequest",
|
||||
request_factory=lambda runtime: _require_conversions().proto_file_download_request(
|
||||
runtime.agent_stub_pb2, file=file
|
||||
),
|
||||
response_parser=lambda response: _require_conversions().file_download_response_from_proto(response),
|
||||
timeout=timeout,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _call_grpc[TProto, TResult](
|
||||
*,
|
||||
url: str,
|
||||
auth_jwe: str,
|
||||
method_name: str,
|
||||
request_factory,
|
||||
response_parser,
|
||||
timeout: float | httpx.Timeout,
|
||||
) -> TResult:
|
||||
"""Execute one unary Agent Stub gRPC call with shared error mapping.
|
||||
|
||||
This helper attaches ``authorization`` metadata for every RPC, normalizes
|
||||
``httpx.Timeout`` into one gRPC timeout value, and translates grpclib status
|
||||
failures into ``AgentStubGRPCError`` while keeping lower-level transport
|
||||
failures in the broader ``AgentStubClientError`` family.
|
||||
"""
|
||||
runtime = _require_runtime()
|
||||
endpoint = parse_agent_stub_endpoint(url)
|
||||
if not endpoint.is_grpc or endpoint.port is None:
|
||||
raise AgentStubClientError("gRPC Agent Stub requests require a grpc://host:port URL")
|
||||
|
||||
channel = runtime.Channel(host=endpoint.host, port=endpoint.port, ssl=False)
|
||||
try:
|
||||
stub = runtime.AgentStubServiceStub(channel)
|
||||
method = getattr(stub, method_name)
|
||||
response = await method(
|
||||
request_factory(runtime),
|
||||
metadata=(("authorization", f"Bearer {auth_jwe}"),),
|
||||
timeout=_grpc_timeout_seconds(timeout),
|
||||
)
|
||||
return response_parser(response)
|
||||
except runtime.GRPCError as exc:
|
||||
detail = getattr(exc, "message", "") or getattr(exc, "details", "") or "request failed"
|
||||
status = getattr(getattr(exc, "status", None), "name", str(getattr(exc, "status", "UNKNOWN")))
|
||||
raise AgentStubGRPCError(status, detail) from exc
|
||||
except runtime.StreamTerminatedError as exc:
|
||||
raise AgentStubClientError(f"Agent Stub gRPC {method_name} request terminated unexpectedly") from exc
|
||||
except OSError as exc:
|
||||
raise AgentStubClientError(f"Agent Stub gRPC {method_name} request failed: {exc}") from exc
|
||||
finally:
|
||||
channel.close()
|
||||
|
||||
|
||||
def _grpc_timeout_seconds(timeout: float | httpx.Timeout) -> float | None:
|
||||
if isinstance(timeout, httpx.Timeout):
|
||||
values = [timeout.read, timeout.connect, timeout.write, timeout.pool]
|
||||
resolved = next((value for value in values if value is not None), None)
|
||||
return float(resolved) if resolved is not None else None
|
||||
return float(timeout)
|
||||
|
||||
|
||||
def _require_runtime() -> _GRPCRuntime:
|
||||
"""Import grpclib and generated protobuf runtime only when gRPC is selected."""
|
||||
try:
|
||||
from grpclib.client import Channel
|
||||
from grpclib.exceptions import GRPCError, StreamTerminatedError
|
||||
|
||||
from dify_agent.agent_stub.grpc._generated import agent_stub_pb2
|
||||
from dify_agent.agent_stub.grpc._generated.agent_stub_grpc import AgentStubServiceStub
|
||||
except ImportError as exc:
|
||||
raise AgentStubMissingGRPCDependencyError(
|
||||
"Agent Stub gRPC transport requires the optional dify-agent[grpc] dependencies"
|
||||
) from exc
|
||||
return _GRPCRuntime(
|
||||
Channel=Channel,
|
||||
AgentStubServiceStub=AgentStubServiceStub,
|
||||
agent_stub_pb2=agent_stub_pb2,
|
||||
GRPCError=GRPCError,
|
||||
StreamTerminatedError=StreamTerminatedError,
|
||||
)
|
||||
|
||||
|
||||
def _require_conversions():
|
||||
"""Import protobuf conversion helpers lazily for HTTP-only installations."""
|
||||
try:
|
||||
from dify_agent.agent_stub.grpc import conversions
|
||||
except ImportError as exc:
|
||||
raise AgentStubMissingGRPCDependencyError(
|
||||
"Agent Stub gRPC transport requires the optional dify-agent[grpc] dependencies"
|
||||
) from exc
|
||||
return conversions
|
||||
|
||||
|
||||
__all__ = [
|
||||
"connect_agent_stub_grpc_sync",
|
||||
"request_agent_stub_file_download_grpc_sync",
|
||||
"request_agent_stub_file_upload_grpc_sync",
|
||||
]
|
||||
263
dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py
Normal file
263
dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py
Normal file
@ -0,0 +1,263 @@
|
||||
"""Client-safe HTTP helpers for Agent Stub control-plane endpoints.
|
||||
|
||||
The main ``Client`` class stays focused on run APIs. Sandbox-visible CLI
|
||||
commands only need a narrow synchronous subset of the stub server contract and
|
||||
must stay safe to import in default installations, so these helpers live under
|
||||
``dify_agent.agent_stub.client`` rather than the standard run client package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import BinaryIO
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, JsonValue, ValidationError
|
||||
|
||||
from dify_agent.agent_stub.client._errors import (
|
||||
AgentStubClientError,
|
||||
AgentStubHTTPError,
|
||||
AgentStubTransferError,
|
||||
AgentStubValidationError,
|
||||
)
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubConnectRequest,
|
||||
AgentStubConnectResponse,
|
||||
AgentStubFileDownloadRequest,
|
||||
AgentStubFileDownloadResponse,
|
||||
AgentStubFileMapping,
|
||||
AgentStubFileUploadRequest,
|
||||
AgentStubFileUploadResponse,
|
||||
agent_stub_connections_url,
|
||||
agent_stub_file_download_request_url,
|
||||
agent_stub_file_upload_request_url,
|
||||
)
|
||||
|
||||
|
||||
def connect_agent_stub_http_sync(
|
||||
*,
|
||||
base_url: str,
|
||||
auth_jwe: str,
|
||||
argv: list[str],
|
||||
metadata: dict[str, JsonValue] | None = None,
|
||||
timeout: float | httpx.Timeout = 30.0,
|
||||
sync_http_client: httpx.Client | None = None,
|
||||
) -> AgentStubConnectResponse:
|
||||
"""Create one HTTP Agent Stub connection using the provided bearer JWE.
|
||||
|
||||
Raises:
|
||||
AgentStubValidationError: if the base URL is invalid, the request DTO is
|
||||
invalid, or the success response body does not match the public
|
||||
Agent Stub response schema.
|
||||
AgentStubHTTPError: if the server returns a non-2xx HTTP response.
|
||||
AgentStubClientError: if the request times out, the transport fails, or
|
||||
the response body cannot be parsed as JSON.
|
||||
"""
|
||||
request_model = _validate_request(argv=argv, metadata=metadata)
|
||||
response = _post_agent_stub_json(
|
||||
base_url=base_url,
|
||||
auth_jwe=auth_jwe,
|
||||
endpoint_name="connect",
|
||||
endpoint_url_factory=agent_stub_connections_url,
|
||||
request_body=request_model.model_dump_json(),
|
||||
timeout=timeout,
|
||||
sync_http_client=sync_http_client,
|
||||
)
|
||||
return _parse_success_response(response=response, response_model=AgentStubConnectResponse, label="connection")
|
||||
|
||||
|
||||
def request_agent_stub_file_upload_http_sync(
|
||||
*,
|
||||
base_url: str,
|
||||
auth_jwe: str,
|
||||
filename: str,
|
||||
mimetype: str,
|
||||
timeout: float | httpx.Timeout = 30.0,
|
||||
sync_http_client: httpx.Client | None = None,
|
||||
) -> AgentStubFileUploadResponse:
|
||||
"""Request one signed upload URL from the HTTP Agent Stub endpoint."""
|
||||
|
||||
try:
|
||||
request_model = AgentStubFileUploadRequest(filename=filename, mimetype=mimetype)
|
||||
except ValidationError as exc:
|
||||
raise AgentStubValidationError("invalid Agent Stub file upload request") from exc
|
||||
response = _post_agent_stub_json(
|
||||
base_url=base_url,
|
||||
auth_jwe=auth_jwe,
|
||||
endpoint_name="file upload request",
|
||||
endpoint_url_factory=agent_stub_file_upload_request_url,
|
||||
request_body=request_model.model_dump_json(),
|
||||
timeout=timeout,
|
||||
sync_http_client=sync_http_client,
|
||||
)
|
||||
return _parse_success_response(response=response, response_model=AgentStubFileUploadResponse, label="file upload")
|
||||
|
||||
|
||||
def request_agent_stub_file_download_http_sync(
|
||||
*,
|
||||
base_url: str,
|
||||
auth_jwe: str,
|
||||
file: AgentStubFileMapping,
|
||||
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)
|
||||
except ValidationError as exc:
|
||||
raise AgentStubValidationError("invalid Agent Stub file download request") from exc
|
||||
response = _post_agent_stub_json(
|
||||
base_url=base_url,
|
||||
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),
|
||||
timeout=timeout,
|
||||
sync_http_client=sync_http_client,
|
||||
)
|
||||
return _parse_success_response(
|
||||
response=response,
|
||||
response_model=AgentStubFileDownloadResponse,
|
||||
label="file download",
|
||||
)
|
||||
|
||||
|
||||
def upload_file_to_signed_url_sync(
|
||||
*,
|
||||
upload_url: str,
|
||||
filename: str,
|
||||
file_obj: BinaryIO,
|
||||
mimetype: str,
|
||||
timeout: float | httpx.Timeout = 120.0,
|
||||
sync_http_client: httpx.Client | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Upload one local file directly to a signed Dify API data-plane URL."""
|
||||
|
||||
owns_client = sync_http_client is None
|
||||
client = sync_http_client or httpx.Client(timeout=timeout, follow_redirects=True)
|
||||
try:
|
||||
response = client.post(
|
||||
upload_url,
|
||||
files={"file": (filename, file_obj, mimetype)},
|
||||
timeout=timeout,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise AgentStubTransferError("signed file upload timed out") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise AgentStubTransferError(f"signed file upload failed: {exc}") from exc
|
||||
finally:
|
||||
if owns_client:
|
||||
client.close()
|
||||
|
||||
payload = _parse_json_payload(response, invalid_json_message="signed file upload returned invalid JSON")
|
||||
if response.is_error:
|
||||
detail = payload.get("detail", payload) if isinstance(payload, dict) else payload
|
||||
raise AgentStubHTTPError(response.status_code, detail)
|
||||
if not isinstance(payload, dict):
|
||||
raise AgentStubValidationError("invalid signed file upload response")
|
||||
return cast(dict[str, object], payload)
|
||||
|
||||
|
||||
def download_file_bytes_from_signed_url_sync(
|
||||
*,
|
||||
download_url: str,
|
||||
timeout: float | httpx.Timeout = 120.0,
|
||||
sync_http_client: httpx.Client | None = None,
|
||||
) -> bytes:
|
||||
"""Download one file directly from a signed Dify API data-plane URL."""
|
||||
|
||||
owns_client = sync_http_client is None
|
||||
client = sync_http_client or httpx.Client(timeout=timeout, follow_redirects=True)
|
||||
try:
|
||||
response = client.get(download_url, timeout=timeout)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise AgentStubTransferError("signed file download timed out") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise AgentStubTransferError(f"signed file download failed: {exc}") from exc
|
||||
finally:
|
||||
if owns_client:
|
||||
client.close()
|
||||
|
||||
if response.is_error:
|
||||
payload = _parse_json_payload(response, invalid_json_message="signed file download returned invalid JSON")
|
||||
detail = payload.get("detail", payload) if isinstance(payload, dict) else payload
|
||||
raise AgentStubHTTPError(response.status_code, detail)
|
||||
return response.content
|
||||
|
||||
|
||||
def _validate_request(*, argv: list[str], metadata: dict[str, JsonValue] | None) -> AgentStubConnectRequest:
|
||||
try:
|
||||
return AgentStubConnectRequest(argv=argv, metadata=cast(dict[str, JsonValue], metadata or {}))
|
||||
except ValidationError as exc:
|
||||
raise AgentStubValidationError("invalid Agent Stub connection request") from exc
|
||||
|
||||
|
||||
def _post_agent_stub_json(
|
||||
*,
|
||||
base_url: str,
|
||||
auth_jwe: str,
|
||||
endpoint_name: str,
|
||||
endpoint_url_factory: Callable[[str], str],
|
||||
request_body: str,
|
||||
timeout: float | httpx.Timeout,
|
||||
sync_http_client: httpx.Client | None,
|
||||
) -> httpx.Response:
|
||||
try:
|
||||
endpoint_url = endpoint_url_factory(base_url)
|
||||
except ValueError as exc:
|
||||
raise AgentStubValidationError("invalid Agent Stub base URL") from exc
|
||||
owns_client = sync_http_client is None
|
||||
client = sync_http_client or httpx.Client(timeout=timeout, follow_redirects=True)
|
||||
try:
|
||||
return client.post(
|
||||
endpoint_url,
|
||||
content=request_body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {auth_jwe}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise AgentStubClientError(f"Agent Stub {endpoint_name} timed out") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise AgentStubClientError(f"Agent Stub {endpoint_name} request failed: {exc}") from exc
|
||||
finally:
|
||||
if owns_client:
|
||||
client.close()
|
||||
|
||||
|
||||
def _parse_success_response[T: BaseModel](
|
||||
*,
|
||||
response: httpx.Response,
|
||||
response_model: type[T],
|
||||
label: str,
|
||||
) -> T:
|
||||
payload = _parse_json_payload(response, invalid_json_message=f"Agent Stub returned invalid JSON for {label}")
|
||||
|
||||
if response.is_error:
|
||||
detail = payload.get("detail", payload) if isinstance(payload, dict) else payload
|
||||
raise AgentStubHTTPError(response.status_code, detail)
|
||||
|
||||
try:
|
||||
return response_model.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
raise AgentStubValidationError(f"invalid Agent Stub {label} response") from exc
|
||||
|
||||
|
||||
def _parse_json_payload(response: httpx.Response, *, invalid_json_message: str) -> object:
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise AgentStubClientError(invalid_json_message) from exc
|
||||
|
||||
|
||||
__all__ = [
|
||||
"connect_agent_stub_http_sync",
|
||||
"download_file_bytes_from_signed_url_sync",
|
||||
"request_agent_stub_file_download_http_sync",
|
||||
"request_agent_stub_file_upload_http_sync",
|
||||
"upload_file_to_signed_url_sync",
|
||||
]
|
||||
53
dify-agent/src/dify_agent/agent_stub/client/_errors.py
Normal file
53
dify-agent/src/dify_agent/agent_stub/client/_errors.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""Shared client-safe exception types for Agent Stub transports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class AgentStubClientError(RuntimeError):
|
||||
"""Base class for client-safe Agent Stub control-plane failures."""
|
||||
|
||||
|
||||
class AgentStubHTTPError(AgentStubClientError):
|
||||
"""Raised when the HTTP transport returns a non-success response."""
|
||||
|
||||
status_code: int
|
||||
detail: object
|
||||
|
||||
def __init__(self, status_code: int, detail: object) -> None:
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
super().__init__(f"Agent Stub HTTP {status_code}: {detail}")
|
||||
|
||||
|
||||
class AgentStubGRPCError(AgentStubClientError):
|
||||
"""Raised when the gRPC transport returns a non-OK status."""
|
||||
|
||||
status: str
|
||||
detail: str
|
||||
|
||||
def __init__(self, status: str, detail: str) -> None:
|
||||
self.status = status
|
||||
self.detail = detail
|
||||
super().__init__(f"Agent Stub gRPC {status}: {detail}")
|
||||
|
||||
|
||||
class AgentStubValidationError(AgentStubClientError):
|
||||
"""Raised when request or response DTO validation fails."""
|
||||
|
||||
|
||||
class AgentStubTransferError(AgentStubClientError):
|
||||
"""Raised when a signed upload/download data-plane request fails."""
|
||||
|
||||
|
||||
class AgentStubMissingGRPCDependencyError(AgentStubClientError):
|
||||
"""Raised when the optional gRPC extra is required but unavailable."""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentStubClientError",
|
||||
"AgentStubGRPCError",
|
||||
"AgentStubHTTPError",
|
||||
"AgentStubMissingGRPCDependencyError",
|
||||
"AgentStubTransferError",
|
||||
"AgentStubValidationError",
|
||||
]
|
||||
3
dify-agent/src/dify_agent/agent_stub/grpc/__init__.py
Normal file
3
dify-agent/src/dify_agent/agent_stub/grpc/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""Internal gRPC helpers for the optional Agent Stub transport."""
|
||||
|
||||
__all__: list[str] = []
|
||||
@ -0,0 +1,3 @@
|
||||
"""Generated protobuf and grpclib code for the Agent Stub gRPC contract."""
|
||||
|
||||
__all__: list[str] = []
|
||||
@ -0,0 +1,80 @@
|
||||
# Generated by the Protocol Buffers compiler. DO NOT EDIT!
|
||||
# source: dify/agent/stub/v1/agent_stub.proto
|
||||
# plugin: grpclib.plugin.main
|
||||
import abc
|
||||
import typing
|
||||
|
||||
import grpclib.client
|
||||
import grpclib.const
|
||||
|
||||
from . import agent_stub_pb2
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import grpclib.server
|
||||
|
||||
|
||||
class AgentStubServiceBase(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
async def Connect(
|
||||
self,
|
||||
stream: "grpclib.server.Stream[agent_stub_pb2.ConnectRequest, agent_stub_pb2.ConnectResponse]",
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def CreateFileUploadRequest(
|
||||
self,
|
||||
stream: "grpclib.server.Stream[agent_stub_pb2.FileUploadRequest, agent_stub_pb2.FileUploadResponse]",
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def CreateFileDownloadRequest(
|
||||
self,
|
||||
stream: "grpclib.server.Stream[agent_stub_pb2.FileDownloadRequest, agent_stub_pb2.FileDownloadResponse]",
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
def __mapping__(self) -> typing.Dict[str, grpclib.const.Handler]:
|
||||
return {
|
||||
"/dify.agent.stub.v1.AgentStubService/Connect": grpclib.const.Handler(
|
||||
self.Connect,
|
||||
grpclib.const.Cardinality.UNARY_UNARY,
|
||||
agent_stub_pb2.ConnectRequest,
|
||||
agent_stub_pb2.ConnectResponse,
|
||||
),
|
||||
"/dify.agent.stub.v1.AgentStubService/CreateFileUploadRequest": grpclib.const.Handler(
|
||||
self.CreateFileUploadRequest,
|
||||
grpclib.const.Cardinality.UNARY_UNARY,
|
||||
agent_stub_pb2.FileUploadRequest,
|
||||
agent_stub_pb2.FileUploadResponse,
|
||||
),
|
||||
"/dify.agent.stub.v1.AgentStubService/CreateFileDownloadRequest": grpclib.const.Handler(
|
||||
self.CreateFileDownloadRequest,
|
||||
grpclib.const.Cardinality.UNARY_UNARY,
|
||||
agent_stub_pb2.FileDownloadRequest,
|
||||
agent_stub_pb2.FileDownloadResponse,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class AgentStubServiceStub:
|
||||
def __init__(self, channel: grpclib.client.Channel) -> None:
|
||||
self.Connect = grpclib.client.UnaryUnaryMethod(
|
||||
channel,
|
||||
"/dify.agent.stub.v1.AgentStubService/Connect",
|
||||
agent_stub_pb2.ConnectRequest,
|
||||
agent_stub_pb2.ConnectResponse,
|
||||
)
|
||||
self.CreateFileUploadRequest = grpclib.client.UnaryUnaryMethod(
|
||||
channel,
|
||||
"/dify.agent.stub.v1.AgentStubService/CreateFileUploadRequest",
|
||||
agent_stub_pb2.FileUploadRequest,
|
||||
agent_stub_pb2.FileUploadResponse,
|
||||
)
|
||||
self.CreateFileDownloadRequest = grpclib.client.UnaryUnaryMethod(
|
||||
channel,
|
||||
"/dify.agent.stub.v1.AgentStubService/CreateFileDownloadRequest",
|
||||
agent_stub_pb2.FileDownloadRequest,
|
||||
agent_stub_pb2.FileDownloadResponse,
|
||||
)
|
||||
@ -0,0 +1,52 @@
|
||||
# pyright: reportAttributeAccessIssue=false
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# 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",
|
||||
)
|
||||
# @@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'
|
||||
)
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "dify_agent.agent_stub.grpc._generated.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
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from google.protobuf.message import Message
|
||||
|
||||
|
||||
class ConnectRequest(Message):
|
||||
protocol_version: int
|
||||
argv: list[str]
|
||||
metadata_json: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
protocol_version: int = ...,
|
||||
argv: Iterable[str] = ...,
|
||||
metadata_json: str = ...,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class ConnectResponse(Message):
|
||||
connection_id: str
|
||||
status: str
|
||||
|
||||
def __init__(self, *, connection_id: str = ..., status: str = ...) -> None: ...
|
||||
|
||||
|
||||
class FileUploadRequest(Message):
|
||||
filename: str
|
||||
mimetype: str
|
||||
|
||||
def __init__(self, *, filename: str = ..., mimetype: str = ...) -> None: ...
|
||||
|
||||
|
||||
class FileUploadResponse(Message):
|
||||
upload_url: str
|
||||
|
||||
def __init__(self, *, upload_url: str = ...) -> None: ...
|
||||
|
||||
|
||||
class FileMapping(Message):
|
||||
transfer_method: str
|
||||
reference: str
|
||||
url: str
|
||||
|
||||
def __init__(self, *, transfer_method: str = ..., reference: str = ..., url: str = ...) -> None: ...
|
||||
def HasField(self, field_name: str) -> bool: ...
|
||||
|
||||
|
||||
class FileDownloadRequest(Message):
|
||||
file: FileMapping
|
||||
|
||||
def __init__(self, *, file: FileMapping | None = ...) -> None: ...
|
||||
|
||||
|
||||
class FileDownloadResponse(Message):
|
||||
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: ...
|
||||
166
dify-agent/src/dify_agent/agent_stub/grpc/conversions.py
Normal file
166
dify-agent/src/dify_agent/agent_stub/grpc/conversions.py
Normal file
@ -0,0 +1,166 @@
|
||||
"""Conversions between Agent Stub protobuf messages and public DTOs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubConnectRequest,
|
||||
AgentStubConnectResponse,
|
||||
AgentStubFileDownloadRequest,
|
||||
AgentStubFileDownloadResponse,
|
||||
AgentStubFileMapping,
|
||||
AgentStubFileUploadRequest,
|
||||
AgentStubFileUploadResponse,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dify_agent.agent_stub.grpc._generated import agent_stub_pb2
|
||||
|
||||
|
||||
def connect_request_from_proto(message: agent_stub_pb2.ConnectRequest) -> AgentStubConnectRequest:
|
||||
"""Validate one protobuf connect request into the public DTO."""
|
||||
metadata: object = {}
|
||||
if message.metadata_json:
|
||||
metadata = json.loads(message.metadata_json)
|
||||
return AgentStubConnectRequest.model_validate(
|
||||
{
|
||||
"protocol_version": message.protocol_version,
|
||||
"argv": list(message.argv),
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def proto_connect_request(
|
||||
pb2_module,
|
||||
*,
|
||||
argv: list[str],
|
||||
metadata: Mapping[str, JsonValue] | None,
|
||||
) -> agent_stub_pb2.ConnectRequest:
|
||||
"""Build one protobuf connect request from public client inputs."""
|
||||
request = AgentStubConnectRequest(argv=argv, metadata=dict(metadata or {}))
|
||||
return pb2_module.ConnectRequest(
|
||||
protocol_version=request.protocol_version,
|
||||
argv=request.argv,
|
||||
metadata_json=json.dumps(request.metadata, separators=(",", ":")),
|
||||
)
|
||||
|
||||
|
||||
def connect_response_from_proto(message: agent_stub_pb2.ConnectResponse) -> AgentStubConnectResponse:
|
||||
"""Validate one protobuf connect response into the public DTO."""
|
||||
return AgentStubConnectResponse.model_validate(
|
||||
{
|
||||
"connection_id": message.connection_id,
|
||||
"status": message.status,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def proto_connect_response(response: AgentStubConnectResponse, *, pb2_module=None) -> agent_stub_pb2.ConnectResponse:
|
||||
"""Build one protobuf connect response from the public DTO."""
|
||||
resolved_pb2 = pb2_module or _require_pb2_module()
|
||||
return resolved_pb2.ConnectResponse(connection_id=response.connection_id, status=response.status)
|
||||
|
||||
|
||||
def file_upload_request_from_proto(message: agent_stub_pb2.FileUploadRequest) -> AgentStubFileUploadRequest:
|
||||
"""Validate one protobuf file-upload request into the public DTO."""
|
||||
return AgentStubFileUploadRequest.model_validate({"filename": message.filename, "mimetype": message.mimetype})
|
||||
|
||||
|
||||
def proto_file_upload_request(pb2_module, *, filename: str, mimetype: str) -> agent_stub_pb2.FileUploadRequest:
|
||||
"""Build one protobuf file-upload request from public client inputs."""
|
||||
request = AgentStubFileUploadRequest(filename=filename, mimetype=mimetype)
|
||||
return pb2_module.FileUploadRequest(filename=request.filename, mimetype=request.mimetype)
|
||||
|
||||
|
||||
def file_upload_response_from_proto(message: agent_stub_pb2.FileUploadResponse) -> AgentStubFileUploadResponse:
|
||||
"""Validate one protobuf file-upload response into the public DTO."""
|
||||
return AgentStubFileUploadResponse.model_validate({"upload_url": message.upload_url})
|
||||
|
||||
|
||||
def proto_file_upload_response(
|
||||
response: AgentStubFileUploadResponse, *, pb2_module=None
|
||||
) -> agent_stub_pb2.FileUploadResponse:
|
||||
"""Build one protobuf file-upload response from the public DTO."""
|
||||
resolved_pb2 = pb2_module or _require_pb2_module()
|
||||
return resolved_pb2.FileUploadResponse(upload_url=response.upload_url)
|
||||
|
||||
|
||||
def file_download_request_from_proto(message: agent_stub_pb2.FileDownloadRequest) -> AgentStubFileDownloadRequest:
|
||||
"""Validate one protobuf file-download request into the public DTO."""
|
||||
file_mapping_kwargs = {
|
||||
"transfer_method": message.file.transfer_method,
|
||||
"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})
|
||||
|
||||
|
||||
def proto_file_download_request(
|
||||
pb2_module,
|
||||
*,
|
||||
file: AgentStubFileMapping,
|
||||
) -> agent_stub_pb2.FileDownloadRequest:
|
||||
"""Build one protobuf file-download request from the public DTO."""
|
||||
mapping = pb2_module.FileMapping(transfer_method=file.transfer_method)
|
||||
if file.reference is not None:
|
||||
mapping.reference = file.reference
|
||||
if file.url is not None:
|
||||
mapping.url = file.url
|
||||
return pb2_module.FileDownloadRequest(file=mapping)
|
||||
|
||||
|
||||
def file_download_response_from_proto(message: agent_stub_pb2.FileDownloadResponse) -> AgentStubFileDownloadResponse:
|
||||
"""Validate one protobuf file-download response into the public DTO."""
|
||||
return AgentStubFileDownloadResponse.model_validate(
|
||||
{
|
||||
"filename": message.filename,
|
||||
"mime_type": message.mime_type if message.HasField("mime_type") else None,
|
||||
"size": message.size,
|
||||
"download_url": message.download_url,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def proto_file_download_response(
|
||||
response: AgentStubFileDownloadResponse,
|
||||
*,
|
||||
pb2_module=None,
|
||||
) -> agent_stub_pb2.FileDownloadResponse:
|
||||
"""Build one protobuf file-download response from the public DTO."""
|
||||
resolved_pb2 = pb2_module or _require_pb2_module()
|
||||
message = resolved_pb2.FileDownloadResponse(
|
||||
filename=response.filename,
|
||||
size=response.size,
|
||||
download_url=response.download_url,
|
||||
)
|
||||
if response.mime_type is not None:
|
||||
message.mime_type = response.mime_type
|
||||
return message
|
||||
|
||||
|
||||
def _require_pb2_module():
|
||||
from dify_agent.agent_stub.grpc._generated import agent_stub_pb2
|
||||
|
||||
return agent_stub_pb2
|
||||
|
||||
|
||||
__all__ = [
|
||||
"connect_request_from_proto",
|
||||
"connect_response_from_proto",
|
||||
"file_download_request_from_proto",
|
||||
"file_download_response_from_proto",
|
||||
"file_upload_request_from_proto",
|
||||
"file_upload_response_from_proto",
|
||||
"proto_connect_request",
|
||||
"proto_connect_response",
|
||||
"proto_file_download_request",
|
||||
"proto_file_download_response",
|
||||
"proto_file_upload_request",
|
||||
"proto_file_upload_response",
|
||||
]
|
||||
43
dify-agent/src/dify_agent/agent_stub/protocol/__init__.py
Normal file
43
dify-agent/src/dify_agent/agent_stub/protocol/__init__.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""Client-safe protocol exports for the Dify Agent Stub package."""
|
||||
|
||||
from .agent_stub import (
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR,
|
||||
AGENT_STUB_PROTOCOL_VERSION,
|
||||
AGENT_STUB_URL_ENV_VAR,
|
||||
AgentStubConnectRequest,
|
||||
AgentStubConnectResponse,
|
||||
AgentStubEndpoint,
|
||||
AgentStubFileDownloadRequest,
|
||||
AgentStubFileDownloadResponse,
|
||||
AgentStubFileMapping,
|
||||
AgentStubFileUploadRequest,
|
||||
AgentStubFileUploadResponse,
|
||||
AgentStubURLScheme,
|
||||
agent_stub_connections_url,
|
||||
agent_stub_file_download_request_url,
|
||||
agent_stub_file_upload_request_url,
|
||||
is_canonical_dify_file_reference,
|
||||
normalize_agent_stub_url,
|
||||
parse_agent_stub_endpoint,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AGENT_STUB_AUTH_JWE_ENV_VAR",
|
||||
"AGENT_STUB_PROTOCOL_VERSION",
|
||||
"AGENT_STUB_URL_ENV_VAR",
|
||||
"AgentStubConnectRequest",
|
||||
"AgentStubConnectResponse",
|
||||
"AgentStubEndpoint",
|
||||
"AgentStubFileDownloadRequest",
|
||||
"AgentStubFileDownloadResponse",
|
||||
"AgentStubFileMapping",
|
||||
"AgentStubFileUploadRequest",
|
||||
"AgentStubFileUploadResponse",
|
||||
"AgentStubURLScheme",
|
||||
"agent_stub_connections_url",
|
||||
"agent_stub_file_download_request_url",
|
||||
"agent_stub_file_upload_request_url",
|
||||
"is_canonical_dify_file_reference",
|
||||
"normalize_agent_stub_url",
|
||||
"parse_agent_stub_endpoint",
|
||||
]
|
||||
243
dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py
Normal file
243
dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py
Normal file
@ -0,0 +1,243 @@
|
||||
"""Client-safe DTOs and endpoint parsing for the Agent Stub protocol.
|
||||
|
||||
The Agent Stub contract is shared by the HTTP router, optional gRPC transport,
|
||||
the sandbox-visible CLI, and tests. Control-plane requests always validate into
|
||||
these Pydantic DTOs before business logic runs, while token issuance and JWE
|
||||
validation stay under ``dify_agent.agent_stub.server.tokens.agent_stub`` so the
|
||||
default package remains free of server-only crypto dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, Final, Literal
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
|
||||
|
||||
|
||||
AGENT_STUB_PROTOCOL_VERSION: Final[int] = 1
|
||||
AGENT_STUB_URL_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_URL"
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR: Final[str] = "DIFY_AGENT_STUB_AUTH_JWE"
|
||||
|
||||
type AgentStubURLScheme = Literal["http", "https", "grpc"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentStubEndpoint:
|
||||
"""Validated Agent Stub endpoint with normalized transport metadata."""
|
||||
|
||||
url: str
|
||||
scheme: AgentStubURLScheme
|
||||
host: str
|
||||
port: int | None
|
||||
path: str
|
||||
|
||||
@property
|
||||
def is_http(self) -> bool:
|
||||
return self.scheme in {"http", "https"}
|
||||
|
||||
@property
|
||||
def is_grpc(self) -> bool:
|
||||
return self.scheme == "grpc"
|
||||
|
||||
|
||||
def parse_agent_stub_endpoint(url: str) -> AgentStubEndpoint:
|
||||
"""Parse one Agent Stub endpoint URL for HTTP or gRPC transport selection.
|
||||
|
||||
HTTP(S) endpoints are normalized by trimming whitespace and removing a final
|
||||
trailing slash from the path while preserving the configured base path.
|
||||
gRPC endpoints must be plain ``grpc://host:port`` targets with no path,
|
||||
query string, or fragment because transport routing happens on the gRPC
|
||||
service name instead of an HTTP URL path.
|
||||
"""
|
||||
stripped = url.strip()
|
||||
if not stripped:
|
||||
raise ValueError("Agent Stub URL must not be empty")
|
||||
parsed = urlsplit(stripped)
|
||||
if parsed.scheme not in {"http", "https", "grpc"}:
|
||||
raise ValueError("Agent Stub URL must use http, https, or grpc")
|
||||
if not parsed.netloc:
|
||||
raise ValueError("Agent Stub URL must include a host")
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ValueError("Agent Stub URL must not include user info")
|
||||
if parsed.query or parsed.fragment:
|
||||
raise ValueError("Agent Stub URL must not include a query string or fragment")
|
||||
if parsed.hostname is None:
|
||||
raise ValueError("Agent Stub URL must include a host")
|
||||
|
||||
scheme = parsed.scheme
|
||||
if scheme == "grpc":
|
||||
if parsed.path not in {"", "/"}:
|
||||
raise ValueError("gRPC Agent Stub URL must not include a path")
|
||||
if parsed.port is None:
|
||||
raise ValueError("gRPC Agent Stub URL must include an explicit port")
|
||||
host = parsed.hostname
|
||||
normalized_url = f"grpc://{_format_url_host(host)}:{parsed.port}"
|
||||
return AgentStubEndpoint(
|
||||
url=normalized_url,
|
||||
scheme="grpc",
|
||||
host=host,
|
||||
port=parsed.port,
|
||||
path="",
|
||||
)
|
||||
|
||||
normalized_path = parsed.path.rstrip("/")
|
||||
normalized_url = urlunsplit((scheme, parsed.netloc, normalized_path, "", ""))
|
||||
return AgentStubEndpoint(
|
||||
url=normalized_url,
|
||||
scheme=scheme, # pyright: ignore[reportArgumentType]
|
||||
host=parsed.hostname,
|
||||
port=parsed.port,
|
||||
path=normalized_path,
|
||||
)
|
||||
|
||||
|
||||
def normalize_agent_stub_url(url: str) -> str:
|
||||
"""Return the normalized Agent Stub URL used across settings and CLI env."""
|
||||
return parse_agent_stub_endpoint(url).url
|
||||
|
||||
|
||||
def agent_stub_connections_url(base_url: str) -> str:
|
||||
"""Return the stable HTTP ``/connections`` endpoint URL for one base URL."""
|
||||
return f"{_require_http_base_url(base_url)}/connections"
|
||||
|
||||
|
||||
def agent_stub_file_upload_request_url(base_url: str) -> str:
|
||||
"""Return the stable HTTP upload-request endpoint URL for one base URL."""
|
||||
return f"{_require_http_base_url(base_url)}/files/upload-request"
|
||||
|
||||
|
||||
def agent_stub_file_download_request_url(base_url: str) -> str:
|
||||
"""Return the stable HTTP download-request endpoint URL for one base URL."""
|
||||
return f"{_require_http_base_url(base_url)}/files/download-request"
|
||||
|
||||
|
||||
def is_canonical_dify_file_reference(reference: str) -> bool:
|
||||
"""Return whether one string matches Dify's opaque file reference format."""
|
||||
prefix = "dify-file-ref:"
|
||||
if not reference.startswith(prefix):
|
||||
return False
|
||||
encoded_payload = reference.removeprefix(prefix)
|
||||
try:
|
||||
payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode()))
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return False
|
||||
record_id = payload.get("record_id")
|
||||
return isinstance(record_id, str) and bool(record_id)
|
||||
|
||||
|
||||
class AgentStubConnectRequest(BaseModel):
|
||||
"""Request body for establishing one Agent Stub control-plane connection."""
|
||||
|
||||
protocol_version: Literal[1] = AGENT_STUB_PROTOCOL_VERSION
|
||||
argv: list[str]
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AgentStubConnectResponse(BaseModel):
|
||||
"""Connection placeholder response returned by the server."""
|
||||
|
||||
connection_id: str
|
||||
status: Literal["connected"] = "connected"
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AgentStubFileUploadRequest(BaseModel):
|
||||
"""Request body for one signed upload URL allocation."""
|
||||
|
||||
filename: str
|
||||
mimetype: str
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AgentStubFileUploadResponse(BaseModel):
|
||||
"""Response body containing the signed data-plane upload URL."""
|
||||
|
||||
upload_url: str
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AgentStubFileMapping(BaseModel):
|
||||
"""Public file mapping used by download-request control-plane calls."""
|
||||
|
||||
transfer_method: Literal["local_file", "tool_file", "datasource_file", "remote_url"]
|
||||
reference: str | None = None
|
||||
url: str | None = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_locator(self) -> "AgentStubFileMapping":
|
||||
if self.transfer_method == "remote_url":
|
||||
if not self.url:
|
||||
raise ValueError("url is required when transfer_method is remote_url")
|
||||
if self.reference is not None:
|
||||
raise ValueError("reference is not allowed when transfer_method is remote_url")
|
||||
return self
|
||||
if not self.reference:
|
||||
raise ValueError("reference is required for non-remote file mappings")
|
||||
if not is_canonical_dify_file_reference(self.reference):
|
||||
raise ValueError("reference must be a canonical Dify file reference")
|
||||
if self.url is not None:
|
||||
raise ValueError("url is not allowed for non-remote file mappings")
|
||||
return self
|
||||
|
||||
|
||||
class AgentStubFileDownloadRequest(BaseModel):
|
||||
"""Request body for one signed download URL allocation."""
|
||||
|
||||
file: AgentStubFileMapping
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AgentStubFileDownloadResponse(BaseModel):
|
||||
"""Response body containing download metadata plus the signed URL."""
|
||||
|
||||
filename: str
|
||||
mime_type: str | None = None
|
||||
size: int
|
||||
download_url: str
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
def _require_http_base_url(base_url: str) -> str:
|
||||
endpoint = parse_agent_stub_endpoint(base_url)
|
||||
if not endpoint.is_http:
|
||||
raise ValueError("HTTP Agent Stub URLs must use http or https")
|
||||
return endpoint.url
|
||||
|
||||
|
||||
def _format_url_host(host: str) -> str:
|
||||
return f"[{host}]" if ":" in host and not host.startswith("[") else host
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AGENT_STUB_AUTH_JWE_ENV_VAR",
|
||||
"AGENT_STUB_PROTOCOL_VERSION",
|
||||
"AGENT_STUB_URL_ENV_VAR",
|
||||
"AgentStubConnectRequest",
|
||||
"AgentStubConnectResponse",
|
||||
"AgentStubEndpoint",
|
||||
"AgentStubFileDownloadRequest",
|
||||
"AgentStubFileDownloadResponse",
|
||||
"AgentStubFileMapping",
|
||||
"AgentStubFileUploadRequest",
|
||||
"AgentStubFileUploadResponse",
|
||||
"AgentStubURLScheme",
|
||||
"agent_stub_connections_url",
|
||||
"agent_stub_file_download_request_url",
|
||||
"agent_stub_file_upload_request_url",
|
||||
"is_canonical_dify_file_reference",
|
||||
"normalize_agent_stub_url",
|
||||
"parse_agent_stub_endpoint",
|
||||
]
|
||||
12
dify-agent/src/dify_agent/agent_stub/server/__init__.py
Normal file
12
dify-agent/src/dify_agent/agent_stub/server/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""Server-only helpers for running or embedding the Dify Agent Stub server."""
|
||||
|
||||
from .app import app, create_agent_stub_app
|
||||
from .grpc_runtime import start_agent_stub_grpc_server
|
||||
from .router import create_agent_stub_router
|
||||
|
||||
__all__ = [
|
||||
"app",
|
||||
"create_agent_stub_app",
|
||||
"create_agent_stub_router",
|
||||
"start_agent_stub_grpc_server",
|
||||
]
|
||||
216
dify-agent/src/dify_agent/agent_stub/server/agent_stub_files.py
Normal file
216
dify-agent/src/dify_agent/agent_stub/server/agent_stub_files.py
Normal file
@ -0,0 +1,216 @@
|
||||
"""Server-side Dify API client for Agent Stub file endpoints.
|
||||
|
||||
The Agent Stub serves only control-plane file endpoints. This module is
|
||||
the trusted bridge from authenticated stub requests into Dify's inner file
|
||||
request APIs. Callers pass a decoded ``AgentStubPrincipal`` and a validated
|
||||
public Agent Stub request DTO; this module injects the execution-context tenant
|
||||
and user fields that sandbox code is not allowed to forge, calls the matching
|
||||
Dify inner API endpoint, and normalizes all expected failures into
|
||||
``AgentStubFileRequestError`` with HTTP-oriented ``status_code`` and ``detail``
|
||||
values that route handlers can map directly into responses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubFileDownloadRequest,
|
||||
AgentStubFileDownloadResponse,
|
||||
AgentStubFileUploadRequest,
|
||||
AgentStubFileUploadResponse,
|
||||
)
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
|
||||
|
||||
class AgentStubFileRequestHandler(Protocol):
|
||||
"""Trusted control-plane bridge from sandbox calls to Dify API inner APIs.
|
||||
|
||||
Implementations are expected to accept authenticated execution context from
|
||||
the stub token principal, inject the required tenant/user metadata into the
|
||||
Dify inner API request, and raise ``AgentStubFileRequestError`` when the
|
||||
downstream call cannot produce a valid control-plane response.
|
||||
"""
|
||||
|
||||
async def create_upload_request(
|
||||
self,
|
||||
*,
|
||||
principal: AgentStubPrincipal,
|
||||
request: AgentStubFileUploadRequest,
|
||||
) -> AgentStubFileUploadResponse: ...
|
||||
|
||||
async def create_download_request(
|
||||
self,
|
||||
*,
|
||||
principal: AgentStubPrincipal,
|
||||
request: AgentStubFileDownloadRequest,
|
||||
) -> AgentStubFileDownloadResponse: ...
|
||||
|
||||
|
||||
class AgentStubFileRequestError(RuntimeError):
|
||||
"""Raised when the Agent Stub cannot complete a file control-plane call.
|
||||
|
||||
``status_code`` and ``detail`` are shaped for direct translation into HTTP
|
||||
responses by FastAPI route handlers, so downstream callers should not need a
|
||||
second error-mapping layer for Dify file-request failures.
|
||||
"""
|
||||
|
||||
status_code: int
|
||||
detail: object
|
||||
|
||||
def __init__(self, status_code: int, detail: object) -> None:
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
super().__init__(str(detail))
|
||||
|
||||
|
||||
class _BackwardsInvocationEnvelope(BaseModel):
|
||||
"""Minimal parser for Dify API plugin-style inner API envelopes."""
|
||||
|
||||
data: object | None = None
|
||||
error: str | None = None
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyApiAgentStubFileRequestHandler:
|
||||
"""Call Dify API inner file request endpoints on behalf of the sandbox.
|
||||
|
||||
The upload path calls ``/inner/api/upload/file/request`` and injects the
|
||||
authenticated execution context's ``tenant_id`` and ``user_id`` along with
|
||||
the requested filename and mimetype. The download path calls
|
||||
``/inner/api/download/file/request`` and injects ``tenant_id``,
|
||||
``user_id``, ``user_from``, and ``invoke_from`` plus the validated public
|
||||
file mapping.
|
||||
|
||||
``user_id`` is mandatory for both operations. Missing user context is
|
||||
rejected before any network call with ``AgentStubFileRequestError(400, ...)``.
|
||||
Timeouts, transport failures, non-2xx responses, invalid JSON, invalid
|
||||
plugin-style envelopes, and invalid success schemas are all normalized into
|
||||
``AgentStubFileRequestError`` so the stub routes can preserve a stable HTTP
|
||||
contract without exposing raw ``httpx`` or Pydantic exceptions.
|
||||
"""
|
||||
|
||||
dify_api_base_url: str
|
||||
dify_api_inner_api_key: str
|
||||
timeout: httpx.Timeout | float = 30.0
|
||||
|
||||
async def create_upload_request(
|
||||
self,
|
||||
*,
|
||||
principal: AgentStubPrincipal,
|
||||
request: AgentStubFileUploadRequest,
|
||||
) -> AgentStubFileUploadResponse:
|
||||
"""Request one signed upload URL from Dify's inner upload endpoint.
|
||||
|
||||
The request payload is derived from authenticated execution context and
|
||||
the public upload DTO. ``principal.execution_context.user_id`` must be
|
||||
present; otherwise the method raises ``AgentStubFileRequestError`` with
|
||||
status ``400`` before contacting Dify.
|
||||
|
||||
Raises:
|
||||
AgentStubFileRequestError: when user context is incomplete, the
|
||||
inner API times out or fails, the response is non-2xx, or the
|
||||
success payload does not contain a non-empty ``url`` string.
|
||||
"""
|
||||
execution_context = self._require_user_context(principal.execution_context)
|
||||
payload = {
|
||||
"tenant_id": execution_context.tenant_id,
|
||||
"user_id": execution_context.user_id,
|
||||
"filename": request.filename,
|
||||
"mimetype": request.mimetype,
|
||||
}
|
||||
data = await self._post_inner_api("/inner/api/upload/file/request", payload)
|
||||
upload_url = data.get("url")
|
||||
if not isinstance(upload_url, str) or not upload_url:
|
||||
raise AgentStubFileRequestError(502, "Dify API upload request response is missing url")
|
||||
return AgentStubFileUploadResponse(upload_url=upload_url)
|
||||
|
||||
async def create_download_request(
|
||||
self,
|
||||
*,
|
||||
principal: AgentStubPrincipal,
|
||||
request: AgentStubFileDownloadRequest,
|
||||
) -> AgentStubFileDownloadResponse:
|
||||
"""Request one signed download URL from Dify's inner download endpoint.
|
||||
|
||||
The request payload combines authenticated execution-context identity
|
||||
fields with the validated public file mapping. ``user_id`` is required
|
||||
and missing user context is rejected locally with
|
||||
``AgentStubFileRequestError(400, ...)``.
|
||||
|
||||
Raises:
|
||||
AgentStubFileRequestError: when user context is incomplete, the
|
||||
inner API times out or fails, the response is non-2xx, the
|
||||
plugin-style envelope is malformed, or the success payload does
|
||||
not match ``AgentStubFileDownloadResponse``.
|
||||
"""
|
||||
execution_context = self._require_user_context(principal.execution_context)
|
||||
payload = {
|
||||
"tenant_id": execution_context.tenant_id,
|
||||
"user_id": execution_context.user_id,
|
||||
"user_from": execution_context.user_from,
|
||||
"invoke_from": execution_context.invoke_from,
|
||||
"file": request.file.model_dump(mode="json", exclude_none=True),
|
||||
}
|
||||
data = await self._post_inner_api("/inner/api/download/file/request", payload)
|
||||
try:
|
||||
return AgentStubFileDownloadResponse.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
raise AgentStubFileRequestError(502, "Dify API download request response is invalid") from exc
|
||||
|
||||
def _require_user_context(
|
||||
self, execution_context: DifyExecutionContextLayerConfig
|
||||
) -> DifyExecutionContextLayerConfig:
|
||||
if execution_context.user_id is None:
|
||||
raise AgentStubFileRequestError(400, "execution context user_id is required for file operations")
|
||||
return execution_context
|
||||
|
||||
async def _post_inner_api(self, path: str, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
url = f"{self.dify_api_base_url.rstrip('/')}{path}"
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
url,
|
||||
json=dict(payload),
|
||||
headers={"X-Inner-Api-Key": self.dify_api_inner_api_key},
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise AgentStubFileRequestError(504, "Dify API file request timed out") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise AgentStubFileRequestError(502, f"Dify API file request failed: {exc}") from exc
|
||||
|
||||
raw_payload = self._parse_json(response)
|
||||
if response.is_error:
|
||||
detail = raw_payload.get("detail", raw_payload) if isinstance(raw_payload, dict) else raw_payload
|
||||
raise AgentStubFileRequestError(response.status_code, detail)
|
||||
try:
|
||||
envelope = _BackwardsInvocationEnvelope.model_validate(raw_payload)
|
||||
except ValidationError as exc:
|
||||
raise AgentStubFileRequestError(502, "Dify API file request response is invalid") from exc
|
||||
if envelope.error:
|
||||
raise AgentStubFileRequestError(400, envelope.error)
|
||||
if not isinstance(envelope.data, dict):
|
||||
raise AgentStubFileRequestError(502, "Dify API file request response is missing data")
|
||||
return dict(envelope.data)
|
||||
|
||||
@staticmethod
|
||||
def _parse_json(response: httpx.Response) -> object:
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise AgentStubFileRequestError(502, "Dify API file request returned invalid JSON") from exc
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentStubFileRequestError",
|
||||
"AgentStubFileRequestHandler",
|
||||
"DifyApiAgentStubFileRequestHandler",
|
||||
]
|
||||
33
dify-agent/src/dify_agent/agent_stub/server/app.py
Normal file
33
dify-agent/src/dify_agent/agent_stub/server/app.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""Standalone FastAPI application factory for the Dify Agent Stub server.
|
||||
|
||||
The standalone stub server is only a convenience wrapper around the shared
|
||||
router. It reuses the main ``ServerSettings`` model and derives the Agent Stub
|
||||
token codec and optional file-request bridge from the same helper methods that
|
||||
the standard run server uses before mounting ``create_agent_stub_router(...)``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from dify_agent.agent_stub.server.router import create_agent_stub_router
|
||||
from dify_agent.server.settings import ServerSettings
|
||||
|
||||
|
||||
def create_agent_stub_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
"""Build the standalone FastAPI app for authenticated stub endpoints."""
|
||||
resolved_settings = settings or ServerSettings()
|
||||
app = FastAPI(title="Dify Agent Stub Server", version="0.1.0")
|
||||
app.include_router(
|
||||
create_agent_stub_router(
|
||||
token_codec=resolved_settings.create_agent_stub_token_codec(),
|
||||
file_request_handler=resolved_settings.create_agent_stub_file_request_handler(),
|
||||
)
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
app = create_agent_stub_app()
|
||||
|
||||
|
||||
__all__ = ["app", "create_agent_stub_app"]
|
||||
70
dify-agent/src/dify_agent/agent_stub/server/cli.py
Normal file
70
dify-agent/src/dify_agent/agent_stub/server/cli.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""Console entry point for the standalone Dify Agent stub server.
|
||||
|
||||
This module backs the ``dify-agent-stub-server`` console script introduced by
|
||||
the stub-package move. HTTP(S) endpoints continue to run through Uvicorn while
|
||||
``grpc://`` Agent Stub URLs switch the process into grpclib server mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
import uvicorn
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import parse_agent_stub_endpoint
|
||||
from dify_agent.agent_stub.server.grpc_bind import AgentStubGRPCBindTarget, derive_agent_stub_grpc_bind_target
|
||||
from dify_agent.agent_stub.server.grpc_runtime import start_agent_stub_grpc_server
|
||||
from dify_agent.server.settings import ServerSettings
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
"""Run the standalone stub server with parsed uvicorn bind options.
|
||||
|
||||
Args:
|
||||
argv: Optional CLI argument list used mainly by tests. When omitted,
|
||||
``argparse`` reads the process command line.
|
||||
|
||||
Side effects:
|
||||
Starts either ``dify_agent.agent_stub.server.app:app`` via
|
||||
``uvicorn.run`` or the grpclib Agent Stub server depending on the
|
||||
configured ``DIFY_AGENT_STUB_URL`` scheme.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(prog="dify-agent-stub-server")
|
||||
parser.add_argument("--host", default=None)
|
||||
parser.add_argument("--port", type=int, default=None)
|
||||
parser.add_argument("--reload", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
settings = ServerSettings()
|
||||
if settings.agent_stub_url is not None and parse_agent_stub_endpoint(settings.agent_stub_url).is_grpc:
|
||||
asyncio.run(_serve_grpc(settings=settings, host=args.host, port=args.port))
|
||||
return
|
||||
uvicorn.run(
|
||||
"dify_agent.agent_stub.server.app:app",
|
||||
host=args.host or "127.0.0.1",
|
||||
port=args.port or 8001,
|
||||
reload=args.reload,
|
||||
)
|
||||
|
||||
|
||||
async def _serve_grpc(*, settings: ServerSettings, host: str | None, port: int | None) -> None:
|
||||
bind_target = derive_agent_stub_grpc_bind_target(
|
||||
public_url=settings.agent_stub_url or "",
|
||||
bind_address=settings.agent_stub_grpc_bind_address,
|
||||
)
|
||||
if host is not None or port is not None:
|
||||
bind_target = AgentStubGRPCBindTarget(host=host or bind_target.host, port=port or bind_target.port)
|
||||
|
||||
server = await start_agent_stub_grpc_server(
|
||||
public_url=settings.agent_stub_url or "",
|
||||
bind_address=bind_target.address,
|
||||
token_codec=settings.create_agent_stub_token_codec(),
|
||||
file_request_handler=settings.create_agent_stub_file_request_handler(),
|
||||
)
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
await server.aclose()
|
||||
|
||||
|
||||
__all__ = ["main"]
|
||||
106
dify-agent/src/dify_agent/agent_stub/server/control_plane.py
Normal file
106
dify-agent/src/dify_agent/agent_stub/server/control_plane.py
Normal file
@ -0,0 +1,106 @@
|
||||
"""Shared Agent Stub control-plane service used by HTTP and gRPC transports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import uuid4
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubConnectResponse,
|
||||
AgentStubFileDownloadRequest,
|
||||
AgentStubFileDownloadResponse,
|
||||
AgentStubFileUploadRequest,
|
||||
AgentStubFileUploadResponse,
|
||||
)
|
||||
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestError, AgentStubFileRequestHandler
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal, AgentStubTokenCodec, AgentStubTokenError
|
||||
|
||||
|
||||
class AgentStubControlPlaneError(RuntimeError):
|
||||
"""Raised when shared Agent Stub business logic cannot complete a request."""
|
||||
|
||||
status_code: int
|
||||
detail: object
|
||||
|
||||
def __init__(self, status_code: int, detail: object) -> None:
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
super().__init__(str(detail))
|
||||
|
||||
|
||||
class AgentStubAuthenticationError(AgentStubControlPlaneError):
|
||||
"""Raised when Agent Stub authorization is missing or invalid."""
|
||||
|
||||
|
||||
class AgentStubConfigurationError(AgentStubControlPlaneError):
|
||||
"""Raised when required server-side Agent Stub dependencies are missing."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AgentStubControlPlaneService:
|
||||
"""Shared business service for authenticated Agent Stub control-plane calls.
|
||||
|
||||
HTTP and gRPC adapters validate or decode transport payloads before calling
|
||||
this service, so this layer focuses only on shared auth, connection-id
|
||||
generation, and file-request delegation.
|
||||
"""
|
||||
|
||||
token_codec: AgentStubTokenCodec | None
|
||||
file_request_handler: AgentStubFileRequestHandler | None = None
|
||||
connection_id_factory: Callable[[], str] = field(default=lambda: str(uuid4()))
|
||||
|
||||
async def connect(self, *, authorization: str | None) -> AgentStubConnectResponse:
|
||||
"""Authenticate and handle one connect request."""
|
||||
_ = self._authenticate(authorization)
|
||||
return AgentStubConnectResponse(connection_id=self.connection_id_factory(), status="connected")
|
||||
|
||||
async def create_file_upload_request(
|
||||
self,
|
||||
*,
|
||||
request: AgentStubFileUploadRequest,
|
||||
authorization: str | None,
|
||||
) -> AgentStubFileUploadResponse:
|
||||
"""Authenticate and delegate one already-validated file-upload request."""
|
||||
principal = self._authenticate(authorization)
|
||||
handler = self._require_file_request_handler()
|
||||
try:
|
||||
return await handler.create_upload_request(principal=principal, request=request)
|
||||
except AgentStubFileRequestError as exc:
|
||||
raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
|
||||
|
||||
async def create_file_download_request(
|
||||
self,
|
||||
*,
|
||||
request: AgentStubFileDownloadRequest,
|
||||
authorization: str | None,
|
||||
) -> AgentStubFileDownloadResponse:
|
||||
"""Authenticate and delegate one already-validated file-download request."""
|
||||
principal = self._authenticate(authorization)
|
||||
handler = self._require_file_request_handler()
|
||||
try:
|
||||
return await handler.create_download_request(principal=principal, request=request)
|
||||
except AgentStubFileRequestError as exc:
|
||||
raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc
|
||||
|
||||
def _authenticate(self, authorization: str | None) -> AgentStubPrincipal:
|
||||
token_codec = self.token_codec
|
||||
if token_codec is None:
|
||||
raise AgentStubConfigurationError(503, "Agent Stub is not configured")
|
||||
try:
|
||||
return token_codec.decode_authorization_header(authorization)
|
||||
except AgentStubTokenError as exc:
|
||||
raise AgentStubAuthenticationError(401, "invalid or missing Agent Stub authorization") from exc
|
||||
|
||||
def _require_file_request_handler(self) -> AgentStubFileRequestHandler:
|
||||
if self.file_request_handler is None:
|
||||
raise AgentStubConfigurationError(503, "Agent Stub file API is not configured")
|
||||
return self.file_request_handler
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentStubAuthenticationError",
|
||||
"AgentStubConfigurationError",
|
||||
"AgentStubControlPlaneError",
|
||||
"AgentStubControlPlaneService",
|
||||
]
|
||||
69
dify-agent/src/dify_agent/agent_stub/server/grpc_bind.py
Normal file
69
dify-agent/src/dify_agent/agent_stub/server/grpc_bind.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""Bind-address helpers for optional Agent Stub gRPC hosting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import parse_agent_stub_endpoint
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentStubGRPCBindTarget:
|
||||
"""Validated host/port bind target for a grpclib Agent Stub server."""
|
||||
|
||||
host: str
|
||||
port: int
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
return f"{_format_host(self.host)}:{self.port}"
|
||||
|
||||
|
||||
def normalize_agent_stub_grpc_bind_address(value: str) -> str:
|
||||
"""Normalize one ``host:port`` gRPC bind address."""
|
||||
target = parse_agent_stub_grpc_bind_address(value)
|
||||
return target.address
|
||||
|
||||
|
||||
def parse_agent_stub_grpc_bind_address(value: str) -> AgentStubGRPCBindTarget:
|
||||
"""Parse one explicit ``host:port`` gRPC bind override."""
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("Agent Stub gRPC bind address must not be empty")
|
||||
parsed = urlsplit(f"grpc://{stripped}")
|
||||
if not parsed.netloc or parsed.hostname is None:
|
||||
raise ValueError("Agent Stub gRPC bind address must include a host")
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ValueError("Agent Stub gRPC bind address must not include user info")
|
||||
if parsed.port is None:
|
||||
raise ValueError("Agent Stub gRPC bind address must include an explicit port")
|
||||
if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
|
||||
raise ValueError("Agent Stub gRPC bind address must be in host:port form")
|
||||
return AgentStubGRPCBindTarget(host=parsed.hostname, port=parsed.port)
|
||||
|
||||
|
||||
def derive_agent_stub_grpc_bind_target(
|
||||
*,
|
||||
public_url: str,
|
||||
bind_address: str | None = None,
|
||||
) -> AgentStubGRPCBindTarget:
|
||||
"""Resolve the runtime gRPC bind target from public URL plus optional override."""
|
||||
if bind_address is not None:
|
||||
return parse_agent_stub_grpc_bind_address(bind_address)
|
||||
endpoint = parse_agent_stub_endpoint(public_url)
|
||||
if not endpoint.is_grpc or endpoint.port is None:
|
||||
raise ValueError("Agent Stub gRPC bind target requires a grpc://host:port public URL")
|
||||
return AgentStubGRPCBindTarget(host="0.0.0.0", port=endpoint.port)
|
||||
|
||||
|
||||
def _format_host(host: str) -> str:
|
||||
return f"[{host}]" if ":" in host and not host.startswith("[") else host
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentStubGRPCBindTarget",
|
||||
"derive_agent_stub_grpc_bind_target",
|
||||
"normalize_agent_stub_grpc_bind_address",
|
||||
"parse_agent_stub_grpc_bind_address",
|
||||
]
|
||||
59
dify-agent/src/dify_agent/agent_stub/server/grpc_runtime.py
Normal file
59
dify-agent/src/dify_agent/agent_stub/server/grpc_runtime.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""Runtime helpers for starting and stopping the optional Agent Stub gRPC server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestHandler
|
||||
from dify_agent.agent_stub.server.control_plane import AgentStubControlPlaneService
|
||||
from dify_agent.agent_stub.server.grpc_bind import AgentStubGRPCBindTarget, derive_agent_stub_grpc_bind_target
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RunningAgentStubGRPCServer:
|
||||
"""Handle for one started grpclib Agent Stub server."""
|
||||
|
||||
server: object
|
||||
bind_target: AgentStubGRPCBindTarget
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Stop accepting new requests and wait for open RPCs to close."""
|
||||
close = getattr(self.server, "close")
|
||||
wait_closed = getattr(self.server, "wait_closed")
|
||||
close()
|
||||
await wait_closed()
|
||||
|
||||
|
||||
async def start_agent_stub_grpc_server(
|
||||
*,
|
||||
public_url: str,
|
||||
bind_address: str | None,
|
||||
token_codec: AgentStubTokenCodec | None,
|
||||
file_request_handler: AgentStubFileRequestHandler | None,
|
||||
) -> RunningAgentStubGRPCServer:
|
||||
"""Start the optional grpclib Agent Stub server for one process."""
|
||||
from dify_agent.agent_stub.server.grpc_service import create_agent_stub_grpc_service
|
||||
|
||||
runtime = _require_runtime()
|
||||
bind_target = derive_agent_stub_grpc_bind_target(public_url=public_url, bind_address=bind_address)
|
||||
service = AgentStubControlPlaneService(token_codec, file_request_handler)
|
||||
server = runtime.Server([create_agent_stub_grpc_service(service)])
|
||||
await server.start(bind_target.host, bind_target.port)
|
||||
return RunningAgentStubGRPCServer(server=server, bind_target=bind_target)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _GRPCRuntime:
|
||||
Server: type
|
||||
|
||||
|
||||
def _require_runtime() -> _GRPCRuntime:
|
||||
try:
|
||||
from grpclib.server import Server
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("Agent Stub gRPC support requires the optional dify-agent[grpc] dependencies") from exc
|
||||
return _GRPCRuntime(Server=Server)
|
||||
|
||||
|
||||
__all__ = ["RunningAgentStubGRPCServer", "start_agent_stub_grpc_server"]
|
||||
244
dify-agent/src/dify_agent/agent_stub/server/grpc_service.py
Normal file
244
dify-agent/src/dify_agent/agent_stub/server/grpc_service.py
Normal file
@ -0,0 +1,244 @@
|
||||
"""gRPC transport adapter for the Agent Stub control plane.
|
||||
|
||||
This module owns the gRPC-specific semantics that differ from the HTTP router:
|
||||
|
||||
- compact-JWE auth is read from inbound ``authorization`` metadata rather than
|
||||
an HTTP header object;
|
||||
- protobuf request-shape or JSON-decoding failures are mapped to
|
||||
``INVALID_ARGUMENT`` before the shared business layer runs;
|
||||
- auth/configuration/downstream failures raised by
|
||||
``AgentStubControlPlaneService`` are translated into gRPC statuses;
|
||||
- structured downstream error details are stringified because gRPC status
|
||||
details are text, unlike the HTTP path which can preserve object-shaped
|
||||
``detail`` payloads.
|
||||
|
||||
The shared control-plane service still owns auth policy, connection-id
|
||||
generation, and file-handler delegation so HTTP and gRPC stay semantically
|
||||
aligned outside these transport-specific mappings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from dify_agent.agent_stub.server.control_plane import (
|
||||
AgentStubAuthenticationError,
|
||||
AgentStubConfigurationError,
|
||||
AgentStubControlPlaneError,
|
||||
AgentStubControlPlaneService,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from grpclib.server import Stream
|
||||
|
||||
from dify_agent.agent_stub.grpc._generated import agent_stub_pb2
|
||||
from dify_agent.agent_stub.grpc._generated.agent_stub_grpc import AgentStubServiceBase
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AgentStubGRPCTransport:
|
||||
"""Shared gRPC adapter that converts protobuf messages around control-plane calls.
|
||||
|
||||
Each method validates the protobuf request at the transport boundary,
|
||||
extracts ``authorization`` metadata, and translates shared control-plane
|
||||
failures into grpclib ``GRPCError`` instances.
|
||||
"""
|
||||
|
||||
service: AgentStubControlPlaneService
|
||||
|
||||
async def connect(
|
||||
self,
|
||||
*,
|
||||
request,
|
||||
metadata: object,
|
||||
):
|
||||
"""Handle one gRPC connect request.
|
||||
|
||||
Invalid protobuf/message-shape input maps to ``INVALID_ARGUMENT``. Auth
|
||||
and configuration failures raised by the shared service are translated by
|
||||
``_grpc_error_from_control_plane_error``.
|
||||
"""
|
||||
authorization = _authorization_from_metadata(metadata)
|
||||
conversions = _require_conversions()
|
||||
try:
|
||||
_ = conversions.connect_request_from_proto(request)
|
||||
response = await self.service.connect(authorization=authorization)
|
||||
return conversions.proto_connect_response(response)
|
||||
except (ValidationError, ValueError, TypeError) as exc:
|
||||
raise _grpc_error("INVALID_ARGUMENT", "invalid Agent Stub connect request") from exc
|
||||
except AgentStubControlPlaneError as exc:
|
||||
raise _grpc_error_from_control_plane_error(exc) from exc
|
||||
|
||||
async def create_file_upload_request(
|
||||
self,
|
||||
*,
|
||||
request,
|
||||
metadata: object,
|
||||
):
|
||||
"""Handle one gRPC file-upload request.
|
||||
|
||||
This transport validates the protobuf request before delegating to the
|
||||
shared service, then stringifies any non-string downstream error detail
|
||||
when mapping the resulting failure into gRPC status text.
|
||||
"""
|
||||
authorization = _authorization_from_metadata(metadata)
|
||||
conversions = _require_conversions()
|
||||
try:
|
||||
validated_request = conversions.file_upload_request_from_proto(request)
|
||||
response = await self.service.create_file_upload_request(
|
||||
request=validated_request,
|
||||
authorization=authorization,
|
||||
)
|
||||
return conversions.proto_file_upload_response(response)
|
||||
except (ValidationError, ValueError, TypeError) as exc:
|
||||
raise _grpc_error("INVALID_ARGUMENT", "invalid Agent Stub file upload request") from exc
|
||||
except AgentStubControlPlaneError as exc:
|
||||
raise _grpc_error_from_control_plane_error(exc) from exc
|
||||
|
||||
async def create_file_download_request(
|
||||
self,
|
||||
*,
|
||||
request,
|
||||
metadata: object,
|
||||
):
|
||||
"""Handle one gRPC file-download request.
|
||||
|
||||
This transport validates the protobuf request before delegating to the
|
||||
shared service, then stringifies any non-string downstream error detail
|
||||
when mapping the resulting failure into gRPC status text.
|
||||
"""
|
||||
authorization = _authorization_from_metadata(metadata)
|
||||
conversions = _require_conversions()
|
||||
try:
|
||||
validated_request = conversions.file_download_request_from_proto(request)
|
||||
response = await self.service.create_file_download_request(
|
||||
request=validated_request,
|
||||
authorization=authorization,
|
||||
)
|
||||
return conversions.proto_file_download_response(response)
|
||||
except (ValidationError, ValueError, TypeError) as exc:
|
||||
raise _grpc_error("INVALID_ARGUMENT", "invalid Agent Stub file download request") from exc
|
||||
except AgentStubControlPlaneError as exc:
|
||||
raise _grpc_error_from_control_plane_error(exc) from exc
|
||||
|
||||
|
||||
def create_agent_stub_grpc_service(
|
||||
service: AgentStubControlPlaneService,
|
||||
) -> AgentStubServiceBase:
|
||||
"""Wrap the shared control-plane service in a grpclib-generated service base.
|
||||
|
||||
The generated grpclib service methods are unary-only and reject missing
|
||||
request messages with ``INVALID_ARGUMENT`` before handing control to the
|
||||
transport adapter.
|
||||
"""
|
||||
try:
|
||||
from dify_agent.agent_stub.grpc._generated.agent_stub_grpc import AgentStubServiceBase
|
||||
except ImportError as exc: # pragma: no cover - exercised via runtime import guard
|
||||
raise RuntimeError("Agent Stub gRPC support requires the optional grpc dependencies") from exc
|
||||
|
||||
transport = AgentStubGRPCTransport(service)
|
||||
|
||||
class _Service(AgentStubServiceBase):
|
||||
async def Connect(
|
||||
self,
|
||||
stream: Stream[agent_stub_pb2.ConnectRequest, agent_stub_pb2.ConnectResponse],
|
||||
) -> None: # type: ignore[name-defined]
|
||||
request = await stream.recv_message()
|
||||
if request is None:
|
||||
raise _grpc_error("INVALID_ARGUMENT", "missing Agent Stub request message")
|
||||
await stream.send_message(await transport.connect(request=request, metadata=stream.metadata))
|
||||
|
||||
async def CreateFileUploadRequest(
|
||||
self,
|
||||
stream: Stream[agent_stub_pb2.FileUploadRequest, agent_stub_pb2.FileUploadResponse],
|
||||
) -> None: # type: ignore[name-defined]
|
||||
request = await stream.recv_message()
|
||||
if request is None:
|
||||
raise _grpc_error("INVALID_ARGUMENT", "missing Agent Stub request message")
|
||||
await stream.send_message(
|
||||
await transport.create_file_upload_request(request=request, metadata=stream.metadata)
|
||||
)
|
||||
|
||||
async def CreateFileDownloadRequest(
|
||||
self,
|
||||
stream: Stream[agent_stub_pb2.FileDownloadRequest, agent_stub_pb2.FileDownloadResponse],
|
||||
) -> None: # type: ignore[name-defined]
|
||||
request = await stream.recv_message()
|
||||
if request is None:
|
||||
raise _grpc_error("INVALID_ARGUMENT", "missing Agent Stub request message")
|
||||
await stream.send_message(
|
||||
await transport.create_file_download_request(request=request, metadata=stream.metadata)
|
||||
)
|
||||
|
||||
return _Service()
|
||||
|
||||
|
||||
def _authorization_from_metadata(metadata: object) -> str | None:
|
||||
"""Extract the optional bearer token from grpclib metadata containers."""
|
||||
if metadata is None:
|
||||
return None
|
||||
if isinstance(metadata, Mapping):
|
||||
value = metadata.get("authorization")
|
||||
return value if isinstance(value, str) else None
|
||||
if isinstance(metadata, Sequence):
|
||||
for item in metadata:
|
||||
if isinstance(item, tuple) and len(item) == 2:
|
||||
key, value = item
|
||||
if isinstance(key, str) and key.lower() == "authorization" and isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _grpc_error_from_control_plane_error(exc: AgentStubControlPlaneError):
|
||||
"""Translate shared control-plane failures into transport-visible gRPC status.
|
||||
|
||||
``detail`` is normalized to text because gRPC status details are strings;
|
||||
HTTP adapters can preserve richer object payloads.
|
||||
"""
|
||||
detail_text = _grpc_detail_text(exc.detail)
|
||||
if isinstance(exc, AgentStubAuthenticationError):
|
||||
return _grpc_error("UNAUTHENTICATED", detail_text)
|
||||
if isinstance(exc, AgentStubConfigurationError):
|
||||
return _grpc_error("UNAVAILABLE", detail_text)
|
||||
if exc.status_code in {408, 504}:
|
||||
return _grpc_error("DEADLINE_EXCEEDED", detail_text)
|
||||
if exc.status_code == 429:
|
||||
return _grpc_error("RESOURCE_EXHAUSTED", detail_text)
|
||||
if exc.status_code == 404:
|
||||
return _grpc_error("NOT_FOUND", detail_text)
|
||||
if exc.status_code == 403:
|
||||
return _grpc_error("PERMISSION_DENIED", detail_text)
|
||||
if 400 <= exc.status_code < 500:
|
||||
return _grpc_error("FAILED_PRECONDITION", detail_text)
|
||||
if 500 <= exc.status_code < 600:
|
||||
return _grpc_error("UNAVAILABLE", detail_text)
|
||||
return _grpc_error("INTERNAL", "internal Agent Stub error")
|
||||
|
||||
|
||||
def _grpc_error(status_name: str, detail: str):
|
||||
from grpclib.const import Status
|
||||
from grpclib.exceptions import GRPCError
|
||||
|
||||
return GRPCError(getattr(Status, status_name), detail)
|
||||
|
||||
|
||||
def _grpc_detail_text(detail: object) -> str:
|
||||
"""Return the text form used for gRPC status details."""
|
||||
if isinstance(detail, str):
|
||||
return detail
|
||||
return str(detail)
|
||||
|
||||
|
||||
def _require_conversions():
|
||||
try:
|
||||
from dify_agent.agent_stub.grpc import conversions
|
||||
except ImportError as exc: # pragma: no cover - exercised by runtime import guard
|
||||
raise RuntimeError("Agent Stub gRPC support requires the optional grpc dependencies") from exc
|
||||
return conversions
|
||||
|
||||
|
||||
__all__ = ["AgentStubGRPCTransport", "create_agent_stub_grpc_service"]
|
||||
29
dify-agent/src/dify_agent/agent_stub/server/router.py
Normal file
29
dify-agent/src/dify_agent/agent_stub/server/router.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""Embeddable router factory for Dify Agent stub endpoints.
|
||||
|
||||
Both the standalone stub server and the standard run server mount the same
|
||||
router so the Agent Stub protocol, token validation, and file-control-plane
|
||||
behavior stay identical regardless of hosting mode. The factory is intentionally
|
||||
settings-agnostic: callers must pass already constructed token-codec and file
|
||||
handler dependencies rather than having this module read environment variables
|
||||
or import server settings directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestHandler
|
||||
from dify_agent.agent_stub.server.routes.agent_stub import create_agent_stub_http_router
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
|
||||
|
||||
|
||||
def create_agent_stub_router(
|
||||
*,
|
||||
token_codec: AgentStubTokenCodec | None,
|
||||
file_request_handler: AgentStubFileRequestHandler | None = None,
|
||||
) -> APIRouter:
|
||||
"""Build the embeddable stub router from pre-built server dependencies."""
|
||||
return create_agent_stub_http_router(token_codec, file_request_handler)
|
||||
|
||||
|
||||
__all__ = ["create_agent_stub_router"]
|
||||
@ -0,0 +1,5 @@
|
||||
"""Route exports for the Dify Agent stub server."""
|
||||
|
||||
from .agent_stub import create_agent_stub_http_router
|
||||
|
||||
__all__ = ["create_agent_stub_http_router"]
|
||||
@ -0,0 +1,68 @@
|
||||
"""FastAPI routes for authenticated Agent Stub control-plane calls.
|
||||
|
||||
The router is a thin HTTP adapter around ``AgentStubControlPlaneService``. It
|
||||
keeps FastAPI-specific request parsing and HTTPException translation here while
|
||||
sharing auth, DTO validation, connection-id generation, and file delegation with
|
||||
the gRPC transport.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubConnectRequest,
|
||||
AgentStubConnectResponse,
|
||||
AgentStubFileDownloadRequest,
|
||||
AgentStubFileDownloadResponse,
|
||||
AgentStubFileUploadRequest,
|
||||
AgentStubFileUploadResponse,
|
||||
)
|
||||
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestHandler
|
||||
from dify_agent.agent_stub.server.control_plane import AgentStubControlPlaneError, AgentStubControlPlaneService
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
|
||||
|
||||
|
||||
def create_agent_stub_http_router(
|
||||
token_codec: AgentStubTokenCodec | None,
|
||||
file_request_handler: AgentStubFileRequestHandler | None = None,
|
||||
) -> APIRouter:
|
||||
"""Create HTTP routes bound to the application's Agent Stub dependencies."""
|
||||
router = APIRouter(prefix="/agent-stub", tags=["agent-stub"])
|
||||
service = AgentStubControlPlaneService(token_codec, file_request_handler)
|
||||
|
||||
@router.post("/connections", response_model=AgentStubConnectResponse)
|
||||
async def create_connection(
|
||||
request: AgentStubConnectRequest,
|
||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||
) -> AgentStubConnectResponse:
|
||||
del request
|
||||
try:
|
||||
return await service.connect(authorization=authorization)
|
||||
except AgentStubControlPlaneError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
@router.post("/files/upload-request", response_model=AgentStubFileUploadResponse)
|
||||
async def create_file_upload_request(
|
||||
request: AgentStubFileUploadRequest,
|
||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||
) -> AgentStubFileUploadResponse:
|
||||
try:
|
||||
return await service.create_file_upload_request(request=request, authorization=authorization)
|
||||
except AgentStubControlPlaneError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
@router.post("/files/download-request", response_model=AgentStubFileDownloadResponse)
|
||||
async def create_file_download_request(
|
||||
request: AgentStubFileDownloadRequest,
|
||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||
) -> AgentStubFileDownloadResponse:
|
||||
try:
|
||||
return await service.create_file_download_request(request=request, authorization=authorization)
|
||||
except AgentStubControlPlaneError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
return router
|
||||
|
||||
|
||||
__all__ = ["create_agent_stub_http_router"]
|
||||
@ -0,0 +1,47 @@
|
||||
"""Server-side environment injection helpers for Agent Stub forwarding.
|
||||
|
||||
Only user-visible ``shell.run`` commands receive these variables. Internal
|
||||
lifecycle commands remain free of Agent Stub credentials so workspace setup and
|
||||
cleanup cannot accidentally inherit user-facing forwarding state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR,
|
||||
AGENT_STUB_URL_ENV_VAR,
|
||||
normalize_agent_stub_url,
|
||||
)
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
|
||||
|
||||
class ShellAgentStubTokenFactory(Protocol):
|
||||
"""Callable boundary for server-side Agent Stub token issuance."""
|
||||
|
||||
def __call__(self, execution_context: DifyExecutionContextLayerConfig, *, session_id: str | None) -> str: ...
|
||||
|
||||
|
||||
def build_shell_agent_stub_env(
|
||||
*,
|
||||
agent_stub_url: str | None,
|
||||
execution_context: DifyExecutionContextLayerConfig | None,
|
||||
token_factory: ShellAgentStubTokenFactory | None,
|
||||
session_id: str | None,
|
||||
) -> dict[str, str] | None:
|
||||
"""Build the shell-visible Agent Stub environment for one user command."""
|
||||
if agent_stub_url is None or execution_context is None or token_factory is None:
|
||||
return None
|
||||
return {
|
||||
AGENT_STUB_URL_ENV_VAR: normalize_agent_stub_url(agent_stub_url),
|
||||
AGENT_STUB_AUTH_JWE_ENV_VAR: token_factory(execution_context, session_id=session_id),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AGENT_STUB_AUTH_JWE_ENV_VAR",
|
||||
"AGENT_STUB_URL_ENV_VAR",
|
||||
"ShellAgentStubTokenFactory",
|
||||
"build_shell_agent_stub_env",
|
||||
]
|
||||
@ -0,0 +1,27 @@
|
||||
"""Server-only token helpers for authenticated Agent Stub routes."""
|
||||
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import (
|
||||
AGENT_STUB_TOKEN_AUDIENCE,
|
||||
AGENT_STUB_TOKEN_ISSUER,
|
||||
AGENT_STUB_TOKEN_SCOPE_CONNECT,
|
||||
AGENT_STUB_TOKEN_TTL_SECONDS,
|
||||
AgentStubPrincipal,
|
||||
AgentStubTokenClaims,
|
||||
AgentStubTokenCodec,
|
||||
AgentStubTokenError,
|
||||
decode_server_secret_key,
|
||||
derive_agent_stub_jwe_key,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AGENT_STUB_TOKEN_AUDIENCE",
|
||||
"AGENT_STUB_TOKEN_ISSUER",
|
||||
"AGENT_STUB_TOKEN_SCOPE_CONNECT",
|
||||
"AGENT_STUB_TOKEN_TTL_SECONDS",
|
||||
"AgentStubPrincipal",
|
||||
"AgentStubTokenClaims",
|
||||
"AgentStubTokenCodec",
|
||||
"AgentStubTokenError",
|
||||
"decode_server_secret_key",
|
||||
"derive_agent_stub_jwe_key",
|
||||
]
|
||||
254
dify-agent/src/dify_agent/agent_stub/server/tokens/agent_stub.py
Normal file
254
dify-agent/src/dify_agent/agent_stub/server/tokens/agent_stub.py
Normal file
@ -0,0 +1,254 @@
|
||||
"""Server-only compact-JWE codec for Agent Stub bearer tokens.
|
||||
|
||||
The Agent Stub accepts only encrypted bearer tokens issued by this
|
||||
server process. The root secret comes from ``DIFY_AGENT_SERVER_SECRET_KEY``
|
||||
and is never used directly as a content-encryption key; a purpose-specific HKDF
|
||||
derivation isolates Agent Stub tokens from any future server-side token
|
||||
families that may reuse the same root secret.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import ClassVar
|
||||
from uuid import uuid4
|
||||
|
||||
from jwcrypto import jwe, jwk
|
||||
from jwcrypto.common import JWException
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
|
||||
|
||||
AGENT_STUB_TOKEN_ISSUER = "dify-agent-server"
|
||||
AGENT_STUB_TOKEN_AUDIENCE = "dify-agent-agent-stub"
|
||||
AGENT_STUB_TOKEN_SCOPE_CONNECT = "agent_stub:connect"
|
||||
AGENT_STUB_TOKEN_TTL_SECONDS = 24 * 60 * 60
|
||||
_AGENT_STUB_JWE_PURPOSE = b"dify-agent:agent-stub:jwe:v1"
|
||||
_REQUIRED_SERVER_SECRET_BYTES = 32
|
||||
_BASE64URL_TEXT_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
_DEFAULT_SERVER_SECRET_ENV_VAR = "DIFY_AGENT_SERVER_SECRET_KEY"
|
||||
|
||||
|
||||
class AgentStubTokenError(RuntimeError):
|
||||
"""Raised when an Agent Stub bearer token is missing or invalid."""
|
||||
|
||||
|
||||
class AgentStubShellClaims(BaseModel):
|
||||
"""Optional shell-session claims embedded in Agent Stub tokens."""
|
||||
|
||||
session_id: str | None = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AgentStubTokenClaims(BaseModel):
|
||||
"""Authenticated claim set carried by one compact JWE bearer token."""
|
||||
|
||||
iss: str
|
||||
aud: str
|
||||
iat: int
|
||||
nbf: int
|
||||
exp: int
|
||||
jti: str
|
||||
scope: list[str]
|
||||
execution_context: DifyExecutionContextLayerConfig
|
||||
shell: AgentStubShellClaims | None = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AgentStubPrincipal:
|
||||
"""Decoded request principal for one authenticated Agent Stub call."""
|
||||
|
||||
execution_context: DifyExecutionContextLayerConfig
|
||||
session_id: str | None
|
||||
scope: list[str]
|
||||
token_id: str
|
||||
|
||||
|
||||
class AgentStubTokenCodec:
|
||||
"""Encode and decode compact JWE Agent Stub bearer tokens."""
|
||||
|
||||
_content_encryption_key: bytes
|
||||
_jwe_key: jwk.JWK
|
||||
|
||||
def __init__(self, content_encryption_key: bytes) -> None:
|
||||
self._content_encryption_key = content_encryption_key
|
||||
self._jwe_key = jwk.JWK(
|
||||
kty="oct",
|
||||
k=_base64url_encode(content_encryption_key),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_server_secret(cls, server_secret_key: str) -> AgentStubTokenCodec:
|
||||
"""Construct a codec from the configured base64url-encoded server secret."""
|
||||
return cls(derive_agent_stub_jwe_key(server_secret_key))
|
||||
|
||||
def build_connection_claims(
|
||||
self,
|
||||
execution_context: DifyExecutionContextLayerConfig,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
now: int | None = None,
|
||||
) -> AgentStubTokenClaims:
|
||||
"""Build the fixed-24h claim set for one Agent Stub connection token."""
|
||||
issued_at = _timestamp(now)
|
||||
shell_claims = AgentStubShellClaims(session_id=session_id) if session_id is not None else None
|
||||
return AgentStubTokenClaims(
|
||||
iss=AGENT_STUB_TOKEN_ISSUER,
|
||||
aud=AGENT_STUB_TOKEN_AUDIENCE,
|
||||
iat=issued_at,
|
||||
nbf=issued_at,
|
||||
exp=issued_at + AGENT_STUB_TOKEN_TTL_SECONDS,
|
||||
jti=str(uuid4()),
|
||||
scope=[AGENT_STUB_TOKEN_SCOPE_CONNECT],
|
||||
execution_context=execution_context,
|
||||
shell=shell_claims,
|
||||
)
|
||||
|
||||
def encode_connection_token(
|
||||
self,
|
||||
execution_context: DifyExecutionContextLayerConfig,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
now: int | None = None,
|
||||
) -> str:
|
||||
"""Encode one fixed-24h Agent Stub compact JWE bearer token."""
|
||||
return self.encode_claims(self.build_connection_claims(execution_context, session_id=session_id, now=now))
|
||||
|
||||
def encode_claims(self, claims: AgentStubTokenClaims) -> str:
|
||||
"""Encrypt one validated Agent Stub claim set as compact JWE."""
|
||||
token = jwe.JWE(
|
||||
plaintext=json.dumps(claims.model_dump(mode="json", exclude_none=True), separators=(",", ":")).encode(
|
||||
"utf-8"
|
||||
),
|
||||
protected=json.dumps({"alg": "dir", "enc": "A256GCM"}),
|
||||
)
|
||||
token.add_recipient(self._jwe_key)
|
||||
return token.serialize(compact=True)
|
||||
|
||||
def decode_authorization_header(self, authorization: str | None, *, now: int | None = None) -> AgentStubPrincipal:
|
||||
"""Decode a ``Bearer <compact-jwe>`` header into a request principal."""
|
||||
if authorization is None or not authorization.startswith("Bearer "):
|
||||
raise AgentStubTokenError("Authorization must be a Bearer compact JWE token")
|
||||
token = authorization.removeprefix("Bearer ").strip()
|
||||
if not token:
|
||||
raise AgentStubTokenError("Authorization bearer token must not be empty")
|
||||
return self.decode_token(token, now=now)
|
||||
|
||||
def decode_token(self, token: str, *, now: int | None = None) -> AgentStubPrincipal:
|
||||
"""Decrypt and validate one compact JWE token string."""
|
||||
decrypted = jwe.JWE()
|
||||
try:
|
||||
decrypted.deserialize(token, key=self._jwe_key)
|
||||
except JWException as exc:
|
||||
raise AgentStubTokenError("failed to decrypt Agent Stub bearer token") from exc
|
||||
|
||||
try:
|
||||
claims = AgentStubTokenClaims.model_validate_json(decrypted.payload)
|
||||
except ValidationError as exc:
|
||||
raise AgentStubTokenError("Agent Stub bearer token payload is invalid") from exc
|
||||
|
||||
current_time = _timestamp(now)
|
||||
_validate_claims(claims, now=current_time)
|
||||
return AgentStubPrincipal(
|
||||
execution_context=claims.execution_context,
|
||||
session_id=claims.shell.session_id if claims.shell is not None else None,
|
||||
scope=list(claims.scope),
|
||||
token_id=claims.jti,
|
||||
)
|
||||
|
||||
|
||||
def decode_server_secret_key(server_secret_key: str, *, env_var_name: str = _DEFAULT_SERVER_SECRET_ENV_VAR) -> bytes:
|
||||
"""Decode and validate the configured server root secret.
|
||||
|
||||
The secret must be strict unpadded base64url text and must decode to
|
||||
exactly 32 bytes. Settings validation uses this helper so operator
|
||||
misconfiguration fails fast before the server starts issuing or accepting
|
||||
Agent Stub tokens.
|
||||
"""
|
||||
normalized = server_secret_key.strip()
|
||||
if not normalized or not _BASE64URL_TEXT_PATTERN.fullmatch(normalized):
|
||||
raise ValueError(f"{env_var_name} must be valid unpadded base64url text")
|
||||
try:
|
||||
decoded = _base64url_decode(normalized)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{env_var_name} must be valid unpadded base64url text") from exc
|
||||
if len(decoded) != _REQUIRED_SERVER_SECRET_BYTES:
|
||||
raise ValueError(f"{env_var_name} must decode to exactly {_REQUIRED_SERVER_SECRET_BYTES} decoded bytes")
|
||||
return decoded
|
||||
|
||||
|
||||
def derive_agent_stub_jwe_key(server_secret_key: str) -> bytes:
|
||||
"""Derive the purpose-scoped 32-byte JWE content-encryption key."""
|
||||
return _hkdf_sha256(decode_server_secret_key(server_secret_key), info=_AGENT_STUB_JWE_PURPOSE, length=32)
|
||||
|
||||
|
||||
def _validate_claims(claims: AgentStubTokenClaims, *, now: int) -> None:
|
||||
if claims.iss != AGENT_STUB_TOKEN_ISSUER:
|
||||
raise AgentStubTokenError(f"Agent Stub bearer token issuer must be {AGENT_STUB_TOKEN_ISSUER!r}")
|
||||
if claims.aud != AGENT_STUB_TOKEN_AUDIENCE:
|
||||
raise AgentStubTokenError(f"Agent Stub bearer token audience must be {AGENT_STUB_TOKEN_AUDIENCE!r}")
|
||||
if now < claims.nbf:
|
||||
raise AgentStubTokenError("Agent Stub bearer token is not valid yet")
|
||||
if now >= claims.exp:
|
||||
raise AgentStubTokenError("Agent Stub bearer token is expired")
|
||||
if AGENT_STUB_TOKEN_SCOPE_CONNECT not in claims.scope:
|
||||
raise AgentStubTokenError(f"Agent Stub bearer token scope must include {AGENT_STUB_TOKEN_SCOPE_CONNECT!r}")
|
||||
|
||||
|
||||
def _hkdf_sha256(input_key_material: bytes, *, info: bytes, length: int) -> bytes:
|
||||
hash_len = hashlib.sha256().digest_size
|
||||
salt = b"\x00" * hash_len
|
||||
pseudorandom_key = hmac.new(salt, input_key_material, hashlib.sha256).digest()
|
||||
output = bytearray()
|
||||
previous_block = b""
|
||||
counter = 1
|
||||
while len(output) < length:
|
||||
previous_block = hmac.new(
|
||||
pseudorandom_key,
|
||||
previous_block + info + bytes([counter]),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
output.extend(previous_block)
|
||||
counter += 1
|
||||
return bytes(output[:length])
|
||||
|
||||
|
||||
def _timestamp(value: int | None) -> int:
|
||||
return int(time.time() if value is None else value)
|
||||
|
||||
|
||||
def _base64url_decode(value: str) -> bytes:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
try:
|
||||
return base64.b64decode(f"{value}{padding}", altchars=b"-_", validate=True)
|
||||
except binascii.Error as exc:
|
||||
raise ValueError("invalid base64url") from exc
|
||||
|
||||
|
||||
def _base64url_encode(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AGENT_STUB_TOKEN_AUDIENCE",
|
||||
"AGENT_STUB_TOKEN_ISSUER",
|
||||
"AGENT_STUB_TOKEN_SCOPE_CONNECT",
|
||||
"AGENT_STUB_TOKEN_TTL_SECONDS",
|
||||
"AgentStubPrincipal",
|
||||
"AgentStubTokenClaims",
|
||||
"AgentStubTokenCodec",
|
||||
"AgentStubTokenError",
|
||||
"decode_server_secret_key",
|
||||
"derive_agent_stub_jwe_key",
|
||||
]
|
||||
@ -1,17 +1,12 @@
|
||||
"""Client-safe DTOs for the Dify execution-context Agenton layer.
|
||||
|
||||
This layer carries Dify-owned execution identifiers plus the tenant/user daemon
|
||||
transport context shared by plugin-backed business layers. The identifiers are
|
||||
for observability and product correlation only; callers must not treat them as
|
||||
authorization proof. Server-only plugin-daemon settings are injected by the
|
||||
runtime provider factory and therefore do not appear in this public schema.
|
||||
|
||||
Protocol note (Agent Files §1.3 / ENG-589): ``invoke_from`` now carries the *real*
|
||||
Dify invocation source (who triggered the run) so downstream file/drive APIs can
|
||||
rebuild the access context, while the agent *run mode* (how the runtime is driven)
|
||||
moved to the dedicated ``agent_mode`` field. For back-compat ``invoke_from`` still
|
||||
accepts the legacy agent-mode literals; new requests set ``agent_mode`` + a real
|
||||
``invoke_from`` + ``user_from``.
|
||||
This layer carries both Dify product execution context (tenant, user, workflow,
|
||||
invoke source) and Agent backend runtime mode. The product-facing fields are
|
||||
used by trusted server-side boundaries such as the Agent Stub when they
|
||||
need to reconstruct Dify API file-access scope without granting the sandbox any
|
||||
direct inner-API credentials. Server-only plugin-daemon settings are injected
|
||||
by the runtime provider factory and therefore do not appear in this public
|
||||
schema.
|
||||
"""
|
||||
|
||||
from typing import ClassVar, Final, Literal, TypeAlias
|
||||
@ -22,8 +17,6 @@ from agenton.layers import LayerConfig
|
||||
|
||||
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID: Final[str] = "dify.execution_context"
|
||||
|
||||
# How the Dify Agent runtime is being driven (the agent run mode).
|
||||
DifyExecutionContextAgentMode: TypeAlias = Literal[
|
||||
"workflow_run",
|
||||
"single_step",
|
||||
@ -31,12 +24,7 @@ DifyExecutionContextAgentMode: TypeAlias = Literal[
|
||||
"babysit",
|
||||
"fasten",
|
||||
]
|
||||
|
||||
# The origin class of the acting user.
|
||||
DifyExecutionContextUserFrom: TypeAlias = Literal["account", "end-user"]
|
||||
|
||||
# The real Dify invocation source. Includes the legacy agent-mode literals so
|
||||
# older requests that carried the run mode in ``invoke_from`` still validate.
|
||||
DifyExecutionContextInvokeFrom: TypeAlias = Literal[
|
||||
"service-api",
|
||||
"openapi",
|
||||
@ -46,12 +34,6 @@ DifyExecutionContextInvokeFrom: TypeAlias = Literal[
|
||||
"debugger",
|
||||
"published",
|
||||
"validation",
|
||||
# legacy agent-mode values (back-compat)
|
||||
"workflow_run",
|
||||
"single_step",
|
||||
"agent_app",
|
||||
"babysit",
|
||||
"fasten",
|
||||
]
|
||||
|
||||
|
||||
@ -60,7 +42,7 @@ class DifyExecutionContextLayerConfig(LayerConfig):
|
||||
|
||||
tenant_id: str
|
||||
user_id: str | None = None
|
||||
user_from: DifyExecutionContextUserFrom | None = None
|
||||
user_from: DifyExecutionContextUserFrom
|
||||
app_id: str | None = None
|
||||
workflow_id: str | None = None
|
||||
workflow_run_id: str | None = None
|
||||
@ -69,11 +51,8 @@ class DifyExecutionContextLayerConfig(LayerConfig):
|
||||
conversation_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
agent_config_version_id: str | None = None
|
||||
# Real Dify invocation source. Optional for back-compat (older requests carried
|
||||
# the agent run mode here instead).
|
||||
invoke_from: DifyExecutionContextInvokeFrom | None = None
|
||||
# The agent run mode. New requests set this explicitly.
|
||||
agent_mode: DifyExecutionContextAgentMode | None = None
|
||||
agent_mode: DifyExecutionContextAgentMode
|
||||
invoke_from: DifyExecutionContextInvokeFrom
|
||||
trace_id: str | None = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
@ -83,6 +62,6 @@ __all__ = [
|
||||
"DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID",
|
||||
"DifyExecutionContextAgentMode",
|
||||
"DifyExecutionContextInvokeFrom",
|
||||
"DifyExecutionContextLayerConfig",
|
||||
"DifyExecutionContextUserFrom",
|
||||
"DifyExecutionContextLayerConfig",
|
||||
]
|
||||
|
||||
@ -18,12 +18,13 @@ side-effecting ``on_context_resume`` attempt fails after issuing shellctl jobs,
|
||||
Agenton still exits ``resource_context()`` but never transitions the layer to
|
||||
``ACTIVE``. In that failed-enter path, normal suspend/delete hooks do not run,
|
||||
so the enter hook itself must perform best-effort business compensation before
|
||||
re-raising the failure.
|
||||
re-raising the failure. Agent Stub env injection uses shellctl's native per-run
|
||||
``env`` argument only for user-visible ``shell.run``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable, Sequence
|
||||
from collections.abc import AsyncGenerator, Callable, Mapping, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
import json
|
||||
import logging
|
||||
@ -45,7 +46,9 @@ from shell_session_manager.shellctl.shared import (
|
||||
)
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import NoLayerDeps, PydanticAILayer, PydanticAIPrompt, PydanticAITool
|
||||
from agenton.layers import LayerDeps, PydanticAILayer, PydanticAIPrompt, PydanticAITool
|
||||
from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
|
||||
|
||||
@ -162,6 +165,12 @@ type ShellRunToolResult = ShellJobObservation | ShellToolErrorObservation
|
||||
type ShellInterruptToolResult = ShellJobStatusObservation | ShellToolErrorObservation
|
||||
|
||||
|
||||
class DifyShellLayerDeps(LayerDeps):
|
||||
"""Optional direct-layer dependencies used by the shell runtime layer."""
|
||||
|
||||
execution_context: DifyExecutionContextLayer | None # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
class ShellctlClientProtocol(Protocol):
|
||||
"""Boundary that the shell layer needs from a shellctl client."""
|
||||
|
||||
@ -170,6 +179,7 @@ class ShellctlClientProtocol(Protocol):
|
||||
script: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
env: Mapping[str, str] | None = None,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> JobResult: ...
|
||||
|
||||
@ -266,7 +276,7 @@ class DifyShellRuntimeState(BaseModel):
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyShellLayer(PydanticAILayer[NoLayerDeps, object, DifyShellLayerConfig, DifyShellRuntimeState]):
|
||||
class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerConfig, DifyShellRuntimeState]):
|
||||
"""Shell tool layer backed by a live shellctl client while active.
|
||||
|
||||
The mutable serializable state lives in ``runtime_state``; the live client is
|
||||
@ -281,6 +291,8 @@ class DifyShellLayer(PydanticAILayer[NoLayerDeps, object, DifyShellLayerConfig,
|
||||
config: DifyShellLayerConfig
|
||||
shellctl_entrypoint: str
|
||||
shellctl_client_factory: ShellctlClientFactory
|
||||
agent_stub_url: str | None = None
|
||||
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None
|
||||
_shellctl_client: ShellctlClientProtocol | None = None
|
||||
|
||||
@classmethod
|
||||
@ -297,18 +309,24 @@ class DifyShellLayer(PydanticAILayer[NoLayerDeps, object, DifyShellLayerConfig,
|
||||
*,
|
||||
shellctl_entrypoint: str | None,
|
||||
shellctl_client_factory: ShellctlClientFactory,
|
||||
agent_stub_url: str | None = None,
|
||||
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None,
|
||||
) -> Self:
|
||||
"""Create the layer from public config plus server-only shellctl settings."""
|
||||
"""Create the layer from public config plus server-only shell settings."""
|
||||
normalized_entrypoint = (shellctl_entrypoint or "").strip()
|
||||
if not normalized_entrypoint:
|
||||
raise ValueError(
|
||||
"DifyShellLayer requires a non-empty DIFY_AGENT_SHELLCTL_ENTRYPOINT when the 'dify.shell' layer is used."
|
||||
)
|
||||
return cls(
|
||||
layer = cls(
|
||||
config=config,
|
||||
shellctl_entrypoint=normalized_entrypoint,
|
||||
shellctl_client_factory=shellctl_client_factory,
|
||||
agent_stub_url=agent_stub_url,
|
||||
agent_stub_token_factory=agent_stub_token_factory,
|
||||
)
|
||||
layer.bind_deps({})
|
||||
return layer
|
||||
|
||||
@property
|
||||
@override
|
||||
@ -434,7 +452,12 @@ class DifyShellLayer(PydanticAILayer[NoLayerDeps, object, DifyShellLayerConfig,
|
||||
"""Start a new shell job inside the session workspace."""
|
||||
try:
|
||||
client = self._require_client()
|
||||
result = await client.run(_wrap_user_script(script), cwd=self._require_workspace_cwd(), timeout=timeout)
|
||||
result = await client.run(
|
||||
_wrap_user_script(script),
|
||||
cwd=self._require_workspace_cwd(),
|
||||
env=self._build_user_shell_run_env(),
|
||||
timeout=timeout,
|
||||
)
|
||||
self._track_job_result(result)
|
||||
return _job_result_observation(result)
|
||||
except (RuntimeError, ValueError, ShellctlClientError) as exc:
|
||||
@ -530,7 +553,7 @@ class DifyShellLayer(PydanticAILayer[NoLayerDeps, object, DifyShellLayerConfig,
|
||||
) -> ShellJobObservation:
|
||||
"""Run an internal lifecycle command, track it, and wait for completion."""
|
||||
client = self._require_client()
|
||||
result = await client.run(script, cwd=cwd, timeout=DEFAULT_TIMEOUT_SECONDS)
|
||||
result = await client.run(script, cwd=cwd, env=None, timeout=DEFAULT_TIMEOUT_SECONDS)
|
||||
self._track_job_result(result)
|
||||
while not result.done:
|
||||
result = await client.wait(
|
||||
@ -637,6 +660,17 @@ class DifyShellLayer(PydanticAILayer[NoLayerDeps, object, DifyShellLayerConfig,
|
||||
self.runtime_state.job_offsets = {}
|
||||
self.runtime_state.job_ids = []
|
||||
|
||||
def _build_user_shell_run_env(self) -> dict[str, str] | None:
|
||||
"""Build per-command Agent Stub env only for user-visible ``shell.run``."""
|
||||
execution_context_layer = self.deps.execution_context
|
||||
execution_context = execution_context_layer.config if execution_context_layer is not None else None
|
||||
return build_shell_agent_stub_env(
|
||||
agent_stub_url=self.agent_stub_url,
|
||||
execution_context=execution_context,
|
||||
token_factory=self.agent_stub_token_factory,
|
||||
session_id=self.runtime_state.session_id,
|
||||
)
|
||||
|
||||
|
||||
def _shell_layer_prefix_prompt() -> str:
|
||||
"""Return the static model-facing shell tool usage guidance."""
|
||||
@ -727,6 +761,7 @@ def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str:
|
||||
|
||||
def _wrap_user_script(script: str) -> str:
|
||||
"""Source Agent Soul env before executing a model-requested shell command."""
|
||||
# TODO: refactor
|
||||
return "\n".join(
|
||||
[
|
||||
'if [ -f ".dify/env.sh" ]; then',
|
||||
@ -786,6 +821,7 @@ def _deduplicate_preserving_order(values: Sequence[str]) -> list[str]:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DifyShellLayerDeps",
|
||||
"DifyShellLayer",
|
||||
"DifyShellRuntimeState",
|
||||
"ShellctlClientFactory",
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
"""Public protocol exports shared by the Dify Agent server and clients."""
|
||||
"""Public run-protocol exports shared by the Dify Agent server and clients.
|
||||
|
||||
Stub-specific protocol DTOs live under ``dify_agent.agent_stub.protocol`` so the
|
||||
run API package boundary stays explicit.
|
||||
"""
|
||||
|
||||
from .schemas import (
|
||||
DIFY_AGENT_HISTORY_LAYER_ID,
|
||||
|
||||
@ -13,7 +13,8 @@ stateful Dify shell layer, and the Dify plugin business-layer family:
|
||||
Public DTOs provide Dify context plus plugin/model/tool data, while server-only
|
||||
plugin daemon settings are injected through the provider factory for
|
||||
``DifyExecutionContextLayer`` and the optional shellctl entrypoint/auth token plus
|
||||
client factory are injected for ``DifyShellLayer``. The resulting ``Compositor``
|
||||
client factory plus optional Agent Stub URL/token issuer are injected for
|
||||
``DifyShellLayer``. The resulting ``Compositor``
|
||||
remains Agenton state-only at the snapshot boundary: live resources such as
|
||||
HTTP clients are injected by runtime-owned providers, may be held on active
|
||||
layer instances inside ``resource_context()``, and never enter session
|
||||
@ -30,6 +31,8 @@ from agenton.layers.types import AllPromptTypes, AllToolTypes, AllUserPromptType
|
||||
from agenton_collections.layers.pydantic_ai import PydanticAIHistoryLayer
|
||||
from agenton_collections.layers.plain.basic import PromptLayer
|
||||
from agenton_collections.transformers.pydantic_ai import PYDANTIC_AI_TRANSFORMERS
|
||||
from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
|
||||
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
|
||||
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
|
||||
from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig
|
||||
@ -38,7 +41,6 @@ from dify_agent.layers.output.output_layer import DifyOutputLayer
|
||||
from dify_agent.layers.shell.configs import DifyShellLayerConfig
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer, create_shellctl_client_factory
|
||||
|
||||
|
||||
type DifyAgentLayerProvider = LayerProvider[Any]
|
||||
|
||||
|
||||
@ -48,6 +50,8 @@ def create_default_layer_providers(
|
||||
plugin_daemon_api_key: str = "",
|
||||
shellctl_entrypoint: str | None = None,
|
||||
shellctl_auth_token: str | None = None,
|
||||
agent_stub_url: str | None = None,
|
||||
agent_stub_token_codec: AgentStubTokenCodec | None = None,
|
||||
) -> tuple[DifyAgentLayerProvider, ...]:
|
||||
"""Return the server provider set of safe config-constructible layers.
|
||||
|
||||
@ -58,6 +62,20 @@ def create_default_layer_providers(
|
||||
setting explicitly.
|
||||
"""
|
||||
shellctl_token = shellctl_auth_token or ""
|
||||
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None
|
||||
if agent_stub_token_codec is not None:
|
||||
|
||||
def build_agent_stub_token(
|
||||
execution_context: DifyExecutionContextLayerConfig,
|
||||
*,
|
||||
session_id: str | None,
|
||||
) -> str:
|
||||
return agent_stub_token_codec.encode_connection_token(
|
||||
execution_context,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
agent_stub_token_factory = build_agent_stub_token
|
||||
return (
|
||||
LayerProvider.from_layer_type(PromptLayer),
|
||||
LayerProvider.from_layer_type(PydanticAIHistoryLayer),
|
||||
@ -76,6 +94,8 @@ def create_default_layer_providers(
|
||||
DifyShellLayerConfig.model_validate(config),
|
||||
shellctl_entrypoint=shellctl_entrypoint,
|
||||
shellctl_client_factory=create_shellctl_client_factory(token=shellctl_token),
|
||||
agent_stub_url=agent_stub_url,
|
||||
agent_stub_token_factory=agent_stub_token_factory,
|
||||
),
|
||||
),
|
||||
LayerProvider.from_layer_type(DifyPluginLLMLayer),
|
||||
|
||||
@ -7,7 +7,9 @@ cancel the agent runtime. Redis persists run records and per-run event streams
|
||||
with configured retention only; it is not used as a job queue. Agenton layers and
|
||||
providers stay state-only: they borrow the lifespan-owned plugin daemon client
|
||||
through the runner and receive shell-layer server settings through provider
|
||||
construction rather than reading environment variables themselves.
|
||||
construction rather than reading environment variables themselves. The standard
|
||||
server always mounts the HTTP Agent Stub router and additionally starts the
|
||||
optional grpclib Agent Stub server when ``DIFY_AGENT_STUB_URL`` uses ``grpc://``.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
@ -17,6 +19,9 @@ import httpx
|
||||
from fastapi import FastAPI
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import parse_agent_stub_endpoint
|
||||
from dify_agent.agent_stub.server.grpc_runtime import start_agent_stub_grpc_server
|
||||
from dify_agent.agent_stub.server.router import create_agent_stub_router
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
from dify_agent.runtime.run_scheduler import RunScheduler
|
||||
from dify_agent.server.routes.runs import create_runs_router
|
||||
@ -29,11 +34,15 @@ from dify_agent.storage.redis_run_store import RedisRunStore
|
||||
def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
"""Build the FastAPI app with one shared Redis store and local scheduler."""
|
||||
resolved_settings = settings or ServerSettings()
|
||||
agent_stub_token_codec = resolved_settings.create_agent_stub_token_codec()
|
||||
agent_stub_file_request_handler = resolved_settings.create_agent_stub_file_request_handler()
|
||||
layer_providers = create_default_layer_providers(
|
||||
plugin_daemon_url=resolved_settings.plugin_daemon_url,
|
||||
plugin_daemon_api_key=resolved_settings.plugin_daemon_api_key,
|
||||
shellctl_entrypoint=resolved_settings.shellctl_entrypoint,
|
||||
shellctl_auth_token=resolved_settings.shellctl_auth_token,
|
||||
agent_stub_url=resolved_settings.agent_stub_url,
|
||||
agent_stub_token_codec=agent_stub_token_codec,
|
||||
)
|
||||
workspace_file_service = (
|
||||
WorkspaceFileService(
|
||||
@ -60,11 +69,24 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
shutdown_grace_seconds=resolved_settings.shutdown_grace_seconds,
|
||||
layer_providers=layer_providers,
|
||||
)
|
||||
grpc_server = None
|
||||
if (
|
||||
resolved_settings.agent_stub_url is not None
|
||||
and parse_agent_stub_endpoint(resolved_settings.agent_stub_url).is_grpc
|
||||
):
|
||||
grpc_server = await start_agent_stub_grpc_server(
|
||||
public_url=resolved_settings.agent_stub_url,
|
||||
bind_address=resolved_settings.agent_stub_grpc_bind_address,
|
||||
token_codec=agent_stub_token_codec,
|
||||
file_request_handler=agent_stub_file_request_handler,
|
||||
)
|
||||
state["store"] = store
|
||||
state["scheduler"] = scheduler
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if grpc_server is not None:
|
||||
await grpc_server.aclose()
|
||||
await scheduler.shutdown()
|
||||
await plugin_daemon_http_client.aclose()
|
||||
await redis.aclose()
|
||||
@ -78,7 +100,14 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
return state["scheduler"] # pyright: ignore[reportReturnType]
|
||||
|
||||
app.include_router(create_runs_router(get_store, get_scheduler))
|
||||
# TODO: refactor
|
||||
app.include_router(create_workspace_files_router(lambda: workspace_file_service))
|
||||
app.include_router(
|
||||
create_agent_stub_router(
|
||||
token_codec=agent_stub_token_codec,
|
||||
file_request_handler=agent_stub_file_request_handler,
|
||||
)
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@ -3,16 +3,22 @@
|
||||
Plugin daemon HTTP client settings describe the single FastAPI lifespan-owned
|
||||
``httpx.AsyncClient`` shared by local run tasks. Layers and Agenton providers do
|
||||
not own that client, so these settings are process resource limits rather than
|
||||
per-run lifecycle knobs. Optional shell-layer settings stay here as well because
|
||||
the server injects them into layer providers instead of letting runtime modules
|
||||
read process environment variables directly.
|
||||
per-run lifecycle knobs. The Agent Stub now also uses this main server settings
|
||||
model directly: the public Agent Stub URL, server secret, optional gRPC bind
|
||||
override, and optional Dify inner API file-request settings all live here under
|
||||
the longstanding ``DIFY_AGENT_...`` environment-variable namespace.
|
||||
"""
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import AnyHttpUrl, Field, TypeAdapter, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import normalize_agent_stub_url, parse_agent_stub_endpoint
|
||||
from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler
|
||||
from dify_agent.agent_stub.server.grpc_bind import normalize_agent_stub_grpc_bind_address
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec, decode_server_secret_key
|
||||
|
||||
DEFAULT_RUN_RETENTION_SECONDS = 3 * 24 * 60 * 60
|
||||
|
||||
|
||||
@ -25,8 +31,13 @@ class ServerSettings(BaseSettings):
|
||||
run_retention_seconds: int = Field(default=DEFAULT_RUN_RETENTION_SECONDS, ge=1)
|
||||
plugin_daemon_url: str = "http://localhost:5002"
|
||||
plugin_daemon_api_key: str = ""
|
||||
dify_api_base_url: str | None = None
|
||||
dify_api_inner_api_key: str | None = None
|
||||
shellctl_entrypoint: str | None = None
|
||||
shellctl_auth_token: str | None = None
|
||||
agent_stub_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_URL")
|
||||
agent_stub_grpc_bind_address: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_GRPC_BIND_ADDRESS")
|
||||
server_secret_key: str | None = None
|
||||
plugin_daemon_connect_timeout: float = Field(default=10.0, ge=0)
|
||||
plugin_daemon_read_timeout: float = Field(default=600.0, ge=0)
|
||||
plugin_daemon_write_timeout: float = Field(default=30.0, ge=0)
|
||||
@ -39,7 +50,98 @@ class ServerSettings(BaseSettings):
|
||||
env_prefix="DIFY_AGENT_",
|
||||
env_file=(".env", "dify-agent/.env"),
|
||||
extra="ignore",
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@field_validator("agent_stub_url")
|
||||
@classmethod
|
||||
def normalize_agent_stub_url_value(cls, value: str | None) -> str | None:
|
||||
"""Normalize the public Agent Stub URL while still validating its scheme."""
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
if stripped.startswith(("http://", "https://")):
|
||||
validated = str(TypeAdapter(AnyHttpUrl).validate_python(stripped))
|
||||
return normalize_agent_stub_url(validated)
|
||||
return normalize_agent_stub_url(stripped)
|
||||
|
||||
@field_validator("agent_stub_grpc_bind_address")
|
||||
@classmethod
|
||||
def normalize_agent_stub_grpc_bind_address_value(cls, value: str | None) -> str | None:
|
||||
"""Normalize the optional explicit Agent Stub gRPC bind override."""
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
return normalize_agent_stub_grpc_bind_address(stripped)
|
||||
|
||||
@field_validator("server_secret_key")
|
||||
@classmethod
|
||||
def validate_server_secret_key(cls, value: str | None) -> str | None:
|
||||
"""Validate the configured base64url-encoded server root secret."""
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
_ = decode_server_secret_key(stripped)
|
||||
return stripped
|
||||
|
||||
@field_validator("dify_api_base_url")
|
||||
@classmethod
|
||||
def normalize_dify_api_base_url(cls, value: str | None) -> str | None:
|
||||
"""Normalize the trusted Dify API base URL used for file request calls."""
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
validated = str(TypeAdapter(AnyHttpUrl).validate_python(stripped))
|
||||
parsed = validated.rstrip("/")
|
||||
if "?" in parsed or "#" in parsed:
|
||||
raise ValueError("DIFY_AGENT_DIFY_API_BASE_URL must not include a query string or fragment")
|
||||
return parsed
|
||||
|
||||
@field_validator("dify_api_inner_api_key")
|
||||
@classmethod
|
||||
def normalize_dify_api_inner_api_key(cls, value: str | None) -> str | None:
|
||||
"""Normalize the optional trusted Dify inner API key."""
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_agent_stub_requirements(self) -> "ServerSettings":
|
||||
"""Require the server secret and Dify API file settings in valid pairs."""
|
||||
if self.agent_stub_url is not None and self.server_secret_key is None:
|
||||
raise ValueError("DIFY_AGENT_SERVER_SECRET_KEY is required when DIFY_AGENT_STUB_URL is set.")
|
||||
if self.agent_stub_grpc_bind_address is not None:
|
||||
if self.agent_stub_url is None:
|
||||
raise ValueError("DIFY_AGENT_STUB_URL is required when DIFY_AGENT_STUB_GRPC_BIND_ADDRESS is set.")
|
||||
if not parse_agent_stub_endpoint(self.agent_stub_url).is_grpc:
|
||||
raise ValueError("DIFY_AGENT_STUB_GRPC_BIND_ADDRESS requires a grpc:// DIFY_AGENT_STUB_URL.")
|
||||
if (self.dify_api_base_url is None) != (self.dify_api_inner_api_key is None):
|
||||
raise ValueError("DIFY_AGENT_DIFY_API_BASE_URL and DIFY_AGENT_DIFY_API_INNER_API_KEY must be set together.")
|
||||
return self
|
||||
|
||||
def create_agent_stub_token_codec(self) -> AgentStubTokenCodec | None:
|
||||
"""Return the Agent Stub token codec when the server secret is configured."""
|
||||
if self.server_secret_key is None:
|
||||
return None
|
||||
return AgentStubTokenCodec.from_server_secret(self.server_secret_key)
|
||||
|
||||
def create_agent_stub_file_request_handler(self) -> DifyApiAgentStubFileRequestHandler | None:
|
||||
"""Return the Dify API file bridge when both Dify API settings are configured."""
|
||||
if self.dify_api_base_url is None or self.dify_api_inner_api_key is None:
|
||||
return None
|
||||
return DifyApiAgentStubFileRequestHandler(
|
||||
dify_api_base_url=self.dify_api_base_url,
|
||||
dify_api_inner_api_key=self.dify_api_inner_api_key,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_RUN_RETENTION_SECONDS", "ServerSettings"]
|
||||
|
||||
155
dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py
Normal file
155
dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py
Normal file
@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from dify_agent.agent_stub.cli._files import download_file_from_environment, upload_file_from_environment
|
||||
from dify_agent.agent_stub.client._errors import AgentStubTransferError
|
||||
|
||||
|
||||
def _reference(record_id: str) -> str:
|
||||
payload = base64.urlsafe_b64encode(json.dumps({"record_id": record_id}, separators=(",", ":")).encode()).decode()
|
||||
return f"dify-file-ref:{payload}"
|
||||
|
||||
|
||||
def test_upload_file_from_environment_requests_signed_url_and_normalizes_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source = tmp_path / "report.pdf"
|
||||
source.write_bytes(b"report-bytes")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._files.request_agent_stub_file_upload_sync",
|
||||
lambda **_kwargs: type("Response", (), {"upload_url": "https://files.example.com/upload"})(),
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def fake_upload_file_to_signed_url_sync(**kwargs):
|
||||
captured["filename"] = kwargs["filename"]
|
||||
captured["mimetype"] = kwargs["mimetype"]
|
||||
captured["file_bytes"] = kwargs["file_obj"].read()
|
||||
kwargs["file_obj"].seek(0)
|
||||
return {
|
||||
"reference": _reference("tool-file-1"),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._files.upload_file_to_signed_url_sync",
|
||||
fake_upload_file_to_signed_url_sync,
|
||||
)
|
||||
|
||||
result = upload_file_from_environment(path=str(source))
|
||||
|
||||
assert result.model_dump() == {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": _reference("tool-file-1"),
|
||||
}
|
||||
assert captured == {
|
||||
"filename": "report.pdf",
|
||||
"mimetype": "application/pdf",
|
||||
"file_bytes": b"report-bytes",
|
||||
}
|
||||
|
||||
|
||||
def test_download_file_from_environment_saves_bytes_and_renames_on_collision(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target_dir = tmp_path / "downloads"
|
||||
target_dir.mkdir()
|
||||
(target_dir / "report.pdf").write_bytes(b"existing")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._files.request_agent_stub_file_download_sync",
|
||||
lambda **_kwargs: type(
|
||||
"Response",
|
||||
(),
|
||||
{
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 12,
|
||||
"download_url": "https://files.example.com/download",
|
||||
},
|
||||
)(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._files.download_file_bytes_from_signed_url_sync",
|
||||
lambda **_kwargs: b"downloaded",
|
||||
)
|
||||
|
||||
result = download_file_from_environment(
|
||||
transfer_method="tool_file",
|
||||
reference_or_url=_reference("tool-file-1"),
|
||||
directory=str(target_dir),
|
||||
)
|
||||
|
||||
assert result.path.name == "report (1).pdf"
|
||||
assert result.path.read_bytes() == b"downloaded"
|
||||
|
||||
|
||||
def test_download_file_from_environment_sanitizes_server_filename(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target_dir = tmp_path / "downloads"
|
||||
target_dir.mkdir()
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._files.request_agent_stub_file_download_sync",
|
||||
lambda **_kwargs: type(
|
||||
"Response",
|
||||
(),
|
||||
{
|
||||
"filename": "../nested/evil.txt",
|
||||
"mime_type": "text/plain",
|
||||
"size": 12,
|
||||
"download_url": "https://files.example.com/download",
|
||||
},
|
||||
)(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._files.download_file_bytes_from_signed_url_sync",
|
||||
lambda **_kwargs: b"downloaded",
|
||||
)
|
||||
|
||||
result = download_file_from_environment(
|
||||
transfer_method="tool_file",
|
||||
reference_or_url=_reference("tool-file-1"),
|
||||
directory=str(target_dir),
|
||||
)
|
||||
|
||||
assert result.path.parent == target_dir
|
||||
assert result.path.name == "evil.txt"
|
||||
assert result.path.read_bytes() == b"downloaded"
|
||||
|
||||
|
||||
def test_upload_file_from_environment_rejects_non_canonical_reference(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source = tmp_path / "report.pdf"
|
||||
source.write_bytes(b"report-bytes")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._files.request_agent_stub_file_upload_sync",
|
||||
lambda **_kwargs: type("Response", (), {"upload_url": "https://files.example.com/upload"})(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._files.upload_file_to_signed_url_sync",
|
||||
lambda **_kwargs: {"reference": "raw-tool-file-uuid"},
|
||||
)
|
||||
|
||||
with pytest.raises(AgentStubTransferError, match="invalid canonical reference"):
|
||||
_ = upload_file_from_environment(path=str(source))
|
||||
196
dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py
Normal file
196
dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py
Normal file
@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from dify_agent.agent_stub.cli.main import main
|
||||
from dify_agent.agent_stub.protocol.agent_stub import AgentStubConnectResponse
|
||||
|
||||
|
||||
def _reference(record_id: str) -> str:
|
||||
payload = base64.urlsafe_b64encode(json.dumps({"record_id": record_id}, separators=(",", ":")).encode()).decode()
|
||||
return f"dify-file-ref:{payload}"
|
||||
|
||||
|
||||
def test_cli_connect_reports_missing_environment_variables(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["connect"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exc_info.value.code == 2
|
||||
assert "DIFY_AGENT_STUB_URL" in captured.err
|
||||
assert "DIFY_AGENT_STUB_AUTH_JWE" in captured.err
|
||||
|
||||
|
||||
def test_cli_connect_supports_json_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
def fake_connect_from_environment(*, argv: list[str]) -> AgentStubConnectResponse:
|
||||
assert argv == ["echo", "hello"]
|
||||
return AgentStubConnectResponse(connection_id="conn-1", status="connected")
|
||||
|
||||
monkeypatch.setattr("dify_agent.agent_stub.cli.main.connect_from_environment", fake_connect_from_environment)
|
||||
|
||||
main(["connect", "--json", "--", "echo", "hello"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert json.loads(captured.out) == {"connection_id": "conn-1", "status": "connected"}
|
||||
|
||||
|
||||
def test_cli_unknown_command_auto_forwards_when_agent_stub_env_is_present(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
def fake_connect_from_environment(*, argv: list[str]) -> AgentStubConnectResponse:
|
||||
assert argv == ["run", "--target", "prod"]
|
||||
return AgentStubConnectResponse(connection_id="conn-1", status="connected")
|
||||
|
||||
monkeypatch.setattr("dify_agent.agent_stub.cli.main.connect_from_environment", fake_connect_from_environment)
|
||||
|
||||
main(["run", "--target", "prod"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out.strip() == "connected conn-1"
|
||||
|
||||
|
||||
def test_cli_unknown_command_reports_missing_environment_variables(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["run", "--target", "prod"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exc_info.value.code == 2
|
||||
assert "Usage: dify-agent" in captured.out
|
||||
assert "connect" in captured.out
|
||||
assert "DIFY_AGENT_STUB_URL" in captured.err
|
||||
assert "DIFY_AGENT_STUB_AUTH_JWE" in captured.err
|
||||
|
||||
|
||||
def test_cli_connect_help_routes_to_typer_help(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["connect", "--help"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exc_info.value.code == 0
|
||||
assert "Establish one Agent Stub connection" in captured.out
|
||||
assert "--json" in captured.out
|
||||
|
||||
|
||||
def test_cli_reports_invalid_agent_stub_url_environment_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub?x=1")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["connect"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exc_info.value.code == 2
|
||||
assert "invalid DIFY_AGENT_STUB_URL" in captured.err
|
||||
assert "query string or fragment" in captured.err
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("invalid_url", "expected_message"),
|
||||
[
|
||||
("not-a-url", "http, https, or grpc"),
|
||||
("ftp://agent.example.com/agent-stub", "http, https, or grpc"),
|
||||
("https:///agent-stub", "include a host"),
|
||||
("grpc://agent.example.com", "explicit port"),
|
||||
],
|
||||
)
|
||||
def test_cli_reports_structurally_invalid_agent_stub_url_environment_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
invalid_url: str,
|
||||
expected_message: str,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", invalid_url)
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["connect"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exc_info.value.code == 2
|
||||
assert "invalid DIFY_AGENT_STUB_URL" in captured.err
|
||||
assert expected_message in captured.err
|
||||
|
||||
|
||||
def test_cli_connect_accepts_grpc_agent_stub_url(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "grpc://agent.example.com:9091")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
|
||||
def fake_connect_from_environment(*, argv: list[str]) -> AgentStubConnectResponse:
|
||||
assert argv == ["echo", "hello"]
|
||||
return AgentStubConnectResponse(connection_id="conn-1", status="connected")
|
||||
|
||||
monkeypatch.setattr("dify_agent.agent_stub.cli.main.connect_from_environment", fake_connect_from_environment)
|
||||
|
||||
main(["connect", "echo", "hello"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out.strip() == "connected conn-1"
|
||||
|
||||
|
||||
def test_cli_file_upload_prints_uploaded_tool_file_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.upload_file_from_environment",
|
||||
lambda *, path: type(
|
||||
"Response",
|
||||
(),
|
||||
{
|
||||
"model_dump_json": lambda self: json.dumps(
|
||||
{
|
||||
"transfer_method": "tool_file",
|
||||
"reference": _reference(Path(path).name),
|
||||
}
|
||||
)
|
||||
},
|
||||
)(),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["file", "upload", "/tmp/report.pdf"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exc_info.value.code == 0
|
||||
assert json.loads(captured.out) == {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": _reference("report.pdf"),
|
||||
}
|
||||
|
||||
|
||||
def test_cli_file_download_prints_saved_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli.main.download_file_from_environment",
|
||||
lambda **_kwargs: type("Response", (), {"path": Path("/tmp/report.pdf")})(),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["file", "download", "tool_file", _reference("tool-file-1"), "/tmp"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exc_info.value.code == 0
|
||||
assert captured.out.strip() == "/tmp/report.pdf"
|
||||
@ -0,0 +1,458 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import builtins
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from dify_agent.agent_stub.client._agent_stub import (
|
||||
connect_agent_stub_sync,
|
||||
download_file_bytes_from_signed_url_sync,
|
||||
request_agent_stub_file_download_sync,
|
||||
request_agent_stub_file_upload_sync,
|
||||
upload_file_to_signed_url_sync,
|
||||
)
|
||||
from dify_agent.agent_stub.client._errors import (
|
||||
AgentStubClientError,
|
||||
AgentStubGRPCError,
|
||||
AgentStubHTTPError,
|
||||
AgentStubMissingGRPCDependencyError,
|
||||
AgentStubTransferError,
|
||||
AgentStubValidationError,
|
||||
)
|
||||
from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileMapping
|
||||
|
||||
|
||||
def _reference(record_id: str) -> str:
|
||||
payload = base64.urlsafe_b64encode(json.dumps({"record_id": record_id}, separators=(",", ":")).encode()).decode()
|
||||
return f"dify-file-ref:{payload}"
|
||||
|
||||
|
||||
def test_connect_agent_stub_sync_posts_connections_request_with_authorization() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert str(request.url) == "https://agent.example.com/agent-stub/connections"
|
||||
assert request.headers["Authorization"] == "Bearer test-jwe"
|
||||
assert json.loads(request.content) == {
|
||||
"protocol_version": 1,
|
||||
"argv": ["connect", "--", "echo", "hello"],
|
||||
"metadata": {},
|
||||
}
|
||||
return httpx.Response(200, json={"connection_id": "conn-1", "status": "connected"})
|
||||
|
||||
http_client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
response = connect_agent_stub_sync(
|
||||
url="https://agent.example.com/agent-stub/",
|
||||
auth_jwe="test-jwe",
|
||||
argv=["connect", "--", "echo", "hello"],
|
||||
sync_http_client=http_client,
|
||||
)
|
||||
finally:
|
||||
http_client.close()
|
||||
|
||||
assert response.connection_id == "conn-1"
|
||||
assert response.status == "connected"
|
||||
|
||||
|
||||
def test_connect_agent_stub_sync_rejects_invalid_base_url() -> None:
|
||||
with pytest.raises(AgentStubValidationError, match="invalid DIFY_AGENT_STUB_URL|invalid Agent Stub base URL"):
|
||||
_ = connect_agent_stub_sync(
|
||||
url="https://agent.example.com/agent-stub?x=1",
|
||||
auth_jwe="test-jwe",
|
||||
argv=["connect"],
|
||||
)
|
||||
|
||||
|
||||
def test_connect_agent_stub_sync_maps_non_2xx_response_to_http_error() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(401, json={"detail": "invalid token"})
|
||||
|
||||
http_client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(AgentStubHTTPError, match="401") as exc_info:
|
||||
_ = connect_agent_stub_sync(
|
||||
url="https://agent.example.com/agent-stub",
|
||||
auth_jwe="test-jwe",
|
||||
argv=["connect"],
|
||||
sync_http_client=http_client,
|
||||
)
|
||||
finally:
|
||||
http_client.close()
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert exc_info.value.detail == "invalid token"
|
||||
|
||||
|
||||
def test_connect_agent_stub_sync_maps_malformed_json_response_to_client_error() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, text="not-json", headers={"Content-Type": "application/json"})
|
||||
|
||||
http_client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(AgentStubClientError, match="invalid JSON"):
|
||||
_ = connect_agent_stub_sync(
|
||||
url="https://agent.example.com/agent-stub",
|
||||
auth_jwe="test-jwe",
|
||||
argv=["connect"],
|
||||
sync_http_client=http_client,
|
||||
)
|
||||
finally:
|
||||
http_client.close()
|
||||
|
||||
|
||||
def test_connect_agent_stub_sync_maps_schema_invalid_success_payload_to_validation_error() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"connection_id": 123, "status": "unexpected"})
|
||||
|
||||
http_client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(AgentStubValidationError, match="invalid Agent Stub connection response"):
|
||||
_ = connect_agent_stub_sync(
|
||||
url="https://agent.example.com/agent-stub",
|
||||
auth_jwe="test-jwe",
|
||||
argv=["connect"],
|
||||
sync_http_client=http_client,
|
||||
)
|
||||
finally:
|
||||
http_client.close()
|
||||
|
||||
|
||||
def test_request_agent_stub_file_upload_sync_posts_upload_request() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert str(request.url) == "https://agent.example.com/agent-stub/files/upload-request"
|
||||
assert json.loads(request.content) == {"filename": "report.pdf", "mimetype": "application/pdf"}
|
||||
return httpx.Response(200, json={"upload_url": "https://files.example.com/upload"})
|
||||
|
||||
http_client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
response = request_agent_stub_file_upload_sync(
|
||||
url="https://agent.example.com/agent-stub",
|
||||
auth_jwe="test-jwe",
|
||||
filename="report.pdf",
|
||||
mimetype="application/pdf",
|
||||
sync_http_client=http_client,
|
||||
)
|
||||
finally:
|
||||
http_client.close()
|
||||
|
||||
assert response.upload_url == "https://files.example.com/upload"
|
||||
|
||||
|
||||
def test_request_agent_stub_file_download_sync_posts_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")}
|
||||
}
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 123,
|
||||
"download_url": "https://files.example.com/download",
|
||||
},
|
||||
)
|
||||
|
||||
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")),
|
||||
sync_http_client=http_client,
|
||||
)
|
||||
finally:
|
||||
http_client.close()
|
||||
|
||||
assert response.download_url == "https://files.example.com/download"
|
||||
|
||||
|
||||
def test_upload_file_to_signed_url_sync_posts_multipart_file() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert str(request.url) == "https://files.example.com/upload"
|
||||
assert b"report.pdf" in request.content
|
||||
return httpx.Response(201, json={"id": "tool-file-1", "name": "report.pdf", "size": 9})
|
||||
|
||||
http_client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
from io import BytesIO
|
||||
|
||||
payload = upload_file_to_signed_url_sync(
|
||||
upload_url="https://files.example.com/upload",
|
||||
filename="report.pdf",
|
||||
file_obj=BytesIO(b"contents!"),
|
||||
mimetype="application/pdf",
|
||||
sync_http_client=http_client,
|
||||
)
|
||||
finally:
|
||||
http_client.close()
|
||||
|
||||
assert payload["id"] == "tool-file-1"
|
||||
|
||||
|
||||
def test_download_file_bytes_from_signed_url_sync_returns_bytes() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, content=b"payload")
|
||||
|
||||
http_client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
payload = download_file_bytes_from_signed_url_sync(
|
||||
download_url="https://files.example.com/download",
|
||||
sync_http_client=http_client,
|
||||
)
|
||||
finally:
|
||||
http_client.close()
|
||||
|
||||
assert payload == b"payload"
|
||||
|
||||
|
||||
def test_download_file_bytes_from_signed_url_sync_maps_timeout_to_transfer_error() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ReadTimeout("boom")
|
||||
|
||||
http_client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(AgentStubTransferError, match="download timed out"):
|
||||
_ = download_file_bytes_from_signed_url_sync(
|
||||
download_url="https://files.example.com/download",
|
||||
sync_http_client=http_client,
|
||||
)
|
||||
finally:
|
||||
http_client.close()
|
||||
|
||||
|
||||
def test_connect_agent_stub_sync_dispatches_grpc_urls(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.client._agent_stub_grpc.connect_agent_stub_grpc_sync",
|
||||
lambda **kwargs: (
|
||||
captured.update(kwargs) or type("Response", (), {"connection_id": "conn-1", "status": "connected"})()
|
||||
),
|
||||
)
|
||||
|
||||
response = connect_agent_stub_sync(url="grpc://agent.example.com:9091", auth_jwe="token", argv=["connect"])
|
||||
|
||||
assert captured["url"] == "grpc://agent.example.com:9091"
|
||||
assert response.connection_id == "conn-1"
|
||||
|
||||
|
||||
def test_connect_agent_stub_sync_reports_missing_grpc_dependencies(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
original_import = builtins.__import__
|
||||
|
||||
def fake_import(name: str, globals=None, locals=None, fromlist=(), level: int = 0):
|
||||
if name in {"grpclib.client", "grpclib.exceptions"}:
|
||||
raise ImportError("grpclib is not installed")
|
||||
return original_import(name, globals, locals, fromlist, level)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
|
||||
with pytest.raises(AgentStubMissingGRPCDependencyError, match=r"optional dify-agent\[grpc\] dependencies"):
|
||||
_ = connect_agent_stub_sync(url="grpc://agent.example.com:9091", auth_jwe="token", argv=["connect"])
|
||||
|
||||
|
||||
def test_request_agent_stub_file_upload_sync_dispatches_grpc_urls(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.client._agent_stub_grpc.request_agent_stub_file_upload_grpc_sync",
|
||||
lambda **kwargs: (
|
||||
captured.update(kwargs) or type("Response", (), {"upload_url": "https://files.example.com/upload"})()
|
||||
),
|
||||
)
|
||||
|
||||
response = request_agent_stub_file_upload_sync(
|
||||
url="grpc://agent.example.com:9091",
|
||||
auth_jwe="token",
|
||||
filename="report.pdf",
|
||||
mimetype="application/pdf",
|
||||
)
|
||||
|
||||
assert captured == {
|
||||
"url": "grpc://agent.example.com:9091",
|
||||
"auth_jwe": "token",
|
||||
"filename": "report.pdf",
|
||||
"mimetype": "application/pdf",
|
||||
"timeout": 30.0,
|
||||
}
|
||||
assert response.upload_url == "https://files.example.com/upload"
|
||||
|
||||
|
||||
def test_request_agent_stub_file_download_sync_dispatches_grpc_urls(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
file_mapping = AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1"))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.client._agent_stub_grpc.request_agent_stub_file_download_grpc_sync",
|
||||
lambda **kwargs: (
|
||||
captured.update(kwargs)
|
||||
or type(
|
||||
"Response",
|
||||
(),
|
||||
{
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 123,
|
||||
"download_url": "https://files.example.com/download",
|
||||
},
|
||||
)()
|
||||
),
|
||||
)
|
||||
|
||||
response = request_agent_stub_file_download_sync(
|
||||
url="grpc://agent.example.com:9091",
|
||||
auth_jwe="token",
|
||||
file=file_mapping,
|
||||
)
|
||||
|
||||
assert captured == {
|
||||
"url": "grpc://agent.example.com:9091",
|
||||
"auth_jwe": "token",
|
||||
"file": file_mapping,
|
||||
"timeout": 30.0,
|
||||
}
|
||||
assert response.download_url == "https://files.example.com/download"
|
||||
|
||||
|
||||
def test_request_agent_stub_file_upload_grpc_sync_attaches_bearer_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import dify_agent.agent_stub.client._agent_stub_grpc as grpc_module
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeChannel:
|
||||
def __init__(self, *, host: str, port: int, ssl: bool) -> None:
|
||||
captured.update(host=host, port=port, ssl=ssl)
|
||||
|
||||
def close(self) -> None:
|
||||
captured["closed"] = True
|
||||
|
||||
class FakeMethod:
|
||||
async def __call__(self, request, *, metadata, timeout):
|
||||
captured.update(request=request, metadata=metadata, timeout=timeout)
|
||||
return {"upload_url": "https://files.example.com/upload"}
|
||||
|
||||
class FakeStub:
|
||||
def __init__(self, channel) -> None:
|
||||
captured["channel"] = channel
|
||||
self.CreateFileUploadRequest = FakeMethod()
|
||||
|
||||
class FakeGRPCError(Exception):
|
||||
def __init__(self, status, message: str) -> None:
|
||||
self.status = status
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
class FakeStreamTerminatedError(Exception):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
grpc_module,
|
||||
"_require_runtime",
|
||||
lambda: SimpleNamespace(
|
||||
Channel=FakeChannel,
|
||||
AgentStubServiceStub=FakeStub,
|
||||
agent_stub_pb2=object(),
|
||||
GRPCError=FakeGRPCError,
|
||||
StreamTerminatedError=FakeStreamTerminatedError,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
grpc_module,
|
||||
"_require_conversions",
|
||||
lambda: SimpleNamespace(
|
||||
proto_file_upload_request=lambda _pb2, *, filename, mimetype: {
|
||||
"filename": filename,
|
||||
"mimetype": mimetype,
|
||||
},
|
||||
file_upload_response_from_proto=lambda response: type(
|
||||
"Response",
|
||||
(),
|
||||
{"upload_url": response["upload_url"]},
|
||||
)(),
|
||||
),
|
||||
)
|
||||
|
||||
response = grpc_module.request_agent_stub_file_upload_grpc_sync(
|
||||
url="grpc://agent.example.com:9091",
|
||||
auth_jwe="test-jwe",
|
||||
filename="report.pdf",
|
||||
mimetype="application/pdf",
|
||||
timeout=12.0,
|
||||
)
|
||||
|
||||
assert captured["host"] == "agent.example.com"
|
||||
assert captured["port"] == 9091
|
||||
assert captured["ssl"] is False
|
||||
assert captured["request"] == {"filename": "report.pdf", "mimetype": "application/pdf"}
|
||||
assert captured["metadata"] == (("authorization", "Bearer test-jwe"),)
|
||||
assert captured["timeout"] == 12.0
|
||||
assert captured["closed"] is True
|
||||
assert response.upload_url == "https://files.example.com/upload"
|
||||
|
||||
|
||||
def test_request_agent_stub_file_download_grpc_sync_maps_grpc_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import dify_agent.agent_stub.client._agent_stub_grpc as grpc_module
|
||||
|
||||
class FakeChannel:
|
||||
def __init__(self, *, host: str, port: int, ssl: bool) -> None:
|
||||
del host, port, ssl
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
class FakeMethod:
|
||||
async def __call__(self, request, *, metadata, timeout):
|
||||
del request, metadata, timeout
|
||||
raise FakeGRPCError(SimpleNamespace(name="RESOURCE_EXHAUSTED"), "rate limited")
|
||||
|
||||
class FakeStub:
|
||||
def __init__(self, channel) -> None:
|
||||
del channel
|
||||
self.CreateFileDownloadRequest = FakeMethod()
|
||||
|
||||
class FakeGRPCError(Exception):
|
||||
def __init__(self, status, message: str) -> None:
|
||||
self.status = status
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
class FakeStreamTerminatedError(Exception):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
grpc_module,
|
||||
"_require_runtime",
|
||||
lambda: SimpleNamespace(
|
||||
Channel=FakeChannel,
|
||||
AgentStubServiceStub=FakeStub,
|
||||
agent_stub_pb2=object(),
|
||||
GRPCError=FakeGRPCError,
|
||||
StreamTerminatedError=FakeStreamTerminatedError,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
grpc_module,
|
||||
"_require_conversions",
|
||||
lambda: SimpleNamespace(
|
||||
proto_file_download_request=lambda _pb2, *, file: {"file": file},
|
||||
file_download_response_from_proto=lambda response: response,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(AgentStubGRPCError, match="RESOURCE_EXHAUSTED") as exc_info:
|
||||
_ = grpc_module.request_agent_stub_file_download_grpc_sync(
|
||||
url="grpc://agent.example.com:9091",
|
||||
auth_jwe="test-jwe",
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1")),
|
||||
)
|
||||
|
||||
assert exc_info.value.status == "RESOURCE_EXHAUSTED"
|
||||
assert exc_info.value.detail == "rate limited"
|
||||
@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubFileMapping,
|
||||
agent_stub_connections_url,
|
||||
agent_stub_file_download_request_url,
|
||||
agent_stub_file_upload_request_url,
|
||||
normalize_agent_stub_url,
|
||||
parse_agent_stub_endpoint,
|
||||
)
|
||||
|
||||
|
||||
def _reference(record_id: str) -> str:
|
||||
payload = base64.urlsafe_b64encode(json.dumps({"record_id": record_id}, separators=(",", ":")).encode()).decode()
|
||||
return f"dify-file-ref:{payload}"
|
||||
|
||||
|
||||
def test_agent_stub_connections_url_handles_trailing_slash_and_no_trailing_slash() -> None:
|
||||
assert agent_stub_connections_url("https://agent.example.com/agent-stub") == (
|
||||
"https://agent.example.com/agent-stub/connections"
|
||||
)
|
||||
assert agent_stub_connections_url("https://agent.example.com/agent-stub/") == (
|
||||
"https://agent.example.com/agent-stub/connections"
|
||||
)
|
||||
|
||||
|
||||
def test_agent_stub_file_request_urls_handle_trailing_slash() -> None:
|
||||
assert agent_stub_file_upload_request_url("https://agent.example.com/agent-stub/") == (
|
||||
"https://agent.example.com/agent-stub/files/upload-request"
|
||||
)
|
||||
assert agent_stub_file_download_request_url("https://agent.example.com/agent-stub") == (
|
||||
"https://agent.example.com/agent-stub/files/download-request"
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_agent_stub_url_rejects_query_and_fragment() -> None:
|
||||
with pytest.raises(ValueError, match="query string or fragment"):
|
||||
_ = normalize_agent_stub_url("https://agent.example.com/agent-stub?x=1")
|
||||
|
||||
with pytest.raises(ValueError, match="query string or fragment"):
|
||||
_ = normalize_agent_stub_url("https://agent.example.com/agent-stub#fragment")
|
||||
|
||||
|
||||
def test_parse_agent_stub_endpoint_rejects_invalid_schemes_and_missing_host() -> None:
|
||||
with pytest.raises(ValueError, match="http, https, or grpc"):
|
||||
_ = normalize_agent_stub_url("not-a-url")
|
||||
|
||||
with pytest.raises(ValueError, match="http, https, or grpc"):
|
||||
_ = normalize_agent_stub_url("ftp://agent.example.com/agent-stub")
|
||||
|
||||
with pytest.raises(ValueError, match="include a host"):
|
||||
_ = normalize_agent_stub_url("https:///agent-stub")
|
||||
|
||||
|
||||
def test_parse_agent_stub_endpoint_accepts_grpc_host_and_port() -> None:
|
||||
endpoint = parse_agent_stub_endpoint("grpc://agent.example.com:9091")
|
||||
|
||||
assert endpoint.url == "grpc://agent.example.com:9091"
|
||||
assert endpoint.is_grpc is True
|
||||
assert endpoint.host == "agent.example.com"
|
||||
assert endpoint.port == 9091
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_url", ["grpc://agent.example.com", "grpc://agent.example.com:9091/path"])
|
||||
def test_parse_agent_stub_endpoint_rejects_invalid_grpc_urls(invalid_url: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_ = parse_agent_stub_endpoint(invalid_url)
|
||||
|
||||
|
||||
def test_agent_stub_file_mapping_validates_reference_and_url_by_transfer_method() -> None:
|
||||
reference = _reference("tool-file-1")
|
||||
assert AgentStubFileMapping(transfer_method="tool_file", reference=reference).reference == reference
|
||||
assert AgentStubFileMapping(transfer_method="remote_url", url="https://example.com/file").url is not None
|
||||
|
||||
with pytest.raises(ValueError, match="reference"):
|
||||
_ = AgentStubFileMapping(transfer_method="local_file")
|
||||
|
||||
with pytest.raises(ValueError, match="url"):
|
||||
_ = AgentStubFileMapping(transfer_method="remote_url")
|
||||
|
||||
with pytest.raises(ValueError, match="canonical Dify file reference"):
|
||||
_ = AgentStubFileMapping(transfer_method="tool_file", reference="raw-tool-file-uuid")
|
||||
|
||||
|
||||
def test_agent_stub_file_mapping_rejects_remote_url_with_reference() -> None:
|
||||
reference = _reference("tool-file-1")
|
||||
with pytest.raises(ValueError, match="reference is not allowed"):
|
||||
_ = AgentStubFileMapping(
|
||||
transfer_method="remote_url",
|
||||
url="https://example.com/file",
|
||||
reference=reference,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("transfer_method", ["tool_file", "local_file", "datasource_file"])
|
||||
def test_agent_stub_file_mapping_rejects_non_remote_with_url(
|
||||
transfer_method: Literal["tool_file", "local_file", "datasource_file"],
|
||||
) -> None:
|
||||
reference = _reference("tool-file-1")
|
||||
with pytest.raises(ValueError, match="url is not allowed"):
|
||||
_ = AgentStubFileMapping(
|
||||
transfer_method=transfer_method,
|
||||
reference=reference,
|
||||
url="https://example.com/file",
|
||||
)
|
||||
@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("google.protobuf")
|
||||
|
||||
from dify_agent.agent_stub.grpc._generated import agent_stub_pb2
|
||||
from dify_agent.agent_stub.grpc.conversions import (
|
||||
connect_request_from_proto,
|
||||
connect_response_from_proto,
|
||||
file_download_request_from_proto,
|
||||
proto_connect_request,
|
||||
proto_file_download_response,
|
||||
)
|
||||
from dify_agent.agent_stub.protocol.agent_stub import AgentStubConnectResponse, AgentStubFileDownloadResponse
|
||||
|
||||
|
||||
def _reference(record_id: str) -> str:
|
||||
payload = base64.urlsafe_b64encode(json.dumps({"record_id": record_id}, separators=(",", ":")).encode()).decode()
|
||||
return f"dify-file-ref:{payload}"
|
||||
|
||||
|
||||
def test_connect_request_from_proto_round_trips_metadata_json() -> None:
|
||||
message = proto_connect_request(
|
||||
agent_stub_pb2,
|
||||
argv=["connect", "--", "echo", "hello"],
|
||||
metadata={"source": "cli"},
|
||||
)
|
||||
|
||||
request = connect_request_from_proto(message)
|
||||
|
||||
assert request.argv == ["connect", "--", "echo", "hello"]
|
||||
assert request.metadata == {"source": "cli"}
|
||||
|
||||
|
||||
def test_file_download_request_from_proto_respects_optional_reference() -> None:
|
||||
message = agent_stub_pb2.FileDownloadRequest(
|
||||
file=agent_stub_pb2.FileMapping(
|
||||
transfer_method="tool_file",
|
||||
reference=_reference("tool-file-1"),
|
||||
)
|
||||
)
|
||||
|
||||
request = file_download_request_from_proto(message)
|
||||
|
||||
assert request.file.reference == _reference("tool-file-1")
|
||||
assert request.file.url is None
|
||||
|
||||
|
||||
def test_connect_request_from_proto_rejects_invalid_metadata_json() -> None:
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
_ = connect_request_from_proto(
|
||||
agent_stub_pb2.ConnectRequest(protocol_version=1, argv=["connect"], metadata_json="not-json")
|
||||
)
|
||||
|
||||
|
||||
def test_proto_responses_preserve_optional_fields() -> None:
|
||||
message = proto_file_download_response(
|
||||
AgentStubFileDownloadResponse(
|
||||
filename="report.pdf",
|
||||
mime_type="application/pdf",
|
||||
size=123,
|
||||
download_url="https://files.example.com/download",
|
||||
)
|
||||
)
|
||||
|
||||
assert message.HasField("mime_type") is True
|
||||
assert (
|
||||
connect_response_from_proto(
|
||||
agent_stub_pb2.ConnectResponse(
|
||||
**AgentStubConnectResponse(connection_id="conn-1", status="connected").model_dump()
|
||||
)
|
||||
).connection_id
|
||||
== "conn-1"
|
||||
)
|
||||
@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from dify_agent.agent_stub.server.app import create_agent_stub_app
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.server.settings import ServerSettings
|
||||
|
||||
|
||||
def _base64url_secret(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _execution_context() -> DifyExecutionContextLayerConfig:
|
||||
return DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="service-api",
|
||||
)
|
||||
|
||||
|
||||
def test_create_agent_stub_app_exposes_same_stub_routes_as_module_app() -> None:
|
||||
stub_app_module = importlib.import_module("dify_agent.agent_stub.server.app")
|
||||
settings = ServerSettings(
|
||||
agent_stub_url="https://agent.example.com/agent-stub",
|
||||
server_secret_key=_base64url_secret(b"1" * 32),
|
||||
)
|
||||
|
||||
created_paths = {getattr(route, "path", None) for route in create_agent_stub_app(settings).routes}
|
||||
module_paths = {getattr(route, "path", None) for route in stub_app_module.app.routes}
|
||||
|
||||
assert "/agent-stub/connections" in created_paths
|
||||
assert "/agent-stub/files/upload-request" in created_paths
|
||||
assert "/agent-stub/files/download-request" in created_paths
|
||||
assert created_paths == module_paths
|
||||
|
||||
|
||||
def test_create_agent_stub_app_can_serve_requests() -> None:
|
||||
app = create_agent_stub_app(ServerSettings())
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/agent-stub/connections",
|
||||
headers={"Authorization": "Bearer token"},
|
||||
json={"protocol_version": 1, "argv": []},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == "Agent Stub is not configured"
|
||||
|
||||
|
||||
def test_create_agent_stub_app_wires_configured_file_handler_for_upload_requests(monkeypatch) -> None:
|
||||
settings = ServerSettings(
|
||||
agent_stub_url="https://agent.example.com/agent-stub",
|
||||
server_secret_key=_base64url_secret(b"1" * 32),
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
)
|
||||
token_codec = settings.create_agent_stub_token_codec()
|
||||
assert token_codec is not None
|
||||
token = token_codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
|
||||
|
||||
original_async_client = httpx.AsyncClient
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert str(request.url) == "https://api.example.com/inner/api/upload/file/request"
|
||||
assert request.headers["X-Inner-Api-Key"] == "inner-secret"
|
||||
return httpx.Response(200, json={"data": {"url": "https://files.example.com/upload"}})
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.server.agent_stub_files.httpx.AsyncClient",
|
||||
lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs),
|
||||
)
|
||||
|
||||
client = TestClient(create_agent_stub_app(settings))
|
||||
response = client.post(
|
||||
"/agent-stub/files/upload-request",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"filename": "report.pdf", "mimetype": "application/pdf"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"upload_url": "https://files.example.com/upload"}
|
||||
@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubFileDownloadRequest,
|
||||
AgentStubFileMapping,
|
||||
AgentStubFileUploadRequest,
|
||||
)
|
||||
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestError, DifyApiAgentStubFileRequestHandler
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
|
||||
|
||||
def _principal() -> AgentStubPrincipal:
|
||||
return AgentStubPrincipal(
|
||||
execution_context=DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
workflow_id="workflow-1",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="service-api",
|
||||
),
|
||||
session_id="session-1",
|
||||
scope=["agent_stub:connect"],
|
||||
token_id="token-1",
|
||||
)
|
||||
|
||||
|
||||
def _patch_async_client(monkeypatch, handler) -> None:
|
||||
original_async_client = httpx.AsyncClient
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.server.agent_stub_files.httpx.AsyncClient",
|
||||
lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs),
|
||||
)
|
||||
|
||||
|
||||
def _reference(record_id: str) -> str:
|
||||
payload = base64.urlsafe_b64encode(json.dumps({"record_id": record_id}, separators=(",", ":")).encode()).decode()
|
||||
return f"dify-file-ref:{payload}"
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_injects_execution_context_for_upload(monkeypatch) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert str(request.url) == "https://api.example.com/inner/api/upload/file/request"
|
||||
assert request.headers["X-Inner-Api-Key"] == "inner-secret"
|
||||
assert json.loads(request.content) == {
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"filename": "report.pdf",
|
||||
"mimetype": "application/pdf",
|
||||
}
|
||||
return httpx.Response(200, json={"data": {"url": "https://files.example.com/upload"}})
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
response = await file_handler.create_upload_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileUploadRequest(filename="report.pdf", mimetype="application/pdf"),
|
||||
)
|
||||
assert response.upload_url == "https://files.example.com/upload"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_injects_execution_context_for_download(monkeypatch) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert str(request.url) == "https://api.example.com/inner/api/download/file/request"
|
||||
assert json.loads(request.content) == {
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"user_from": "account",
|
||||
"invoke_from": "service-api",
|
||||
"file": {"transfer_method": "tool_file", "reference": _reference("tool-file-1")},
|
||||
}
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"data": {
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 123,
|
||||
"download_url": "https://files.example.com/download",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_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"))
|
||||
),
|
||||
)
|
||||
assert response.download_url == "https://files.example.com/download"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_rejects_missing_user_id() -> None:
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
)
|
||||
principal = _principal()
|
||||
principal.execution_context = principal.execution_context.model_copy(update={"user_id": None})
|
||||
|
||||
async def scenario() -> None:
|
||||
try:
|
||||
await file_handler.create_upload_request(
|
||||
principal=principal,
|
||||
request=AgentStubFileUploadRequest(filename="report.pdf", mimetype="application/pdf"),
|
||||
)
|
||||
except AgentStubFileRequestError as exc:
|
||||
assert "user_id" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected AgentStubFileRequestError")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_maps_non_2xx_response(monkeypatch) -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(403, json={"detail": "forbidden"})
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
try:
|
||||
await file_handler.create_upload_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileUploadRequest(filename="report.pdf", mimetype="application/pdf"),
|
||||
)
|
||||
except AgentStubFileRequestError as exc:
|
||||
assert exc.status_code == 403
|
||||
assert exc.detail == "forbidden"
|
||||
else:
|
||||
raise AssertionError("expected AgentStubFileRequestError")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_maps_error_envelope(monkeypatch) -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"error": "bad request"})
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
try:
|
||||
await file_handler.create_download_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileDownloadRequest(
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1"))
|
||||
),
|
||||
)
|
||||
except AgentStubFileRequestError as exc:
|
||||
assert exc.status_code == 400
|
||||
assert exc.detail == "bad request"
|
||||
else:
|
||||
raise AssertionError("expected AgentStubFileRequestError")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_rejects_upload_response_missing_url(monkeypatch) -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"data": {}})
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
try:
|
||||
await file_handler.create_upload_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileUploadRequest(filename="report.pdf", mimetype="application/pdf"),
|
||||
)
|
||||
except AgentStubFileRequestError as exc:
|
||||
assert exc.status_code == 502
|
||||
assert exc.detail == "Dify API upload request response is missing url"
|
||||
else:
|
||||
raise AssertionError("expected AgentStubFileRequestError")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_rejects_invalid_download_response_schema(monkeypatch) -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"data": {"filename": "report.pdf"}})
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
dify_api_base_url="https://api.example.com",
|
||||
dify_api_inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
try:
|
||||
await file_handler.create_download_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileDownloadRequest(
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1"))
|
||||
),
|
||||
)
|
||||
except AgentStubFileRequestError as exc:
|
||||
assert exc.status_code == 502
|
||||
assert exc.detail == "Dify API download request response is invalid"
|
||||
else:
|
||||
raise AssertionError("expected AgentStubFileRequestError")
|
||||
|
||||
asyncio.run(scenario())
|
||||
@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import secrets
|
||||
import time
|
||||
from typing import cast
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileDownloadResponse, AgentStubFileUploadResponse
|
||||
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestError, AgentStubFileRequestHandler
|
||||
from dify_agent.agent_stub.server.routes.agent_stub import create_agent_stub_http_router
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
|
||||
|
||||
def _base64url_secret(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _token_codec() -> AgentStubTokenCodec:
|
||||
return AgentStubTokenCodec.from_server_secret(_base64url_secret(secrets.token_bytes(32)))
|
||||
|
||||
|
||||
def _execution_context() -> DifyExecutionContextLayerConfig:
|
||||
return DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="service-api",
|
||||
)
|
||||
|
||||
|
||||
def _reference(record_id: str) -> str:
|
||||
encoded = base64.urlsafe_b64encode(f'{{"record_id":"{record_id}"}}'.encode()).decode()
|
||||
return f"dify-file-ref:{encoded}"
|
||||
|
||||
|
||||
def test_agent_stub_connections_route_returns_connected_for_valid_bearer_jwe() -> None:
|
||||
codec = _token_codec()
|
||||
token = codec.encode_connection_token(_execution_context(), session_id="abc12ff", now=int(time.time()) - 1)
|
||||
app = FastAPI()
|
||||
app.include_router(create_agent_stub_http_router(codec))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/agent-stub/connections",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"protocol_version": 1, "argv": ["connect", "--", "echo", "hello"], "metadata": {"source": "cli"}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "connected"
|
||||
assert isinstance(response.json()["connection_id"], str)
|
||||
|
||||
|
||||
def test_agent_stub_connections_route_returns_401_for_missing_authorization() -> None:
|
||||
codec = _token_codec()
|
||||
app = FastAPI()
|
||||
app.include_router(create_agent_stub_http_router(codec))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/agent-stub/connections", json={"protocol_version": 1, "argv": []})
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "invalid or missing Agent Stub authorization"
|
||||
|
||||
|
||||
def test_agent_stub_connections_route_returns_401_for_invalid_bearer_token() -> None:
|
||||
codec = _token_codec()
|
||||
app = FastAPI()
|
||||
app.include_router(create_agent_stub_http_router(codec))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/agent-stub/connections",
|
||||
headers={"Authorization": "Bearer invalid-token"},
|
||||
json={"protocol_version": 1, "argv": []},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "invalid or missing Agent Stub authorization"
|
||||
|
||||
|
||||
def test_agent_stub_connections_route_returns_503_when_server_has_no_token_codec() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(create_agent_stub_http_router(None))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/agent-stub/connections",
|
||||
headers={"Authorization": "Bearer token"},
|
||||
json={"protocol_version": 1, "argv": []},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == "Agent Stub is not configured"
|
||||
|
||||
|
||||
def test_agent_stub_connections_route_returns_422_for_invalid_request_body() -> None:
|
||||
codec = _token_codec()
|
||||
token = codec.encode_connection_token(_execution_context(), session_id="abc12ff", now=int(time.time()) - 1)
|
||||
app = FastAPI()
|
||||
app.include_router(create_agent_stub_http_router(codec))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/agent-stub/connections",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"protocol_version": 2, "argv": "not-a-list"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_agent_stub_file_upload_route_forwards_authenticated_request() -> None:
|
||||
codec = _token_codec()
|
||||
token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
|
||||
|
||||
class FakeHandler:
|
||||
async def create_upload_request(self, *, principal, request):
|
||||
assert principal.execution_context.tenant_id == "tenant-1"
|
||||
assert request.filename == "report.pdf"
|
||||
return AgentStubFileUploadResponse(upload_url="https://files.example.com/upload")
|
||||
|
||||
async def create_download_request(self, *, principal, request):
|
||||
del principal, request
|
||||
raise AssertionError("unexpected download request")
|
||||
|
||||
handler = cast(AgentStubFileRequestHandler, cast(object, FakeHandler()))
|
||||
app = FastAPI()
|
||||
app.include_router(create_agent_stub_http_router(codec, handler))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/agent-stub/files/upload-request",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"filename": "report.pdf", "mimetype": "application/pdf"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"upload_url": "https://files.example.com/upload"}
|
||||
|
||||
|
||||
def test_agent_stub_file_download_route_forwards_authenticated_request() -> None:
|
||||
codec = _token_codec()
|
||||
token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
|
||||
|
||||
class FakeHandler:
|
||||
async def create_download_request(self, *, principal, request):
|
||||
assert principal.execution_context.user_id == "user-1"
|
||||
assert request.file.transfer_method == "tool_file"
|
||||
return AgentStubFileDownloadResponse(
|
||||
filename="report.pdf",
|
||||
mime_type="application/pdf",
|
||||
size=123,
|
||||
download_url="https://files.example.com/download",
|
||||
)
|
||||
|
||||
async def create_upload_request(self, *, principal, request):
|
||||
del principal, request
|
||||
raise AssertionError("unexpected upload request")
|
||||
|
||||
handler = cast(AgentStubFileRequestHandler, cast(object, FakeHandler()))
|
||||
app = FastAPI()
|
||||
app.include_router(create_agent_stub_http_router(codec, handler))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/agent-stub/files/download-request",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"file": {"transfer_method": "tool_file", "reference": _reference("tool-file-1")}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 123,
|
||||
"download_url": "https://files.example.com/download",
|
||||
}
|
||||
|
||||
|
||||
def test_agent_stub_file_routes_return_503_when_file_api_is_unconfigured() -> None:
|
||||
codec = _token_codec()
|
||||
token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
|
||||
app = FastAPI()
|
||||
app.include_router(create_agent_stub_http_router(codec, None))
|
||||
client = TestClient(app)
|
||||
|
||||
upload_response = client.post(
|
||||
"/agent-stub/files/upload-request",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"filename": "report.pdf", "mimetype": "application/pdf"},
|
||||
)
|
||||
download_response = client.post(
|
||||
"/agent-stub/files/download-request",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"file": {"transfer_method": "tool_file", "reference": _reference("tool-file-1")}},
|
||||
)
|
||||
|
||||
assert upload_response.status_code == 503
|
||||
assert download_response.status_code == 503
|
||||
assert upload_response.json()["detail"] == "Agent Stub file API is not configured"
|
||||
assert download_response.json()["detail"] == "Agent Stub file API is not configured"
|
||||
|
||||
|
||||
def test_agent_stub_file_route_maps_handler_errors_to_http_errors() -> None:
|
||||
codec = _token_codec()
|
||||
token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
|
||||
|
||||
class FakeHandler:
|
||||
async def create_upload_request(self, *, principal, request):
|
||||
del principal, request
|
||||
raise AgentStubFileRequestError(400, "bad request")
|
||||
|
||||
async def create_download_request(self, *, principal, request):
|
||||
del principal, request
|
||||
raise AssertionError("unexpected download request")
|
||||
|
||||
handler = cast(AgentStubFileRequestHandler, cast(object, FakeHandler()))
|
||||
app = FastAPI()
|
||||
app.include_router(create_agent_stub_http_router(codec, handler))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/agent-stub/files/upload-request",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"filename": "report.pdf", "mimetype": "application/pdf"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "bad request"
|
||||
|
||||
|
||||
def test_agent_stub_file_route_preserves_structured_handler_error_details() -> None:
|
||||
codec = _token_codec()
|
||||
token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
|
||||
|
||||
class FakeHandler:
|
||||
async def create_upload_request(self, *, principal, request):
|
||||
del principal, request
|
||||
raise AgentStubFileRequestError(400, {"detail": "bad request", "code": "inner_api_error"})
|
||||
|
||||
async def create_download_request(self, *, principal, request):
|
||||
del principal, request
|
||||
raise AssertionError("unexpected download request")
|
||||
|
||||
handler = cast(AgentStubFileRequestHandler, cast(object, FakeHandler()))
|
||||
app = FastAPI()
|
||||
app.include_router(create_agent_stub_http_router(codec, handler))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/agent-stub/files/upload-request",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"filename": "report.pdf", "mimetype": "application/pdf"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == {"detail": "bad request", "code": "inner_api_error"}
|
||||
141
dify-agent/tests/local/dify_agent/agent_stub/server/test_cli.py
Normal file
141
dify-agent/tests/local/dify_agent/agent_stub/server/test_cli.py
Normal file
@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import cast
|
||||
|
||||
import dify_agent.agent_stub.server.cli as cli_module
|
||||
from dify_agent.server.settings import ServerSettings
|
||||
|
||||
|
||||
def test_stub_server_cli_uses_default_uvicorn_settings(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_run(app: str, *, host: str, port: int, reload: bool) -> None:
|
||||
captured.update(app=app, host=host, port=port, reload=reload)
|
||||
|
||||
monkeypatch.setattr(cli_module.uvicorn, "run", fake_run)
|
||||
|
||||
cli_module.main([])
|
||||
|
||||
assert captured == {
|
||||
"app": "dify_agent.agent_stub.server.app:app",
|
||||
"host": "127.0.0.1",
|
||||
"port": 8001,
|
||||
"reload": False,
|
||||
}
|
||||
|
||||
|
||||
def test_stub_server_cli_passes_explicit_uvicorn_settings(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_run(app: str, *, host: str, port: int, reload: bool) -> None:
|
||||
captured.update(app=app, host=host, port=port, reload=reload)
|
||||
|
||||
monkeypatch.setattr(cli_module.uvicorn, "run", fake_run)
|
||||
|
||||
cli_module.main(["--host", "0.0.0.0", "--port", "9000", "--reload"])
|
||||
|
||||
assert captured == {
|
||||
"app": "dify_agent.agent_stub.server.app:app",
|
||||
"host": "0.0.0.0",
|
||||
"port": 9000,
|
||||
"reload": True,
|
||||
}
|
||||
|
||||
|
||||
def test_stub_server_cli_switches_to_grpc_when_agent_stub_url_uses_grpc(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_serve_grpc(*, settings, host, port) -> None:
|
||||
captured.update(settings=settings, host=host, port=port)
|
||||
|
||||
monkeypatch.setattr(cli_module, "_serve_grpc", fake_serve_grpc)
|
||||
monkeypatch.setattr(
|
||||
cli_module, "ServerSettings", lambda: type("Settings", (), {"agent_stub_url": "grpc://agent:9091"})()
|
||||
)
|
||||
|
||||
cli_module.main(["--host", "0.0.0.0", "--port", "9092"])
|
||||
|
||||
assert captured["host"] == "0.0.0.0"
|
||||
assert captured["port"] == 9092
|
||||
|
||||
|
||||
def test_serve_grpc_derives_default_bind_target_and_closes_server(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeServer:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
fake_server = FakeServer()
|
||||
|
||||
async def fake_start_agent_stub_grpc_server(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return fake_server
|
||||
|
||||
class FakeEvent:
|
||||
async def wait(self) -> None:
|
||||
return None
|
||||
|
||||
settings = type(
|
||||
"Settings",
|
||||
(),
|
||||
{
|
||||
"agent_stub_url": "grpc://agent.example.com:9091",
|
||||
"agent_stub_grpc_bind_address": None,
|
||||
"create_agent_stub_token_codec": lambda self: "token-codec",
|
||||
"create_agent_stub_file_request_handler": lambda self: "file-handler",
|
||||
},
|
||||
)()
|
||||
|
||||
monkeypatch.setattr(cli_module, "start_agent_stub_grpc_server", fake_start_agent_stub_grpc_server)
|
||||
monkeypatch.setattr(cli_module.asyncio, "Event", FakeEvent)
|
||||
|
||||
asyncio.run(cli_module._serve_grpc(settings=cast(ServerSettings, cast(object, settings)), host=None, port=None))
|
||||
|
||||
assert captured == {
|
||||
"public_url": "grpc://agent.example.com:9091",
|
||||
"bind_address": "0.0.0.0:9091",
|
||||
"token_codec": "token-codec",
|
||||
"file_request_handler": "file-handler",
|
||||
}
|
||||
assert fake_server.closed is True
|
||||
|
||||
|
||||
def test_serve_grpc_applies_cli_host_port_overrides(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeServer:
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
async def fake_start_agent_stub_grpc_server(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return FakeServer()
|
||||
|
||||
class FakeEvent:
|
||||
async def wait(self) -> None:
|
||||
return None
|
||||
|
||||
settings = type(
|
||||
"Settings",
|
||||
(),
|
||||
{
|
||||
"agent_stub_url": "grpc://agent.example.com:9091",
|
||||
"agent_stub_grpc_bind_address": "127.0.0.1:9191",
|
||||
"create_agent_stub_token_codec": lambda self: None,
|
||||
"create_agent_stub_file_request_handler": lambda self: None,
|
||||
},
|
||||
)()
|
||||
|
||||
monkeypatch.setattr(cli_module, "start_agent_stub_grpc_server", fake_start_agent_stub_grpc_server)
|
||||
monkeypatch.setattr(cli_module.asyncio, "Event", FakeEvent)
|
||||
|
||||
asyncio.run(
|
||||
cli_module._serve_grpc(settings=cast(ServerSettings, cast(object, settings)), host="0.0.0.0", port=9292)
|
||||
)
|
||||
|
||||
assert captured["bind_address"] == "0.0.0.0:9292"
|
||||
@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from dify_agent.agent_stub.server.grpc_bind import (
|
||||
derive_agent_stub_grpc_bind_target,
|
||||
parse_agent_stub_grpc_bind_address,
|
||||
)
|
||||
|
||||
|
||||
def test_derive_agent_stub_grpc_bind_target_defaults_to_all_interfaces() -> None:
|
||||
target = derive_agent_stub_grpc_bind_target(public_url="grpc://agent.example.com:9091")
|
||||
|
||||
assert target.host == "0.0.0.0"
|
||||
assert target.port == 9091
|
||||
assert target.address == "0.0.0.0:9091"
|
||||
|
||||
|
||||
def test_derive_agent_stub_grpc_bind_target_prefers_explicit_override() -> None:
|
||||
target = derive_agent_stub_grpc_bind_target(
|
||||
public_url="grpc://agent.example.com:9091",
|
||||
bind_address="127.0.0.1:9191",
|
||||
)
|
||||
|
||||
assert target.host == "127.0.0.1"
|
||||
assert target.port == 9191
|
||||
|
||||
|
||||
def test_parse_agent_stub_grpc_bind_address_rejects_missing_port() -> None:
|
||||
with pytest.raises(ValueError, match="explicit port"):
|
||||
_ = parse_agent_stub_grpc_bind_address("127.0.0.1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["user@0.0.0.0:9091", "user:password@0.0.0.0:9091"])
|
||||
def test_parse_agent_stub_grpc_bind_address_rejects_user_info(value: str) -> None:
|
||||
with pytest.raises(ValueError, match="must not include user info"):
|
||||
_ = parse_agent_stub_grpc_bind_address(value)
|
||||
@ -0,0 +1,277 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import secrets
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("grpclib")
|
||||
pytest.importorskip("google.protobuf")
|
||||
|
||||
from grpclib.const import Status
|
||||
from grpclib.exceptions import GRPCError
|
||||
|
||||
from dify_agent.agent_stub.grpc._generated import agent_stub_pb2
|
||||
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestError, AgentStubFileRequestHandler
|
||||
from dify_agent.agent_stub.server.control_plane import AgentStubControlPlaneService
|
||||
from dify_agent.agent_stub.server.grpc_service import AgentStubGRPCTransport
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
|
||||
|
||||
def _base64url_secret(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _token_codec() -> AgentStubTokenCodec:
|
||||
return AgentStubTokenCodec.from_server_secret(_base64url_secret(secrets.token_bytes(32)))
|
||||
|
||||
|
||||
def _execution_context() -> DifyExecutionContextLayerConfig:
|
||||
return DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="service-api",
|
||||
)
|
||||
|
||||
|
||||
def _reference(record_id: str) -> str:
|
||||
payload = base64.urlsafe_b64encode(json.dumps({"record_id": record_id}, separators=(",", ":")).encode()).decode()
|
||||
return f"dify-file-ref:{payload}"
|
||||
|
||||
|
||||
def test_agent_stub_grpc_transport_connects_with_bearer_metadata() -> None:
|
||||
codec = _token_codec()
|
||||
token = codec.encode_connection_token(_execution_context())
|
||||
transport = AgentStubGRPCTransport(
|
||||
AgentStubControlPlaneService(
|
||||
codec,
|
||||
connection_id_factory=lambda: "conn-1",
|
||||
)
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
response = await transport.connect(
|
||||
request=agent_stub_pb2.ConnectRequest(
|
||||
protocol_version=1,
|
||||
argv=["connect"],
|
||||
metadata_json='{"source":"cli"}',
|
||||
),
|
||||
metadata=(("authorization", f"Bearer {token}"),),
|
||||
)
|
||||
assert response.connection_id == "conn-1"
|
||||
assert response.status == "connected"
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_agent_stub_grpc_transport_maps_missing_authorization_to_unauthenticated() -> None:
|
||||
codec = _token_codec()
|
||||
transport = AgentStubGRPCTransport(AgentStubControlPlaneService(codec))
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(GRPCError) as exc_info:
|
||||
await transport.connect(
|
||||
request=agent_stub_pb2.ConnectRequest(protocol_version=1, argv=["connect"], metadata_json="{}"),
|
||||
metadata=(),
|
||||
)
|
||||
assert exc_info.value.status == Status.UNAUTHENTICATED
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_agent_stub_grpc_transport_delegates_file_upload_requests() -> None:
|
||||
codec = _token_codec()
|
||||
token = codec.encode_connection_token(_execution_context())
|
||||
|
||||
class FakeHandler:
|
||||
async def create_upload_request(self, *, principal, request):
|
||||
assert principal.execution_context.tenant_id == "tenant-1"
|
||||
assert request.filename == "report.pdf"
|
||||
return type("Response", (), {"upload_url": "https://files.example.com/upload"})()
|
||||
|
||||
async def create_download_request(self, *, principal, request):
|
||||
del principal, request
|
||||
raise AssertionError("unexpected download request")
|
||||
|
||||
transport = AgentStubGRPCTransport(
|
||||
AgentStubControlPlaneService(
|
||||
codec,
|
||||
cast(AgentStubFileRequestHandler, cast(object, FakeHandler())),
|
||||
)
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
response = await transport.create_file_upload_request(
|
||||
request=agent_stub_pb2.FileUploadRequest(filename="report.pdf", mimetype="application/pdf"),
|
||||
metadata=(("authorization", f"Bearer {token}"),),
|
||||
)
|
||||
assert response.upload_url == "https://files.example.com/upload"
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_agent_stub_grpc_transport_delegates_file_download_requests() -> None:
|
||||
codec = _token_codec()
|
||||
token = codec.encode_connection_token(_execution_context())
|
||||
|
||||
class FakeHandler:
|
||||
async def create_download_request(self, *, principal, request):
|
||||
assert principal.execution_context.user_id == "user-1"
|
||||
assert request.file.reference == _reference("tool-file-1")
|
||||
return type(
|
||||
"Response",
|
||||
(),
|
||||
{
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 123,
|
||||
"download_url": "https://files.example.com/download",
|
||||
},
|
||||
)()
|
||||
|
||||
async def create_upload_request(self, *, principal, request):
|
||||
del principal, request
|
||||
raise AssertionError("unexpected upload request")
|
||||
|
||||
transport = AgentStubGRPCTransport(
|
||||
AgentStubControlPlaneService(
|
||||
codec,
|
||||
cast(AgentStubFileRequestHandler, cast(object, FakeHandler())),
|
||||
)
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
response = await transport.create_file_download_request(
|
||||
request=agent_stub_pb2.FileDownloadRequest(
|
||||
file=agent_stub_pb2.FileMapping(
|
||||
transfer_method="tool_file",
|
||||
reference=_reference("tool-file-1"),
|
||||
)
|
||||
),
|
||||
metadata=(("authorization", f"Bearer {token}"),),
|
||||
)
|
||||
assert response.download_url == "https://files.example.com/download"
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_agent_stub_grpc_transport_stringifies_structured_file_error_details() -> None:
|
||||
codec = _token_codec()
|
||||
token = codec.encode_connection_token(_execution_context())
|
||||
|
||||
class FakeHandler:
|
||||
async def create_upload_request(self, *, principal, request):
|
||||
del principal, request
|
||||
raise AgentStubFileRequestError(400, {"detail": "bad request", "code": "inner_api_error"})
|
||||
|
||||
async def create_download_request(self, *, principal, request):
|
||||
del principal, request
|
||||
raise AssertionError("unexpected download request")
|
||||
|
||||
transport = AgentStubGRPCTransport(
|
||||
AgentStubControlPlaneService(
|
||||
codec,
|
||||
cast(AgentStubFileRequestHandler, cast(object, FakeHandler())),
|
||||
)
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(GRPCError) as exc_info:
|
||||
await transport.create_file_upload_request(
|
||||
request=agent_stub_pb2.FileUploadRequest(filename="report.pdf", mimetype="application/pdf"),
|
||||
metadata=(("authorization", f"Bearer {token}"),),
|
||||
)
|
||||
assert exc_info.value.status == Status.FAILED_PRECONDITION
|
||||
assert isinstance(exc_info.value.message, str)
|
||||
assert "bad request" in exc_info.value.message
|
||||
assert "inner_api_error" in exc_info.value.message
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_agent_stub_grpc_transport_maps_missing_token_codec_to_unavailable() -> None:
|
||||
transport = AgentStubGRPCTransport(AgentStubControlPlaneService(None))
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(GRPCError) as exc_info:
|
||||
await transport.connect(
|
||||
request=agent_stub_pb2.ConnectRequest(protocol_version=1, argv=["connect"], metadata_json="{}"),
|
||||
metadata=(("authorization", "Bearer token"),),
|
||||
)
|
||||
assert exc_info.value.status == Status.UNAVAILABLE
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_agent_stub_grpc_transport_maps_missing_file_handler_to_unavailable() -> None:
|
||||
codec = _token_codec()
|
||||
token = codec.encode_connection_token(_execution_context())
|
||||
transport = AgentStubGRPCTransport(AgentStubControlPlaneService(codec, None))
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(GRPCError) as exc_info:
|
||||
await transport.create_file_upload_request(
|
||||
request=agent_stub_pb2.FileUploadRequest(filename="report.pdf", mimetype="application/pdf"),
|
||||
metadata=(("authorization", f"Bearer {token}"),),
|
||||
)
|
||||
assert exc_info.value.status == Status.UNAVAILABLE
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_agent_stub_grpc_transport_maps_invalid_upload_request_to_invalid_argument() -> None:
|
||||
codec = _token_codec()
|
||||
token = codec.encode_connection_token(_execution_context())
|
||||
transport = AgentStubGRPCTransport(AgentStubControlPlaneService(codec))
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(GRPCError) as exc_info:
|
||||
await transport.create_file_upload_request(
|
||||
request=SimpleNamespace(filename=None, mimetype="application/pdf"),
|
||||
metadata=(("authorization", f"Bearer {token}"),),
|
||||
)
|
||||
assert exc_info.value.status == Status.INVALID_ARGUMENT
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_agent_stub_grpc_transport_maps_invalid_download_request_to_invalid_argument() -> None:
|
||||
codec = _token_codec()
|
||||
token = codec.encode_connection_token(_execution_context())
|
||||
transport = AgentStubGRPCTransport(AgentStubControlPlaneService(codec))
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(GRPCError) as exc_info:
|
||||
await transport.create_file_download_request(
|
||||
request=agent_stub_pb2.FileDownloadRequest(
|
||||
file=agent_stub_pb2.FileMapping(transfer_method="tool_file")
|
||||
),
|
||||
metadata=(("authorization", f"Bearer {token}"),),
|
||||
)
|
||||
assert exc_info.value.status == Status.INVALID_ARGUMENT
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(scenario())
|
||||
@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import time
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from dify_agent.agent_stub.server.router import create_agent_stub_router
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
|
||||
|
||||
def _base64url_secret(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _execution_context() -> DifyExecutionContextLayerConfig:
|
||||
return DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="service-api",
|
||||
)
|
||||
|
||||
|
||||
def _token_codec() -> AgentStubTokenCodec:
|
||||
return AgentStubTokenCodec.from_server_secret(_base64url_secret(b"1" * 32))
|
||||
|
||||
|
||||
def test_create_agent_stub_router_mounts_all_agent_stub_routes() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(create_agent_stub_router(token_codec=None))
|
||||
|
||||
paths = {getattr(route, "path", None) for route in app.routes}
|
||||
|
||||
assert "/agent-stub/connections" in paths
|
||||
assert "/agent-stub/files/upload-request" in paths
|
||||
assert "/agent-stub/files/download-request" in paths
|
||||
|
||||
|
||||
def test_create_agent_stub_router_returns_503_for_unconfigured_services() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(create_agent_stub_router(token_codec=None))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/agent-stub/connections",
|
||||
headers={"Authorization": "Bearer token"},
|
||||
json={"protocol_version": 1, "argv": []},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == "Agent Stub is not configured"
|
||||
|
||||
|
||||
def test_create_agent_stub_router_wires_configured_token_codec_for_connections() -> None:
|
||||
token_codec = _token_codec()
|
||||
token = token_codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(create_agent_stub_router(token_codec=token_codec))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/agent-stub/connections",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"protocol_version": 1, "argv": ["connect"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "connected"
|
||||
assert isinstance(response.json()["connection_id"], str)
|
||||
@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import (
|
||||
AGENT_STUB_TOKEN_AUDIENCE,
|
||||
AGENT_STUB_TOKEN_ISSUER,
|
||||
AGENT_STUB_TOKEN_SCOPE_CONNECT,
|
||||
AGENT_STUB_TOKEN_TTL_SECONDS,
|
||||
AgentStubTokenCodec,
|
||||
AgentStubTokenError,
|
||||
decode_server_secret_key,
|
||||
)
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
|
||||
|
||||
def _base64url_secret(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _codec() -> AgentStubTokenCodec:
|
||||
return AgentStubTokenCodec.from_server_secret(_base64url_secret(secrets.token_bytes(32)))
|
||||
|
||||
|
||||
def _execution_context() -> DifyExecutionContextLayerConfig:
|
||||
return DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
node_id="node-1",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="service-api",
|
||||
trace_id="trace-1",
|
||||
)
|
||||
|
||||
|
||||
def test_agent_stub_token_codec_round_trips_execution_context_from_bearer_token() -> None:
|
||||
codec = _codec()
|
||||
token = codec.encode_connection_token(_execution_context(), session_id="abc12ff", now=1_780_395_720)
|
||||
|
||||
principal = codec.decode_authorization_header(f"Bearer {token}", now=1_780_395_720)
|
||||
|
||||
assert principal.execution_context == _execution_context()
|
||||
assert principal.session_id == "abc12ff"
|
||||
assert principal.scope == [AGENT_STUB_TOKEN_SCOPE_CONNECT]
|
||||
|
||||
|
||||
def test_agent_stub_token_codec_rejects_expired_tokens() -> None:
|
||||
codec = _codec()
|
||||
token = codec.encode_connection_token(_execution_context(), now=1_780_395_720)
|
||||
|
||||
with pytest.raises(AgentStubTokenError, match="expired"):
|
||||
_ = codec.decode_authorization_header(
|
||||
f"Bearer {token}",
|
||||
now=1_780_395_720 + AGENT_STUB_TOKEN_TTL_SECONDS + 1,
|
||||
)
|
||||
|
||||
|
||||
def test_agent_stub_token_codec_rejects_tokens_before_nbf() -> None:
|
||||
codec = _codec()
|
||||
claims = codec.build_connection_claims(_execution_context(), now=1_780_395_720)
|
||||
token = codec.encode_claims(claims.model_copy(update={"nbf": 1_780_395_721}))
|
||||
|
||||
with pytest.raises(AgentStubTokenError, match="not valid yet"):
|
||||
_ = codec.decode_authorization_header(f"Bearer {token}", now=1_780_395_720)
|
||||
|
||||
|
||||
def test_agent_stub_token_codec_rejects_wrong_audience_and_scope() -> None:
|
||||
codec = _codec()
|
||||
claims = codec.build_connection_claims(_execution_context(), now=1_780_395_720)
|
||||
wrong_issuer_token = codec.encode_claims(claims.model_copy(update={"iss": "another-issuer"}))
|
||||
wrong_audience_token = codec.encode_claims(claims.model_copy(update={"aud": "another-audience"}))
|
||||
wrong_scope_token = codec.encode_claims(claims.model_copy(update={"scope": ["other:scope"]}))
|
||||
|
||||
with pytest.raises(AgentStubTokenError, match=AGENT_STUB_TOKEN_ISSUER):
|
||||
_ = codec.decode_authorization_header(f"Bearer {wrong_issuer_token}", now=1_780_395_720)
|
||||
|
||||
with pytest.raises(AgentStubTokenError, match=AGENT_STUB_TOKEN_AUDIENCE):
|
||||
_ = codec.decode_authorization_header(f"Bearer {wrong_audience_token}", now=1_780_395_720)
|
||||
|
||||
with pytest.raises(AgentStubTokenError, match=AGENT_STUB_TOKEN_SCOPE_CONNECT):
|
||||
_ = codec.decode_authorization_header(f"Bearer {wrong_scope_token}", now=1_780_395_720)
|
||||
|
||||
|
||||
def test_agent_stub_token_codec_rejects_wrong_key_and_malformed_authorization_header() -> None:
|
||||
codec = _codec()
|
||||
other_codec = _codec()
|
||||
token = codec.encode_connection_token(_execution_context(), now=1_780_395_720)
|
||||
|
||||
with pytest.raises(AgentStubTokenError, match="decrypt"):
|
||||
_ = other_codec.decode_authorization_header(f"Bearer {token}", now=1_780_395_720)
|
||||
|
||||
with pytest.raises(AgentStubTokenError, match="Bearer"):
|
||||
_ = codec.decode_authorization_header(f"Basic {token}", now=1_780_395_720)
|
||||
|
||||
|
||||
def test_agent_stub_token_codec_builds_fixed_server_claims() -> None:
|
||||
codec = _codec()
|
||||
|
||||
claims = codec.build_connection_claims(_execution_context(), session_id="abc12ff", now=1_780_395_720)
|
||||
|
||||
assert claims.iss == AGENT_STUB_TOKEN_ISSUER
|
||||
assert claims.aud == AGENT_STUB_TOKEN_AUDIENCE
|
||||
assert claims.scope == [AGENT_STUB_TOKEN_SCOPE_CONNECT]
|
||||
assert claims.exp - claims.iat == AGENT_STUB_TOKEN_TTL_SECONDS
|
||||
assert claims.shell is not None
|
||||
assert claims.shell.session_id == "abc12ff"
|
||||
|
||||
|
||||
def test_decode_server_secret_key_rejects_padding_quotes_and_invalid_characters() -> None:
|
||||
secret = _base64url_secret(secrets.token_bytes(32))
|
||||
|
||||
with pytest.raises(ValueError, match="unpadded base64url"):
|
||||
_ = decode_server_secret_key(f'"{secret}"')
|
||||
|
||||
with pytest.raises(ValueError, match="unpadded base64url"):
|
||||
_ = decode_server_secret_key(f"{secret}=")
|
||||
|
||||
with pytest.raises(ValueError, match="unpadded base64url"):
|
||||
_ = decode_server_secret_key(f"{secret[:-1]}!")
|
||||
@ -25,7 +25,13 @@ from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
|
||||
|
||||
def _execution_context_config() -> DifyExecutionContextLayerConfig:
|
||||
return DifyExecutionContextLayerConfig(tenant_id="tenant-1", user_id="user-1", invoke_from="workflow_run")
|
||||
return DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="service-api",
|
||||
)
|
||||
|
||||
|
||||
def _llm_config() -> DifyPluginLLMLayerConfig:
|
||||
|
||||
@ -31,31 +31,42 @@ def test_execution_context_accepts_real_invoke_from_user_from_and_agent_mode() -
|
||||
assert config.agent_mode == "agent_app"
|
||||
|
||||
|
||||
def test_execution_context_still_accepts_legacy_agent_mode_in_invoke_from() -> None:
|
||||
# Back-compat: older requests carried the run mode in invoke_from.
|
||||
config = DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run")
|
||||
assert config.invoke_from == "workflow_run"
|
||||
assert config.agent_mode is None
|
||||
def test_execution_context_rejects_legacy_agent_mode_in_invoke_from() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_ = DifyExecutionContextLayerConfig.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"user_from": "account",
|
||||
"agent_mode": "workflow_run",
|
||||
"invoke_from": "workflow_run",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_execution_context_layer_config_forbids_runtime_settings_and_unknown_fields() -> None:
|
||||
config = DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
workflow_id="workflow-1",
|
||||
invoke_from="workflow_run",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="service-api",
|
||||
)
|
||||
|
||||
assert config.tenant_id == "tenant-1"
|
||||
assert config.user_id == "user-1"
|
||||
assert config.user_from == "account"
|
||||
assert config.workflow_id == "workflow-1"
|
||||
assert config.invoke_from == "workflow_run"
|
||||
assert config.agent_mode == "workflow_run"
|
||||
assert config.invoke_from == "service-api"
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
_ = DifyExecutionContextLayerConfig.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"invoke_from": "workflow_run",
|
||||
"user_from": "account",
|
||||
"agent_mode": "workflow_run",
|
||||
"invoke_from": "service-api",
|
||||
"daemon_url": "http://daemon",
|
||||
}
|
||||
)
|
||||
@ -64,7 +75,9 @@ def test_execution_context_layer_config_forbids_runtime_settings_and_unknown_fie
|
||||
_ = DifyExecutionContextLayerConfig.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"invoke_from": "workflow_run",
|
||||
"user_from": "account",
|
||||
"agent_mode": "workflow_run",
|
||||
"invoke_from": "service-api",
|
||||
"unknown": "value",
|
||||
}
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user