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:
zyssyz123
2026-06-04 05:46:42 +00:00
committed by GitHub
co-authored by Claude Opus 4.8 autofix-ci[bot]
parent 6e3c9597ff
commit 5b5a06136a
14 changed files with 911 additions and 44 deletions
@@ -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(
*,