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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user