mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 22:02:23 +08:00
Track backend integration, Ruff cleanup and migration reconciliation in ENG-1271. Retain required copy contracts without implementing frontend interactions.
489 lines
18 KiB
Python
489 lines
18 KiB
Python
"""Pure admission for the installed, qualified JSON Process parse workflow.
|
|
|
|
This module inspects raw source before Graphon builds a Tool node. Its resolved
|
|
parameter envelope is an expectation for the later native adapter, never a
|
|
replacement for native parameter conversion or JSONPath evaluation.
|
|
"""
|
|
|
|
import re
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from typing import Any, NoReturn
|
|
|
|
from core.workflow.variable_prefixes import (
|
|
CONVERSATION_VARIABLE_NODE_ID,
|
|
ENVIRONMENT_VARIABLE_NODE_ID,
|
|
SYSTEM_VARIABLE_NODE_ID,
|
|
)
|
|
from runtime_contracts.builder.execution import (
|
|
AdmittedNodeBinding,
|
|
BuilderExecutionPolicyError,
|
|
QualifiedJsonToolBinding,
|
|
canonical_digest,
|
|
)
|
|
from services.dify_builder.application.execution_policy import (
|
|
RestrictedAdmissionSnapshot,
|
|
admit_raw_execution_metadata,
|
|
)
|
|
from services.dify_builder.revision import node_config_digest
|
|
|
|
__all__ = [
|
|
"JsonToolAdmission",
|
|
"QualifiedJsonToolBinding",
|
|
"admit_json_workflow",
|
|
"resolve_json_parameters",
|
|
"validate_json_parameters",
|
|
]
|
|
|
|
_PLUGIN_ID = "langgenius/json_process"
|
|
_PROVIDER_ID = "langgenius/json_process/json_process"
|
|
_UNIQUE_IDENTIFIER = "langgenius/json_process:0.0.8@3333687ec0412e1f2a1798d632fc5f3187f36ebd4bf2432a599a96c74e92f503"
|
|
_NODE_ID = re.compile(r"[A-Za-z0-9_-]{1,128}\Z", re.ASCII)
|
|
_RESERVED_NATIVE_OWNERS = frozenset(
|
|
{SYSTEM_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID}
|
|
)
|
|
_START_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,63}\Z", re.ASCII)
|
|
_PURE_MIXED = re.compile(r"\{\{#([A-Za-z0-9_]{1,50})\.([A-Za-z_][A-Za-z0-9_]{0,29})#\}\}\Z", re.ASCII)
|
|
_PATH_PART = re.compile(r"\.([A-Za-z_][A-Za-z0-9_]{0,63})|\[([0-9]{1,5}|\*)\]", re.ASCII)
|
|
_UNSAFE_CONTENT = re.compile(r"\.|\{\{#|#\}\}|\\u(?:002e|0023|007b|007d)", re.IGNORECASE)
|
|
_START_DATA = {"type", "version", "variables", "title", "desc", "selected"}
|
|
_END_DATA = {"type", "version", "outputs", "title", "desc", "selected"}
|
|
_TOOL_DATA = {
|
|
"type",
|
|
"version",
|
|
"tool_node_version",
|
|
"provider_type",
|
|
"provider_id",
|
|
"provider_name",
|
|
"plugin_id",
|
|
"plugin_unique_identifier",
|
|
"tool_name",
|
|
"tool_label",
|
|
"tool_description",
|
|
"tool_configurations",
|
|
"tool_parameters",
|
|
"paramSchemas",
|
|
"params",
|
|
"output_schema",
|
|
"title",
|
|
"desc",
|
|
"selected",
|
|
"credential_id",
|
|
"credential_type",
|
|
"credential_name",
|
|
"is_team_authorization",
|
|
"provider_show_name",
|
|
"provider_icon",
|
|
"provider_icon_dark",
|
|
}
|
|
_PARAM_SCHEMA_KEYS = {
|
|
"auto_generate",
|
|
"default",
|
|
"form",
|
|
"human_description",
|
|
"input_schema",
|
|
"label",
|
|
"llm_description",
|
|
"max",
|
|
"min",
|
|
"multiple",
|
|
"name",
|
|
"options",
|
|
"placeholder",
|
|
"precision",
|
|
"required",
|
|
"scope",
|
|
"template",
|
|
"type",
|
|
}
|
|
_GRAPH_KEYS = {"nodes", "edges", "viewport"}
|
|
_NODE_KEYS = {
|
|
"id",
|
|
"data",
|
|
"height",
|
|
"position",
|
|
"positionAbsolute",
|
|
"selected",
|
|
"sourcePosition",
|
|
"targetPosition",
|
|
"type",
|
|
"width",
|
|
"zIndex",
|
|
}
|
|
_EDGE_KEYS = {
|
|
"id",
|
|
"source",
|
|
"target",
|
|
"sourceHandle",
|
|
"targetHandle",
|
|
"type",
|
|
"data",
|
|
"zIndex",
|
|
}
|
|
_MAX_CONTENT = 65536
|
|
_MAX_FILTER = 1024
|
|
_MAX_PROVIDER_SHOW_NAME = 256
|
|
_MAX_PROVIDER_ICON = 2048
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class JsonToolAdmission:
|
|
nodes: tuple[AdmittedNodeBinding, AdmittedNodeBinding, AdmittedNodeBinding]
|
|
start_node_id: str
|
|
tool_node_id: str
|
|
end_node_id: str
|
|
|
|
|
|
def _refuse(reason: str = "unsupported_json_tool_configuration") -> NoReturn:
|
|
raise BuilderExecutionPolicyError(reason)
|
|
|
|
|
|
def _bounded_text(value: object, limit: int) -> str:
|
|
if type(value) is not str:
|
|
_refuse()
|
|
if len(value) > limit:
|
|
_refuse("json_execution_limit")
|
|
try:
|
|
size = len(value.encode("utf-8"))
|
|
except UnicodeError:
|
|
_refuse()
|
|
if size > limit:
|
|
_refuse("json_execution_limit")
|
|
return value
|
|
|
|
|
|
def _validate_path(value: object) -> str:
|
|
path = _bounded_text(value, _MAX_FILTER)
|
|
if not path.startswith("$"):
|
|
_refuse("unsupported_json_filter")
|
|
index, count, wildcards = 1, 0, 0
|
|
while index < len(path):
|
|
match = _PATH_PART.match(path, index)
|
|
if match is None:
|
|
_refuse("unsupported_json_filter")
|
|
count += 1
|
|
if count > 16:
|
|
_refuse("unsupported_json_filter")
|
|
part = match.group(0)
|
|
if part == "[*]":
|
|
wildcards += 1
|
|
if wildcards > 1:
|
|
_refuse("unsupported_json_filter")
|
|
elif match.group(2) is not None and int(match.group(2)) > 65535:
|
|
_refuse("unsupported_json_filter")
|
|
index = match.end()
|
|
return path
|
|
|
|
|
|
def _validate_content(value: object, *, constant: bool) -> str:
|
|
content = _bounded_text(value, _MAX_CONTENT)
|
|
if constant and _UNSAFE_CONTENT.search(content):
|
|
_refuse("unsupported_json_content_constant")
|
|
return content
|
|
|
|
|
|
def _start_variables(data: Mapping[str, Any]) -> set[str]:
|
|
variables = data.get("variables")
|
|
if not isinstance(variables, list) or len(variables) > 128:
|
|
_refuse("unsupported_start_input")
|
|
names: set[str] = set()
|
|
for item in variables:
|
|
if not isinstance(item, dict) or set(item) - {"variable", "type", "label", "required", "max_length", "options"}:
|
|
_refuse("unsupported_start_input")
|
|
name = item.get("variable")
|
|
if (
|
|
type(name) is not str
|
|
or not _START_NAME.fullmatch(name)
|
|
or name in names
|
|
or item.get("type") not in {"text-input", "paragraph", "select"}
|
|
or ("required" in item and type(item["required"]) is not bool)
|
|
or (
|
|
"max_length" in item
|
|
and (type(item["max_length"]) is not int or not 0 <= item["max_length"] <= _MAX_CONTENT)
|
|
)
|
|
or (
|
|
"options" in item
|
|
and (
|
|
not isinstance(item["options"], list) or any(type(option) is not str for option in item["options"])
|
|
)
|
|
)
|
|
):
|
|
_refuse("unsupported_start_input")
|
|
names.add(name)
|
|
return names
|
|
|
|
|
|
def _string_source(raw: object, *, parameter: str, start_id: str, names: set[str]) -> tuple[str, str]:
|
|
if not isinstance(raw, dict) or set(raw) != {"type", "value"}:
|
|
_refuse()
|
|
kind, value = raw["type"], raw["value"]
|
|
if kind == "constant":
|
|
if parameter == "content":
|
|
return kind, _validate_content(value, constant=True)
|
|
return kind, _validate_path(value)
|
|
if kind == "variable":
|
|
if (
|
|
not isinstance(value, list)
|
|
or len(value) != 2
|
|
or type(value[0]) is not str
|
|
or type(value[1]) is not str
|
|
or value[0] != start_id
|
|
or value[1] not in names
|
|
):
|
|
_refuse("unsupported_json_selector")
|
|
return kind, value[1]
|
|
if kind == "mixed":
|
|
if type(value) is not str:
|
|
_refuse("unsupported_json_selector")
|
|
match = _PURE_MIXED.fullmatch(value)
|
|
if match is None or match.group(1) != start_id or match.group(2) not in names:
|
|
_refuse("unsupported_json_selector")
|
|
return kind, match.group(2)
|
|
_refuse("unsupported_json_selector")
|
|
|
|
|
|
def _ensure_ascii(value: object) -> bool:
|
|
if type(value) is bool:
|
|
return value
|
|
if isinstance(value, dict) and set(value) == {"type", "value"} and value["type"] == "constant":
|
|
if type(value["value"]) is bool:
|
|
return value["value"]
|
|
_refuse()
|
|
|
|
|
|
def _preflight_tool(data: Mapping[str, Any], start_id: str, names: set[str]) -> None:
|
|
if set(data) - _TOOL_DATA or data.get("type") != "tool" or data.get("version", "1") != "1":
|
|
_refuse()
|
|
if (
|
|
data.get("tool_node_version") != "2"
|
|
or data.get("provider_type") != "builtin"
|
|
or data.get("provider_id") != _PROVIDER_ID
|
|
or data.get("plugin_id") != _PLUGIN_ID
|
|
or data.get("tool_name") != "parse"
|
|
or (
|
|
data.get("plugin_unique_identifier") is not None
|
|
and data.get("plugin_unique_identifier") != _UNIQUE_IDENTIFIER
|
|
)
|
|
or data.get("provider_name") not in (None, _PROVIDER_ID)
|
|
):
|
|
_refuse()
|
|
if any(data.get(key) is not None for key in ("credential_id", "credential_type", "credential_name")):
|
|
_refuse("unsupported_json_credentials")
|
|
# Native editor display fields are inert here; credentials and dispatch still use the qualified binding.
|
|
if "is_team_authorization" in data and type(data["is_team_authorization"]) is not bool:
|
|
_refuse()
|
|
for key, limit in (
|
|
("provider_show_name", _MAX_PROVIDER_SHOW_NAME),
|
|
("provider_icon", _MAX_PROVIDER_ICON),
|
|
("provider_icon_dark", _MAX_PROVIDER_ICON),
|
|
):
|
|
if key in data:
|
|
_bounded_text(data[key], limit)
|
|
if data.get("output_schema", {}) != {}:
|
|
_refuse()
|
|
parameters, configurations = data.get("tool_parameters"), data.get("tool_configurations")
|
|
if (
|
|
not isinstance(parameters, dict)
|
|
or set(parameters) != {"content", "json_filter"}
|
|
or not isinstance(configurations, dict)
|
|
or set(configurations) != {"ensure_ascii"}
|
|
):
|
|
_refuse()
|
|
_ensure_ascii(configurations["ensure_ascii"])
|
|
for name in ("content", "json_filter"):
|
|
_string_source(parameters[name], parameter=name, start_id=start_id, names=names)
|
|
schemas = data.get("paramSchemas")
|
|
if not isinstance(schemas, list) or len(schemas) != 3:
|
|
_refuse()
|
|
schema_by_name = {}
|
|
for schema in schemas:
|
|
if (
|
|
not isinstance(schema, dict)
|
|
or set(schema) - _PARAM_SCHEMA_KEYS
|
|
or type(schema.get("name")) is not str
|
|
or schema["name"] in schema_by_name
|
|
):
|
|
_refuse()
|
|
schema_by_name[schema["name"]] = schema
|
|
if set(schema_by_name) != {"content", "json_filter", "ensure_ascii"}:
|
|
_refuse()
|
|
for name, form, kind in (
|
|
("content", "llm", "string"),
|
|
("json_filter", "llm", "string"),
|
|
("ensure_ascii", "form", "boolean"),
|
|
):
|
|
schema = schema_by_name[name]
|
|
if (
|
|
schema.get("form") != form
|
|
or schema.get("type") != kind
|
|
or schema.get("scope") is not None
|
|
or schema.get("auto_generate") is not None
|
|
or schema.get("input_schema") is not None
|
|
or schema.get("template") is not None
|
|
or schema.get("multiple") is not False
|
|
or schema.get("options") != []
|
|
or schema.get("required") is not (name != "ensure_ascii")
|
|
or schema.get("default") != (True if name == "ensure_ascii" else None)
|
|
):
|
|
_refuse()
|
|
if data.get("params") != {"content": "", "json_filter": "", "ensure_ascii": ""}:
|
|
_refuse()
|
|
|
|
|
|
def admit_json_workflow(snapshot: RestrictedAdmissionSnapshot) -> JsonToolAdmission:
|
|
"""Require exactly the audited native Start→Tool→End graph and raw values."""
|
|
from pydantic import ValidationError
|
|
|
|
from graphon.nodes.end.end_node import EndNode
|
|
from graphon.nodes.start.start_node import StartNode
|
|
from graphon.nodes.tool.tool_node import ToolNode
|
|
from services.workflow_runtime.node_factory import resolve_workflow_node_class
|
|
|
|
admit_raw_execution_metadata(snapshot)
|
|
graph = snapshot.graph
|
|
if set(graph) - _GRAPH_KEYS:
|
|
_refuse("unsupported_graph")
|
|
nodes, edges = graph.get("nodes"), graph.get("edges")
|
|
if not isinstance(nodes, list) or len(nodes) != 3 or not isinstance(edges, list) or len(edges) != 2:
|
|
_refuse("unsupported_graph")
|
|
by_type: dict[str, tuple[str, dict[str, Any]]] = {}
|
|
seen: set[str] = set()
|
|
for node in nodes:
|
|
if not isinstance(node, dict) or set(node) - _NODE_KEYS or not isinstance(node.get("data"), dict):
|
|
_refuse("unsupported_graph")
|
|
data, node_id = node["data"], node.get("id")
|
|
if (
|
|
type(node_id) is not str
|
|
or not _NODE_ID.fullmatch(node_id)
|
|
or node_id in seen
|
|
or node_id in _RESERVED_NATIVE_OWNERS
|
|
):
|
|
_refuse("unsupported_graph")
|
|
if any(
|
|
node.get(key) or data.get(key)
|
|
for key in ("parentId", "parent_id", "iteration_id", "loop_id", "isInIteration", "isInLoop")
|
|
):
|
|
_refuse("unsupported_graph")
|
|
kind = data.get("type")
|
|
if type(kind) is not str or kind not in {"start", "tool", "end"} or kind in by_type:
|
|
_refuse("unsupported_graph")
|
|
seen.add(node_id)
|
|
by_type[kind] = (node_id, data)
|
|
if set(by_type) != {"start", "tool", "end"}:
|
|
_refuse("unsupported_graph")
|
|
start_id, start_data = by_type["start"]
|
|
tool_id, tool_data = by_type["tool"]
|
|
end_id, end_data = by_type["end"]
|
|
if set(start_data) - _START_DATA or set(end_data) - _END_DATA:
|
|
_refuse()
|
|
names = _start_variables(start_data)
|
|
_preflight_tool(tool_data, start_id, names)
|
|
outputs = end_data.get("outputs")
|
|
if not isinstance(outputs, list) or not outputs or len(outputs) > 128:
|
|
_refuse("unsupported_selector")
|
|
tool_output = False
|
|
output_names: set[str] = set()
|
|
for output in outputs:
|
|
if not isinstance(output, dict) or set(output) not in (
|
|
{"variable", "value_selector"},
|
|
{"variable", "value_selector", "value_type"},
|
|
):
|
|
_refuse("unsupported_selector")
|
|
if "value_type" in output and (type(output["value_type"]) is not str or output["value_type"] != "string"):
|
|
_refuse("unsupported_selector")
|
|
name, selector = output["variable"], output["value_selector"]
|
|
if type(name) is not str or not _START_NAME.fullmatch(name) or name in output_names:
|
|
_refuse("unsupported_selector")
|
|
output_names.add(name)
|
|
if not isinstance(selector, list) or len(selector) != 2 or any(type(x) is not str for x in selector):
|
|
_refuse("unsupported_selector")
|
|
if selector == [tool_id, "text"]:
|
|
tool_output = True
|
|
elif selector[0] != start_id or selector[1] not in names:
|
|
_refuse("unsupported_selector")
|
|
if not tool_output:
|
|
_refuse("unsupported_selector")
|
|
connections = set()
|
|
for edge in edges:
|
|
if (
|
|
not isinstance(edge, dict)
|
|
or set(edge) - _EDGE_KEYS
|
|
or edge.get("sourceHandle", "source") != "source"
|
|
or edge.get("targetHandle", "target") != "target"
|
|
):
|
|
_refuse("unsupported_graph")
|
|
if not all(type(edge.get(key)) is str for key in ("source", "target")):
|
|
_refuse("unsupported_graph")
|
|
edge_data = edge.get("data", {})
|
|
if not isinstance(edge_data, dict) or any(
|
|
edge_data.get(key) for key in ("isInIteration", "isInLoop", "iteration_id", "loop_id")
|
|
):
|
|
_refuse("unsupported_graph")
|
|
connections.add((edge["source"], edge["target"]))
|
|
if connections != {(start_id, tool_id), (tool_id, end_id)}:
|
|
_refuse("unsupported_graph")
|
|
# Source checks above run before graph hashing, native class construction or logging.
|
|
canonical_digest(graph)
|
|
audited = {"start": StartNode, "tool": ToolNode, "end": EndNode}
|
|
bindings = []
|
|
for kind in ("start", "tool", "end"):
|
|
node_id, data = by_type[kind]
|
|
version = data.get("version", "1")
|
|
if version != "1":
|
|
_refuse("unsupported_node_implementation")
|
|
cls = resolve_workflow_node_class(node_type=kind, node_version=version, node_data=data)
|
|
if cls is not audited[kind] or cls.version() != "1":
|
|
_refuse("unsupported_node_implementation")
|
|
try:
|
|
cls.validate_node_data(data)
|
|
except (ValidationError, ValueError, TypeError):
|
|
_refuse("invalid_node_configuration")
|
|
bindings.append(
|
|
AdmittedNodeBinding(
|
|
node_id=node_id,
|
|
implementation=f"{cls.__module__}.{cls.__qualname__}",
|
|
node_version="1",
|
|
normalized_config_digest=node_config_digest(data),
|
|
)
|
|
)
|
|
return JsonToolAdmission(
|
|
nodes=(bindings[0], bindings[1], bindings[2]), start_node_id=start_id, tool_node_id=tool_id, end_node_id=end_id
|
|
)
|
|
|
|
|
|
def resolve_json_parameters(
|
|
snapshot: RestrictedAdmissionSnapshot, effective_inputs: Mapping[str, object]
|
|
) -> dict[str, str | bool]:
|
|
"""Compute the expected scalar envelope solely from owned effective Start inputs."""
|
|
admitted = admit_json_workflow(snapshot)
|
|
if not isinstance(effective_inputs, Mapping):
|
|
_refuse("unsupported_input_value")
|
|
nodes = {node["id"]: node["data"] for node in snapshot.graph["nodes"]}
|
|
data = nodes[admitted.tool_node_id]
|
|
names = _start_variables(nodes[admitted.start_node_id])
|
|
resolved: dict[str, str | bool] = {}
|
|
for parameter in ("content", "json_filter"):
|
|
kind, source = _string_source(
|
|
data["tool_parameters"][parameter], parameter=parameter, start_id=admitted.start_node_id, names=names
|
|
)
|
|
if kind == "constant":
|
|
resolved[parameter] = source
|
|
else:
|
|
value = effective_inputs.get(source)
|
|
resolved[parameter] = (
|
|
_validate_content(value, constant=False) if parameter == "content" else _validate_path(value)
|
|
)
|
|
resolved["ensure_ascii"] = _ensure_ascii(data["tool_configurations"]["ensure_ascii"])
|
|
validate_json_parameters(resolved)
|
|
return resolved
|
|
|
|
|
|
def validate_json_parameters(parameters: Mapping[str, object]) -> None:
|
|
"""Validate the final native scalar envelope before its digest or dispatch."""
|
|
if not isinstance(parameters, Mapping) or set(parameters) != {"content", "json_filter", "ensure_ascii"}:
|
|
_refuse("invalid_json_parameters")
|
|
_validate_content(parameters["content"], constant=False)
|
|
_validate_path(parameters["json_filter"])
|
|
if type(parameters["ensure_ascii"]) is not bool:
|
|
_refuse("invalid_json_parameters")
|