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
@@ -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