mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:23:44 +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:
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(
|
||||
*,
|
||||
|
||||
@ -26,6 +26,32 @@ class DeclaredOutputType(StrEnum):
|
||||
FILE = "file"
|
||||
|
||||
|
||||
class AgentCliToolAuthorizationStatus(StrEnum):
|
||||
"""Authorization state for Agent-scoped CLI tools.
|
||||
|
||||
Missing status keeps backward compatibility with draft rows and CLI tools that
|
||||
do not need pre-authorization. Explicit denied-like states are blocked by the
|
||||
composer/publish validators and skipped by runtime request builders.
|
||||
"""
|
||||
|
||||
AUTHORIZED = "authorized"
|
||||
PRE_AUTHORIZED = "pre_authorized"
|
||||
ALLOWED = "allowed"
|
||||
NOT_REQUIRED = "not_required"
|
||||
UNAUTHORIZED = "unauthorized"
|
||||
PENDING = "pending"
|
||||
DENIED = "denied"
|
||||
FORBIDDEN = "forbidden"
|
||||
|
||||
|
||||
class AgentCliToolRiskLevel(StrEnum):
|
||||
"""Risk marker for CLI tool bootstrap commands."""
|
||||
|
||||
SAFE = "safe"
|
||||
DANGEROUS = "dangerous"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class OutputErrorStrategy(StrEnum):
|
||||
"""Per-output failure handling strategy.
|
||||
|
||||
@ -80,6 +106,13 @@ class AgentCliToolConfig(AgentFlexibleConfig):
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = None
|
||||
command: str | None = None
|
||||
invoke_metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
pre_authorized: bool | None = None
|
||||
authorization_status: AgentCliToolAuthorizationStatus | None = None
|
||||
permission: dict[str, Any] = Field(default_factory=dict)
|
||||
dangerous: bool = False
|
||||
dangerous_acknowledged: bool = False
|
||||
risk_level: AgentCliToolRiskLevel | None = None
|
||||
|
||||
|
||||
class AgentKnowledgeDatasetConfig(AgentFlexibleConfig):
|
||||
@ -121,6 +154,8 @@ class AgentSecretRefConfig(AgentFlexibleConfig):
|
||||
type: str | None = Field(default=None, max_length=64)
|
||||
id: str | None = Field(default=None, max_length=255)
|
||||
provider: str | None = Field(default=None, max_length=255)
|
||||
permission: dict[str, Any] = Field(default_factory=dict)
|
||||
permission_status: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class AgentSandboxProviderConfig(AgentFlexibleConfig):
|
||||
|
||||
@ -10919,14 +10919,41 @@ default (the config form sends the full desired feature state on save).
|
||||
| suggested_questions_after_answer | [AgentSuggestedQuestionsAfterAnswerFeatureConfig](#agentsuggestedquestionsafteranswerfeatureconfig) | Follow-up suggestions config, e.g. {'enabled': true} | No |
|
||||
| text_to_speech | [AgentTextToSpeechFeatureConfig](#agenttexttospeechfeatureconfig) | Text-to-speech config | No |
|
||||
|
||||
#### AgentCliToolAuthorizationStatus
|
||||
|
||||
Authorization state for Agent-scoped CLI tools.
|
||||
|
||||
Missing status keeps backward compatibility with draft rows and CLI tools that
|
||||
do not need pre-authorization. Explicit denied-like states are blocked by the
|
||||
composer/publish validators and skipped by runtime request builders.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentCliToolAuthorizationStatus | string | Authorization state for Agent-scoped CLI tools. Missing status keeps backward compatibility with draft rows and CLI tools that do not need pre-authorization. Explicit denied-like states are blocked by the composer/publish validators and skipped by runtime request builders. | |
|
||||
|
||||
#### AgentCliToolConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| authorization_status | [AgentCliToolAuthorizationStatus](#agentclitoolauthorizationstatus) | | No |
|
||||
| command | string | | No |
|
||||
| dangerous | boolean | | No |
|
||||
| dangerous_acknowledged | boolean | | No |
|
||||
| description | string | | No |
|
||||
| enabled | boolean | | No |
|
||||
| invoke_metadata | object | | No |
|
||||
| name | string | | No |
|
||||
| permission | object | | No |
|
||||
| pre_authorized | boolean | | No |
|
||||
| risk_level | [AgentCliToolRiskLevel](#agentclitoolrisklevel) | | No |
|
||||
|
||||
#### AgentCliToolRiskLevel
|
||||
|
||||
Risk marker for CLI tool bootstrap commands.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentCliToolRiskLevel | string | Risk marker for CLI tool bootstrap commands. | |
|
||||
|
||||
#### AgentComposerAgentResponse
|
||||
|
||||
@ -11324,6 +11351,8 @@ Visibility and lifecycle scope of an Agent record.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| id | string | | No |
|
||||
| name | string | | No |
|
||||
| permission | object | | No |
|
||||
| permission_status | string | | No |
|
||||
| provider | string | | No |
|
||||
| type | string | | No |
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
@ -19,6 +20,21 @@ _PLAINTEXT_SECRET_KEYS = {
|
||||
"secret_key",
|
||||
}
|
||||
|
||||
# Env/secret names become shell ``export`` identifiers in the sandbox bootstrap, so
|
||||
# they must be valid shell identifiers. Validating here fails fast at composer save
|
||||
# with a friendly error instead of at run time in the agent backend shell layer.
|
||||
_SHELL_ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
_CLI_TOOL_NAME_KEYS = ("name", "tool_name", "label")
|
||||
_CLI_TOOL_COMMAND_KEYS = ("command", "install_command", "install", "setup_command")
|
||||
_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",
|
||||
)
|
||||
|
||||
|
||||
class ComposerConfigValidator:
|
||||
@classmethod
|
||||
@ -33,7 +49,9 @@ class ComposerConfigValidator:
|
||||
|
||||
@classmethod
|
||||
def validate_agent_soul(cls, agent_soul: AgentSoulConfig) -> None:
|
||||
cls._reject_plaintext_secrets(agent_soul.model_dump(mode="json"), path="agent_soul")
|
||||
dumped = agent_soul.model_dump(mode="json")
|
||||
cls._reject_plaintext_secrets(dumped, path="agent_soul")
|
||||
cls._validate_shell_config(dumped)
|
||||
|
||||
@classmethod
|
||||
def validate_node_job(cls, node_job: WorkflowNodeJobConfig) -> None:
|
||||
@ -57,6 +75,53 @@ class ComposerConfigValidator:
|
||||
cls.validate_node_job(config)
|
||||
return config
|
||||
|
||||
@classmethod
|
||||
def _validate_shell_config(cls, soul: dict[str, Any]) -> None:
|
||||
"""Fail fast on shell env/secret/CLI config the sandbox would otherwise reject at run time."""
|
||||
env = soul.get("env") or {}
|
||||
seen_env_names: set[str] = set()
|
||||
for section in ("variables", "secret_refs"):
|
||||
entries = env.get(section)
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
raw_name = entry.get("name")
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
# Unnamed draft rows are tolerated; only named entries are bound to the shell.
|
||||
continue
|
||||
name = raw_name.strip()
|
||||
if not _SHELL_ENV_NAME_PATTERN.fullmatch(name):
|
||||
raise InvalidComposerConfigError(
|
||||
f"env/secret name '{name}' must be a valid shell identifier (^[A-Za-z_][A-Za-z0-9_]*$)."
|
||||
)
|
||||
if section == "secret_refs" and cls._permission_denied(entry):
|
||||
raise InvalidComposerConfigError(f"secret reference '{name}' is not authorized for this agent.")
|
||||
if name in seen_env_names:
|
||||
raise InvalidComposerConfigError(
|
||||
f"duplicate env/secret name '{name}': environment variables and secret references "
|
||||
"share the shell namespace."
|
||||
)
|
||||
seen_env_names.add(name)
|
||||
|
||||
tools = soul.get("tools") or {}
|
||||
cli_tools = tools.get("cli_tools")
|
||||
if isinstance(cli_tools, list):
|
||||
for entry in cli_tools:
|
||||
if not isinstance(entry, dict) or entry.get("enabled") is False:
|
||||
continue
|
||||
has_name = any(isinstance(entry.get(key), str) and entry[key].strip() for key in _CLI_TOOL_NAME_KEYS)
|
||||
has_command = cls._has_install_command(entry)
|
||||
if not has_name and not has_command:
|
||||
raise InvalidComposerConfigError("an enabled CLI tool must declare a name or an install command.")
|
||||
if cls._permission_denied(entry) or entry.get("pre_authorized") is False:
|
||||
raise InvalidComposerConfigError("an enabled CLI tool is not authorized for runtime bootstrap.")
|
||||
if cls._dangerous_without_acknowledgement(entry):
|
||||
raise InvalidComposerConfigError(
|
||||
"a dangerous CLI tool command must be explicitly acknowledged before save."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _reject_plaintext_secrets(cls, value: Any, *, path: str) -> None:
|
||||
if isinstance(value, dict):
|
||||
@ -69,3 +134,39 @@ class ComposerConfigValidator:
|
||||
elif isinstance(value, list):
|
||||
for index, nested in enumerate(value):
|
||||
cls._reject_plaintext_secrets(nested, path=f"{path}[{index}]")
|
||||
|
||||
@classmethod
|
||||
def _has_install_command(cls, entry: dict[str, Any]) -> bool:
|
||||
raw_commands = entry.get("install_commands")
|
||||
if isinstance(raw_commands, list) and any(
|
||||
isinstance(command, str) and command.strip() for command in raw_commands
|
||||
):
|
||||
return True
|
||||
return any(isinstance(entry.get(key), str) and entry[key].strip() for key in _CLI_TOOL_COMMAND_KEYS)
|
||||
|
||||
@classmethod
|
||||
def _permission_denied(cls, entry: dict[str, Any]) -> bool:
|
||||
permission = entry.get("permission")
|
||||
if isinstance(permission, dict):
|
||||
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 = entry.get(key)
|
||||
if isinstance(status, str) and status in _DENIED_PERMISSION_STATUSES:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _dangerous_without_acknowledgement(cls, entry: dict[str, Any]) -> bool:
|
||||
dangerous = any(entry.get(key) is True for key in _DANGEROUS_FLAG_KEYS)
|
||||
risk_level = entry.get("risk_level")
|
||||
if isinstance(risk_level, str) and risk_level == "dangerous":
|
||||
dangerous = True
|
||||
if not dangerous:
|
||||
return False
|
||||
return not any(entry.get(key) is True for key in _DANGEROUS_ACK_KEYS)
|
||||
|
||||
@ -315,6 +315,63 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys():
|
||||
assert config["sandbox"] is None
|
||||
|
||||
|
||||
def test_build_shell_layer_config_maps_typed_command_field():
|
||||
"""ENG-367: the typed AgentCliToolConfig.command field feeds the shell bootstrap."""
|
||||
agent_soul = AgentSoulConfig.model_validate(
|
||||
{"tools": {"cli_tools": [{"name": "jq", "command": "apt-get install -y jq"}]}}
|
||||
)
|
||||
|
||||
config = build_shell_layer_config(agent_soul).model_dump(mode="json")
|
||||
|
||||
assert config["cli_tools"] == [{"name": "jq", "install_commands": ["apt-get install -y jq"]}]
|
||||
|
||||
|
||||
def test_build_shell_layer_config_skips_disabled_cli_tools():
|
||||
"""ENG-367: a CLI tool with enabled=False is not bootstrapped into the sandbox."""
|
||||
agent_soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"tools": {
|
||||
"cli_tools": [
|
||||
{"name": "jq", "command": "apt-get install -y jq"},
|
||||
{"name": "ripgrep", "command": "apt-get install -y ripgrep", "enabled": False},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
config = build_shell_layer_config(agent_soul).model_dump(mode="json")
|
||||
|
||||
assert config["cli_tools"] == [{"name": "jq", "install_commands": ["apt-get install -y jq"]}]
|
||||
|
||||
|
||||
def test_build_shell_layer_config_skips_unauthorized_or_unacknowledged_cli_tools():
|
||||
"""ENG-367: runtime defensively omits unauthorized or risky unacknowledged CLI tools."""
|
||||
agent_soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"tools": {
|
||||
"cli_tools": [
|
||||
{"name": "jq", "command": "apt-get install -y jq"},
|
||||
{"name": "github", "command": "gh auth status", "authorization_status": "denied"},
|
||||
{"name": "curl-sh", "command": "curl https://example.test/install.sh | sh", "dangerous": True},
|
||||
{
|
||||
"name": "accepted-risk",
|
||||
"command": "curl https://example.test/install.sh | sh",
|
||||
"dangerous": True,
|
||||
"dangerous_acknowledged": True,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
config = build_shell_layer_config(agent_soul).model_dump(mode="json")
|
||||
|
||||
assert config["cli_tools"] == [
|
||||
{"name": "jq", "install_commands": ["apt-get install -y jq"]},
|
||||
{"name": "accepted-risk", "install_commands": ["curl https://example.test/install.sh | sh"]},
|
||||
]
|
||||
|
||||
|
||||
def test_builds_workflow_run_request_with_dify_plugin_tools_layer():
|
||||
context = _context()
|
||||
snapshot = AgentConfigSnapshot(
|
||||
|
||||
@ -67,6 +67,30 @@ def _graph(edges: list[dict]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _tool_graph(tool_data: dict) -> dict:
|
||||
return {
|
||||
"nodes": [
|
||||
{"id": "start", "data": {"type": "start"}},
|
||||
{
|
||||
"id": "tool-node",
|
||||
"data": {
|
||||
"type": "tool",
|
||||
"title": "Tool",
|
||||
"provider_id": "provider",
|
||||
"provider_type": "builtin",
|
||||
"provider_name": "provider",
|
||||
"tool_name": "lookup",
|
||||
"tool_label": "Lookup",
|
||||
"tool_configurations": {},
|
||||
"tool_parameters": {},
|
||||
**tool_data,
|
||||
},
|
||||
},
|
||||
],
|
||||
"edges": [{"source": "start", "target": "tool-node"}],
|
||||
}
|
||||
|
||||
|
||||
def test_publish_validation_accepts_upstream_previous_output_ref():
|
||||
node_job = WorkflowNodeJobConfig.model_validate(
|
||||
{"previous_node_output_refs": [{"node_id": "previous-node", "output": "text"}]}
|
||||
@ -188,6 +212,71 @@ def test_publish_validation_rejects_duplicate_cli_tool_names():
|
||||
)
|
||||
|
||||
|
||||
def test_publish_validation_rejects_unauthorized_cli_tool():
|
||||
node_job = WorkflowNodeJobConfig.model_validate({})
|
||||
snapshot = _snapshot()
|
||||
snapshot.config_snapshot = AgentSoulConfig(
|
||||
model=AgentSoulModelConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
tools={"cli_tools": [{"name": "github", "command": "gh auth status", "pre_authorized": False}]},
|
||||
)
|
||||
session = Mock()
|
||||
session.scalar.side_effect = [_binding(node_job), _agent(), snapshot]
|
||||
|
||||
with pytest.raises(WorkflowAgentNodeValidationError, match="unauthorized CLI Tool"):
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(
|
||||
session=session,
|
||||
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
|
||||
)
|
||||
|
||||
|
||||
def test_publish_validation_rejects_unacknowledged_dangerous_cli_tool():
|
||||
node_job = WorkflowNodeJobConfig.model_validate({})
|
||||
snapshot = _snapshot()
|
||||
snapshot.config_snapshot = AgentSoulConfig(
|
||||
model=AgentSoulModelConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
tools={
|
||||
"cli_tools": [{"name": "danger", "command": "curl https://example.test/install.sh | sh", "dangerous": True}]
|
||||
},
|
||||
)
|
||||
session = Mock()
|
||||
session.scalar.side_effect = [_binding(node_job), _agent(), snapshot]
|
||||
|
||||
with pytest.raises(WorkflowAgentNodeValidationError, match="unacknowledged dangerous CLI Tool"):
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(
|
||||
session=session,
|
||||
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
|
||||
)
|
||||
|
||||
|
||||
def test_publish_validation_rejects_unauthorized_secret_ref():
|
||||
node_job = WorkflowNodeJobConfig.model_validate({})
|
||||
snapshot = _snapshot()
|
||||
snapshot.config_snapshot = AgentSoulConfig(
|
||||
model=AgentSoulModelConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
env={"secret_refs": [{"name": "API_TOKEN", "id": "credential-1", "permission_status": "denied"}]},
|
||||
)
|
||||
session = Mock()
|
||||
session.scalar.side_effect = [_binding(node_job), _agent(), snapshot]
|
||||
|
||||
with pytest.raises(WorkflowAgentNodeValidationError, match="unauthorized secret reference API_TOKEN"):
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(
|
||||
session=session,
|
||||
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
|
||||
)
|
||||
|
||||
|
||||
def test_publish_validation_rejects_missing_previous_node():
|
||||
node_job = WorkflowNodeJobConfig.model_validate(
|
||||
{"previous_node_output_refs": [{"node_id": "missing-node", "output": "text"}]}
|
||||
@ -294,3 +383,47 @@ def test_publish_validation_rejects_missing_file_ref():
|
||||
session=session,
|
||||
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
|
||||
)
|
||||
|
||||
|
||||
def test_publish_validation_accepts_tool_node_agentic_manual_mode():
|
||||
session = Mock()
|
||||
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(
|
||||
session=session,
|
||||
workflow=_workflow(_tool_graph({"agentic_mode": {"state": "manual"}})),
|
||||
)
|
||||
|
||||
|
||||
def test_publish_validation_accepts_tool_node_agentic_parameter_draft():
|
||||
session = Mock()
|
||||
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(
|
||||
session=session,
|
||||
workflow=_workflow(_tool_graph({"agentic_mode": {"state": "agentic", "parameter_draft": {"query": "x"}}})),
|
||||
)
|
||||
|
||||
|
||||
def test_publish_validation_rejects_incomplete_tool_node_agentic_config():
|
||||
session = Mock()
|
||||
|
||||
with pytest.raises(WorkflowAgentNodeValidationError, match="incomplete agentic mode config"):
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(
|
||||
session=session,
|
||||
workflow=_workflow(_tool_graph({"agentic_mode": True})),
|
||||
)
|
||||
|
||||
with pytest.raises(WorkflowAgentNodeValidationError, match="incomplete agentic mode config"):
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(
|
||||
session=session,
|
||||
workflow=_workflow(_tool_graph({"agentic_mode": {"state": "agentic", "complete": False}})),
|
||||
)
|
||||
|
||||
|
||||
def test_publish_validation_rejects_unauthorized_tool_node_agentic_config():
|
||||
session = Mock()
|
||||
|
||||
with pytest.raises(WorkflowAgentNodeValidationError, match="unauthorized agentic mode config"):
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(
|
||||
session=session,
|
||||
workflow=_workflow(_tool_graph({"agentic_mode": {"state": "agentic", "permission": {"allowed": False}}})),
|
||||
)
|
||||
|
||||
@ -664,6 +664,94 @@ def test_composer_validator_rejects_stage_4_declared_output_violations():
|
||||
)
|
||||
|
||||
|
||||
def test_composer_validator_rejects_invalid_shell_env_and_cli():
|
||||
"""ENG-367/368: env/secret names must be valid shell identifiers (no collisions),
|
||||
and an enabled CLI tool must declare a name or install command — caught at composer
|
||||
save instead of failing later in the agent backend shell layer."""
|
||||
# env var name is not a valid shell identifier
|
||||
with pytest.raises(InvalidComposerConfigError):
|
||||
ComposerConfigValidator.validate_agent_soul_dict({"env": {"variables": [{"name": "bad-name"}]}})
|
||||
|
||||
# secret ref name is not a valid shell identifier
|
||||
with pytest.raises(InvalidComposerConfigError):
|
||||
ComposerConfigValidator.validate_agent_soul_dict({"env": {"secret_refs": [{"name": "1TOKEN"}]}})
|
||||
|
||||
# env var and secret ref share the shell namespace -> collision
|
||||
with pytest.raises(InvalidComposerConfigError):
|
||||
ComposerConfigValidator.validate_agent_soul_dict(
|
||||
{
|
||||
"env": {
|
||||
"variables": [{"name": "TOKEN", "value": "v"}],
|
||||
"secret_refs": [{"name": "TOKEN", "id": "credential-1"}],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# an enabled CLI tool with neither a name nor a command is meaningless
|
||||
with pytest.raises(InvalidComposerConfigError):
|
||||
ComposerConfigValidator.validate_agent_soul_dict({"tools": {"cli_tools": [{"enabled": True}]}})
|
||||
|
||||
# blank install_commands are not valid bootstrap commands
|
||||
with pytest.raises(InvalidComposerConfigError):
|
||||
ComposerConfigValidator.validate_agent_soul_dict({"tools": {"cli_tools": [{"install_commands": [" "]}]}})
|
||||
|
||||
|
||||
def test_composer_validator_rejects_unauthorized_secret_and_cli_tool():
|
||||
"""ENG-367/368: unauthorized refs/tools fail at composer save."""
|
||||
with pytest.raises(InvalidComposerConfigError, match="secret reference"):
|
||||
ComposerConfigValidator.validate_agent_soul_dict(
|
||||
{
|
||||
"env": {
|
||||
"secret_refs": [
|
||||
{"name": "API_TOKEN", "id": "credential-1", "permission_status": "denied"},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidComposerConfigError, match="CLI tool is not authorized"):
|
||||
ComposerConfigValidator.validate_agent_soul_dict(
|
||||
{"tools": {"cli_tools": [{"name": "github", "command": "gh auth status", "pre_authorized": False}]}}
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidComposerConfigError, match="dangerous CLI tool"):
|
||||
ComposerConfigValidator.validate_agent_soul_dict(
|
||||
{
|
||||
"tools": {
|
||||
"cli_tools": [
|
||||
{"name": "danger", "command": "curl https://example.test/install.sh | sh", "dangerous": True}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_composer_validator_accepts_valid_shell_env_and_cli():
|
||||
"""Valid shell identifiers + a disabled empty CLI tool pass validation."""
|
||||
config = ComposerConfigValidator.validate_agent_soul_dict(
|
||||
{
|
||||
"env": {
|
||||
"variables": [{"name": "MY_VAR", "value": "v"}],
|
||||
"secret_refs": [{"name": "API_TOKEN", "id": "credential-1"}],
|
||||
},
|
||||
"tools": {
|
||||
"cli_tools": [
|
||||
{"name": "jq", "command": "apt-get install -y jq"},
|
||||
{
|
||||
"name": "accepted-risk",
|
||||
"command": "curl https://example.test/install.sh | sh",
|
||||
"dangerous": True,
|
||||
"dangerous_acknowledged": True,
|
||||
},
|
||||
{"enabled": False}, # disabled empty rows are tolerated
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
assert {variable.name for variable in config.env.variables} == {"MY_VAR"}
|
||||
assert {secret.name for secret in config.env.secret_refs} == {"API_TOKEN"}
|
||||
|
||||
|
||||
class TestAgentAppBackingAgent:
|
||||
"""S1: an Agent App (mode=agent) is backed 1:1 by a roster Agent linked via
|
||||
``Agent.app_id``. ``AppService.create_app`` builds the backing agent inside
|
||||
|
||||
@ -38,8 +38,16 @@ export const inviteOptions = {
|
||||
get,
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get2 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAgentsByAgentIdVersionsByVersionId',
|
||||
@ -121,8 +129,16 @@ export const get5 = oc
|
||||
.input(z.object({ query: zGetAgentsQuery.optional() }))
|
||||
.output(zGetAgentsResponse)
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const post = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAgents',
|
||||
|
||||
@ -257,6 +257,10 @@ export type AgentTextToSpeechFeatureConfig = {
|
||||
export type AgentSecretRefConfig = {
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
permission?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
permission_status?: string | null
|
||||
provider?: string | null
|
||||
type?: string | null
|
||||
[key: string]: unknown
|
||||
@ -354,10 +358,21 @@ export type AgentSkillRefConfig = {
|
||||
}
|
||||
|
||||
export type AgentCliToolConfig = {
|
||||
authorization_status?: AgentCliToolAuthorizationStatus
|
||||
command?: string | null
|
||||
dangerous?: boolean
|
||||
dangerous_acknowledged?: boolean
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
invoke_metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
name?: string | null
|
||||
permission?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
pre_authorized?: boolean | null
|
||||
risk_level?: AgentCliToolRiskLevel
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@ -390,6 +405,18 @@ export type AgentModelResponseFormatConfig = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentCliToolAuthorizationStatus
|
||||
= | 'allowed'
|
||||
| 'authorized'
|
||||
| 'denied'
|
||||
| 'forbidden'
|
||||
| 'not_required'
|
||||
| 'pending'
|
||||
| 'pre_authorized'
|
||||
| 'unauthorized'
|
||||
|
||||
export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown'
|
||||
|
||||
export type AgentSoulDifyToolCredentialRef = {
|
||||
id?: string | null
|
||||
provider?: string | null
|
||||
|
||||
@ -218,6 +218,8 @@ export const zAgentTextToSpeechFeatureConfig = z.object({
|
||||
export const zAgentSecretRefConfig = z.object({
|
||||
id: z.string().max(255).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
permission: z.record(z.string(), z.unknown()).optional(),
|
||||
permission_status: z.string().max(64).nullish(),
|
||||
provider: z.string().max(255).nullish(),
|
||||
type: z.string().max(64).nullish(),
|
||||
})
|
||||
@ -378,16 +380,6 @@ export const zAgentSoulSkillsFilesConfig = z.object({
|
||||
skills: z.array(zAgentSkillRefConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentCliToolConfig
|
||||
*/
|
||||
export const zAgentCliToolConfig = z.object({
|
||||
command: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
name: z.string().max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentModelResponseFormatConfig
|
||||
*/
|
||||
@ -430,6 +422,50 @@ export const zAgentSuggestedQuestionsAfterAnswerFeatureConfig = z.object({
|
||||
prompt: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentCliToolAuthorizationStatus
|
||||
*
|
||||
* Authorization state for Agent-scoped CLI tools.
|
||||
*
|
||||
* Missing status keeps backward compatibility with draft rows and CLI tools that
|
||||
* do not need pre-authorization. Explicit denied-like states are blocked by the
|
||||
* composer/publish validators and skipped by runtime request builders.
|
||||
*/
|
||||
export const zAgentCliToolAuthorizationStatus = z.enum([
|
||||
'allowed',
|
||||
'authorized',
|
||||
'denied',
|
||||
'forbidden',
|
||||
'not_required',
|
||||
'pending',
|
||||
'pre_authorized',
|
||||
'unauthorized',
|
||||
])
|
||||
|
||||
/**
|
||||
* AgentCliToolRiskLevel
|
||||
*
|
||||
* Risk marker for CLI tool bootstrap commands.
|
||||
*/
|
||||
export const zAgentCliToolRiskLevel = z.enum(['dangerous', 'safe', 'unknown'])
|
||||
|
||||
/**
|
||||
* AgentCliToolConfig
|
||||
*/
|
||||
export const zAgentCliToolConfig = z.object({
|
||||
authorization_status: zAgentCliToolAuthorizationStatus.optional(),
|
||||
command: z.string().nullish(),
|
||||
dangerous: z.boolean().optional().default(false),
|
||||
dangerous_acknowledged: z.boolean().optional().default(false),
|
||||
description: z.string().nullish(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
invoke_metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
name: z.string().max(255).nullish(),
|
||||
permission: z.record(z.string(), z.unknown()).optional(),
|
||||
pre_authorized: z.boolean().nullish(),
|
||||
risk_level: zAgentCliToolRiskLevel.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulDifyToolCredentialRef
|
||||
*
|
||||
|
||||
@ -789,8 +789,16 @@ export const advancedChat = {
|
||||
workflows: workflows2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get4 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByAppIdAgentComposerCandidates',
|
||||
@ -804,8 +812,16 @@ export const candidates = {
|
||||
get: get4,
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const post9 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAppsByAppIdAgentComposerValidate',
|
||||
@ -824,8 +840,16 @@ export const validate = {
|
||||
post: post9,
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get5 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByAppIdAgentComposer',
|
||||
@ -835,8 +859,16 @@ export const get5 = oc
|
||||
.input(z.object({ params: zGetAppsByAppIdAgentComposerPath }))
|
||||
.output(zGetAppsByAppIdAgentComposerResponse)
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const put = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'PUT',
|
||||
operationId: 'putAppsByAppIdAgentComposer',
|
||||
@ -3685,8 +3717,16 @@ export const loop2 = {
|
||||
nodes: nodes6,
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get62 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidates',
|
||||
@ -3702,8 +3742,16 @@ export const candidates2 = {
|
||||
get: get62,
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const post48 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpact',
|
||||
@ -3722,8 +3770,16 @@ export const impact = {
|
||||
post: post48,
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const post49 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRoster',
|
||||
@ -3742,8 +3798,16 @@ export const saveToRoster = {
|
||||
post: post49,
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const post50 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidate',
|
||||
@ -3762,8 +3826,16 @@ export const validate2 = {
|
||||
post: post50,
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get63 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposer',
|
||||
@ -3773,8 +3845,16 @@ export const get63 = oc
|
||||
.input(z.object({ params: zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath }))
|
||||
.output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse)
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const put5 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'PUT',
|
||||
operationId: 'putAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposer',
|
||||
|
||||
@ -1628,10 +1628,21 @@ export type WorkflowPreviousNodeOutputRef = {
|
||||
export type DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
|
||||
|
||||
export type AgentCliToolConfig = {
|
||||
authorization_status?: AgentCliToolAuthorizationStatus
|
||||
command?: string | null
|
||||
dangerous?: boolean
|
||||
dangerous_acknowledged?: boolean
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
invoke_metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
name?: string | null
|
||||
permission?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
pre_authorized?: boolean | null
|
||||
risk_level?: AgentCliToolRiskLevel
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@ -1767,6 +1778,10 @@ export type DeclaredOutputFileConfig = {
|
||||
export type AgentSecretRefConfig = {
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
permission?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
permission_status?: string | null
|
||||
provider?: string | null
|
||||
type?: string | null
|
||||
[key: string]: unknown
|
||||
@ -1854,6 +1869,18 @@ export type AgentSoulDifyToolConfig = {
|
||||
tool_name: string
|
||||
}
|
||||
|
||||
export type AgentCliToolAuthorizationStatus
|
||||
= | 'allowed'
|
||||
| 'authorized'
|
||||
| 'denied'
|
||||
| 'forbidden'
|
||||
| 'not_required'
|
||||
| 'pending'
|
||||
| 'pre_authorized'
|
||||
| 'unauthorized'
|
||||
|
||||
export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown'
|
||||
|
||||
export type AgentModerationIoConfig = {
|
||||
enabled?: boolean
|
||||
preset_response?: string | null
|
||||
|
||||
@ -1690,16 +1690,6 @@ export const zAgentComposerNodeJobCandidatesResponse = z.object({
|
||||
previous_node_outputs: z.array(zWorkflowPreviousNodeOutputRef).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentCliToolConfig
|
||||
*/
|
||||
export const zAgentCliToolConfig = z.object({
|
||||
command: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
name: z.string().max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerDifyToolCandidateResponse
|
||||
*/
|
||||
@ -1732,27 +1722,6 @@ export const zAgentSkillRefConfig = z.object({
|
||||
path: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerSoulCandidatesResponse
|
||||
*/
|
||||
export const zAgentComposerSoulCandidatesResponse = z.object({
|
||||
cli_tools: z.array(zAgentCliToolConfig).optional(),
|
||||
dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(),
|
||||
human_contacts: z.array(zAgentHumanContactConfig).optional(),
|
||||
knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
|
||||
skills_files: z.array(zAgentSkillRefConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerCandidatesResponse
|
||||
*/
|
||||
export const zAgentComposerCandidatesResponse = z.object({
|
||||
allowed_node_job_candidates: zAgentComposerNodeJobCandidatesResponse.optional(),
|
||||
allowed_soul_candidates: zAgentComposerSoulCandidatesResponse.optional(),
|
||||
capabilities: zComposerCandidateCapabilities.optional(),
|
||||
variant: zComposerVariant,
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleModelConfig
|
||||
*/
|
||||
@ -1978,6 +1947,8 @@ export const zDeclaredOutputFileConfig = z.object({
|
||||
export const zAgentSecretRefConfig = z.object({
|
||||
id: z.string().max(255).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
permission: z.record(z.string(), z.unknown()).optional(),
|
||||
permission_status: z.string().max(64).nullish(),
|
||||
provider: z.string().max(255).nullish(),
|
||||
type: z.string().max(64).nullish(),
|
||||
})
|
||||
@ -2114,6 +2085,71 @@ export const zWorkflowNodeJobMetadata = z.object({
|
||||
file_refs: z.array(zAgentFileRefConfig).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentCliToolAuthorizationStatus
|
||||
*
|
||||
* Authorization state for Agent-scoped CLI tools.
|
||||
*
|
||||
* Missing status keeps backward compatibility with draft rows and CLI tools that
|
||||
* do not need pre-authorization. Explicit denied-like states are blocked by the
|
||||
* composer/publish validators and skipped by runtime request builders.
|
||||
*/
|
||||
export const zAgentCliToolAuthorizationStatus = z.enum([
|
||||
'allowed',
|
||||
'authorized',
|
||||
'denied',
|
||||
'forbidden',
|
||||
'not_required',
|
||||
'pending',
|
||||
'pre_authorized',
|
||||
'unauthorized',
|
||||
])
|
||||
|
||||
/**
|
||||
* AgentCliToolRiskLevel
|
||||
*
|
||||
* Risk marker for CLI tool bootstrap commands.
|
||||
*/
|
||||
export const zAgentCliToolRiskLevel = z.enum(['dangerous', 'safe', 'unknown'])
|
||||
|
||||
/**
|
||||
* AgentCliToolConfig
|
||||
*/
|
||||
export const zAgentCliToolConfig = z.object({
|
||||
authorization_status: zAgentCliToolAuthorizationStatus.optional(),
|
||||
command: z.string().nullish(),
|
||||
dangerous: z.boolean().optional().default(false),
|
||||
dangerous_acknowledged: z.boolean().optional().default(false),
|
||||
description: z.string().nullish(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
invoke_metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
name: z.string().max(255).nullish(),
|
||||
permission: z.record(z.string(), z.unknown()).optional(),
|
||||
pre_authorized: z.boolean().nullish(),
|
||||
risk_level: zAgentCliToolRiskLevel.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerSoulCandidatesResponse
|
||||
*/
|
||||
export const zAgentComposerSoulCandidatesResponse = z.object({
|
||||
cli_tools: z.array(zAgentCliToolConfig).optional(),
|
||||
dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(),
|
||||
human_contacts: z.array(zAgentHumanContactConfig).optional(),
|
||||
knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
|
||||
skills_files: z.array(zAgentSkillRefConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerCandidatesResponse
|
||||
*/
|
||||
export const zAgentComposerCandidatesResponse = z.object({
|
||||
allowed_node_job_candidates: zAgentComposerNodeJobCandidatesResponse.optional(),
|
||||
allowed_soul_candidates: zAgentComposerSoulCandidatesResponse.optional(),
|
||||
capabilities: zComposerCandidateCapabilities.optional(),
|
||||
variant: zComposerVariant,
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentModerationIOConfig
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user