From aeefe548ffbc23f69c6a632cb086da7621e22c2c Mon Sep 17 00:00:00 2001 From: Charles Yao Date: Tue, 20 Oct 2026 17:41:17 -0700 Subject: [PATCH] Replace remaining dynamic runtime access with explicit capabilities --- api/controllers/console/dify_builder.py | 21 ++++++++++++++++--- api/core/workflow/node_runtime.py | 14 +++++++++++-- .../workflow_runtime/workflow_generator.py | 18 +++++++++++++--- .../workflow_runtime/workflow_runner.py | 2 +- .../services/test_builder_agent_binding.py | 4 +++- .../models/test_dify_builder_typed_models.py | 4 ++-- .../tasks/test_dify_builder_queue_config.py | 2 +- .../local/dify_agent/client/test_client.py | 3 ++- 8 files changed, 54 insertions(+), 14 deletions(-) diff --git a/api/controllers/console/dify_builder.py b/api/controllers/console/dify_builder.py index 1d79fe36517..7d16b59d3da 100644 --- a/api/controllers/console/dify_builder.py +++ b/api/controllers/console/dify_builder.py @@ -32,7 +32,7 @@ import dataclasses import functools import logging from collections.abc import Callable, Iterator -from typing import cast +from typing import Literal, cast from flask import Flask, Response, current_app, request from flask_restx import Resource @@ -727,7 +727,14 @@ class DifyBuilderAgentPingApi(Resource): return _ping(request.get_json(silent=True), _actor(current_user, current_tenant_id)) -def _native_command(*, actor: Actor, session_id: str, command: str, payload_model=None, request_id=None): +def _native_command( + *, + actor: Actor, + session_id: str, + command: Literal["start", "get", "decide", "resume", "cancel"], + payload_model=None, + request_id=None, +): """Transport-only strict parsing; never include raw validation/owner errors.""" kwargs = {"actor": actor, "session_id": session_id} if request_id is not None: @@ -739,7 +746,15 @@ def _native_command(*, actor: Actor, session_id: str, command: str, payload_mode raise BadRequest("Invalid native test request.") from None kwargs.update(payload.model_dump(exclude={"schema_version"})) try: - receipt = getattr(build_native_test_api_service(actor=actor), command)(**kwargs) + service = build_native_test_api_service(actor=actor) + commands: dict[str, Callable[..., object]] = { + "start": service.start, + "get": service.get, + "decide": service.decide, + "resume": service.resume, + "cancel": service.cancel, + } + receipt = commands[command](**kwargs) except (BuilderExecutionPolicyError, NativeContextUnavailable) as error: if error.reason_code in {"native_owner_mismatch", "execution_owner_mismatch"}: raise NotFound("Native test not found.") from None diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py index bf4dbd78c01..5dbcac6e4db 100644 --- a/api/core/workflow/node_runtime.py +++ b/api/core/workflow/node_runtime.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Generator, Mapping, Sequence from dataclasses import dataclass from enum import Enum -from typing import TYPE_CHECKING, Any, Literal, Protocol, cast, overload, override +from typing import TYPE_CHECKING, Any, Literal, Protocol, cast, overload, override, runtime_checkable from pydantic import JsonValue from sqlalchemy import select @@ -528,6 +528,12 @@ class _WorkflowToolRuntimeBinding: trace_session_id: str | None = None +@runtime_checkable +class _ConfirmedGuardTool(Protocol): + @property + def confirmed_guard(self) -> ConfirmedToolGuard | None: ... + + class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): def __init__( self, @@ -706,7 +712,11 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): raise self._map_invocation_exception( BuilderExecutionPolicyError("confirmed_guard_unavailable"), provider_name=provider_name ) - if getattr(tool, "confirmed_guard", None) is not guard: + try: + same_guard = isinstance(tool, _ConfirmedGuardTool) and tool.confirmed_guard is guard + except AttributeError: + same_guard = False + if not same_guard: raise self._map_invocation_exception( BuilderExecutionPolicyError("execution_launch_mismatch"), provider_name=provider_name ) diff --git a/api/services/workflow_runtime/workflow_generator.py b/api/services/workflow_runtime/workflow_generator.py index 42b15fdd563..b382a1d7c44 100644 --- a/api/services/workflow_runtime/workflow_generator.py +++ b/api/services/workflow_runtime/workflow_generator.py @@ -5,7 +5,7 @@ import logging import threading import uuid from collections.abc import Generator, Mapping, Sequence -from typing import TYPE_CHECKING, Any, Literal, Protocol, overload +from typing import TYPE_CHECKING, Any, Literal, Protocol, overload, runtime_checkable from flask import Flask, current_app from pydantic import ValidationError @@ -79,10 +79,22 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -def _extract_trace_session_id_from_debug_args(args: Mapping[str, Any] | Any) -> dict[str, str]: +@runtime_checkable +class _DebugTraceArgs(Protocol): + @property + def trace_session_id(self) -> object: ... + + +def _extract_trace_session_id_from_debug_args(args: object) -> dict[str, str]: if isinstance(args, Mapping): return extract_trace_session_id_from_args(args) - return extract_trace_session_id_from_args({"trace_session_id": getattr(args, "trace_session_id", None)}) + if isinstance(args, _DebugTraceArgs): + try: + trace_session_id = args.trace_session_id + except AttributeError: + return {} + return extract_trace_session_id_from_args({"trace_session_id": trace_session_id}) + return {} def _no_draft_variable_saver( diff --git a/api/services/workflow_runtime/workflow_runner.py b/api/services/workflow_runtime/workflow_runner.py index 02c872991fb..18179fd37f8 100644 --- a/api/services/workflow_runtime/workflow_runner.py +++ b/api/services/workflow_runtime/workflow_runner.py @@ -265,7 +265,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner): node_id=root_node_id, inputs=inputs, aliases=get_compatible_start_aliases( - workflow_kind=getattr(self._workflow, "kind_or_standard", None), + workflow_kind=self._workflow.kind_or_standard, root_node_id=root_node_id, ), ) diff --git a/api/tests/test_containers_integration_tests/services/test_builder_agent_binding.py b/api/tests/test_containers_integration_tests/services/test_builder_agent_binding.py index 311e9a92a2b..7a0e8d11348 100644 --- a/api/tests/test_containers_integration_tests/services/test_builder_agent_binding.py +++ b/api/tests/test_containers_integration_tests/services/test_builder_agent_binding.py @@ -8,6 +8,7 @@ from concurrent.futures import ThreadPoolExecutor from threading import Event import pytest +from psycopg2 import Error as Psycopg2Error from sqlalchemy import select, text, update from sqlalchemy.exc import DBAPIError @@ -145,7 +146,8 @@ def test_publication_pointer_cannot_change_between_prepare_and_native_sync(bindi assert entered.wait(timeout=5) with pytest.raises(DBAPIError) as error: attempted.result(timeout=5) - assert (getattr(error.value.orig, "sqlstate", None) or getattr(error.value.orig, "pgcode", None)) == "55P03" + assert isinstance(error.value.orig, Psycopg2Error) + assert error.value.orig.pgcode == "55P03" graph = workflow.graph_dict graph["nodes"].append({"id": "review", "data": data}) workflow.graph = json.dumps(graph) diff --git a/api/tests/unit_tests/models/test_dify_builder_typed_models.py b/api/tests/unit_tests/models/test_dify_builder_typed_models.py index d1bdbc36386..e2f4273cb0f 100644 --- a/api/tests/unit_tests/models/test_dify_builder_typed_models.py +++ b/api/tests/unit_tests/models/test_dify_builder_typed_models.py @@ -77,7 +77,7 @@ def test_builder_insert_lifecycle_keeps_server_defaults_and_caller_ids(model): assert row.id is None for name in ("created_at", "updated_at"): if name in model.__table__.columns: - assert getattr(row, name) is None + assert sa.inspect(row).attrs[name].value is None with Session(engine) as session: session.add(row) session.flush() @@ -86,7 +86,7 @@ def test_builder_insert_lifecycle_keeps_server_defaults_and_caller_ids(model): assert all(identity) for name in ("created_at", "updated_at"): if name in model.__table__.columns: - assert isinstance(getattr(row, name), datetime) + assert isinstance(sa.inspect(row).attrs[name].value, datetime) if model is dify_builder.DifyBuilderSession: assert row.version == 1 session.commit() diff --git a/api/tests/unit_tests/tasks/test_dify_builder_queue_config.py b/api/tests/unit_tests/tasks/test_dify_builder_queue_config.py index 90c823090b1..fcf22720266 100644 --- a/api/tests/unit_tests/tasks/test_dify_builder_queue_config.py +++ b/api/tests/unit_tests/tasks/test_dify_builder_queue_config.py @@ -11,7 +11,7 @@ DEFAULT_QUEUE_PATTERN = re.compile(r'^\s*(?:QUEUES|DEFAULT_QUEUES)="([^"]+,.*?)" def test_advance_task_uses_dedicated_queue() -> None: - assert getattr(advance_session, "queue", None) == DIFY_BUILDER_QUEUE + assert advance_session.queue == DIFY_BUILDER_QUEUE @pytest.mark.parametrize( diff --git a/dify-agent/tests/local/dify_agent/client/test_client.py b/dify-agent/tests/local/dify_agent/client/test_client.py index 6755f6b59f6..d44f8774480 100644 --- a/dify-agent/tests/local/dify_agent/client/test_client.py +++ b/dify-agent/tests/local/dify_agent/client/test_client.py @@ -1262,7 +1262,8 @@ def test_private_checkpoint_client_refuses_without_retry_or_history_leak(asynchr with pytest.raises((DifyAgentHTTPError, client_module.DifyAgentClientError)) as caught: client.read_native_checkpoint_sync("run-1", _private_checkpoint_request()) assert "private-canary" not in str(caught.value) - assert "private-canary" not in str(getattr(caught.value, "detail", "")) + if isinstance(caught.value, DifyAgentHTTPError): + assert "private-canary" not in str(caught.value.detail) assert len(calls) == 1