feat(agent): Sandbox / CLI Agent (dify.shell) + read-only sandbox file inspector (#36984)

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-03 22:37:31 +00:00
committed by GitHub
co-authored by Claude Opus 4.8 autofix-ci[bot]
parent d3058d63bd
commit 44725dde74
36 changed files with 3761 additions and 240 deletions
@@ -12,24 +12,24 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset(
"model",
"structured_output",
"tools.dify_tools",
"tools.cli_tools",
"env",
"sandbox",
}
)
RESERVED_AGENT_BACKEND_FEATURES = frozenset(
{
"skills_files",
"tools.cli_tools",
"knowledge",
"human",
"env",
"sandbox",
"memory",
}
)
def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any]:
"""Describe PRD capabilities that are persisted but not executed in phase 3."""
"""Describe PRD capabilities supported by or still reserved from Agent backend runtime."""
warnings: list[dict[str, str]] = []
soul_dump = agent_soul.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
for section in sorted(RESERVED_AGENT_BACKEND_FEATURES):
@@ -48,6 +48,9 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed")
reserved_status["tools.dify_tools"] = "supported_when_config_valid"
reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap"
reserved_status["env"] = "supported_by_shell_bootstrap"
reserved_status["sandbox"] = "forwarded_to_shell_layer_config"
return {
"supported": sorted(SUPPORTED_AGENT_BACKEND_FEATURES),
@@ -2,11 +2,19 @@ from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Literal, Protocol, cast
from typing import Any, Literal, Protocol, assert_never, cast
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
from dify_agent.layers.shell import (
DifyShellCliToolConfig,
DifyShellEnvVarConfig,
DifyShellLayerConfig,
DifyShellSandboxConfig,
DifyShellSecretRefConfig,
)
from dify_agent.protocol import CreateRunRequest
from pydantic import BaseModel
from clients.agent_backend import (
AgentBackendModelConfig,
@@ -15,6 +23,7 @@ from clients.agent_backend import (
AgentBackendWorkflowNodeRunInput,
redact_for_agent_backend_log,
)
from configs import dify_config
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.variables.segments import Segment
@@ -123,10 +132,12 @@ class WorkflowAgentRuntimeRequestBuilder:
)
except WorkflowAgentPluginToolsBuildError as error:
raise WorkflowAgentRuntimeRequestBuildError(error.error_code, str(error)) from error
if tools_layer is not None:
if tools_layer is not None or agent_soul.tools.cli_tools:
metadata["agent_tools"] = {
"dify_tool_count": len(tools_layer.tools),
"dify_tool_names": [tool.name or tool.tool_name for tool in tools_layer.tools],
"dify_tool_count": len(tools_layer.tools) if tools_layer is not None else 0,
"dify_tool_names": [tool.name or tool.tool_name for tool in tools_layer.tools]
if tools_layer is not None
else [],
"cli_tool_count": len(agent_soul.tools.cli_tools),
}
@@ -165,6 +176,8 @@ class WorkflowAgentRuntimeRequestBuilder:
user_prompt=user_prompt,
output=self._build_output_config(node_job.declared_outputs),
tools=tools_layer,
include_shell=dify_config.AGENT_SHELL_ENABLED,
shell_config=build_shell_layer_config(agent_soul),
session_snapshot=context.session_snapshot,
idempotency_key=self._idempotency_key(context),
metadata=metadata,
@@ -372,7 +385,9 @@ class WorkflowAgentRuntimeRequestBuilder:
"mime_type": {"type": "string"},
"url": {"type": "string"},
},
"required": ["file_id"],
}
assert_never(output_type)
@staticmethod
def _normalize_credentials(credentials: Mapping[str, Any]) -> dict[str, str | int | float | bool | None]:
@@ -383,3 +398,73 @@ class WorkflowAgentRuntimeRequestBuilder:
else:
normalized[key] = str(value)
return normalized
def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfig:
"""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],
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
],
sandbox=DifyShellSandboxConfig(
provider=agent_soul.sandbox.provider,
config=sandbox_config,
)
if agent_soul.sandbox.provider or sandbox_config
else None,
)
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"):
raw_command = data.get(key)
if isinstance(raw_command, str) and raw_command.strip():
commands.append(raw_command)
name = data.get("name") or data.get("tool_name") or data.get("label")
if not commands and not isinstance(name, str):
return None
return DifyShellCliToolConfig(name=name if isinstance(name, str) else None, install_commands=commands)
def _shell_env_var(item: object) -> DifyShellEnvVarConfig | None:
data = _plain_mapping(item)
name = _name_from_mapping(data)
if name is None:
return None
value = data.get("value", data.get("default", ""))
if not isinstance(value, str):
value = str(value)
return DifyShellEnvVarConfig(name=name, value=value)
def _shell_secret_ref(item: object) -> DifyShellSecretRefConfig | None:
data = _plain_mapping(item)
name = _name_from_mapping(data)
if name is None:
return None
ref = data.get("ref") or data.get("id") or data.get("credential_id") or data.get("provider_credential_id")
return DifyShellSecretRefConfig(name=name, ref=str(ref) if ref is not None else None)
def _plain_mapping(item: object) -> dict[str, Any]:
if isinstance(item, BaseModel):
return item.model_dump(mode="python", exclude_none=True, exclude_defaults=True)
if isinstance(item, Mapping):
return dict(item)
return {}
def _name_from_mapping(item: Mapping[str, Any]) -> str | None:
for key in ("name", "key", "env_name", "variable"):
value = item.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
+12 -2
View File
@@ -303,8 +303,18 @@ class WorkflowAgentNodeValidator:
f"Workflow Agent node {binding.node_id} has duplicate Dify Plugin Tool name {exposed_name}."
)
exposed_names.add(exposed_name)
# CLI tools remain saved-but-not-executed. They are allowed at publish
# time so existing Agent Soul drafts are not blocked by a reserved field.
cli_tool_names: set[str] = set()
for cli_tool in agent_soul.tools.cli_tools:
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
normalized_name = name.strip()
if normalized_name in cli_tool_names:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} has duplicate CLI Tool name {normalized_name}."
)
cli_tool_names.add(normalized_name)
@staticmethod
def _validate_file_ref(