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:
盐粒 Yanli 2026-06-10 12:04:32 +09:00 committed by GitHub
parent 629e046303
commit ba9975a083
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
120 changed files with 8589 additions and 497 deletions

View File

@ -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,

View File

@ -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")

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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

View File

@ -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)

View File

@ -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,

View File

@ -23,6 +23,7 @@ class UploadConfig(ResponseModel):
class FileResponse(ResponseModel):
id: str
reference: str | None = None
name: str
size: int
extension: str | None = None

View File

@ -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).

View File

@ -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 |

View File

@ -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 |

View File

@ -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 |

View File

@ -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 |

View 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,
)

View File

@ -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:

View File

@ -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",
)

View File

@ -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",
)

View File

@ -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.",

View File

@ -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

View File

@ -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):

View File

@ -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"""

View File

@ -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

View File

@ -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:

View 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",
},
}
)

View File

@ -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())

View File

@ -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
)

View File

@ -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():

View File

@ -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
# ──────────────────────────────────────────────────────────────────────────────

View File

@ -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"]

View File

@ -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

View 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"}],
},
}
)

View 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"},
)

View File

@ -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
View File

@ -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]]

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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`.

View File

@ -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(

View File

@ -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(

View 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;
}

View File

@ -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",

View 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] = []

View File

@ -0,0 +1,3 @@
"""Client-safe CLI package for the ``dify-agent`` sandbox command."""
__all__: list[str] = []

View 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"]

View 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",
]

View 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",
]

View 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"]

View 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",
]

View 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",
]

View 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",
]

View 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",
]

View 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",
]

View File

@ -0,0 +1,3 @@
"""Internal gRPC helpers for the optional Agent Stub transport."""
__all__: list[str] = []

View File

@ -0,0 +1,3 @@
"""Generated protobuf and grpclib code for the Agent Stub gRPC contract."""
__all__: list[str] = []

View File

@ -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,
)

View File

@ -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)

View File

@ -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: ...

View 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",
]

View 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",
]

View 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",
]

View 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",
]

View 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",
]

View 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"]

View 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"]

View 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",
]

View 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",
]

View 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"]

View 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"]

View 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"]

View File

@ -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"]

View File

@ -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"]

View File

@ -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",
]

View File

@ -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",
]

View 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",
]

View File

@ -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",
]

View File

@ -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",

View File

@ -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,

View File

@ -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),

View File

@ -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

View File

@ -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"]

View 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))

View 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"

View File

@ -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"

View File

@ -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",
)

View 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"
)

View File

@ -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"}

View File

@ -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())

View File

@ -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"}

View 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"

View File

@ -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)

View File

@ -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())

View File

@ -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)

View File

@ -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]}!")

View File

@ -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:

View File

@ -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