mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
fix(dify-builder): ground only credentials and host endpoints, never data inputs
The test form and mock data skipped every start variable an http node read, so a search query or a language header was never mocked. Only a host-position URL template, credential-keyed header/param lines and an api-key auth now count, with one shared is_credential_key in core. A bare "key" param, an empty credential and a bare "Bearer" are grounded too, and an existing Dify template is never re-grounded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e741ce902b
commit
e54a2cbd2a
7 files changed
+470
-91
No files matched your search
@@ -0,0 +1,70 @@
|
||||
"""Whether a header/param/field key names a credential.
|
||||
|
||||
Pure, no I/O, stdlib-only -- ``core/dify_builder`` must not import from
|
||||
``services``, so this is the single shared home for ``is_credential_key``
|
||||
(moved here in the Task 3 review fix round) and its params-only sibling
|
||||
``is_credential_param_key``. Both ``core/dify_builder/handlers_fix.py``
|
||||
(deciding which start variables a mocked/prefilled form must skip) and
|
||||
``services/dify_builder/agent/build.py`` (deciding which literals to ground)
|
||||
need the SAME rule, so there is one definition; ``user_supplied.py`` imports
|
||||
and re-exports both so existing ``services``-side callers see no API change.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# A dict/field key's last word-segment that, by itself, names a credential
|
||||
# regardless of what comes before it (so "access_token" and "auth" both
|
||||
# qualify, but "token_limit" does not -- "limit" is the last segment).
|
||||
_CREDENTIAL_LAST_SEGMENTS = {"authorization", "auth", "token", "secret", "password", "passwd"}
|
||||
|
||||
# A last segment of "key" only counts as a credential when the segment (or
|
||||
# merged word) before it names what kind of key it is -- otherwise ordinary
|
||||
# fields like "key_points" would match on "key" alone.
|
||||
_CREDENTIAL_KEY_PREFIXES = {"api", "access", "secret", "private", "auth"}
|
||||
|
||||
# Splits an identifier into camelCase words: an uppercase run immediately
|
||||
# followed by "Xy" (e.g. the "API" in "APIKey"), or an optional leading
|
||||
# capital plus a run of lowercase/digits (e.g. "api", "Key", "3").
|
||||
_CAMEL_SEGMENT_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z0-9]+|[A-Z]+|[0-9]+")
|
||||
|
||||
|
||||
def _key_segments(key: str) -> list[str]:
|
||||
"""Lowercase word-segments of a dict/field key, split on ``_``, ``-``,
|
||||
``.``, whitespace, and camelCase boundaries -- so ``x-api-key``,
|
||||
``apiKey`` and ``api_key`` all segment to ``["api", "key"]``."""
|
||||
segments: list[str] = []
|
||||
for part in re.split(r"[_\-.\s]+", key or ""):
|
||||
if not part:
|
||||
continue
|
||||
segments.extend(match.lower() for match in _CAMEL_SEGMENT_RE.findall(part))
|
||||
return segments
|
||||
|
||||
|
||||
def is_credential_key(key: str) -> bool:
|
||||
"""True iff ``key`` names a credential, decided by its LAST segment --
|
||||
not by containing a credential-ish substring anywhere. So
|
||||
``password_policy``, ``token_limit``, ``session_token_expiry`` and
|
||||
``max_tokens`` are NOT credential keys, but ``api_key``, ``apiKey``,
|
||||
``x-api-key``, ``access_token`` and ``Authorization`` are.
|
||||
"""
|
||||
segments = _key_segments(key)
|
||||
if not segments:
|
||||
return False
|
||||
last = segments[-1]
|
||||
if last in _CREDENTIAL_LAST_SEGMENTS or last == "apikey":
|
||||
return True
|
||||
return last == "key" and len(segments) >= 2 and segments[-2] in _CREDENTIAL_KEY_PREFIXES
|
||||
|
||||
|
||||
def is_credential_param_key(key: str) -> bool:
|
||||
"""``is_credential_key`` PLUS a bare ``key`` (case-insensitive, the WHOLE
|
||||
key -- not merely its last segment): a raw ``?key=...`` query parameter is
|
||||
a common way an API names its API key, which ``is_credential_key`` alone
|
||||
misses (its last-segment rule requires a qualifying prefix like
|
||||
``api``/``access`` before a bare ``key``). Params-only -- a header simply
|
||||
named ``key`` is not a realistic credential header the way a query param
|
||||
is, so this rule is not applied to headers. ``cache_key``/``key_points``
|
||||
still segment to more than just ``key`` and stay non-credential, same as
|
||||
under ``is_credential_key``.
|
||||
"""
|
||||
return is_credential_key(key) or (key or "").strip().lower() == "key"
|
||||
@@ -30,6 +30,7 @@ import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from core.dify_builder import credentials
|
||||
from core.dify_builder.changes import describe_changed_nodes
|
||||
from core.dify_builder.contract import (
|
||||
AssistantTurnItem,
|
||||
@@ -460,22 +461,64 @@ def upload_variable_names(schema: StartSchema) -> set[str]:
|
||||
|
||||
|
||||
# ``{{#<node_id>.<var>#}}`` -- the Dify template reference syntax -- may sit
|
||||
# anywhere inside a URL string (e.g. ``https://x.com/{{#s.path#}}``), so this
|
||||
# searches the whole string rather than anchoring to it.
|
||||
# anywhere inside a string (e.g. ``https://x.com/{{#s.path#}}``), so this
|
||||
# searches the whole string rather than anchoring to it, except where a
|
||||
# caller below deliberately anchors it with ``.match``.
|
||||
_ENDPOINT_TEMPLATE_RE = re.compile(r"\{\{#([^.#{}]+)\.([^.#{}]+)#\}\}")
|
||||
|
||||
|
||||
def _start_var_names(text: str, start_id: str) -> set[str]:
|
||||
"""Every start-node template's variable name occurring ANYWHERE in
|
||||
``text`` -- used where the field IS the credential/endpoint itself (an
|
||||
``authorization.config.api_key``, or the value half of a credential-keyed
|
||||
header/param line), so position within it doesn't matter."""
|
||||
return {var_name for node_id, var_name in _ENDPOINT_TEMPLATE_RE.findall(text or "") if node_id == start_id}
|
||||
|
||||
|
||||
def _leading_start_var_name(text: str, start_id: str) -> str | None:
|
||||
"""The variable name of a start-node template occupying the HOST
|
||||
position: ``text``, stripped of whitespace, STARTS with the template --
|
||||
not merely containing one later, e.g. in a path or query
|
||||
(``https://wttr.in/{{#s.city#}}``'s ``city`` is an ordinary data input,
|
||||
not an endpoint). Returns None when there is no such leading template.
|
||||
"""
|
||||
match = _ENDPOINT_TEMPLATE_RE.match((text or "").strip())
|
||||
if match and match.group(1) == start_id:
|
||||
return match.group(2)
|
||||
return None
|
||||
|
||||
|
||||
def _credential_line_var_names(text: str, start_id: str, key_is_credential) -> set[str]:
|
||||
"""Start-variable templates in the VALUE half of each ``Key: Value`` line
|
||||
of a ``headers``/``params`` text block whose KEY names a credential per
|
||||
``key_is_credential`` -- an ordinary data field read via a header/param
|
||||
(a search query, an ``Accept-Language``) must stay mockable, so only a
|
||||
credential-keyed line's value counts."""
|
||||
names: set[str] = set()
|
||||
for line in (text or "").split("\n"):
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, _sep, value = line.partition(":")
|
||||
if key_is_credential(key.strip()):
|
||||
names |= _start_var_names(value, start_id)
|
||||
return names
|
||||
|
||||
|
||||
def endpoint_variable_names(graph: Graph) -> set[str]:
|
||||
"""Names of the start variables an http-request node reads its URL,
|
||||
headers, params, or API-key authorization from.
|
||||
"""Names of the start variables an http-request node reads a real
|
||||
endpoint or credential from: the url's HOST position, a credential-keyed
|
||||
``headers``/``params`` line, or an ``api-key`` authorization's
|
||||
``config.api_key``.
|
||||
|
||||
Nothing can invent a real endpoint or credential any more than it can
|
||||
invent an upload (ESQ1-302 / build.py's ``_ground_placeholder_endpoints``
|
||||
and Task 3's credential grounding), so these are values a human still has
|
||||
to supply even when every other field is mocked for them. Only a
|
||||
reference to the graph's own START node counts -- a reference to some
|
||||
other node's output is an ordinary wired value, not an endpoint/credential
|
||||
waiting on the user.
|
||||
to supply even when every other field is mocked for them. An ORDINARY
|
||||
data input the workflow happens to read via the url path/query, or via a
|
||||
non-credential header/param (a search query, an ``Accept-Language``), is
|
||||
NOT collected -- it stays mockable, same as any other start variable.
|
||||
Only a reference to the graph's own START node counts at all -- a
|
||||
reference to some other node's output is an ordinary wired value.
|
||||
"""
|
||||
start_id = ""
|
||||
for node in graph.get("nodes", []):
|
||||
@@ -486,23 +529,21 @@ def endpoint_variable_names(graph: Graph) -> set[str]:
|
||||
if not start_id:
|
||||
return set()
|
||||
names: set[str] = set()
|
||||
|
||||
def _collect(text: str) -> None:
|
||||
for node_id, var_name in _ENDPOINT_TEMPLATE_RE.findall(text):
|
||||
if node_id == start_id:
|
||||
names.add(var_name)
|
||||
|
||||
for node in graph.get("nodes", []):
|
||||
data = node.get("data") or {}
|
||||
if data.get("type") != "http-request":
|
||||
continue
|
||||
_collect(str(data.get("url") or ""))
|
||||
_collect(str(data.get("headers") or ""))
|
||||
_collect(str(data.get("params") or ""))
|
||||
leading = _leading_start_var_name(str(data.get("url") or ""), start_id)
|
||||
if leading:
|
||||
names.add(leading)
|
||||
names |= _credential_line_var_names(str(data.get("headers") or ""), start_id, credentials.is_credential_key)
|
||||
names |= _credential_line_var_names(
|
||||
str(data.get("params") or ""), start_id, credentials.is_credential_param_key
|
||||
)
|
||||
authorization = data.get("authorization")
|
||||
auth_config = authorization.get("config") if isinstance(authorization, dict) else None
|
||||
if isinstance(auth_config, dict):
|
||||
_collect(str(auth_config.get("api_key") or ""))
|
||||
if isinstance(authorization, dict) and authorization.get("type") == "api-key" and isinstance(auth_config, dict):
|
||||
names |= _start_var_names(str(auth_config.get("api_key") or ""), start_id)
|
||||
return names
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user