mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
fix(agent): complete CLI-tool + env shell bootstrap & add composer validation (ENG-367/368) (#37033)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
autofix-ci[bot]
parent
6e3c9597ff
commit
5b5a06136a
@@ -43,6 +43,15 @@ from models.provider_ids import ModelProviderID
|
||||
|
||||
from .output_failure_orchestrator import retry_idempotency_key
|
||||
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
|
||||
|
||||
_DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"})
|
||||
_DANGEROUS_FLAG_KEYS = ("dangerous", "dangerous_command", "requires_confirmation")
|
||||
_DANGEROUS_ACK_KEYS = (
|
||||
"dangerous_acknowledged",
|
||||
"dangerous_accepted",
|
||||
"risk_accepted",
|
||||
"approved",
|
||||
)
|
||||
from .runtime_feature_manifest import build_runtime_feature_manifest
|
||||
|
||||
|
||||
@@ -404,7 +413,11 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
|
||||
"""Map Agent Soul shell-adjacent fields into the Agent backend shell config."""
|
||||
sandbox_config = _plain_mapping(agent_soul.sandbox.config)
|
||||
return DifyShellLayerConfig(
|
||||
cli_tools=[tool for tool in (_shell_cli_tool(item) for item in agent_soul.tools.cli_tools) if tool is not None],
|
||||
cli_tools=[
|
||||
tool
|
||||
for tool in (_shell_cli_tool(item) for item in agent_soul.tools.cli_tools if _cli_tool_enabled(item))
|
||||
if tool is not None
|
||||
],
|
||||
env=[env for env in (_shell_env_var(item) for item in agent_soul.env.variables) if env is not None],
|
||||
secret_refs=[
|
||||
secret for secret in (_shell_secret_ref(item) for item in agent_soul.env.secret_refs) if secret is not None
|
||||
@@ -418,13 +431,26 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
|
||||
)
|
||||
|
||||
|
||||
def _cli_tool_enabled(item: object) -> bool:
|
||||
"""A CLI tool is bootstrapped unless explicitly disabled (default is enabled)."""
|
||||
data = _plain_mapping(item)
|
||||
if data.get("enabled") is False:
|
||||
return False
|
||||
if data.get("pre_authorized") is False or _permission_denied(data):
|
||||
return False
|
||||
if _dangerous_without_acknowledgement(data):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _shell_cli_tool(item: object) -> DifyShellCliToolConfig | None:
|
||||
data = _plain_mapping(item)
|
||||
commands: list[str] = []
|
||||
raw_commands = data.get("install_commands")
|
||||
if isinstance(raw_commands, list):
|
||||
commands.extend(str(command) for command in raw_commands if str(command).strip())
|
||||
for key in ("install_command", "install", "setup_command"):
|
||||
# ``command`` is the typed AgentCliToolConfig field; the rest are accepted aliases.
|
||||
for key in ("install_command", "install", "setup_command", "command"):
|
||||
raw_command = data.get(key)
|
||||
if isinstance(raw_command, str) and raw_command.strip():
|
||||
commands.append(raw_command)
|
||||
@@ -468,3 +494,30 @@ def _name_from_mapping(item: Mapping[str, Any]) -> str | None:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _permission_denied(data: Mapping[str, Any]) -> bool:
|
||||
permission = data.get("permission")
|
||||
if isinstance(permission, Mapping):
|
||||
allowed = permission.get("allowed")
|
||||
if allowed is False:
|
||||
return True
|
||||
status = permission.get("status") or permission.get("state")
|
||||
if isinstance(status, str) and status in _DENIED_PERMISSION_STATUSES:
|
||||
return True
|
||||
|
||||
for key in ("authorization_status", "permission_status", "status"):
|
||||
status = data.get(key)
|
||||
if isinstance(status, str) and status in _DENIED_PERMISSION_STATUSES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _dangerous_without_acknowledgement(data: Mapping[str, Any]) -> bool:
|
||||
dangerous = any(data.get(key) is True for key in _DANGEROUS_FLAG_KEYS)
|
||||
risk_level = data.get("risk_level")
|
||||
if isinstance(risk_level, str) and risk_level == "dangerous":
|
||||
dangerous = True
|
||||
if not dangerous:
|
||||
return False
|
||||
return not any(data.get(key) is True for key in _DANGEROUS_ACK_KEYS)
|
||||
|
||||
@@ -55,6 +55,9 @@ class WorkflowAgentNodeValidator:
|
||||
}
|
||||
)
|
||||
_SUPPORTED_HUMAN_CONTACT_CHANNELS = frozenset({"email", "slack", "web_app", "webapp", "chat"})
|
||||
_AGENTIC_TOOL_CONFIG_KEYS = ("agentic_mode", "agenticMode", "agentic")
|
||||
_MANUAL_TOOL_AGENTIC_STATES = frozenset({"manual", "expert", "expert_zone", "exited"})
|
||||
_DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"})
|
||||
|
||||
@classmethod
|
||||
def validate_draft_workflow(cls, *, session: Session, workflow: Workflow) -> None:
|
||||
@@ -85,6 +88,10 @@ class WorkflowAgentNodeValidator:
|
||||
continue
|
||||
cls.validate_binding(session=session, binding=binding, topology=topology)
|
||||
|
||||
if require_binding:
|
||||
for node_id, node_data in cls.iter_tool_nodes(graph):
|
||||
cls._validate_tool_node_agentic_mode(node_id=node_id, node_data=node_data)
|
||||
|
||||
@classmethod
|
||||
def validate_binding(
|
||||
cls,
|
||||
@@ -132,6 +139,7 @@ class WorkflowAgentNodeValidator:
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Workflow Agent node {binding.node_id} requires Agent Soul model config."
|
||||
)
|
||||
cls._validate_agent_soul_env(binding=binding, agent_soul=agent_soul)
|
||||
cls._validate_agent_soul_tools(binding=binding, agent_soul=agent_soul)
|
||||
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
|
||||
cls.validate_node_job(session=session, binding=binding, node_job=node_job, topology=topology)
|
||||
@@ -214,6 +222,21 @@ class WorkflowAgentNodeValidator:
|
||||
if node_data.get("type") == BuiltinNodeTypes.AGENT and str(node_data.get("version")) == "2":
|
||||
yield node_id, node_data
|
||||
|
||||
@staticmethod
|
||||
def iter_tool_nodes(graph_dict: Mapping[str, Any]) -> Iterator[tuple[str, Mapping[str, Any]]]:
|
||||
nodes = graph_dict.get("nodes")
|
||||
if not isinstance(nodes, list):
|
||||
return
|
||||
for node in nodes:
|
||||
if not isinstance(node, Mapping):
|
||||
continue
|
||||
node_id = node.get("id")
|
||||
node_data = node.get("data")
|
||||
if not isinstance(node_id, str) or not isinstance(node_data, Mapping):
|
||||
continue
|
||||
if node_data.get("type") == BuiltinNodeTypes.TOOL:
|
||||
yield node_id, node_data
|
||||
|
||||
@staticmethod
|
||||
def selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> list[str] | None:
|
||||
for key in ("selector", "variable_selector", "value_selector"):
|
||||
@@ -306,6 +329,20 @@ class WorkflowAgentNodeValidator:
|
||||
|
||||
cli_tool_names: set[str] = set()
|
||||
for cli_tool in agent_soul.tools.cli_tools:
|
||||
if not cli_tool.enabled:
|
||||
continue
|
||||
if cls._permission_denied(cli_tool.model_dump(mode="python", exclude_none=True, exclude_defaults=True)):
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Workflow Agent node {binding.node_id} has unauthorized CLI Tool config."
|
||||
)
|
||||
if cli_tool.pre_authorized is False:
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Workflow Agent node {binding.node_id} has unauthorized CLI Tool config."
|
||||
)
|
||||
if cls._dangerous_cli_without_acknowledgement(cli_tool.model_dump(mode="python")):
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Workflow Agent node {binding.node_id} has unacknowledged dangerous CLI Tool config."
|
||||
)
|
||||
name = cli_tool.get("name") or cli_tool.get("tool_name") or cli_tool.get("label")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
@@ -316,6 +353,118 @@ class WorkflowAgentNodeValidator:
|
||||
)
|
||||
cli_tool_names.add(normalized_name)
|
||||
|
||||
@classmethod
|
||||
def _validate_agent_soul_env(
|
||||
cls,
|
||||
*,
|
||||
binding: WorkflowAgentNodeBinding,
|
||||
agent_soul: AgentSoulConfig,
|
||||
) -> None:
|
||||
seen_names: set[str] = set()
|
||||
for env_var in agent_soul.env.variables:
|
||||
name = env_var.name
|
||||
if not name:
|
||||
continue
|
||||
if name in seen_names:
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Workflow Agent node {binding.node_id} has duplicate env/secret name {name}."
|
||||
)
|
||||
seen_names.add(name)
|
||||
for secret_ref in agent_soul.env.secret_refs:
|
||||
name = secret_ref.name
|
||||
if not name:
|
||||
continue
|
||||
if cls._permission_denied(secret_ref.model_dump(mode="python", exclude_none=True, exclude_defaults=True)):
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Workflow Agent node {binding.node_id} has unauthorized secret reference {name}."
|
||||
)
|
||||
if name in seen_names:
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Workflow Agent node {binding.node_id} has duplicate env/secret name {name}."
|
||||
)
|
||||
seen_names.add(name)
|
||||
|
||||
@classmethod
|
||||
def _validate_tool_node_agentic_mode(cls, *, node_id: str, node_data: Mapping[str, Any]) -> None:
|
||||
agentic_config = cls._extract_tool_agentic_config(node_data)
|
||||
if agentic_config is None or agentic_config is False:
|
||||
return
|
||||
if agentic_config is True:
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Tool node {node_id} has incomplete agentic mode config for publishing."
|
||||
)
|
||||
if not isinstance(agentic_config, Mapping):
|
||||
raise WorkflowAgentNodeValidationError(f"Tool node {node_id} has invalid agentic mode config.")
|
||||
|
||||
if agentic_config.get("enabled") is False:
|
||||
return
|
||||
if cls._permission_denied(agentic_config):
|
||||
raise WorkflowAgentNodeValidationError(f"Tool node {node_id} has unauthorized agentic mode config.")
|
||||
if agentic_config.get("complete") is False:
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Tool node {node_id} has incomplete agentic mode config for publishing."
|
||||
)
|
||||
|
||||
state = agentic_config.get("state") or agentic_config.get("mode")
|
||||
if isinstance(state, str) and state in cls._MANUAL_TOOL_AGENTIC_STATES:
|
||||
return
|
||||
|
||||
if cls._extract_agentic_parameter_draft(agentic_config) is None and not cls._tool_node_has_manual_parameters(
|
||||
node_data
|
||||
):
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Tool node {node_id} has incomplete agentic mode config for publishing."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _extract_tool_agentic_config(cls, node_data: Mapping[str, Any]) -> object | None:
|
||||
for key in cls._AGENTIC_TOOL_CONFIG_KEYS:
|
||||
if key in node_data:
|
||||
return node_data[key]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_agentic_parameter_draft(agentic_config: Mapping[str, Any]) -> Mapping[str, Any] | None:
|
||||
for key in ("parameter_draft", "parameters_draft", "draft_parameters", "inferred_parameters", "parameters"):
|
||||
value = agentic_config.get(key)
|
||||
if isinstance(value, Mapping) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _tool_node_has_manual_parameters(node_data: Mapping[str, Any]) -> bool:
|
||||
for key in ("tool_parameters", "tool_configurations"):
|
||||
value = node_data.get(key)
|
||||
if isinstance(value, Mapping) and value:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _permission_denied(cls, value: Mapping[str, Any]) -> bool:
|
||||
permission = value.get("permission")
|
||||
if isinstance(permission, Mapping):
|
||||
allowed = permission.get("allowed")
|
||||
if allowed is False:
|
||||
return True
|
||||
status = permission.get("status") or permission.get("state")
|
||||
if isinstance(status, str) and status in cls._DENIED_PERMISSION_STATUSES:
|
||||
return True
|
||||
status = value.get("permission_status") or value.get("authorization_status")
|
||||
return isinstance(status, str) and status in cls._DENIED_PERMISSION_STATUSES
|
||||
|
||||
@staticmethod
|
||||
def _dangerous_cli_without_acknowledgement(value: Mapping[str, Any]) -> bool:
|
||||
dangerous = any(value.get(key) is True for key in ("dangerous", "dangerous_command", "requires_confirmation"))
|
||||
risk_level = value.get("risk_level")
|
||||
if isinstance(risk_level, str) and risk_level == "dangerous":
|
||||
dangerous = True
|
||||
if not dangerous:
|
||||
return False
|
||||
return not any(
|
||||
value.get(key) is True
|
||||
for key in ("dangerous_acknowledged", "dangerous_accepted", "risk_accepted", "approved")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_file_ref(
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user