From ba9975a08302c1f1f01fdaff1b00b5424a74d1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9B=90=E7=B2=92=20Yanli?= Date: Wed, 10 Jun 2026 12:04:32 +0900 Subject: [PATCH] feat(dify-agent): sync shell and back proxy updates (#37159) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/files/upload.py | 5 +- api/controllers/inner_api/plugin/plugin.py | 55 ++- api/core/plugin/entities/request.py | 54 ++- api/core/workflow/file_reference.py | 49 ++ .../workflow/nodes/agent_v2/agent_node.py | 4 +- .../nodes/agent_v2/file_tenant_validator.py | 46 +- .../workflow/nodes/agent_v2/output_adapter.py | 309 +++++++----- .../nodes/agent_v2/output_type_checker.py | 93 +++- .../nodes/agent_v2/runtime_request_builder.py | 56 ++- api/factories/file_factory/builders.py | 2 +- api/fields/file_fields.py | 1 + api/models/agent_config_entities.py | 33 +- api/openapi/markdown/console-swagger.md | 1 + api/openapi/markdown/openapi-swagger.md | 1 + api/openapi/markdown/service-swagger.md | 1 + api/openapi/markdown/web-swagger.md | 1 + api/services/file_request_service.py | 80 +++ .../workflow/node_output_inspector_service.py | 169 +++++-- .../clients/agent_backend/test_client.py | 7 +- .../clients/agent_backend/test_fake_client.py | 7 +- .../agent_backend/test_request_builder.py | 21 +- .../controllers/console/test_files.py | 2 + .../controllers/files/test_upload.py | 2 + .../inner_api/plugin/test_plugin.py | 55 +++ .../controllers/service_api/app/test_file.py | 1 + .../agent_app/test_runtime_request_builder.py | 7 +- .../core/plugin/entities/test_request.py | 126 +++++ .../nodes/agent_v2/test_agent_node.py | 170 ++++++- .../agent_v2/test_file_tenant_validator.py | 88 ++-- .../nodes/agent_v2/test_output_adapter.py | 372 +++++++++++++- .../agent_v2/test_output_type_checker.py | 194 +++++++- .../agent_v2/test_runtime_request_builder.py | 18 +- .../unit_tests/factories/test_file_factory.py | 2 + .../models/test_agent_config_entities.py | 94 ++++ .../services/test_file_request_service.py | 76 +++ .../test_node_output_inspector_service.py | 170 ++++++- api/uv.lock | 16 +- dify-agent/.example.env | 17 + dify-agent/docker/shellctl/Dockerfile | 2 +- .../docs/dify-agent/get-started/index.md | 5 + dify-agent/docs/dify-agent/guide/index.md | 10 + .../user-manual/shell-layer/index.md | 32 +- .../run_server_consumer.py | 4 +- .../run_server_sync_client.py | 4 +- .../proto/dify/agent/stub/v1/agent_stub.proto | 46 ++ dify-agent/pyproject.toml | 13 +- .../src/dify_agent/agent_stub/__init__.py | 9 + .../src/dify_agent/agent_stub/cli/__init__.py | 3 + .../dify_agent/agent_stub/cli/_agent_stub.py | 20 + .../src/dify_agent/agent_stub/cli/_env.py | 59 +++ .../src/dify_agent/agent_stub/cli/_files.py | 139 ++++++ .../src/dify_agent/agent_stub/cli/main.py | 158 ++++++ .../dify_agent/agent_stub/client/__init__.py | 31 ++ .../agent_stub/client/_agent_stub.py | 122 +++++ .../agent_stub/client/_agent_stub_grpc.py | 242 +++++++++ .../agent_stub/client/_agent_stub_http.py | 263 ++++++++++ .../dify_agent/agent_stub/client/_errors.py | 53 ++ .../dify_agent/agent_stub/grpc/__init__.py | 3 + .../agent_stub/grpc/_generated/__init__.py | 3 + .../grpc/_generated/agent_stub_grpc.py | 80 +++ .../grpc/_generated/agent_stub_pb2.py | 52 ++ .../grpc/_generated/agent_stub_pb2.pyi | 71 +++ .../dify_agent/agent_stub/grpc/conversions.py | 166 +++++++ .../agent_stub/protocol/__init__.py | 43 ++ .../agent_stub/protocol/agent_stub.py | 243 ++++++++++ .../dify_agent/agent_stub/server/__init__.py | 12 + .../agent_stub/server/agent_stub_files.py | 216 +++++++++ .../src/dify_agent/agent_stub/server/app.py | 33 ++ .../src/dify_agent/agent_stub/server/cli.py | 70 +++ .../agent_stub/server/control_plane.py | 106 ++++ .../dify_agent/agent_stub/server/grpc_bind.py | 69 +++ .../agent_stub/server/grpc_runtime.py | 59 +++ .../agent_stub/server/grpc_service.py | 244 ++++++++++ .../dify_agent/agent_stub/server/router.py | 29 ++ .../agent_stub/server/routes/__init__.py | 5 + .../agent_stub/server/routes/agent_stub.py | 68 +++ .../agent_stub/server/shell_agent_stub_env.py | 47 ++ .../agent_stub/server/tokens/__init__.py | 27 ++ .../agent_stub/server/tokens/agent_stub.py | 254 ++++++++++ .../layers/execution_context/configs.py | 43 +- .../src/dify_agent/layers/shell/layer.py | 52 +- .../src/dify_agent/protocol/__init__.py | 6 +- .../dify_agent/runtime/compositor_factory.py | 24 +- dify-agent/src/dify_agent/server/app.py | 31 +- dify-agent/src/dify_agent/server/settings.py | 110 ++++- .../dify_agent/agent_stub/cli/test_files.py | 155 ++++++ .../dify_agent/agent_stub/cli/test_main.py | 196 ++++++++ .../client/test_agent_stub_client.py | 458 ++++++++++++++++++ .../protocol/test_agent_stub_protocol.py | 111 +++++ .../protocol/test_grpc_conversions.py | 78 +++ .../agent_stub/server/test_agent_stub_app.py | 90 ++++ .../server/test_agent_stub_files.py | 239 +++++++++ .../server/test_agent_stub_routes.py | 263 ++++++++++ .../dify_agent/agent_stub/server/test_cli.py | 141 ++++++ .../agent_stub/server/test_grpc_bind.py | 37 ++ .../agent_stub/server/test_grpc_service.py | 277 +++++++++++ .../agent_stub/server/test_router.py | 74 +++ .../server/tokens/test_agent_stub.py | 125 +++++ .../layers/dify_plugin/test_layers.py | 8 +- .../layers/execution_context/test_configs.py | 31 +- .../layers/execution_context/test_layer.py | 12 +- .../dify_agent/layers/shell/test_layer.py | 138 +++++- .../protocol/test_protocol_schemas.py | 22 +- .../runtime/test_compositor_factory.py | 28 ++ .../local/dify_agent/runtime/test_runner.py | 83 +++- .../tests/local/dify_agent/server/test_app.py | 167 ++++++- .../dify_agent/server/test_runs_routes.py | 7 +- .../local/dify_agent/server/test_settings.py | 147 ++++++ .../dify_agent/test_client_safe_exports.py | 24 +- .../dify_agent/test_import_boundaries.py | 39 +- dify-agent/tests/local/test_packaging.py | 52 +- dify-agent/uv.lock | 290 ++++++++++- .../generated/api/console/files/types.gen.ts | 1 + .../generated/api/console/files/zod.gen.ts | 1 + .../generated/api/openapi/types.gen.ts | 1 + .../generated/api/openapi/zod.gen.ts | 1 + .../generated/api/service/types.gen.ts | 1 + .../generated/api/service/zod.gen.ts | 1 + .../contracts/generated/api/web/types.gen.ts | 1 + .../contracts/generated/api/web/zod.gen.ts | 1 + 120 files changed, 8589 insertions(+), 497 deletions(-) create mode 100644 api/services/file_request_service.py create mode 100644 api/tests/unit_tests/core/plugin/entities/test_request.py create mode 100644 api/tests/unit_tests/models/test_agent_config_entities.py create mode 100644 api/tests/unit_tests/services/test_file_request_service.py create mode 100644 dify-agent/proto/dify/agent/stub/v1/agent_stub.proto create mode 100644 dify-agent/src/dify_agent/agent_stub/__init__.py create mode 100644 dify-agent/src/dify_agent/agent_stub/cli/__init__.py create mode 100644 dify-agent/src/dify_agent/agent_stub/cli/_agent_stub.py create mode 100644 dify-agent/src/dify_agent/agent_stub/cli/_env.py create mode 100644 dify-agent/src/dify_agent/agent_stub/cli/_files.py create mode 100644 dify-agent/src/dify_agent/agent_stub/cli/main.py create mode 100644 dify-agent/src/dify_agent/agent_stub/client/__init__.py create mode 100644 dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py create mode 100644 dify-agent/src/dify_agent/agent_stub/client/_agent_stub_grpc.py create mode 100644 dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py create mode 100644 dify-agent/src/dify_agent/agent_stub/client/_errors.py create mode 100644 dify-agent/src/dify_agent/agent_stub/grpc/__init__.py create mode 100644 dify-agent/src/dify_agent/agent_stub/grpc/_generated/__init__.py create mode 100644 dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_grpc.py create mode 100644 dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_pb2.py create mode 100644 dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_pb2.pyi create mode 100644 dify-agent/src/dify_agent/agent_stub/grpc/conversions.py create mode 100644 dify-agent/src/dify_agent/agent_stub/protocol/__init__.py create mode 100644 dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py create mode 100644 dify-agent/src/dify_agent/agent_stub/server/__init__.py create mode 100644 dify-agent/src/dify_agent/agent_stub/server/agent_stub_files.py create mode 100644 dify-agent/src/dify_agent/agent_stub/server/app.py create mode 100644 dify-agent/src/dify_agent/agent_stub/server/cli.py create mode 100644 dify-agent/src/dify_agent/agent_stub/server/control_plane.py create mode 100644 dify-agent/src/dify_agent/agent_stub/server/grpc_bind.py create mode 100644 dify-agent/src/dify_agent/agent_stub/server/grpc_runtime.py create mode 100644 dify-agent/src/dify_agent/agent_stub/server/grpc_service.py create mode 100644 dify-agent/src/dify_agent/agent_stub/server/router.py create mode 100644 dify-agent/src/dify_agent/agent_stub/server/routes/__init__.py create mode 100644 dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py create mode 100644 dify-agent/src/dify_agent/agent_stub/server/shell_agent_stub_env.py create mode 100644 dify-agent/src/dify_agent/agent_stub/server/tokens/__init__.py create mode 100644 dify-agent/src/dify_agent/agent_stub/server/tokens/agent_stub.py create mode 100644 dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py create mode 100644 dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py create mode 100644 dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_client.py create mode 100644 dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py create mode 100644 dify-agent/tests/local/dify_agent/agent_stub/protocol/test_grpc_conversions.py create mode 100644 dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_app.py create mode 100644 dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_files.py create mode 100644 dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py create mode 100644 dify-agent/tests/local/dify_agent/agent_stub/server/test_cli.py create mode 100644 dify-agent/tests/local/dify_agent/agent_stub/server/test_grpc_bind.py create mode 100644 dify-agent/tests/local/dify_agent/agent_stub/server/test_grpc_service.py create mode 100644 dify-agent/tests/local/dify_agent/agent_stub/server/test_router.py create mode 100644 dify-agent/tests/local/dify_agent/agent_stub/server/tokens/test_agent_stub.py diff --git a/api/controllers/files/upload.py b/api/controllers/files/upload.py index 7d588b95dd..661135f295 100644 --- a/api/controllers/files/upload.py +++ b/api/controllers/files/upload.py @@ -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, diff --git a/api/controllers/inner_api/plugin/plugin.py b/api/controllers/inner_api/plugin/plugin.py index f445cbecc6..d385ca5737 100644 --- a/api/controllers/inner_api/plugin/plugin.py +++ b/api/controllers/inner_api/plugin/plugin.py @@ -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") diff --git a/api/core/plugin/entities/request.py b/api/core/plugin/entities/request.py index 06b39c9fd5..d47dac9eaf 100644 --- a/api/core/plugin/entities/request.py +++ b/api/core/plugin/entities/request.py @@ -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): diff --git a/api/core/workflow/file_reference.py b/api/core/workflow/file_reference.py index c80acb3783..25b4a59bfe 100644 --- a/api/core/workflow/file_reference.py +++ b/api/core/workflow/file_reference.py @@ -1,3 +1,23 @@ +"""Opaque file reference helpers for workflow/runtime-facing file identities. + +The canonical Dify file reference format is ``dify-file-ref:`` +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 diff --git a/api/core/workflow/nodes/agent_v2/agent_node.py b/api/core/workflow/nodes/agent_v2/agent_node.py index d5c478c8ac..bdcbb77626 100644 --- a/api/core/workflow/nodes/agent_v2/agent_node.py +++ b/api/core/workflow/nodes/agent_v2/agent_node.py @@ -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 diff --git a/api/core/workflow/nodes/agent_v2/file_tenant_validator.py b/api/core/workflow/nodes/agent_v2/file_tenant_validator.py index 404d1036b6..ea6382fa8e 100644 --- a/api/core/workflow/nodes/agent_v2/file_tenant_validator.py +++ b/api/core/workflow/nodes/agent_v2/file_tenant_validator.py @@ -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 diff --git a/api/core/workflow/nodes/agent_v2/output_adapter.py b/api/core/workflow/nodes/agent_v2/output_adapter.py index 3204bb3051..b679c409ba 100644 --- a/api/core/workflow/nodes/agent_v2/output_adapter.py +++ b/api/core/workflow/nodes/agent_v2/output_adapter.py @@ -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") diff --git a/api/core/workflow/nodes/agent_v2/output_type_checker.py b/api/core/workflow/nodes/agent_v2/output_type_checker.py index b31472c5a2..1b39ee59c6 100644 --- a/api/core/workflow/nodes/agent_v2/output_type_checker.py +++ b/api/core/workflow/nodes/agent_v2/output_type_checker.py @@ -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": ""}``; 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 diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index 4ddb096c8c..e1f9fbdaba 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -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) diff --git a/api/factories/file_factory/builders.py b/api/factories/file_factory/builders.py index 7026af23b8..67b1c646db 100644 --- a/api/factories/file_factory/builders.py +++ b/api/factories/file_factory/builders.py @@ -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, diff --git a/api/fields/file_fields.py b/api/fields/file_fields.py index eb10308851..1cd6e8af6a 100644 --- a/api/fields/file_fields.py +++ b/api/fields/file_fields.py @@ -23,6 +23,7 @@ class UploadConfig(ResponseModel): class FileResponse(ResponseModel): id: str + reference: str | None = None name: str size: int extension: str | None = None diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index 115bd180cd..095108bbc7 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -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). diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index 1610fd6bdf..69f5744655 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -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 | diff --git a/api/openapi/markdown/openapi-swagger.md b/api/openapi/markdown/openapi-swagger.md index 8c19adb203..f04f23027c 100644 --- a/api/openapi/markdown/openapi-swagger.md +++ b/api/openapi/markdown/openapi-swagger.md @@ -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 | diff --git a/api/openapi/markdown/service-swagger.md b/api/openapi/markdown/service-swagger.md index 687a47e012..f32e998bd1 100644 --- a/api/openapi/markdown/service-swagger.md +++ b/api/openapi/markdown/service-swagger.md @@ -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 | diff --git a/api/openapi/markdown/web-swagger.md b/api/openapi/markdown/web-swagger.md index eafccdc04b..d24dc48ee6 100644 --- a/api/openapi/markdown/web-swagger.md +++ b/api/openapi/markdown/web-swagger.md @@ -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 | diff --git a/api/services/file_request_service.py b/api/services/file_request_service.py new file mode 100644 index 0000000000..a4be0bbec8 --- /dev/null +++ b/api/services/file_request_service.py @@ -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, + ) diff --git a/api/services/workflow/node_output_inspector_service.py b/api/services/workflow/node_output_inspector_service.py index cae5ed9c15..3bed0d8c71 100644 --- a/api/services/workflow/node_output_inspector_service.py +++ b/api/services/workflow/node_output_inspector_service.py @@ -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: diff --git a/api/tests/unit_tests/clients/agent_backend/test_client.py b/api/tests/unit_tests/clients/agent_backend/test_client.py index 19a1d41af5..6b61c920a7 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_client.py +++ b/api/tests/unit_tests/clients/agent_backend/test_client.py @@ -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", ) diff --git a/api/tests/unit_tests/clients/agent_backend/test_fake_client.py b/api/tests/unit_tests/clients/agent_backend/test_fake_client.py index ddeed9621a..9b3e206031 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_fake_client.py +++ b/api/tests/unit_tests/clients/agent_backend/test_fake_client.py @@ -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", ) diff --git a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py index de1b5c7190..87257475f2 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py +++ b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py @@ -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.", diff --git a/api/tests/unit_tests/controllers/console/test_files.py b/api/tests/unit_tests/controllers/console/test_files.py index f6ef1cb824..e62af24c08 100644 --- a/api/tests/unit_tests/controllers/console/test_files.py +++ b/api/tests/unit_tests/controllers/console/test_files.py @@ -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 diff --git a/api/tests/unit_tests/controllers/files/test_upload.py b/api/tests/unit_tests/controllers/files/test_upload.py index ff6ba0e9a1..7c98b088ce 100644 --- a/api/tests/unit_tests/controllers/files/test_upload.py +++ b/api/tests/unit_tests/controllers/files/test_upload.py @@ -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): diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py index 844f04fe72..45f6ad3103 100644 --- a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py +++ b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py @@ -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""" diff --git a/api/tests/unit_tests/controllers/service_api/app/test_file.py b/api/tests/unit_tests/controllers/service_api/app/test_file.py index e44f6cd06c..184723a06a 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_file.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_file.py @@ -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 diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py index f3348b117b..a7287d3f2b 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -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: diff --git a/api/tests/unit_tests/core/plugin/entities/test_request.py b/api/tests/unit_tests/core/plugin/entities/test_request.py new file mode 100644 index 0000000000..05ff074ea9 --- /dev/null +++ b/api/tests/unit_tests/core/plugin/entities/test_request.py @@ -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", + }, + } + ) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py index 6b1f737c96..81ca22dbb9 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py @@ -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()) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_file_tenant_validator.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_file_tenant_validator.py index 7188990b33..2d25968fe7 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_file_tenant_validator.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_file_tenant_validator.py @@ -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 + ) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_adapter.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_adapter.py index 96af5e4892..49e24cc677 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_adapter.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_adapter.py @@ -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(): diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_type_checker.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_type_checker.py index b0e88c7a95..3c472b3fb5 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_type_checker.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_type_checker.py @@ -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": ""}``.""" - 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 # ────────────────────────────────────────────────────────────────────────────── diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 865707126c..8286240f8f 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -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 → 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"] diff --git a/api/tests/unit_tests/factories/test_file_factory.py b/api/tests/unit_tests/factories/test_file_factory.py index cae43ee2fc..963612e68a 100644 --- a/api/tests/unit_tests/factories/test_file_factory.py +++ b/api/tests/unit_tests/factories/test_file_factory.py @@ -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 diff --git a/api/tests/unit_tests/models/test_agent_config_entities.py b/api/tests/unit_tests/models/test_agent_config_entities.py new file mode 100644 index 0000000000..51e51fb6d4 --- /dev/null +++ b/api/tests/unit_tests/models/test_agent_config_entities.py @@ -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"}], + }, + } + ) diff --git a/api/tests/unit_tests/services/test_file_request_service.py b/api/tests/unit_tests/services/test_file_request_service.py new file mode 100644 index 0000000000..3bab033dfe --- /dev/null +++ b/api/tests/unit_tests/services/test_file_request_service.py @@ -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"}, + ) diff --git a/api/tests/unit_tests/services/workflow/test_node_output_inspector_service.py b/api/tests/unit_tests/services/workflow/test_node_output_inspector_service.py index 601a51e830..6f6c56fd67 100644 --- a/api/tests/unit_tests/services/workflow/test_node_output_inspector_service.py +++ b/api/tests/unit_tests/services/workflow/test_node_output_inspector_service.py @@ -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 # ────────────────────────────────────────────────────────────────────────────── diff --git a/api/uv.lock b/api/uv.lock index 782c7715ad..6b97978229 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -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]] diff --git a/dify-agent/.example.env b/dify-agent/.example.env index 679a467488..c4637f2c98 100644 --- a/dify-agent/.example.env +++ b/dify-agent/.example.env @@ -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 diff --git a/dify-agent/docker/shellctl/Dockerfile b/dify-agent/docker/shellctl/Dockerfile index c4bb051203..24f6460064 100644 --- a/dify-agent/docker/shellctl/Dockerfile +++ b/dify-agent/docker/shellctl/Dockerfile @@ -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 diff --git a/dify-agent/docs/dify-agent/get-started/index.md b/dify-agent/docs/dify-agent/get-started/index.md index ff755aa183..78a7db8437 100644 --- a/dify-agent/docs/dify-agent/get-started/index.md +++ b/dify-agent/docs/dify-agent/get-started/index.md @@ -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: diff --git a/dify-agent/docs/dify-agent/guide/index.md b/dify-agent/docs/dify-agent/guide/index.md index e191caa613..c3662478db 100644 --- a/dify-agent/docs/dify-agent/guide/index.md +++ b/dify-agent/docs/dify-agent/guide/index.md @@ -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 diff --git a/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md b/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md index 795c20b675..eb2e39f298 100644 --- a/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md +++ b/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md @@ -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`. diff --git a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py index fb07c352d1..4ac7029fca 100644 --- a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py +++ b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py @@ -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( diff --git a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py index 90ae65d39b..4b2540506e 100644 --- a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py +++ b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py @@ -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( diff --git a/dify-agent/proto/dify/agent/stub/v1/agent_stub.proto b/dify-agent/proto/dify/agent/stub/v1/agent_stub.proto new file mode 100644 index 0000000000..11a1a4aa54 --- /dev/null +++ b/dify-agent/proto/dify/agent/stub/v1/agent_stub.proto @@ -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; +} diff --git a/dify-agent/pyproject.toml b/dify-agent/pyproject.toml index 4205b738ce..e274d9144f 100644 --- a/dify-agent/pyproject.toml +++ b/dify-agent/pyproject.toml @@ -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", diff --git a/dify-agent/src/dify_agent/agent_stub/__init__.py b/dify-agent/src/dify_agent/agent_stub/__init__.py new file mode 100644 index 0000000000..94a2dab865 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/__init__.py @@ -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] = [] diff --git a/dify-agent/src/dify_agent/agent_stub/cli/__init__.py b/dify-agent/src/dify_agent/agent_stub/cli/__init__.py new file mode 100644 index 0000000000..91bd1f4117 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/cli/__init__.py @@ -0,0 +1,3 @@ +"""Client-safe CLI package for the ``dify-agent`` sandbox command.""" + +__all__: list[str] = [] diff --git a/dify-agent/src/dify_agent/agent_stub/cli/_agent_stub.py b/dify-agent/src/dify_agent/agent_stub/cli/_agent_stub.py new file mode 100644 index 0000000000..2acd714411 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/cli/_agent_stub.py @@ -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"] diff --git a/dify-agent/src/dify_agent/agent_stub/cli/_env.py b/dify-agent/src/dify_agent/agent_stub/cli/_env.py new file mode 100644 index 0000000000..1c906db872 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/cli/_env.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/agent_stub/cli/_files.py b/dify-agent/src/dify_agent/agent_stub/cli/_files.py new file mode 100644 index 0000000000..b46bc7409c --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/cli/_files.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/agent_stub/cli/main.py b/dify-agent/src/dify_agent/agent_stub/cli/main.py new file mode 100644 index 0000000000..87bf870f7c --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/cli/main.py @@ -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"] diff --git a/dify-agent/src/dify_agent/agent_stub/client/__init__.py b/dify-agent/src/dify_agent/agent_stub/client/__init__.py new file mode 100644 index 0000000000..c8413e00cf --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/client/__init__.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py new file mode 100644 index 0000000000..9c546cdbd0 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_grpc.py b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_grpc.py new file mode 100644 index 0000000000..84d02aadc2 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_grpc.py @@ -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 `` 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", +] diff --git a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py new file mode 100644 index 0000000000..f3ea57d3cd --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/agent_stub/client/_errors.py b/dify-agent/src/dify_agent/agent_stub/client/_errors.py new file mode 100644 index 0000000000..b495728ee9 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/client/_errors.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/agent_stub/grpc/__init__.py b/dify-agent/src/dify_agent/agent_stub/grpc/__init__.py new file mode 100644 index 0000000000..d6c26981fe --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/grpc/__init__.py @@ -0,0 +1,3 @@ +"""Internal gRPC helpers for the optional Agent Stub transport.""" + +__all__: list[str] = [] diff --git a/dify-agent/src/dify_agent/agent_stub/grpc/_generated/__init__.py b/dify-agent/src/dify_agent/agent_stub/grpc/_generated/__init__.py new file mode 100644 index 0000000000..60221b58ea --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/grpc/_generated/__init__.py @@ -0,0 +1,3 @@ +"""Generated protobuf and grpclib code for the Agent Stub gRPC contract.""" + +__all__: list[str] = [] diff --git a/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_grpc.py b/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_grpc.py new file mode 100644 index 0000000000..e8850813cc --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_grpc.py @@ -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, + ) diff --git a/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_pb2.py b/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_pb2.py new file mode 100644 index 0000000000..5b07bec9eb --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_pb2.py @@ -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) diff --git a/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_pb2.pyi b/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_pb2.pyi new file mode 100644 index 0000000000..32f1fc443d --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/grpc/_generated/agent_stub_pb2.pyi @@ -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: ... diff --git a/dify-agent/src/dify_agent/agent_stub/grpc/conversions.py b/dify-agent/src/dify_agent/agent_stub/grpc/conversions.py new file mode 100644 index 0000000000..3bfa4c5066 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/grpc/conversions.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py b/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py new file mode 100644 index 0000000000..c9b4d429e1 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py b/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py new file mode 100644 index 0000000000..1a98778f9e --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/agent_stub/server/__init__.py b/dify-agent/src/dify_agent/agent_stub/server/__init__.py new file mode 100644 index 0000000000..4521aa7bc0 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/server/__init__.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/agent_stub/server/agent_stub_files.py b/dify-agent/src/dify_agent/agent_stub/server/agent_stub_files.py new file mode 100644 index 0000000000..06765f0897 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/server/agent_stub_files.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/agent_stub/server/app.py b/dify-agent/src/dify_agent/agent_stub/server/app.py new file mode 100644 index 0000000000..fa329cd476 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/server/app.py @@ -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"] diff --git a/dify-agent/src/dify_agent/agent_stub/server/cli.py b/dify-agent/src/dify_agent/agent_stub/server/cli.py new file mode 100644 index 0000000000..734a66f20a --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/server/cli.py @@ -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"] diff --git a/dify-agent/src/dify_agent/agent_stub/server/control_plane.py b/dify-agent/src/dify_agent/agent_stub/server/control_plane.py new file mode 100644 index 0000000000..89c9b2e42d --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/server/control_plane.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/agent_stub/server/grpc_bind.py b/dify-agent/src/dify_agent/agent_stub/server/grpc_bind.py new file mode 100644 index 0000000000..9f4ee9e000 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/server/grpc_bind.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/agent_stub/server/grpc_runtime.py b/dify-agent/src/dify_agent/agent_stub/server/grpc_runtime.py new file mode 100644 index 0000000000..0ef28ae5dc --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/server/grpc_runtime.py @@ -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"] diff --git a/dify-agent/src/dify_agent/agent_stub/server/grpc_service.py b/dify-agent/src/dify_agent/agent_stub/server/grpc_service.py new file mode 100644 index 0000000000..48749a4843 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/server/grpc_service.py @@ -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"] diff --git a/dify-agent/src/dify_agent/agent_stub/server/router.py b/dify-agent/src/dify_agent/agent_stub/server/router.py new file mode 100644 index 0000000000..dadde9b053 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/server/router.py @@ -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"] diff --git a/dify-agent/src/dify_agent/agent_stub/server/routes/__init__.py b/dify-agent/src/dify_agent/agent_stub/server/routes/__init__.py new file mode 100644 index 0000000000..fc580486a6 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/server/routes/__init__.py @@ -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"] diff --git a/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py b/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py new file mode 100644 index 0000000000..cdadb73864 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py @@ -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"] diff --git a/dify-agent/src/dify_agent/agent_stub/server/shell_agent_stub_env.py b/dify-agent/src/dify_agent/agent_stub/server/shell_agent_stub_env.py new file mode 100644 index 0000000000..1ed72213e8 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/server/shell_agent_stub_env.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/agent_stub/server/tokens/__init__.py b/dify-agent/src/dify_agent/agent_stub/server/tokens/__init__.py new file mode 100644 index 0000000000..278b3aaccb --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/server/tokens/__init__.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/agent_stub/server/tokens/agent_stub.py b/dify-agent/src/dify_agent/agent_stub/server/tokens/agent_stub.py new file mode 100644 index 0000000000..7dc87e8d42 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/server/tokens/agent_stub.py @@ -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 `` 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", +] diff --git a/dify-agent/src/dify_agent/layers/execution_context/configs.py b/dify-agent/src/dify_agent/layers/execution_context/configs.py index f72e59292a..2b042add7b 100644 --- a/dify-agent/src/dify_agent/layers/execution_context/configs.py +++ b/dify-agent/src/dify_agent/layers/execution_context/configs.py @@ -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", ] diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py index 35aa69d4b3..b51174d972 100644 --- a/dify-agent/src/dify_agent/layers/shell/layer.py +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -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", diff --git a/dify-agent/src/dify_agent/protocol/__init__.py b/dify-agent/src/dify_agent/protocol/__init__.py index 2e3c959548..c31800e3bf 100644 --- a/dify-agent/src/dify_agent/protocol/__init__.py +++ b/dify-agent/src/dify_agent/protocol/__init__.py @@ -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, diff --git a/dify-agent/src/dify_agent/runtime/compositor_factory.py b/dify-agent/src/dify_agent/runtime/compositor_factory.py index 88dfd65e9a..8454513af2 100644 --- a/dify-agent/src/dify_agent/runtime/compositor_factory.py +++ b/dify-agent/src/dify_agent/runtime/compositor_factory.py @@ -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), diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py index 1c59d575ea..90e422b643 100644 --- a/dify-agent/src/dify_agent/server/app.py +++ b/dify-agent/src/dify_agent/server/app.py @@ -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 diff --git a/dify-agent/src/dify_agent/server/settings.py b/dify-agent/src/dify_agent/server/settings.py index 9d48c3bfe6..7c24fbb9f9 100644 --- a/dify-agent/src/dify_agent/server/settings.py +++ b/dify-agent/src/dify_agent/server/settings.py @@ -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"] diff --git a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py new file mode 100644 index 0000000000..3f2e044be5 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py @@ -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)) diff --git a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py new file mode 100644 index 0000000000..b6fa85049b --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py @@ -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" diff --git a/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_client.py b/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_client.py new file mode 100644 index 0000000000..b97dff3e3e --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_client.py @@ -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" diff --git a/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py b/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py new file mode 100644 index 0000000000..b2dd3629c1 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py @@ -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", + ) diff --git a/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_grpc_conversions.py b/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_grpc_conversions.py new file mode 100644 index 0000000000..eaefac6a2b --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_grpc_conversions.py @@ -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" + ) diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_app.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_app.py new file mode 100644 index 0000000000..be8d9e4031 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_app.py @@ -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"} diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_files.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_files.py new file mode 100644 index 0000000000..c9b0c3fab6 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_files.py @@ -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()) diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py new file mode 100644 index 0000000000..fb3fd264f2 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py @@ -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"} diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_cli.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_cli.py new file mode 100644 index 0000000000..0d1f5b89c0 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_cli.py @@ -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" diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_grpc_bind.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_grpc_bind.py new file mode 100644 index 0000000000..0fe7ad3666 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_grpc_bind.py @@ -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) diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_grpc_service.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_grpc_service.py new file mode 100644 index 0000000000..addcec7cf6 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_grpc_service.py @@ -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()) diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_router.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_router.py new file mode 100644 index 0000000000..3f8f4b18b0 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_router.py @@ -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) diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/tokens/test_agent_stub.py b/dify-agent/tests/local/dify_agent/agent_stub/server/tokens/test_agent_stub.py new file mode 100644 index 0000000000..75823ef501 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/tokens/test_agent_stub.py @@ -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]}!") diff --git a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py index 515e187ef3..a1cb137c10 100644 --- a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py +++ b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py @@ -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: diff --git a/dify-agent/tests/local/dify_agent/layers/execution_context/test_configs.py b/dify-agent/tests/local/dify_agent/layers/execution_context/test_configs.py index 1cd268ad2f..cf41a884f4 100644 --- a/dify-agent/tests/local/dify_agent/layers/execution_context/test_configs.py +++ b/dify-agent/tests/local/dify_agent/layers/execution_context/test_configs.py @@ -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", } ) diff --git a/dify-agent/tests/local/dify_agent/layers/execution_context/test_layer.py b/dify-agent/tests/local/dify_agent/layers/execution_context/test_layer.py index 6757cf7d7b..ce09374417 100644 --- a/dify-agent/tests/local/dify_agent/layers/execution_context/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/execution_context/test_layer.py @@ -9,7 +9,13 @@ from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer def _execution_context_layer() -> DifyExecutionContextLayer: return DifyExecutionContextLayer.from_config_with_settings( - DifyExecutionContextLayerConfig(tenant_id="tenant-1", user_id="user-1", invoke_from="workflow_run"), + DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_id="user-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), daemon_url="http://plugin-daemon", daemon_api_key="daemon-secret", ) @@ -89,7 +95,9 @@ def test_execution_context_layer_lifecycle_does_not_manage_http_client() -> None "execution_context": DifyExecutionContextLayerConfig( tenant_id="tenant-1", user_id="user-1", - invoke_from="workflow_run", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", ) } ) as run: diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py index 638da96ddc..c1459b8df2 100644 --- a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py @@ -1,5 +1,5 @@ import asyncio -from collections.abc import Callable +from collections.abc import Callable, Mapping import secrets import time from dataclasses import dataclass @@ -8,6 +8,9 @@ import pytest from agenton.compositor import Compositor, LayerNode, LayerProvider from agenton.layers import LifecycleState +from dify_agent.agent_stub.server.shell_agent_stub_env import AGENT_STUB_AUTH_JWE_ENV_VAR, AGENT_STUB_URL_ENV_VAR +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig +from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer from dify_agent.layers.shell import ( DIFY_SHELL_LAYER_TYPE_ID, DifyShellCliToolConfig, @@ -80,6 +83,7 @@ class RunCall: script: str cwd: str | None timeout: float + env: Mapping[str, str] | None @dataclass(slots=True) @@ -122,7 +126,7 @@ class FakeShellctlClient: def __init__( self, *, - run_handler: Callable[[str, str | None, float], JobResult] | None = None, + run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], JobResult] | None = None, wait_handler: Callable[[str, int, float], JobResult] | None = None, input_handler: Callable[[str, str, int, float], JobResult] | None = None, terminate_handler: Callable[[str, float], JobStatusView] | None = None, @@ -141,12 +145,19 @@ class FakeShellctlClient: self.events = [] self.closed = False - async def run(self, script: str, *, cwd: str | None = None, timeout: float = 10.0) -> JobResult: - self.run_calls.append(RunCall(script=script, cwd=cwd, timeout=timeout)) + async def run( + self, + script: str, + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + timeout: float = 10.0, + ) -> JobResult: + self.run_calls.append(RunCall(script=script, cwd=cwd, timeout=timeout, env=env)) self.events.append(("run", script)) if self._run_handler is None: raise AssertionError("Unexpected run() call") - return self._run_handler(script, cwd, timeout) + return self._run_handler(script, cwd, env, timeout) async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> JobResult: self.wait_calls.append(WaitCall(job_id=job_id, offset=offset, timeout=timeout)) @@ -197,6 +208,29 @@ def _shell_layer( ) +def _execution_context_layer() -> DifyExecutionContextLayer: + return DifyExecutionContextLayer.from_config_with_settings( + DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_id="user-1", + user_from="account", + app_id="app-1", + workflow_id="workflow-1", + workflow_run_id="workflow-run-1", + node_id="node-1", + node_execution_id="node-execution-1", + conversation_id="conversation-1", + agent_id="agent-1", + agent_config_version_id="agent-config-version-1", + agent_mode="workflow_run", + invoke_from="service-api", + trace_id="trace-1", + ), + daemon_url="http://plugin-daemon", + daemon_api_key="daemon-secret", + ) + + def _shell_provider(*, client_factory: ShellctlClientFactory) -> LayerProvider[DifyShellLayer]: return LayerProvider.from_factory( layer_type=DifyShellLayer, @@ -219,8 +253,9 @@ def test_shell_layer_create_generates_5_plus_2_hex_session_id_and_retries_worksp monkeypatch.setattr(time, "time", lambda: 0x12345F) monkeypatch.setattr(secrets, "token_hex", lambda nbytes: next(random_suffixes)) - def run_handler(script: str, cwd: str | None, timeout: float) -> JobResult: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: assert cwd is None + assert env is None assert timeout == 30.0 if "2345faa" in script: return _job_result("mkdir-collision", status=JobStatusName.EXITED, done=True, exit_code=17) @@ -270,7 +305,7 @@ def test_shell_layer_suspend_leaves_client_open_until_resource_context_exits() - def test_shell_layer_suspend_and_resume_reuse_state_with_fresh_clients() -> None: first_client = FakeShellctlClient( - run_handler=lambda _script, _cwd, _timeout: _job_result( + run_handler=lambda _script, _cwd, _env, _timeout: _job_result( "mkdir-job", status=JobStatusName.EXITED, done=True, @@ -325,9 +360,10 @@ def test_shell_layer_suspend_and_resume_reuse_state_with_fresh_clients() -> None def test_shell_layer_delete_removes_workspace_then_force_deletes_tracked_jobs_and_closes_client() -> None: - def run_handler(script: str, cwd: str | None, timeout: float) -> JobResult: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: assert script == 'rm -rf -- "$HOME/workspace/abc12ff"' assert cwd is None + assert env is None assert timeout == 30.0 return _job_result("cleanup-job", status=JobStatusName.RUNNING, done=False, offset=3) @@ -364,7 +400,7 @@ def test_shell_layer_delete_removes_workspace_then_force_deletes_tracked_jobs_an def test_shell_layer_create_failure_force_deletes_internal_jobs_before_reraising() -> None: client = FakeShellctlClient( - run_handler=lambda _script, _cwd, _timeout: _job_result( + run_handler=lambda _script, _cwd, _env, _timeout: _job_result( "mkdir-failed", status=JobStatusName.EXITED, done=True, @@ -391,7 +427,8 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte monkeypatch.setattr(time, "time", lambda: 0xABC12) monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "ff") - def run_handler(script: str, cwd: str | None, timeout: float) -> JobResult: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: + assert env is None if cwd is None: assert timeout == 30.0 return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0) @@ -429,10 +466,11 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -> None: - def run_handler(script: str, cwd: str | None, timeout: float) -> JobResult: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: assert script.endswith("\npwd") assert '. ".dify/env.sh"' in script assert cwd == "~/workspace/abc12ff" + assert env is None assert timeout == 2.5 return _job_result( "user-job", @@ -543,6 +581,84 @@ def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() - assert client.closed is True +def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() -> None: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: + del cwd, timeout + if script.endswith("\npwd"): + assert '. ".dify/env.sh"' in script + assert env is not None + return _job_result("user-job", status=JobStatusName.EXITED, done=True, exit_code=0) + assert env is None + return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0) + + client = FakeShellctlClient(run_handler=run_handler) + layer = DifyShellLayer.from_config_with_settings( + DifyShellLayerConfig(), + shellctl_entrypoint="http://shellctl", + shellctl_client_factory=lambda _entrypoint: client, + agent_stub_url="https://agent.example.com/agent-stub", + agent_stub_token_factory=lambda execution_context, *, session_id: ( + f"token-for:{execution_context.tenant_id}:{session_id}" + ), + ) + layer.deps = layer.deps_type(execution_context=_execution_context_layer()) + tools = {tool.name: tool for tool in layer.tools} + + async def scenario() -> None: + async with layer.resource_context(): + await layer.on_context_create() + run_result = await tools["shell_run"].function_schema.call( + {"script": "pwd"}, + None, # pyright: ignore[reportArgumentType] + ) + assert run_result["job_id"] == "user-job" + + asyncio.run(scenario()) + + user_run_call = next(call for call in client.run_calls if call.script.endswith("\npwd")) + internal_run_calls = [call for call in client.run_calls if not call.script.endswith("\npwd")] + + assert user_run_call.env == { + AGENT_STUB_URL_ENV_VAR: "https://agent.example.com/agent-stub", + AGENT_STUB_AUTH_JWE_ENV_VAR: f"token-for:tenant-1:{layer.runtime_state.session_id}", + } + assert internal_run_calls + assert all(call.env is None for call in internal_run_calls) + + +def test_shell_layer_skips_agent_stub_env_without_execution_context_dependency() -> None: + client = FakeShellctlClient( + run_handler=lambda _script, _cwd, _env, _timeout: _job_result( + "user-job", + status=JobStatusName.EXITED, + done=True, + exit_code=0, + ) + ) + layer = DifyShellLayer.from_config_with_settings( + DifyShellLayerConfig(), + shellctl_entrypoint="http://shellctl", + shellctl_client_factory=lambda _entrypoint: client, + agent_stub_url="https://agent.example.com/agent-stub", + agent_stub_token_factory=lambda execution_context, *, session_id: ( + f"token-for:{execution_context.tenant_id}:{session_id}" + ), + ) + tools = {tool.name: tool for tool in layer.tools} + + async def scenario() -> None: + async with layer.resource_context(): + layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + _ = await tools["shell_run"].function_schema.call( + {"script": "pwd"}, + None, # pyright: ignore[reportArgumentType] + ) + + asyncio.run(scenario()) + + assert client.run_calls[0].env is None + + def test_shell_layer_tools_reject_untracked_job_ids_without_shellctl_calls() -> None: client = FakeShellctlClient() layer = _shell_layer(client_factory=lambda _entrypoint: client) diff --git a/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py b/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py index e64eb4953f..76606b9132 100644 --- a/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py +++ b/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py @@ -102,11 +102,13 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_ prompt_config = PromptLayerConfig(prefix="system", user="hello") execution_context_config = DifyExecutionContextLayerConfig( tenant_id="tenant-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="service-api", trace_id="trace-1", ) llm_config = DifyPluginLLMLayerConfig( @@ -156,6 +158,7 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_ assert payload["composition"]["layers"][1]["config"] == { "tenant_id": "tenant-1", "user_id": None, + "user_from": "account", "app_id": None, "workflow_id": "workflow-1", "workflow_run_id": "workflow-run-1", @@ -164,7 +167,8 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_ "conversation_id": None, "agent_id": None, "agent_config_version_id": None, - "invoke_from": "workflow_run", + "agent_mode": "workflow_run", + "invoke_from": "service-api", "trace_id": "trace-1", } assert payload["purpose"] == "workflow_node" @@ -209,7 +213,12 @@ def test_create_run_request_accepts_plugin_tools_layer_with_prepared_parameters_ { "name": "execution_context", "type": DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, - "config": {"tenant_id": "tenant-1", "invoke_from": "workflow_run"}, + "config": { + "tenant_id": "tenant-1", + "user_from": "account", + "agent_mode": "workflow_run", + "invoke_from": "service-api", + }, }, { "name": DIFY_AGENT_MODEL_LAYER_ID, @@ -338,7 +347,12 @@ def test_create_run_request_rejects_removed_top_level_execution_context() -> Non _ = CreateRunRequest.model_validate( { "composition": {"layers": []}, - "execution_context": {"tenant_id": "tenant-1", "invoke_from": "workflow_run"}, + "execution_context": { + "tenant_id": "tenant-1", + "user_from": "account", + "agent_mode": "workflow_run", + "invoke_from": "service-api", + }, } ) diff --git a/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py b/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py index 8808cf7a96..687b03bcb2 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py @@ -1,7 +1,9 @@ import pytest import dify_agent.runtime.compositor_factory as compositor_factory_module +from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.layers.shell.layer import DifyShellLayer from dify_agent.runtime.compositor_factory import create_default_layer_providers @@ -75,3 +77,29 @@ def test_shell_provider_rejects_blank_settings_entrypoint_only_when_shell_layer_ with pytest.raises(ValueError, match="DIFY_AGENT_SHELLCTL_ENTRYPOINT"): _ = shell_provider.create_layer(DifyShellLayerConfig()) + + +def test_default_layer_providers_build_agent_stub_token_factory_from_agent_stub_codec() -> None: + codec = AgentStubTokenCodec.from_server_secret("MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTE") + + providers = create_default_layer_providers( + shellctl_entrypoint="http://shellctl.example", + agent_stub_url="https://agent.example.com/agent-stub", + agent_stub_token_codec=codec, + ) + shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID) + shell_layer = shell_provider.create_layer(DifyShellLayerConfig()) + + token = shell_layer.agent_stub_token_factory( + DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_id="user-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), + session_id="abc12ff", + ) + + assert isinstance(token, str) + assert token diff --git a/dify-agent/tests/local/dify_agent/runtime/test_runner.py b/dify-agent/tests/local/dify_agent/runtime/test_runner.py index c826a76522..93f18446d5 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_runner.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_runner.py @@ -58,15 +58,22 @@ class StaticToolsTestLayer(ToolsLayer): class FakeRunnerShellctlClient: - run_calls: list[tuple[str, str | None, float]] + run_calls: list[tuple[str, str | None, Mapping[str, str] | None, float]] closed: bool def __init__(self) -> None: self.run_calls = [] self.closed = False - async def run(self, script: str, *, cwd: str | None = None, timeout: float = 10.0) -> JobResult: - self.run_calls.append((script, cwd, timeout)) + async def run( + self, + script: str, + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + timeout: float = 10.0, + ) -> JobResult: + self.run_calls.append((script, cwd, env, timeout)) return JobResult( job_id="mkdir-job", status=JobStatusName.EXITED, @@ -127,7 +134,12 @@ def _request( RunLayerSpec( name=execution_context_layer_name, type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, - config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), + config=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), ), RunLayerSpec( name=llm_layer_name, @@ -383,7 +395,12 @@ def test_runner_passes_dynamic_dify_plugin_tools_to_agent(monkeypatch: pytest.Mo RunLayerSpec( name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, - config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), + config=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, @@ -472,7 +489,12 @@ def test_runner_rejects_duplicate_tool_names_across_dynamic_tool_layers( RunLayerSpec( name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, - config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), + config=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, @@ -591,7 +613,12 @@ def test_runner_rejects_duplicate_tool_names_between_static_and_dynamic_tools( RunLayerSpec( name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, - config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), + config=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, @@ -701,7 +728,12 @@ def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers( RunLayerSpec( name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, - config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), + config=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, @@ -1206,7 +1238,12 @@ def test_runner_rejects_misnamed_output_layer_before_model_resolution(monkeypatc RunLayerSpec( name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, - config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), + config=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, @@ -1275,7 +1312,12 @@ def test_runner_rejects_multiple_output_layers_before_model_resolution(monkeypat RunLayerSpec( name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, - config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), + config=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, @@ -1366,7 +1408,12 @@ def test_runner_rejects_reserved_output_name_with_wrong_layer_type_before_model_ RunLayerSpec( name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, - config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), + config=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, @@ -1609,7 +1656,12 @@ def test_runner_treats_missing_shell_entrypoint_as_validation_error() -> None: RunLayerSpec( name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, - config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), + config=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, @@ -1656,7 +1708,12 @@ def test_runner_treats_invalid_shell_snapshot_offsets_as_validation_error() -> N RunLayerSpec( name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, - config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), + config=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, diff --git a/dify-agent/tests/local/dify_agent/server/test_app.py b/dify-agent/tests/local/dify_agent/server/test_app.py index 2ed57a963c..534b42e764 100644 --- a/dify-agent/tests/local/dify_agent/server/test_app.py +++ b/dify-agent/tests/local/dify_agent/server/test_app.py @@ -1,8 +1,11 @@ from __future__ import annotations import asyncio +import base64 +import time from typing import ClassVar +import httpx import pytest from fastapi.testclient import TestClient from shell_session_manager.shellctl.client import ShellctlClient @@ -18,6 +21,35 @@ from dify_agent.server.settings import ServerSettings from dify_agent.storage.redis_run_store import RedisRunStore +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 _patch_app_lifecycle(monkeypatch: pytest.MonkeyPatch) -> tuple[FakeRedis, FakePluginDaemonHttpClient]: + fake_redis = FakeRedis() + fake_http_client = FakePluginDaemonHttpClient() + FakeRunScheduler.created.clear() + FakeRedisModule.fake_redis = fake_redis + monkeypatch.setattr(app_module, "Redis", FakeRedisModule) + monkeypatch.setattr(app_module, "RunScheduler", FakeRunScheduler) + + def fake_create_plugin_daemon_http_client(_settings: ServerSettings) -> FakePluginDaemonHttpClient: + return fake_http_client + + monkeypatch.setattr(app_module, "create_plugin_daemon_http_client", fake_create_plugin_daemon_http_client) + return fake_redis, fake_http_client + + class FakeRedis: closed: bool @@ -78,6 +110,16 @@ class FakePluginDaemonHttpClient: self.is_closed = True +class FakeAgentStubGRPCServer: + closed: bool + + def __init__(self) -> None: + self.closed = False + + async def aclose(self) -> None: + self.closed = True + + class FakeTimeout: connect: float read: float @@ -118,17 +160,7 @@ class FakeHttpxModule: def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pytest.MonkeyPatch) -> None: - fake_redis = FakeRedis() - fake_http_client = FakePluginDaemonHttpClient() - FakeRunScheduler.created.clear() - FakeRedisModule.fake_redis = fake_redis - monkeypatch.setattr(app_module, "Redis", FakeRedisModule) - monkeypatch.setattr(app_module, "RunScheduler", FakeRunScheduler) - - def fake_create_plugin_daemon_http_client(_settings: ServerSettings) -> FakePluginDaemonHttpClient: - return fake_http_client - - monkeypatch.setattr(app_module, "create_plugin_daemon_http_client", fake_create_plugin_daemon_http_client) + fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch) settings = ServerSettings( redis_url="redis://example.invalid/0", @@ -139,6 +171,10 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt plugin_daemon_api_key="daemon-secret", shellctl_entrypoint="http://shellctl", shellctl_auth_token="shell-secret", + 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", plugin_daemon_connect_timeout=1, plugin_daemon_read_timeout=2, plugin_daemon_write_timeout=3, @@ -158,7 +194,12 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt provider for provider in layer_providers if provider.type_id == "dify.execution_context" ) execution_context_layer = execution_context_provider.create_layer( - DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run") + DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ) ) shell_provider = next(provider for provider in layer_providers if provider.type_id == "dify.shell") shell_layer = shell_provider.create_layer(DifyShellLayerConfig()) @@ -167,6 +208,7 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt assert execution_context_layer.daemon_url == "http://plugin-daemon" assert execution_context_layer.daemon_api_key == "daemon-secret" assert shell_layer.shellctl_entrypoint == "http://shellctl" + assert shell_layer.agent_stub_url == "https://agent.example.com/agent-stub" shellctl_client = shell_layer.shellctl_client_factory("http://shellctl") assert isinstance(shellctl_client, ShellctlClient) assert shellctl_client.token == "shell-secret" @@ -177,12 +219,113 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt store = scheduler.store assert isinstance(store, RedisRunStore) assert store.run_retention_seconds == 7 + assert any(getattr(route, "path", None) == "/agent-stub/connections" for route in create_app(settings).routes) + assert any( + getattr(route, "path", None) == "/agent-stub/files/upload-request" for route in create_app(settings).routes + ) + assert any( + getattr(route, "path", None) == "/agent-stub/files/download-request" + for route in create_app(settings).routes + ) assert FakeRunScheduler.created[0].shutdown_called is True assert FakeRunScheduler.created[0].plugin_daemon_http_client.is_closed is True assert fake_redis.closed is True +def test_create_app_wires_authenticated_agent_stub_connection_route(monkeypatch: pytest.MonkeyPatch) -> None: + fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch) + settings = ServerSettings( + redis_url="redis://example.invalid/0", + agent_stub_url="https://agent.example.com/agent-stub", + server_secret_key=_base64url_secret(b"1" * 32), + ) + 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) + + with TestClient(create_app(settings)) as client: + 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) + assert FakeRunScheduler.created[0].shutdown_called is True + assert fake_http_client.is_closed is True + assert fake_redis.closed is True + + +def test_create_app_wires_authenticated_agent_stub_file_upload_route(monkeypatch: pytest.MonkeyPatch) -> None: + fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch) + settings = ServerSettings( + redis_url="redis://example.invalid/0", + 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), + ) + + with TestClient(create_app(settings)) as client: + 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"} + assert FakeRunScheduler.created[0].shutdown_called is True + assert fake_http_client.is_closed is True + assert fake_redis.closed is True + + +def test_create_app_starts_and_stops_agent_stub_grpc_server_for_grpc_url(monkeypatch: pytest.MonkeyPatch) -> None: + fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch) + started: dict[str, object] = {} + fake_grpc_server = FakeAgentStubGRPCServer() + + async def fake_start_agent_stub_grpc_server(**kwargs): + started.update(kwargs) + return fake_grpc_server + + monkeypatch.setattr(app_module, "start_agent_stub_grpc_server", fake_start_agent_stub_grpc_server) + + settings = ServerSettings( + redis_url="redis://example.invalid/0", + agent_stub_url="grpc://agent.example.com:9091", + agent_stub_grpc_bind_address="0.0.0.0:9191", + server_secret_key=_base64url_secret(b"1" * 32), + ) + + with TestClient(create_app(settings)): + assert started["public_url"] == "grpc://agent.example.com:9091" + assert started["bind_address"] == "0.0.0.0:9191" + + assert fake_grpc_server.closed is True + assert FakeRunScheduler.created[0].shutdown_called is True + assert fake_http_client.is_closed is True + assert fake_redis.closed is True + + def test_create_plugin_daemon_http_client_uses_configured_httpx_construction_args( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/dify-agent/tests/local/dify_agent/server/test_runs_routes.py b/dify-agent/tests/local/dify_agent/server/test_runs_routes.py index a33590e208..d925580d6e 100644 --- a/dify-agent/tests/local/dify_agent/server/test_runs_routes.py +++ b/dify-agent/tests/local/dify_agent/server/test_runs_routes.py @@ -106,7 +106,12 @@ def test_create_run_accepts_valid_full_plugin_graph() -> None: { "name": "execution-context-renamed", "type": "dify.execution_context", - "config": {"tenant_id": "tenant-1", "invoke_from": "workflow_run"}, + "config": { + "tenant_id": "tenant-1", + "user_from": "account", + "agent_mode": "workflow_run", + "invoke_from": "service-api", + }, }, { "name": DIFY_AGENT_MODEL_LAYER_ID, diff --git a/dify-agent/tests/local/dify_agent/server/test_settings.py b/dify-agent/tests/local/dify_agent/server/test_settings.py index 86fd862927..07b8e09f53 100644 --- a/dify-agent/tests/local/dify_agent/server/test_settings.py +++ b/dify-agent/tests/local/dify_agent/server/test_settings.py @@ -1,10 +1,22 @@ +from __future__ import annotations + from pathlib import Path +import secrets import pytest +from pydantic import ValidationError +from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler +from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec from dify_agent.server.settings import ServerSettings +def _base64url_secret(value: bytes) -> str: + import base64 + + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + def test_server_settings_reads_shellctl_entrypoint_from_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DIFY_AGENT_SHELLCTL_ENTRYPOINT", "http://shellctl.example") @@ -31,3 +43,138 @@ def test_server_settings_defaults_shellctl_auth_token_to_none( settings = ServerSettings() assert settings.shellctl_auth_token is None + + +def test_server_settings_reads_agent_stub_settings_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub/") + monkeypatch.setenv("DIFY_AGENT_SERVER_SECRET_KEY", _base64url_secret(secrets.token_bytes(32))) + + settings = ServerSettings() + + assert settings.agent_stub_url == "https://agent.example.com/agent-stub" + + +def test_server_settings_ignores_obsolete_legacy_settings_namespace(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DIFY_AGENT_SHELL_BACK_PROXY_PUBLIC_URL", "https://agent.example.com/back-proxy/") + monkeypatch.setenv("DIFY_AGENT_BACK_PROXY_URL", "https://agent.example.com/back-proxy/") + + settings = ServerSettings() + + assert settings.agent_stub_url is None + + +def test_server_settings_rejects_agent_stub_url_with_query_or_fragment() -> None: + secret = _base64url_secret(secrets.token_bytes(32)) + + with pytest.raises(ValidationError, match="query string or fragment"): + _ = ServerSettings( + agent_stub_url="https://agent.example.com/agent-stub?x=1", + server_secret_key=secret, + ) + + with pytest.raises(ValidationError, match="query string or fragment"): + _ = ServerSettings( + agent_stub_url="https://agent.example.com/agent-stub#fragment", + server_secret_key=secret, + ) + + +def test_server_settings_rejects_public_agent_stub_url_without_secret_key() -> None: + with pytest.raises(ValidationError, match="DIFY_AGENT_SERVER_SECRET_KEY"): + _ = ServerSettings(agent_stub_url="https://agent.example.com/agent-stub") + + +def test_server_settings_accepts_grpc_agent_stub_url_and_bind_override() -> None: + settings = ServerSettings( + agent_stub_url="grpc://agent.example.com:9091", + agent_stub_grpc_bind_address="0.0.0.0:9191", + server_secret_key=_base64url_secret(secrets.token_bytes(32)), + ) + + assert settings.agent_stub_url == "grpc://agent.example.com:9091" + assert settings.agent_stub_grpc_bind_address == "0.0.0.0:9191" + + +def test_server_settings_rejects_grpc_bind_override_without_grpc_url() -> None: + with pytest.raises(ValidationError, match="grpc://"): + _ = ServerSettings( + agent_stub_url="https://agent.example.com/agent-stub", + agent_stub_grpc_bind_address="0.0.0.0:9191", + server_secret_key=_base64url_secret(secrets.token_bytes(32)), + ) + + +def test_server_settings_rejects_invalid_server_secret_key() -> None: + with pytest.raises(ValidationError, match="32 decoded bytes"): + _ = ServerSettings(server_secret_key=_base64url_secret(b"short")) + + +def test_server_settings_rejects_padded_or_quoted_server_secret_key() -> None: + secret = _base64url_secret(secrets.token_bytes(32)) + + with pytest.raises(ValidationError, match="unpadded base64url"): + _ = ServerSettings(server_secret_key=f"{secret}=") + + with pytest.raises(ValidationError, match="unpadded base64url"): + _ = ServerSettings(server_secret_key=f'"{secret}"') + + +def test_server_settings_normalizes_dify_api_base_url_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DIFY_AGENT_DIFY_API_BASE_URL", "https://api.example.com/") + monkeypatch.setenv("DIFY_AGENT_DIFY_API_INNER_API_KEY", "inner-secret") + + settings = ServerSettings() + + assert settings.dify_api_base_url == "https://api.example.com" + assert settings.dify_api_inner_api_key == "inner-secret" + + +def test_server_settings_requires_dify_api_base_url_and_key_together() -> None: + with pytest.raises(ValidationError, match="DIFY_AGENT_DIFY_API_BASE_URL"): + _ = ServerSettings(dify_api_base_url="https://api.example.com") + + with pytest.raises(ValidationError, match="DIFY_AGENT_DIFY_API_BASE_URL"): + _ = ServerSettings(dify_api_inner_api_key="inner-secret") + + +def test_server_settings_rejects_dify_api_base_url_with_query_or_fragment() -> None: + with pytest.raises(ValidationError, match="query string or fragment"): + _ = ServerSettings( + dify_api_base_url="https://api.example.com?x=1", + dify_api_inner_api_key="inner-secret", + ) + + with pytest.raises(ValidationError, match="query string or fragment"): + _ = ServerSettings( + dify_api_base_url="https://api.example.com#frag", + dify_api_inner_api_key="inner-secret", + ) + + +def test_server_settings_create_agent_stub_token_codec_returns_none_without_secret() -> None: + assert ServerSettings().create_agent_stub_token_codec() is None + + +def test_server_settings_create_agent_stub_token_codec_returns_codec_when_secret_is_configured() -> None: + settings = ServerSettings(server_secret_key=_base64url_secret(secrets.token_bytes(32))) + + codec = settings.create_agent_stub_token_codec() + + assert isinstance(codec, AgentStubTokenCodec) + + +def test_server_settings_create_agent_stub_file_request_handler_returns_none_without_full_settings() -> None: + assert ServerSettings().create_agent_stub_file_request_handler() is None + + +def test_server_settings_create_agent_stub_file_request_handler_returns_handler_when_configured() -> None: + settings = ServerSettings( + dify_api_base_url="https://api.example.com", + dify_api_inner_api_key="inner-secret", + ) + + handler = settings.create_agent_stub_file_request_handler() + + assert isinstance(handler, DifyApiAgentStubFileRequestHandler) + assert handler.dify_api_base_url == "https://api.example.com" + assert handler.dify_api_inner_api_key == "inner-secret" diff --git a/dify-agent/tests/local/dify_agent/test_client_safe_exports.py b/dify-agent/tests/local/dify_agent/test_client_safe_exports.py index 8c12d9346c..7bcf551593 100644 --- a/dify-agent/tests/local/dify_agent/test_client_safe_exports.py +++ b/dify-agent/tests/local/dify_agent/test_client_safe_exports.py @@ -54,7 +54,11 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat requirement_name(requirement) for requirement in pyproject["project"].get("optional-dependencies", {}).get("server", []) } - server_only_dependency_names = server_dependency_names - default_dependency_names + grpc_dependency_names = { + requirement_name(requirement) + for requirement in pyproject["project"].get("optional-dependencies", {}).get("grpc", []) + } + server_only_dependency_names = (server_dependency_names | grpc_dependency_names) - default_dependency_names agenton_layers = importlib.import_module("agenton.layers") agenton_compositor = importlib.import_module("agenton.compositor") @@ -64,6 +68,9 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat dify_agent = importlib.import_module("dify_agent") client_module = importlib.import_module("dify_agent.client") protocol_module = importlib.import_module("dify_agent.protocol") + agent_stub_client_module = importlib.import_module("dify_agent.agent_stub.client") + agent_stub_protocol_module = importlib.import_module("dify_agent.agent_stub.protocol") + agent_stub_cli_main_module = importlib.import_module("dify_agent.agent_stub.cli.main") shell_module = importlib.import_module("dify_agent.layers.shell") execution_context_module = importlib.import_module("dify_agent.layers.execution_context") plugin_module = importlib.import_module("dify_agent.layers.dify_plugin") @@ -79,11 +86,26 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat assert protocol_module.CreateRunRequest is not None assert protocol_module.RunComposition is not None assert protocol_module.RunLayerSpec is not None + assert agent_stub_client_module.connect_agent_stub_sync is not None + assert agent_stub_protocol_module.AgentStubConnectRequest is not None + assert agent_stub_cli_main_module.main is not None assert shell_module.DifyShellLayerConfig is not None assert execution_context_module.DifyExecutionContextLayerConfig is not None assert plugin_module.DifyPluginLLMLayerConfig is not None assert output_module.DifyOutputLayerConfig is not None + grpc_error = importlib.import_module("dify_agent.agent_stub.client._errors").AgentStubMissingGRPCDependencyError + try: + agent_stub_client_module.connect_agent_stub_sync( + url="grpc://agent.example.com:9091", + auth_jwe="test-jwe", + argv=["connect"], + ) + except grpc_error: + pass + else: + raise AssertionError("grpc:// dispatch should fail with AgentStubMissingGRPCDependencyError without grpc extras") + unexpectedly_installed = [] for dependency_name in sorted(server_only_dependency_names): try: diff --git a/dify-agent/tests/local/dify_agent/test_import_boundaries.py b/dify-agent/tests/local/dify_agent/test_import_boundaries.py index db7b683b3e..19f1c6c731 100644 --- a/dify-agent/tests/local/dify_agent/test_import_boundaries.py +++ b/dify-agent/tests/local/dify_agent/test_import_boundaries.py @@ -104,7 +104,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> ], assertions=[ "assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')", - "assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig']", + "assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextAgentMode', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig', 'DifyExecutionContextUserFrom']", "assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']", "assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']", "assert dify_agent_layers_shell.__all__ == ['DIFY_SHELL_LAYER_TYPE_ID', 'DifyShellCliToolConfig', 'DifyShellEnvVarConfig', 'DifyShellLayerConfig', 'DifyShellSandboxConfig', 'DifyShellSecretRefConfig']", @@ -112,6 +112,43 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> ) +def test_agent_stub_cli_main_import_is_client_safe() -> None: + _run_import_check( + blocked_imports=[ + "dify_agent.server", + "dify_agent.agent_stub.server", + "fastapi", + "google.protobuf", + "grpclib", + "jwcrypto", + "pydantic_settings", + "redis", + "shell_session_manager", + ], + imports=["dify_agent.agent_stub.cli.main"], + assertions=["assert hasattr(dify_agent_agent_stub_cli_main, 'main')"], + ) + + +def test_agent_stub_client_and_protocol_imports_are_client_safe() -> None: + _run_import_check( + blocked_imports=[ + "dify_agent.server", + "dify_agent.agent_stub.server", + "fastapi", + "jwcrypto", + "pydantic_settings", + "redis", + "shell_session_manager", + ], + imports=["dify_agent.agent_stub.client", "dify_agent.agent_stub.protocol"], + assertions=[ + "assert hasattr(dify_agent_agent_stub_client, 'connect_agent_stub_sync')", + "assert hasattr(dify_agent_agent_stub_protocol, 'AgentStubConnectRequest')", + ], + ) + + def test_agenton_collection_roots_do_not_eagerly_import_pydantic_ai_implementations() -> None: _run_import_check( blocked_imports=[ diff --git a/dify-agent/tests/local/test_packaging.py b/dify-agent/tests/local/test_packaging.py index 26c5c1828b..6de34442af 100644 --- a/dify-agent/tests/local/test_packaging.py +++ b/dify-agent/tests/local/test_packaging.py @@ -7,20 +7,38 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[2] CLIENT_SHARED_DTO_DEPENDENCIES = { - "httpx>=0.28.1", - "pydantic>=2.12.5,<3", - "pydantic-ai-slim>=1.85.1", - "typing-extensions>=4.12.2", + "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", } SERVER_RUNTIME_DEPENDENCIES = { - "fastapi>=0.136.0", - "graphon~=0.2.2", - "jsonschema>=4.23.0", - "pydantic-ai-slim[anthropic,google,openai]>=1.85.1", - "pydantic-settings>=2.12.0", - "redis>=5", - "uvicorn[standard]>=0.38.0", + "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.2.0", + "uvicorn[standard]==0.46.0", +} + +GRPC_RUNTIME_DEPENDENCIES = { + "grpclib[protobuf]>=0.4.9,<0.5.0", + "protobuf>=6.33.5,<7.0.0", +} + +DEV_DEPENDENCIES = { + "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", + "ruff>=0.15.11", } @@ -33,7 +51,9 @@ def test_project_dependencies_split_client_and_server_requirements() -> None: project = pyproject["project"] assert set(project["dependencies"]) == CLIENT_SHARED_DTO_DEPENDENCIES + assert set(project["optional-dependencies"]["grpc"]) == GRPC_RUNTIME_DEPENDENCIES assert set(project["optional-dependencies"]["server"]) == SERVER_RUNTIME_DEPENDENCIES + assert set(pyproject["dependency-groups"]["dev"]) == DEV_DEPENDENCIES def test_default_package_discovery_excludes_example_packages() -> None: @@ -43,3 +63,13 @@ def test_default_package_discovery_excludes_example_packages() -> None: assert find_config["where"] == ["src"] assert "agenton_examples*" not in find_config["include"] assert "dify_agent_examples*" not in find_config["include"] + + +def test_project_declares_console_script_and_shellctl_docker_version() -> None: + pyproject = _read_pyproject() + scripts = pyproject["project"]["scripts"] + dockerfile = (PROJECT_ROOT / "docker" / "shellctl" / "Dockerfile").read_text(encoding="utf-8") + + assert scripts["dify-agent"] == "dify_agent.agent_stub.cli.main:main" + assert scripts["dify-agent-stub-server"] == "dify_agent.agent_stub.server.cli:main" + assert "shell-session-manager==2.2.0" in dockerfile diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock index a126c15ade..69b54dc113 100644 --- a/dify-agent/uv.lock +++ b/dify-agent/uv.lock @@ -587,14 +587,20 @@ dependencies = [ { name = "httpx" }, { name = "pydantic" }, { name = "pydantic-ai-slim" }, + { name = "typer" }, { name = "typing-extensions" }, ] [package.optional-dependencies] +grpc = [ + { name = "grpclib", extra = ["protobuf"] }, + { name = "protobuf" }, +] server = [ { name = "fastapi" }, { name = "graphon" }, { name = "jsonschema" }, + { name = "jwcrypto" }, { name = "pydantic-ai-slim", extra = ["anthropic", "google", "openai"] }, { name = "pydantic-settings" }, { name = "redis" }, @@ -606,6 +612,7 @@ server = [ dev = [ { name = "basedpyright" }, { name = "coverage" }, + { name = "grpcio-tools" }, { name = "pytest" }, { name = "pytest-examples" }, { name = "pytest-mock" }, @@ -622,23 +629,28 @@ docs = [ 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" }, @@ -886,6 +898,108 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] +[[package]] +name = "grpcio" +version = "1.81.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/f3/23f47b24f8d8c2028eba501db3acfbb2f592cbb5995eaa6e363a627b74d7/grpcio-1.81.0.tar.gz", hash = "sha256:a5acd7efd3b1fe9b4eb0bcaaa1507eed68a0ad0678b654c3f7b464df9ba9dca5", size = 13032272, upload-time = "2026-06-01T05:56:22.827Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/d5/896a3aaf07068d707d88b282a04914b872db4d32d3c7e6d88e43a3b911fa/grpcio-1.81.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:57b3b0e73a518fa286959b40c3eddd02703504ca186e8b7b2945954519bd8b2c", size = 6053538, upload-time = "2026-06-01T05:54:58.965Z" }, + { url = "https://files.pythonhosted.org/packages/68/6a/7e3eafa4727cd405ff917605ed2949e2af162f233f5cbdd773723a5fea7d/grpcio-1.81.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8bb1789c94322a13336a2b6c58d9c14d68f8628b6e24205a799c69f5bf8516ce", size = 12053447, upload-time = "2026-06-01T05:55:01.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/79/a4302aa82428de48a922421f522b027a1a727ab4d0926368454aa953d36d/grpcio-1.81.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e4d053900a0d24b75d7521139a3872150301b3d6bde3bed5e12318fb25791e4d", size = 6595872, upload-time = "2026-06-01T05:55:04.946Z" }, + { url = "https://files.pythonhosted.org/packages/b4/1f/7ff2850eaefbecf99af3f624dbb28dd1ad6c5fd4c1d8c26909ed6482673b/grpcio-1.81.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:db217c2e52931719f9937bd12082cd4d7b495b35803d5760686975c285924bf8", size = 7303857, upload-time = "2026-06-01T05:55:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/1f3896a9baae1f2aedf4e99c55291d6fa1f30ad9603d63bc18bda967b53e/grpcio-1.81.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19f201da7b4e5c0559198abe5a97157e726f3abe6e8f5e832d4a50740f6dcc22", size = 6809676, upload-time = "2026-06-01T05:55:09.513Z" }, + { url = "https://files.pythonhosted.org/packages/34/8b/3441983718095208c5d797fd3239882e97ea89a629f41c8df94b4eef4df9/grpcio-1.81.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:275144b0115353339dbb8a6f28a9cf8997b5bf40e37f8f66ac0b0ea57e95b43f", size = 7412654, upload-time = "2026-06-01T05:55:12.777Z" }, + { url = "https://files.pythonhosted.org/packages/3c/98/1eddf07df6e4fe85cf67502a793f7b05468b2dca3d1ef35b972cf5d54468/grpcio-1.81.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5192857589f223e5a98ff0e31f6e551b19040e647d17bfe10116c8a2ce3b8696", size = 8408026, upload-time = "2026-06-01T05:55:15.514Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/3860341e6a1f5347be6ab35c6c0e1e3a8eb59d010388207fd561dcf01a88/grpcio-1.81.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6ff087cb1f563f47b504b4e29e684129fc5ae4863faf3ebca08a327764ee6cb", size = 7849498, upload-time = "2026-06-01T05:55:18.078Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3f/0ea06bd85c701966aa3f8f37314f2ed83520d2b7590f42d643d445d8bc8b/grpcio-1.81.0-cp312-cp312-win32.whl", hash = "sha256:98c6240f563178fc5877bd50e6ff274463e53e1472128f4110742450739659fa", size = 4184161, upload-time = "2026-06-01T05:55:20.127Z" }, + { url = "https://files.pythonhosted.org/packages/39/e3/a7c387406827a86f99ad7838b995bf9b4a182ffe2d2c439ed2873efec952/grpcio-1.81.0-cp312-cp312-win_amd64.whl", hash = "sha256:87e33b7afcfb3585121b5f007d2c52b8c534104d18f556e840d35193ca2a9141", size = 4929958, upload-time = "2026-06-01T05:55:22.736Z" }, + { url = "https://files.pythonhosted.org/packages/f3/29/779ee53c931d0fd55c1d459fde43e485172caa3ac87cbd43d003a13a0185/grpcio-1.81.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:62bbe463c9f0f2ff24e31bd25f8dd8b4bae78900e315915a3195a0ef1471a855", size = 6054973, upload-time = "2026-06-01T05:55:25.043Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b6/7211807926b5a17f8d9a5d47c739a163d6812fefe3e4714e81cf92945ed7/grpcio-1.81.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43c121e135ae44d1559b430db2b2dfad7421cbbe40e1deba506c7dc62b439719", size = 12048662, upload-time = "2026-06-01T05:55:28.453Z" }, + { url = "https://files.pythonhosted.org/packages/64/89/b1b93ef6b34bd20bbaf707fa99133bc9cc302139d5ec6f77a165c7169796/grpcio-1.81.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f345de40ef2e65f63645d53d251824e6070e07804827c5b00ec2e44555f9f901", size = 6599116, upload-time = "2026-06-01T05:55:31.185Z" }, + { url = "https://files.pythonhosted.org/packages/eb/bc/c89f9b9d1c22895715356a1e009554dae66319e97826bb4d30bcda7d29e8/grpcio-1.81.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8c0855a350886f713b9e458e2a10d208009dcaa849f574e39cd6067db1fe1279", size = 7307591, upload-time = "2026-06-01T05:55:33.463Z" }, + { url = "https://files.pythonhosted.org/packages/65/4a/1df2a4cb4a1386e066ab7e4175e34bb884b35ccb60d3621c09c84af6aabb/grpcio-1.81.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a524cd530900bd24511fcb7f2ed144da4ea37711c4b094475d0bceca7a93a170", size = 6811797, upload-time = "2026-06-01T05:55:36.731Z" }, + { url = "https://files.pythonhosted.org/packages/8d/dc/fa189d20601a1be25b08850cfb733879bbb1047b62a8feec3a60e3e1a87b/grpcio-1.81.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e7746ba3e6efc9e2b748eff59470a2b8684d5a9ec607c6580bcaa5be175820bc", size = 7415131, upload-time = "2026-06-01T05:55:39.451Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/5625c48cb48d23c6631b3e5294f88e4c751f22a52591ae78859fab96dca1/grpcio-1.81.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:aaaa4f7f2057d795952e4eacf3f342be8b5b156992f6ac85023c8b98794ebd47", size = 8408398, upload-time = "2026-06-01T05:55:42.219Z" }, + { url = "https://files.pythonhosted.org/packages/75/34/0f8202c6809a46c2b4d69125ef3667c40b1c211f8e19930e5fa1f1197039/grpcio-1.81.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0fba53cb96004b2b7fb758b46b2288cb49d0b658316a4e73f3ef67230616ee65", size = 7844481, upload-time = "2026-06-01T05:55:44.849Z" }, + { url = "https://files.pythonhosted.org/packages/c0/95/c3366b5b5edf4c4adc90f2e29ca16e57965a8e56dc8d2ee89565ba1905bb/grpcio-1.81.0-cp313-cp313-win32.whl", hash = "sha256:c197e2ef75a442528072b29e9755da299110e8610e8bcbb59a6b4cf55384f005", size = 4182777, upload-time = "2026-06-01T05:55:47.459Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a7/932f2f748511a32e641a2aba0d30dded3ed6e8bc330e0924e4d5d86853e6/grpcio-1.81.0-cp313-cp313-win_amd64.whl", hash = "sha256:194eddfacc84d80f50512e9fd4ee851d5f2499f18f299c95aa8fb4748f0537e0", size = 4928085, upload-time = "2026-06-01T05:55:50.158Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1d/28b231333857deb840bc3d182ae087510170ea6d68f21393aeb0fe499530/grpcio-1.81.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:a9351055f52660b58f3d4890ea66188b5134399f82b11aa0c55bd4b99eff5390", size = 6055712, upload-time = "2026-06-01T05:55:52.889Z" }, + { url = "https://files.pythonhosted.org/packages/e8/b8/999c14f9dff0fc47549d2e827cba1343ddc18e1d1bf0d06d2cf628eecbd9/grpcio-1.81.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:300f3337b6425fd16ead9a4f9b2ac25801acb64aa5bc0b99eb69901645b2b1d2", size = 12057189, upload-time = "2026-06-01T05:55:55.952Z" }, + { url = "https://files.pythonhosted.org/packages/1e/3d/1fbde079572562af65351151d840525a13879eb7b481d35b55cd64c6127a/grpcio-1.81.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:97bbd623f7ded558fd4f7cb5a4f600c4d4de65c5dd364c83a5b14b2a10a2d3b5", size = 6608136, upload-time = "2026-06-01T05:55:59.069Z" }, + { url = "https://files.pythonhosted.org/packages/32/89/1f17cb6882abfd8e5a303a25d5d1665abef5a8c499a96198c65a651d1b85/grpcio-1.81.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ff83d889e3ebf6341c8c7864ad8031591ad5ca61599072fc511644d1eb962d2b", size = 7307045, upload-time = "2026-06-01T05:56:02.376Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/f98e91b2e755652e637ea2144318b0229b290062199f761b445fe1fa6015/grpcio-1.81.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c4fe218c5a35e1d87a5a26544237f1fa41dfd9cbd3c856b0810a30061f8b0aaf", size = 6812794, upload-time = "2026-06-01T05:56:05.777Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0c/77892d715ac41e7ec0ace2a50080ffb64e189188056f607a66fe0014d1ee/grpcio-1.81.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b8b025b6af43ee0ad4a70307025d77bcab5adde7c4597786010d802c203e9fc5", size = 7422767, upload-time = "2026-06-01T05:56:08.524Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b8/aa04590c6564714d94954515f15a236e59d4b9b3ad01e615f1b706d7792d/grpcio-1.81.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:3d4e0ce5a40a998cf608c8ba60ecfe18fdf364a9aa193ae4ac3faeecd0e86757", size = 8408551, upload-time = "2026-06-01T05:56:11.283Z" }, + { url = "https://files.pythonhosted.org/packages/43/3d/4f4a3450a1973568910c6909cb74abbf2126f68aefae5976962f9f7ad50d/grpcio-1.81.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa948712c8e5fa40ec250870bda14bc7578e1bb832a8912d9d2a0f720518edbe", size = 7846468, upload-time = "2026-06-01T05:56:14.536Z" }, + { url = "https://files.pythonhosted.org/packages/88/f4/5827fd248221ad3b44161c23ce9b5f4ee405b04fc6da5fd402a9aa87a84a/grpcio-1.81.0-cp314-cp314-win32.whl", hash = "sha256:fbbe81314a9d92156abce8b62c09364eb8bafc0ca2a19919a45ec64b5c6cb664", size = 4264427, upload-time = "2026-06-01T05:56:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/127dc2b246096ad50ef7c8d9b7b31d757787aeb796368bcdd4454e4204c4/grpcio-1.81.0-cp314-cp314-win_amd64.whl", hash = "sha256:b93cee313cae4e113fbb3a0ce1ea5633db6f63cfde2b2dc1d817429026b2a50b", size = 5070848, upload-time = "2026-06-01T05:56:19.735Z" }, +] + +[[package]] +name = "grpcio-tools" +version = "1.81.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/b5/72f688670ce56ea59b05ea13430f06cbb728dd354dac508544fc7d4b5c95/grpcio_tools-1.81.0.tar.gz", hash = "sha256:0733d773eca8cb461f4f2a1b79c64c123db9661be41b08184b81497b2b991ccb", size = 6235718, upload-time = "2026-06-01T05:58:34.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/e3/3c4f9da489413ef3f3dc9f7bc49a270270ec99fb3a00fd4302a2f59a7be2/grpcio_tools-1.81.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:374fe0435447f283e3c69f719ef1bc66f2e187a239ce25444b2de45cb3a6a744", size = 2585927, upload-time = "2026-06-01T05:57:25.397Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b2/de7aba18f87d722c215ae168add975b9e7729cfaf7a1292be43f87685fa1/grpcio_tools-1.81.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:dce33d09851bc15dead814bd9d21023bffdf0f838ecf65995a2456e5692831fd", size = 5815566, upload-time = "2026-06-01T05:57:27.714Z" }, + { url = "https://files.pythonhosted.org/packages/eb/13/8f71b4830f129d896560c66964a3a8f4e33fbd59854396015e7449b75d3a/grpcio_tools-1.81.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58e65dd13f7ffc25f5a9cd9890fddfd39b3c51e5c3c1acd987813b1dc1173704", size = 2635519, upload-time = "2026-06-01T05:57:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e0/3ad58f1791c346a1fefc69ef3fcd19d63e3778736d4746f12b39f900b78b/grpcio_tools-1.81.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:760775f79bbefa321cd327fe1019a9d6ad0d93acb1ede7b7905c712679542fd7", size = 2958250, upload-time = "2026-06-01T05:57:31.836Z" }, + { url = "https://files.pythonhosted.org/packages/79/8a/3212db57815df0fa2a02e857e402c1abea15ec6b5fb63ebf306d90f2fb07/grpcio_tools-1.81.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7af4faf34376c57f4f3c42aa05f065d3b10e774b8a8d8b27d659d5cc351e5c75", size = 2698437, upload-time = "2026-06-01T05:57:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d5/4245bbb4c14b54ac539b5b59f5298750d75211e144e1e8b35e1af5144d6d/grpcio_tools-1.81.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9945945edc14022abab07caec0ebc16bf51219e586b4f008d09334cc479655c", size = 3152159, upload-time = "2026-06-01T05:57:36.065Z" }, + { url = "https://files.pythonhosted.org/packages/59/b1/59500d9fe41209e0887c66d80879ba80d0cc8e1327e24cc783eb879fe7a7/grpcio_tools-1.81.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:61d9ad6b5c0f3857663701bdc2cbb3da7f7d835ce9a8d597ffca443124a96894", size = 3710468, upload-time = "2026-06-01T05:57:38.367Z" }, + { url = "https://files.pythonhosted.org/packages/0c/14/86fc8b64db62851bf5cb1c945b22da7ab0dec6e0b7002ec374247482d404/grpcio_tools-1.81.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ffa74b84d201bea407f22e00ac0367f12df48a0073b0ffd9f3be9d8126a55245", size = 3370795, upload-time = "2026-06-01T05:57:40.724Z" }, + { url = "https://files.pythonhosted.org/packages/94/95/4fd57d9f948adadbe5b3e8e3b0d3c121ae5fe8276721097ab03361ce7adf/grpcio_tools-1.81.0-cp312-cp312-win32.whl", hash = "sha256:f1f407697873acbf1d961c6fb9223114a3e679938469d4186623b8b872dbdae0", size = 1008449, upload-time = "2026-06-01T05:57:42.491Z" }, + { url = "https://files.pythonhosted.org/packages/33/94/7567ddd3a13e24bbc5e146c6ac735004ab7303048115ccb032d61b5c2305/grpcio_tools-1.81.0-cp312-cp312-win_amd64.whl", hash = "sha256:283bb3465331a4034b14dce35425c47b0cfbd287b09a6e9d15c9f26fbb17e799", size = 1174889, upload-time = "2026-06-01T05:57:44.405Z" }, + { url = "https://files.pythonhosted.org/packages/f2/05/f0606a1b2e830d5fddfcd77c5d8e928f26dc221ced386fccf31a6efda57e/grpcio_tools-1.81.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:33c579bf24040dbdce751e05b5bcedc13dafffa8f2dfa07193bc05960dc95b49", size = 2586070, upload-time = "2026-06-01T05:57:46.677Z" }, + { url = "https://files.pythonhosted.org/packages/c4/4a/3b6817547d65d9f7a106ea6a2352125d08b44ce1d120b64ca0c565d896e6/grpcio_tools-1.81.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2a369a9e27fcb6279387744778d67a64551de82db0e9cf7e1e9451c22f719e07", size = 5813211, upload-time = "2026-06-01T05:57:49.159Z" }, + { url = "https://files.pythonhosted.org/packages/bf/fc/078422558ebb337233379cdb0e4cc0d3d3218933d105003bae2790ff976f/grpcio_tools-1.81.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b2b26111795d0e7a72fa483d377a75b57203edaac8bbbac1e9b8f174e7b01ff3", size = 2634663, upload-time = "2026-06-01T05:57:51.41Z" }, + { url = "https://files.pythonhosted.org/packages/53/2b/043f2d62d6f28a0962f29f180ae24770020a06985b14a2d8f0d489531d71/grpcio_tools-1.81.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:a741007631248dd903f880b575a63c0662433ed4b966262ab0f437ea860c9b7e", size = 2957926, upload-time = "2026-06-01T05:57:54.103Z" }, + { url = "https://files.pythonhosted.org/packages/76/d3/be8c1f7c5ca6adccba66ef787b4bba304a3247c1319a33ed330719931ba3/grpcio_tools-1.81.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bac1c6ddc5eb3762257e773829b4e00ea7d0592f02498f27e5bdb75cd3182d69", size = 2697761, upload-time = "2026-06-01T05:57:56.494Z" }, + { url = "https://files.pythonhosted.org/packages/19/9d/c1650c72059f7d20d94597430ecbe0139d92c7e409cf007d0b5d765e40ec/grpcio_tools-1.81.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e5d241c694a226bada06dae9accc47c5c25e586f49464823498e84cff63eedea", size = 3151460, upload-time = "2026-06-01T05:57:58.756Z" }, + { url = "https://files.pythonhosted.org/packages/6b/82/2aad863738dc4f749df93d97f81a8aebdd0a7c5daee7157c259ecea7dbbc/grpcio_tools-1.81.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:475286558410e4d0394fc8129559080c22835ece3c23cac194b46c5ad7d0fad1", size = 3710466, upload-time = "2026-06-01T05:58:01.327Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/b79a5b132bd5db76ce48e686579ded0311bfa7ea19b8cb9ca88aea3e8d64/grpcio_tools-1.81.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98b57a592a75553f4dd38db3993b037953245991b8df9f06927a8978962dcd82", size = 3370487, upload-time = "2026-06-01T05:58:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/96/6f/a6a74a51b71aca801f4dcce6bd8bb7d3fd8d556ff191f0bd0de631b97dd1/grpcio_tools-1.81.0-cp313-cp313-win32.whl", hash = "sha256:78c2514bf172b20631685840fea0c5d1bec5518b649d0a498f6dd8f91dfce56a", size = 1008234, upload-time = "2026-06-01T05:58:05.668Z" }, + { url = "https://files.pythonhosted.org/packages/5a/91/02a4c529dd0a77c2d768b415ac334f6a9ea9d61c6f4c38e54d1a4394530c/grpcio_tools-1.81.0-cp313-cp313-win_amd64.whl", hash = "sha256:a87ea8056beea56b24353d27b7f0ab814daabb372aa517d2e179470e66fd8f6b", size = 1174523, upload-time = "2026-06-01T05:58:07.711Z" }, + { url = "https://files.pythonhosted.org/packages/13/1f/7885e23074d813ab71ba3ea689ecf5cb3bb3c76c51cc01bd393451f10257/grpcio_tools-1.81.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:bf2ffd98c754d54a5affddfe3a18d4822b972c5c37ca6a33a456c94e6f3dd82b", size = 2585943, upload-time = "2026-06-01T05:58:10.077Z" }, + { url = "https://files.pythonhosted.org/packages/40/f4/a88116147d377a88fccef9b43667235835d504d60ebe0e0dcc81bc9c4b20/grpcio_tools-1.81.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:4a8d9fdf42537cd1924f7e95013771aa73c89501ccf112b7517c22ca825be9f5", size = 5813367, upload-time = "2026-06-01T05:58:12.545Z" }, + { url = "https://files.pythonhosted.org/packages/2a/1e/01f310f0427dcddaf0097e4101f041d437ba8199a7ed62621788f2601042/grpcio_tools-1.81.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:564624654f5a0377adc69f226f93ec5ff52715030c2f846af6c54ccc4ca2d225", size = 2634992, upload-time = "2026-06-01T05:58:14.92Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ee/10cec754cb89cf45039ef4a8ff500ab5c567278fc4f6333347dba20c99fb/grpcio_tools-1.81.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3f1ac691debbdbf00e7635ecacf28647f48f67fb4b14120a4541c8bb0f8333ae", size = 2957912, upload-time = "2026-06-01T05:58:17.596Z" }, + { url = "https://files.pythonhosted.org/packages/d2/71/cc273059fa3620d424df91a8faabd5875d4c20a0300875e721d15decd74b/grpcio_tools-1.81.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f35da7b3b9537ecce9a5cfd967b1316a815378d5a3729e9ea556b22a14f6e315", size = 2697709, upload-time = "2026-06-01T05:58:19.653Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d5/c72f9e7d18425586bbc5b4fba78570fab23cb9810fb34a8841f7baaef22b/grpcio_tools-1.81.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1e21049aeedd9d62e5e58146e5d00f3b75674d8d8d3cc69709ab950082be8421", size = 3151885, upload-time = "2026-06-01T05:58:22.366Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f9/7a3d7b3bce72fe22611a79d3790440c16af9d524a8bd1d38a89a44c65570/grpcio_tools-1.81.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:019f05a17b5495d561603f75a74a4a76ad22456a95dc7623c7be4e4b44391b88", size = 3710403, upload-time = "2026-06-01T05:58:25.014Z" }, + { url = "https://files.pythonhosted.org/packages/58/2d/b41fe47b83eb197a48fdcbf48d04f5923c5fd62d4b1a7f2820720562d7ae/grpcio_tools-1.81.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3401d6d4668c064c9ed344825fe97ff076cd8ba719a24e3d3c7169b604cbf00f", size = 3370524, upload-time = "2026-06-01T05:58:27.096Z" }, + { url = "https://files.pythonhosted.org/packages/94/d3/a4565146ab83232ddf86a3de497937662dc06649739f978763379c256311/grpcio_tools-1.81.0-cp314-cp314-win32.whl", hash = "sha256:5783b6758244f6eaceb41a0e651828824b0d0c92724ddf4b68879ced9bfd9b50", size = 1030574, upload-time = "2026-06-01T05:58:28.972Z" }, + { url = "https://files.pythonhosted.org/packages/35/72/f2102f3737b94e14b8ce56394918c0a4303e80d5d8b0627fc4ee85927f79/grpcio_tools-1.81.0-cp314-cp314-win_amd64.whl", hash = "sha256:69f8355b723db7b5e26a3bff76f9deb3a407d22fe289bca486ccf95d6133cad0", size = 1207499, upload-time = "2026-06-01T05:58:31.606Z" }, +] + +[[package]] +name = "grpclib" +version = "0.4.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h2" }, + { name = "multidict" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/28/5a2c299ec82a876a252c5919aa895a6f1d1d35c96417c5ce4a4660dc3a80/grpclib-0.4.9.tar.gz", hash = "sha256:cc589c330fa81004c6400a52a566407574498cb5b055fa927013361e21466c46", size = 84798, upload-time = "2025-12-14T22:23:14.349Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/90/b0cbbd9efcc82816c58f31a34963071aa19fb792a212a5d9caf8e0fc3097/grpclib-0.4.9-py3-none-any.whl", hash = "sha256:7762ec1c8ed94dfad597475152dd35cbd11aecaaca2f243e29702435ca24cf0e", size = 77063, upload-time = "2025-12-14T22:23:13.224Z" }, +] + +[package.optional-dependencies] +protobuf = [ + { name = "protobuf" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -895,6 +1009,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + [[package]] name = "hf-xet" version = "1.4.3" @@ -927,6 +1054,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload-time = "2026-03-31T22:40:12.152Z" }, ] +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + [[package]] name = "html5lib" version = "1.1" @@ -1017,6 +1153,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/02/4f3f8997d1ea7fe0146b343e5e14bd065fa87af790d07e5576d31b31cc18/huggingface_hub-1.11.0-py3-none-any.whl", hash = "sha256:42a6de0afbfeb5e022222d36398f029679db4eb4778801aafda32257ae9131ab", size = 645499, upload-time = "2026-04-16T13:07:37.716Z" }, ] +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "idna" version = "3.12" @@ -1176,6 +1321,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "jwcrypto" +version = "1.5.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/90/f065668004d22715c1940d6e88e4c3afc8ee16d5664e4478d2c8fd23a250/jwcrypto-1.5.7.tar.gz", hash = "sha256:70204d7cca406eda8c82352e3c41ba2d946610dafd19e54403f0a1f4f18633c6", size = 89535, upload-time = "2026-04-07T00:35:36.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/24/fb7da4d6613de7001feaf540d4b5969c6b5a1c42839043b0196cb13aa057/jwcrypto-1.5.7-py3-none-any.whl", hash = "sha256:729463fefe28b6de5cf1ebfda3e94f1a1b41d2799148ef98a01cb9678ebe2bb0", size = 94799, upload-time = "2026-04-07T00:35:35.085Z" }, +] + [[package]] name = "langdetect" version = "1.0.9" @@ -1526,6 +1684,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, ] +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "murmurhash" version = "1.0.15" @@ -2023,6 +2280,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/80/368139067603e590a000122355f9c8576c8ebed4fb0b8849feaa2698489d/preshed-3.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:b980f3ea9bb74b7f94464bc3d6eb3c9162b6b79b531febd14c6465c24344d2cc", size = 119339, upload-time = "2026-03-23T08:57:18.882Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "psutil" version = "7.2.2" @@ -3014,7 +3286,7 @@ wheels = [ [[package]] name = "shell-session-manager" -version = "2.1.1" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, @@ -3026,9 +3298,9 @@ dependencies = [ { name = "typer" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/64/8d12611e48553d61423d5e302d178e67bd968a35f1709e26024f4e04fc3b/shell_session_manager-2.1.1.tar.gz", hash = "sha256:bf490809161244beb95cabad62d32a59b351b7b5993e375d49b6fcf3835ae31c", size = 47064, upload-time = "2026-05-29T20:04:27.625Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/84/aa6a86e7686b0c1e67b17ce4f5db6a42f115f1269d1f85362e22416b5829/shell_session_manager-2.2.0.tar.gz", hash = "sha256:ed31f12eecd30ad342dab9713651e2cb259b9beea6a6043842b73616c21b3070", size = 49479, upload-time = "2026-06-02T12:49:34.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/74/64d6db5888f6e7c7dcf0b4960e9ffa8c38425fa906cd60e99ed0bd88def7/shell_session_manager-2.1.1-py3-none-any.whl", hash = "sha256:6b53c813ac386bbf3244c375edf9cce675c89a2041d33a969ef69d8d74f89ac6", size = 45742, upload-time = "2026-05-29T20:04:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/82/cc/71fa09d0d865ee652312067d9d13c51b7800cdc0e54afe0e076ad1a29520/shell_session_manager-2.2.0-py3-none-any.whl", hash = "sha256:338cca9716facec60cc3985c1d88837c2f23abbc17ff2b61e58b4e4a0f9f19ad", size = 47240, upload-time = "2026-06-02T12:49:33.585Z" }, ] [[package]] @@ -3413,17 +3685,17 @@ wheels = [ [[package]] name = "typer" -version = "0.24.2" +version = "0.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc" }, { name = "click" }, { name = "rich" }, { name = "shellingham" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/b8/9ebb531b6c2d377af08ac6746a5df3425b21853a5d2260876919b58a2a4a/typer-0.24.2.tar.gz", hash = "sha256:ec070dcfca1408e85ee203c6365001e818c3b7fffe686fd07ff2d68095ca0480", size = 119849, upload-time = "2026-04-22T17:45:34.413Z" } +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/39/d1/9484b497e0a0410b901c12b8251c3e746e1e863f7d28419ffe06f7892fda/typer-0.24.2-py3-none-any.whl", hash = "sha256:b618bc3d721f9a8d30f3e05565be26416d06e9bcc29d49bc491dc26aba674fa8", size = 55977, upload-time = "2026-04-22T17:45:33.055Z" }, + { 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]] diff --git a/packages/contracts/generated/api/console/files/types.gen.ts b/packages/contracts/generated/api/console/files/types.gen.ts index e2ce3cf501..277300ce7a 100644 --- a/packages/contracts/generated/api/console/files/types.gen.ts +++ b/packages/contracts/generated/api/console/files/types.gen.ts @@ -32,6 +32,7 @@ export type FileResponse = { name: string original_url?: string | null preview_url?: string | null + reference?: string | null size: number source_url?: string | null tenant_id?: string | null diff --git a/packages/contracts/generated/api/console/files/zod.gen.ts b/packages/contracts/generated/api/console/files/zod.gen.ts index 1886afe790..389c79558e 100644 --- a/packages/contracts/generated/api/console/files/zod.gen.ts +++ b/packages/contracts/generated/api/console/files/zod.gen.ts @@ -39,6 +39,7 @@ export const zFileResponse = z.object({ name: z.string(), original_url: z.string().nullish(), preview_url: z.string().nullish(), + reference: z.string().nullish(), size: z.int(), source_url: z.string().nullish(), tenant_id: z.string().nullish(), diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index 7fcd742db2..a20ede5b59 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -154,6 +154,7 @@ export type FileResponse = { name: string original_url?: string | null preview_url?: string | null + reference?: string | null size: number source_url?: string | null tenant_id?: string | null diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index e143752736..0b6a950be5 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -134,6 +134,7 @@ export const zFileResponse = z.object({ name: z.string(), original_url: z.string().nullish(), preview_url: z.string().nullish(), + reference: z.string().nullish(), size: z.int(), source_url: z.string().nullish(), tenant_id: z.string().nullish(), diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index c90316be79..a702afe549 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -558,6 +558,7 @@ export type FileResponse = { name: string original_url?: string | null preview_url?: string | null + reference?: string | null size: number source_url?: string | null tenant_id?: string | null diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index 086cf1913b..b664eec6f2 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -669,6 +669,7 @@ export const zFileResponse = z.object({ name: z.string(), original_url: z.string().nullish(), preview_url: z.string().nullish(), + reference: z.string().nullish(), size: z.int(), source_url: z.string().nullish(), tenant_id: z.string().nullish(), diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index d55fc42389..d6adf72055 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -94,6 +94,7 @@ export type FileResponse = { name: string original_url?: string | null preview_url?: string | null + reference?: string | null size: number source_url?: string | null tenant_id?: string | null diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index 818bceced1..baf367eb7a 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -126,6 +126,7 @@ export const zFileResponse = z.object({ name: z.string(), original_url: z.string().nullish(), preview_url: z.string().nullish(), + reference: z.string().nullish(), size: z.int(), source_url: z.string().nullish(), tenant_id: z.string().nullish(),