mirror of
https://github.com/langgenius/dify.git
synced 2026-06-12 19:28:01 +08:00
feat(agent): support cli tool scoped env (#37324)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@ -11,6 +11,7 @@ composition-driven.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import ClassVar, cast
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
@ -142,6 +143,30 @@ class AgentBackendModelConfig(BaseModel):
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
# ``DifyPluginLLMLayerConfig.model_settings`` is pydantic_ai's ``ModelSettings``
|
||||
# TypedDict (closed: unknown keys are rejected, explicit ``None`` values fail the
|
||||
# per-field type checks). Agent Soul model settings carry a wider, nullable shape
|
||||
# (``stop`` / ``response_format`` plus null-padded fields), so the layer config
|
||||
# only receives the keys the runtime contract accepts.
|
||||
_AGENT_MODEL_SETTINGS_PASSTHROUGH_KEYS = (
|
||||
"temperature",
|
||||
"top_p",
|
||||
"presence_penalty",
|
||||
"frequency_penalty",
|
||||
"max_tokens",
|
||||
)
|
||||
|
||||
|
||||
def _agent_model_settings(settings: Mapping[str, JsonValue]) -> dict[str, JsonValue] | None:
|
||||
sanitized: dict[str, JsonValue] = {
|
||||
key: settings[key] for key in _AGENT_MODEL_SETTINGS_PASSTHROUGH_KEYS if settings.get(key) is not None
|
||||
}
|
||||
stop = settings.get("stop")
|
||||
if isinstance(stop, list) and stop:
|
||||
sanitized["stop_sequences"] = stop
|
||||
return sanitized or None
|
||||
|
||||
|
||||
class AgentBackendOutputConfig(BaseModel):
|
||||
"""API-side structured output declaration for the conventional output layer.
|
||||
|
||||
@ -283,7 +308,7 @@ class AgentBackendRunRequestBuilder:
|
||||
model_provider=run_input.model.model_provider,
|
||||
model=run_input.model.model,
|
||||
credentials=run_input.model.credentials,
|
||||
model_settings=run_input.model.model_settings or None,
|
||||
model_settings=_agent_model_settings(run_input.model.model_settings),
|
||||
),
|
||||
)
|
||||
)
|
||||
@ -300,12 +325,14 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
|
||||
if run_input.include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). The layer declares NoLayerDeps,
|
||||
# so the spec carries no deps; shellctl connection is server-injected.
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context so
|
||||
# the agent server can mint per-command Agent Stub env (back proxy);
|
||||
# shellctl connection itself is server-injected.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
@ -437,7 +464,7 @@ class AgentBackendRunRequestBuilder:
|
||||
model_provider=run_input.model.model_provider,
|
||||
model=run_input.model.model,
|
||||
credentials=run_input.model.credentials,
|
||||
model_settings=run_input.model.model_settings or None,
|
||||
model_settings=_agent_model_settings(run_input.model.model_settings),
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -455,12 +482,14 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
|
||||
if run_input.include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). The layer declares NoLayerDeps,
|
||||
# so the spec carries no deps; shellctl connection is server-injected.
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context so
|
||||
# the agent server can mint per-command Agent Stub env (back proxy);
|
||||
# shellctl connection itself is server-injected.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
|
||||
@ -508,7 +508,32 @@ def _shell_cli_tool(item: object) -> DifyShellCliToolConfig | None:
|
||||
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)
|
||||
tool_env = data.get("env") if isinstance(data.get("env"), Mapping) else {}
|
||||
env = [
|
||||
env_var
|
||||
for env_var in (_shell_env_var(item) for item in _env_entries(tool_env, "variables"))
|
||||
if env_var is not None
|
||||
]
|
||||
secret_refs = [
|
||||
secret_ref
|
||||
for secret_ref in (_shell_secret_ref(item) for item in _env_entries(tool_env, "secret_refs"))
|
||||
if secret_ref is not None
|
||||
]
|
||||
return DifyShellCliToolConfig(
|
||||
name=name if isinstance(name, str) else None,
|
||||
install_commands=commands,
|
||||
env=env,
|
||||
secret_refs=secret_refs,
|
||||
)
|
||||
|
||||
|
||||
def _env_entries(env: object, key: str) -> list[object]:
|
||||
if not isinstance(env, Mapping):
|
||||
return []
|
||||
entries = env.get(key)
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
return entries
|
||||
|
||||
|
||||
def _shell_env_var(item: object) -> DifyShellEnvVarConfig | None:
|
||||
|
||||
@ -363,8 +363,37 @@ class WorkflowAgentNodeValidator:
|
||||
agent_soul: AgentSoulConfig,
|
||||
) -> None:
|
||||
seen_names: set[str] = set()
|
||||
for env_var in agent_soul.env.variables:
|
||||
name = env_var.name
|
||||
cls._validate_env_entries(
|
||||
binding=binding,
|
||||
seen_names=seen_names,
|
||||
variables=agent_soul.env.variables,
|
||||
secret_refs=agent_soul.env.secret_refs,
|
||||
label="agent",
|
||||
)
|
||||
for cli_tool in agent_soul.tools.cli_tools:
|
||||
if not cli_tool.enabled:
|
||||
continue
|
||||
name = cli_tool.get("name") or cli_tool.get("tool_name") or cli_tool.get("label") or "<unnamed>"
|
||||
cls._validate_env_entries(
|
||||
binding=binding,
|
||||
seen_names=seen_names,
|
||||
variables=cli_tool.env.variables,
|
||||
secret_refs=cli_tool.env.secret_refs,
|
||||
label=f"CLI Tool {name}",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _validate_env_entries(
|
||||
cls,
|
||||
*,
|
||||
binding: WorkflowAgentNodeBinding,
|
||||
seen_names: set[str],
|
||||
variables: list[Any],
|
||||
secret_refs: list[Any],
|
||||
label: str,
|
||||
) -> None:
|
||||
for env_var in variables:
|
||||
name = cls._env_name(env_var)
|
||||
if not name:
|
||||
continue
|
||||
if name in seen_names:
|
||||
@ -372,13 +401,13 @@ class WorkflowAgentNodeValidator:
|
||||
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
|
||||
for secret_ref in secret_refs:
|
||||
name = cls._env_name(secret_ref)
|
||||
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}."
|
||||
f"Workflow Agent node {binding.node_id} has unauthorized secret reference {name} in {label}."
|
||||
)
|
||||
if name in seen_names:
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
@ -386,6 +415,15 @@ class WorkflowAgentNodeValidator:
|
||||
)
|
||||
seen_names.add(name)
|
||||
|
||||
@staticmethod
|
||||
def _env_name(value: Any) -> str | None:
|
||||
if hasattr(value, "get"):
|
||||
for key in ("name", "key", "env_name", "variable"):
|
||||
item = value.get(key)
|
||||
if isinstance(item, str) and item.strip():
|
||||
return item.strip()
|
||||
return None
|
||||
|
||||
@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)
|
||||
|
||||
@ -117,6 +117,37 @@ class AgentPermissionConfig(BaseModel):
|
||||
state: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class AgentEnvVariableConfig(AgentFlexibleConfig):
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
key: str | None = Field(default=None, max_length=255)
|
||||
env_name: str | None = Field(default=None, max_length=255)
|
||||
variable: str | None = Field(default=None, max_length=255)
|
||||
type: str | None = Field(default=None, max_length=64)
|
||||
value: RuntimeParameterValue = None
|
||||
default: RuntimeParameterValue = None
|
||||
required: bool = False
|
||||
|
||||
|
||||
class AgentSecretRefConfig(AgentFlexibleConfig):
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
key: str | None = Field(default=None, max_length=255)
|
||||
env_name: str | None = Field(default=None, max_length=255)
|
||||
variable: str | None = Field(default=None, max_length=255)
|
||||
type: str | None = Field(default=None, max_length=64)
|
||||
id: str | None = Field(default=None, max_length=255)
|
||||
ref: str | None = Field(default=None, max_length=255)
|
||||
credential_id: str | None = Field(default=None, max_length=255)
|
||||
provider_credential_id: str | None = Field(default=None, max_length=255)
|
||||
provider: str | None = Field(default=None, max_length=255)
|
||||
permission: AgentPermissionConfig | None = None
|
||||
permission_status: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class AgentCliToolEnvConfig(BaseModel):
|
||||
variables: list[AgentEnvVariableConfig] = Field(default_factory=list)
|
||||
secret_refs: list[AgentSecretRefConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentCliToolConfig(AgentFlexibleConfig):
|
||||
enabled: bool = True
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
@ -129,6 +160,7 @@ class AgentCliToolConfig(AgentFlexibleConfig):
|
||||
install: str | None = None
|
||||
setup_command: str | None = None
|
||||
invoke_metadata: dict[str, Any] = Field(default_factory=dict, json_schema_extra={"x-dify-opaque": True})
|
||||
env: AgentCliToolEnvConfig = Field(default_factory=AgentCliToolEnvConfig)
|
||||
pre_authorized: bool | None = None
|
||||
authorization_status: AgentCliToolAuthorizationStatus | None = None
|
||||
permission: AgentPermissionConfig | None = None
|
||||
@ -173,32 +205,6 @@ class AgentHumanToolConfig(AgentFlexibleConfig):
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class AgentEnvVariableConfig(AgentFlexibleConfig):
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
key: str | None = Field(default=None, max_length=255)
|
||||
env_name: str | None = Field(default=None, max_length=255)
|
||||
variable: str | None = Field(default=None, max_length=255)
|
||||
type: str | None = Field(default=None, max_length=64)
|
||||
value: RuntimeParameterValue = None
|
||||
default: RuntimeParameterValue = None
|
||||
required: bool = False
|
||||
|
||||
|
||||
class AgentSecretRefConfig(AgentFlexibleConfig):
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
key: str | None = Field(default=None, max_length=255)
|
||||
env_name: str | None = Field(default=None, max_length=255)
|
||||
variable: str | None = Field(default=None, max_length=255)
|
||||
type: str | None = Field(default=None, max_length=64)
|
||||
id: str | None = Field(default=None, max_length=255)
|
||||
ref: str | None = Field(default=None, max_length=255)
|
||||
credential_id: str | None = Field(default=None, max_length=255)
|
||||
provider_credential_id: str | None = Field(default=None, max_length=255)
|
||||
provider: str | None = Field(default=None, max_length=255)
|
||||
permission: AgentPermissionConfig | None = None
|
||||
permission_status: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class AgentSandboxProviderConfig(AgentFlexibleConfig):
|
||||
image: str | None = None
|
||||
working_dir: str | None = None
|
||||
|
||||
@ -11853,6 +11853,7 @@ composer/publish validators and skipped by runtime request builders.
|
||||
| dangerous_command | boolean | | No |
|
||||
| description | string | | No |
|
||||
| enabled | boolean | | No |
|
||||
| env | [AgentCliToolEnvConfig](#agentclitoolenvconfig) | | No |
|
||||
| install | string | | No |
|
||||
| install_command | string | | No |
|
||||
| install_commands | [ string ] | | No |
|
||||
@ -11867,6 +11868,13 @@ composer/publish validators and skipped by runtime request builders.
|
||||
| setup_command | string | | No |
|
||||
| tool_name | string | | No |
|
||||
|
||||
#### AgentCliToolEnvConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| secret_refs | [ [AgentSecretRefConfig](#agentsecretrefconfig) ] | | No |
|
||||
| variables | [ [AgentEnvVariableConfig](#agentenvvariableconfig) ] | | No |
|
||||
|
||||
#### AgentCliToolRiskLevel
|
||||
|
||||
Risk marker for CLI tool bootstrap commands.
|
||||
|
||||
@ -241,32 +241,9 @@ class ComposerConfigValidator:
|
||||
@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)
|
||||
env = soul.get("env") or {}
|
||||
cls._validate_env_config(env, seen_env_names=seen_env_names, label="agent")
|
||||
|
||||
tools = soul.get("tools") or {}
|
||||
cli_tools = tools.get("cli_tools")
|
||||
@ -284,6 +261,56 @@ class ComposerConfigValidator:
|
||||
raise InvalidComposerConfigError(
|
||||
"a dangerous CLI tool command must be explicitly acknowledged before save."
|
||||
)
|
||||
tool_name = cls._cli_tool_name(entry) or "<unnamed>"
|
||||
cls._validate_env_config(
|
||||
entry.get("env") or {},
|
||||
seen_env_names=seen_env_names,
|
||||
label=f"CLI tool '{tool_name}'",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _validate_env_config(cls, env: Any, *, seen_env_names: set[str], label: str) -> None:
|
||||
if not isinstance(env, dict):
|
||||
return
|
||||
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
|
||||
name = cls._env_name(entry)
|
||||
if name is None:
|
||||
# Unnamed draft rows are tolerated; only named entries are bound to the shell.
|
||||
continue
|
||||
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 {label}.")
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
def _env_name(entry: dict[str, Any]) -> str | None:
|
||||
for key in ("name", "key", "env_name", "variable"):
|
||||
value = entry.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _cli_tool_name(entry: dict[str, Any]) -> str | None:
|
||||
for key in _CLI_TOOL_NAME_KEYS:
|
||||
value = entry.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _reject_plaintext_secrets(cls, value: Any, *, path: str) -> None:
|
||||
|
||||
@ -304,8 +304,9 @@ def test_workflow_request_builder_adds_shell_layer_when_include_shell():
|
||||
assert DIFY_SHELL_LAYER_ID in layers
|
||||
shell = layers[DIFY_SHELL_LAYER_ID]
|
||||
assert shell.type == DIFY_SHELL_LAYER_TYPE_ID
|
||||
# The shell layer declares NoLayerDeps, so the spec must carry no deps.
|
||||
assert not shell.deps
|
||||
# The shell layer depends on execution_context so the agent server can mint
|
||||
# per-command Agent Stub env for sandbox CLI forwarding.
|
||||
assert shell.deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
shell_config = cast(DifyShellLayerConfig, shell.config)
|
||||
assert shell_config.env[0].name == "PROJECT_NAME"
|
||||
|
||||
@ -324,6 +325,6 @@ def test_agent_app_request_builder_adds_shell_layer_when_include_shell():
|
||||
|
||||
assert DIFY_SHELL_LAYER_ID in layers
|
||||
assert layers[DIFY_SHELL_LAYER_ID].type == DIFY_SHELL_LAYER_TYPE_ID
|
||||
assert not layers[DIFY_SHELL_LAYER_ID].deps
|
||||
assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config)
|
||||
assert shell_config.env[0].name == "APP_ENV"
|
||||
|
||||
@ -330,9 +330,9 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys():
|
||||
config = build_shell_layer_config(agent_soul).model_dump(mode="json")
|
||||
|
||||
assert config["cli_tools"] == [
|
||||
{"name": "node", "install_commands": ["apt-get install -y nodejs"]},
|
||||
{"name": "python", "install_commands": ["pip install pytest"]},
|
||||
{"name": None, "install_commands": ["apk add git"]},
|
||||
{"name": "node", "install_commands": ["apt-get install -y nodejs"], "env": [], "secret_refs": []},
|
||||
{"name": "python", "install_commands": ["pip install pytest"], "env": [], "secret_refs": []},
|
||||
{"name": None, "install_commands": ["apk add git"], "env": [], "secret_refs": []},
|
||||
]
|
||||
assert config["env"] == [
|
||||
{"name": "PROJECT_NAME", "value": "demo"},
|
||||
@ -353,7 +353,9 @@ def test_build_shell_layer_config_maps_typed_command_field():
|
||||
|
||||
config = build_shell_layer_config(agent_soul).model_dump(mode="json")
|
||||
|
||||
assert config["cli_tools"] == [{"name": "jq", "install_commands": ["apt-get install -y jq"]}]
|
||||
assert config["cli_tools"] == [
|
||||
{"name": "jq", "install_commands": ["apt-get install -y jq"], "env": [], "secret_refs": []}
|
||||
]
|
||||
|
||||
|
||||
def test_build_shell_layer_config_skips_disabled_cli_tools():
|
||||
@ -371,7 +373,9 @@ def test_build_shell_layer_config_skips_disabled_cli_tools():
|
||||
|
||||
config = build_shell_layer_config(agent_soul).model_dump(mode="json")
|
||||
|
||||
assert config["cli_tools"] == [{"name": "jq", "install_commands": ["apt-get install -y jq"]}]
|
||||
assert config["cli_tools"] == [
|
||||
{"name": "jq", "install_commands": ["apt-get install -y jq"], "env": [], "secret_refs": []}
|
||||
]
|
||||
|
||||
|
||||
def test_build_shell_layer_config_skips_unauthorized_or_unacknowledged_cli_tools():
|
||||
@ -397,8 +401,43 @@ def test_build_shell_layer_config_skips_unauthorized_or_unacknowledged_cli_tools
|
||||
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"]},
|
||||
{"name": "jq", "install_commands": ["apt-get install -y jq"], "env": [], "secret_refs": []},
|
||||
{
|
||||
"name": "accepted-risk",
|
||||
"install_commands": ["curl https://example.test/install.sh | sh"],
|
||||
"env": [],
|
||||
"secret_refs": [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_build_shell_layer_config_maps_cli_tool_scoped_env():
|
||||
agent_soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"tools": {
|
||||
"cli_tools": [
|
||||
{
|
||||
"name": "github",
|
||||
"command": "apt-get install -y gh",
|
||||
"env": {
|
||||
"variables": [{"name": "GH_HOST", "value": "github.com"}],
|
||||
"secret_refs": [{"name": "GITHUB_TOKEN", "credential_id": "credential-1"}],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
config = build_shell_layer_config(agent_soul).model_dump(mode="json")
|
||||
|
||||
assert config["cli_tools"] == [
|
||||
{
|
||||
"name": "github",
|
||||
"install_commands": ["apt-get install -y gh"],
|
||||
"env": [{"name": "GH_HOST", "value": "github.com"}],
|
||||
"secret_refs": [{"name": "GITHUB_TOKEN", "ref": "credential-1"}],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -277,6 +277,60 @@ def test_publish_validation_rejects_unauthorized_secret_ref():
|
||||
)
|
||||
|
||||
|
||||
def test_publish_validation_rejects_cli_tool_scoped_env_conflicts_and_unauthorized_secret_refs():
|
||||
node_job = WorkflowNodeJobConfig.model_validate({})
|
||||
snapshot = _snapshot()
|
||||
snapshot.config_snapshot = AgentSoulConfig(
|
||||
model=AgentSoulModelConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
env={"variables": [{"name": "TOKEN", "value": "agent"}]},
|
||||
tools={
|
||||
"cli_tools": [
|
||||
{
|
||||
"name": "github",
|
||||
"env": {"secret_refs": [{"name": "TOKEN", "id": "credential-1"}]},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
session = Mock()
|
||||
session.scalar.side_effect = [_binding(node_job), _agent(), snapshot]
|
||||
|
||||
with pytest.raises(WorkflowAgentNodeValidationError, match="duplicate env/secret name TOKEN"):
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(
|
||||
session=session,
|
||||
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
|
||||
)
|
||||
|
||||
snapshot.config_snapshot = AgentSoulConfig(
|
||||
model=AgentSoulModelConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
tools={
|
||||
"cli_tools": [
|
||||
{
|
||||
"name": "github",
|
||||
"env": {
|
||||
"secret_refs": [{"name": "GITHUB_TOKEN", "id": "credential-1", "permission_status": "denied"}]
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
session.scalar.side_effect = [_binding(node_job), _agent(), snapshot]
|
||||
|
||||
with pytest.raises(WorkflowAgentNodeValidationError, match="unauthorized secret reference GITHUB_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"}]}
|
||||
|
||||
@ -734,6 +734,28 @@ def test_composer_validator_rejects_invalid_shell_env_and_cli():
|
||||
}
|
||||
)
|
||||
|
||||
# CLI tool scoped env shares the same shell namespace as agent-level env.
|
||||
with pytest.raises(InvalidComposerConfigError):
|
||||
ComposerConfigValidator.validate_agent_soul_dict(
|
||||
{
|
||||
"env": {"variables": [{"name": "TOKEN", "value": "v"}]},
|
||||
"tools": {
|
||||
"cli_tools": [
|
||||
{
|
||||
"name": "github",
|
||||
"env": {"secret_refs": [{"name": "TOKEN", "credential_id": "credential-1"}]},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# CLI tool scoped env names are validated before runtime.
|
||||
with pytest.raises(InvalidComposerConfigError):
|
||||
ComposerConfigValidator.validate_agent_soul_dict(
|
||||
{"tools": {"cli_tools": [{"name": "github", "env": {"variables": [{"name": "BAD-NAME"}]}}]}}
|
||||
)
|
||||
|
||||
# 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}]}})
|
||||
@ -783,7 +805,14 @@ def test_composer_validator_accepts_valid_shell_env_and_cli():
|
||||
},
|
||||
"tools": {
|
||||
"cli_tools": [
|
||||
{"name": "jq", "command": "apt-get install -y jq"},
|
||||
{
|
||||
"name": "jq",
|
||||
"command": "apt-get install -y jq",
|
||||
"env": {
|
||||
"variables": [{"name": "JQ_COLOR", "value": "1"}],
|
||||
"secret_refs": [{"name": "JQ_TOKEN", "id": "credential-2"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "accepted-risk",
|
||||
"command": "curl https://example.test/install.sh | sh",
|
||||
@ -797,6 +826,8 @@ def test_composer_validator_accepts_valid_shell_env_and_cli():
|
||||
)
|
||||
assert {variable.name for variable in config.env.variables} == {"MY_VAR"}
|
||||
assert {secret.name for secret in config.env.secret_refs} == {"API_TOKEN"}
|
||||
assert config.tools.cli_tools[0].env.variables[0].name == "JQ_COLOR"
|
||||
assert config.tools.cli_tools[0].env.secret_refs[0].name == "JQ_TOKEN"
|
||||
|
||||
|
||||
class TestAgentAppBackingAgent:
|
||||
|
||||
@ -18,20 +18,6 @@ DIFY_SHELL_LAYER_TYPE_ID: Final[str] = "dify.shell"
|
||||
_ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
class DifyShellCliToolConfig(BaseModel):
|
||||
"""One CLI tool declaration that can bootstrap itself in the sandbox."""
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
install_commands: list[str] = Field(default_factory=list)
|
||||
|
||||
@field_validator("install_commands")
|
||||
@classmethod
|
||||
def _reject_blank_install_commands(cls, value: list[str]) -> list[str]:
|
||||
return [command for command in (item.strip() for item in value) if command]
|
||||
|
||||
|
||||
class DifyShellEnvVarConfig(BaseModel):
|
||||
"""One shell environment variable exported for every sandbox command."""
|
||||
|
||||
@ -64,6 +50,22 @@ class DifyShellSecretRefConfig(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class DifyShellCliToolConfig(BaseModel):
|
||||
"""One CLI tool declaration that can bootstrap itself in the sandbox."""
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
install_commands: list[str] = Field(default_factory=list)
|
||||
env: list[DifyShellEnvVarConfig] = Field(default_factory=list)
|
||||
secret_refs: list[DifyShellSecretRefConfig] = Field(default_factory=list)
|
||||
|
||||
@field_validator("install_commands")
|
||||
@classmethod
|
||||
def _reject_blank_install_commands(cls, value: list[str]) -> list[str]:
|
||||
return [command for command in (item.strip() for item in value) if command]
|
||||
|
||||
|
||||
class DifyShellSandboxConfig(BaseModel):
|
||||
"""Sandbox provider selection persisted in Agent Soul."""
|
||||
|
||||
|
||||
@ -730,6 +730,10 @@ def _workspace_cwd(session_id: str) -> str:
|
||||
|
||||
def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str:
|
||||
"""Return the workspace bootstrap script for env + CLI tool declarations."""
|
||||
has_bootstrap = bool(config.env or config.secret_refs or config.cli_tools or config.sandbox is not None)
|
||||
if not has_bootstrap:
|
||||
return ""
|
||||
|
||||
lines: list[str] = [
|
||||
"set -eu",
|
||||
'mkdir -p ".dify"',
|
||||
@ -741,6 +745,11 @@ def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str:
|
||||
# Secret refs are resolved outside this public DTO. Preserve the env var
|
||||
# name without inventing a value so host-provided env can flow through.
|
||||
lines.append(f'export {secret_ref.name}="${{{secret_ref.name}:-}}"')
|
||||
for tool in config.cli_tools:
|
||||
for env_var in tool.env:
|
||||
lines.append(f"export {env_var.name}={_shquote(env_var.value)}")
|
||||
for secret_ref in tool.secret_refs:
|
||||
lines.append(f'export {secret_ref.name}="${{{secret_ref.name}:-}}"')
|
||||
if config.sandbox is not None:
|
||||
if config.sandbox.provider:
|
||||
lines.append(f"export DIFY_SANDBOX_PROVIDER={_shquote(config.sandbox.provider)}")
|
||||
@ -751,12 +760,13 @@ def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str:
|
||||
[
|
||||
"DIFY_ENV_EOF",
|
||||
'chmod 600 ".dify/env.sh"',
|
||||
'. ".dify/env.sh"',
|
||||
]
|
||||
)
|
||||
for tool in config.cli_tools:
|
||||
for command in tool.install_commands:
|
||||
lines.append(command)
|
||||
return "\n".join(lines) if len(lines) > 5 or config.cli_tools else ""
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _wrap_user_script(script: str) -> str:
|
||||
|
||||
@ -43,7 +43,10 @@ def test_shell_layer_config_accepts_agent_soul_shell_settings() -> None:
|
||||
config = DifyShellLayerConfig(
|
||||
cli_tools=[
|
||||
DifyShellCliToolConfig(
|
||||
name="ripgrep", install_commands=[" apt-get update ", "", "apt-get install -y ripgrep"]
|
||||
name="ripgrep",
|
||||
install_commands=[" apt-get update ", "", "apt-get install -y ripgrep"],
|
||||
env=[DifyShellEnvVarConfig(name="RG_CONFIG_PATH", value="/workspace/.ripgreprc")],
|
||||
secret_refs=[DifyShellSecretRefConfig(name="GITHUB_TOKEN", ref="credential-2")],
|
||||
)
|
||||
],
|
||||
env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo")],
|
||||
@ -52,6 +55,8 @@ def test_shell_layer_config_accepts_agent_soul_shell_settings() -> None:
|
||||
)
|
||||
|
||||
assert config.cli_tools[0].install_commands == ["apt-get update", "apt-get install -y ripgrep"]
|
||||
assert config.cli_tools[0].env[0].name == "RG_CONFIG_PATH"
|
||||
assert config.cli_tools[0].secret_refs[0].ref == "credential-2"
|
||||
assert config.env[0].name == "PROJECT_NAME"
|
||||
assert config.secret_refs[0].ref == "credential-1"
|
||||
assert config.sandbox is not None
|
||||
|
||||
@ -436,8 +436,11 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte
|
||||
assert "export PROJECT_NAME='demo project'" in script
|
||||
assert "export QUOTED='it'\\''s ok'" in script
|
||||
assert 'export OPENAI_API_KEY="${OPENAI_API_KEY:-}"' in script
|
||||
assert "export RG_CONFIG_PATH='.ripgreprc'" in script
|
||||
assert 'export GITHUB_TOKEN="${GITHUB_TOKEN:-}"' in script
|
||||
assert "export DIFY_SANDBOX_PROVIDER='independent'" in script
|
||||
assert "export DIFY_SANDBOX_CONFIG_JSON='{\"cpu\": 2}'" in script
|
||||
assert '. ".dify/env.sh"' in script
|
||||
assert "apt-get install -y ripgrep" in script
|
||||
return _job_result("bootstrap-job", status=JobStatusName.EXITED, done=True, exit_code=0)
|
||||
|
||||
@ -445,7 +448,14 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte
|
||||
layer = _shell_layer(
|
||||
client_factory=lambda _entrypoint: client,
|
||||
config=DifyShellLayerConfig(
|
||||
cli_tools=[DifyShellCliToolConfig(name="ripgrep", install_commands=["apt-get install -y ripgrep"])],
|
||||
cli_tools=[
|
||||
DifyShellCliToolConfig(
|
||||
name="ripgrep",
|
||||
install_commands=["apt-get install -y ripgrep"],
|
||||
env=[DifyShellEnvVarConfig(name="RG_CONFIG_PATH", value=".ripgreprc")],
|
||||
secret_refs=[DifyShellSecretRefConfig(name="GITHUB_TOKEN", ref="secret-2")],
|
||||
)
|
||||
],
|
||||
env=[
|
||||
DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo project"),
|
||||
DifyShellEnvVarConfig(name="QUOTED", value="it's ok"),
|
||||
|
||||
@ -384,6 +384,7 @@ export type AgentCliToolConfig = {
|
||||
dangerous_command?: boolean
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
env?: AgentCliToolEnvConfig
|
||||
install?: string | null
|
||||
install_command?: string | null
|
||||
install_commands?: Array<string>
|
||||
@ -447,6 +448,11 @@ export type AgentCliToolAuthorizationStatus
|
||||
| 'pre_authorized'
|
||||
| 'unauthorized'
|
||||
|
||||
export type AgentCliToolEnvConfig = {
|
||||
secret_refs?: Array<AgentSecretRefConfig>
|
||||
variables?: Array<AgentEnvVariableConfig>
|
||||
}
|
||||
|
||||
export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown'
|
||||
|
||||
export type AgentSoulDifyToolCredentialRef = {
|
||||
|
||||
@ -471,6 +471,14 @@ export const zAgentCliToolAuthorizationStatus = z.enum([
|
||||
'unauthorized',
|
||||
])
|
||||
|
||||
/**
|
||||
* AgentCliToolEnvConfig
|
||||
*/
|
||||
export const zAgentCliToolEnvConfig = z.object({
|
||||
secret_refs: z.array(zAgentSecretRefConfig).optional(),
|
||||
variables: z.array(zAgentEnvVariableConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentCliToolRiskLevel
|
||||
*
|
||||
@ -491,6 +499,7 @@ export const zAgentCliToolConfig = z.object({
|
||||
dangerous_command: z.boolean().optional().default(false),
|
||||
description: z.string().nullish(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
env: zAgentCliToolEnvConfig.optional(),
|
||||
install: z.string().nullish(),
|
||||
install_command: z.string().nullish(),
|
||||
install_commands: z.array(z.string()).optional(),
|
||||
|
||||
@ -1721,6 +1721,7 @@ export type AgentCliToolConfig = {
|
||||
dangerous_command?: boolean
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
env?: AgentCliToolEnvConfig
|
||||
install?: string | null
|
||||
install_command?: string | null
|
||||
install_commands?: Array<string>
|
||||
@ -2015,6 +2016,11 @@ export type AgentCliToolAuthorizationStatus
|
||||
| 'pre_authorized'
|
||||
| 'unauthorized'
|
||||
|
||||
export type AgentCliToolEnvConfig = {
|
||||
secret_refs?: Array<AgentSecretRefConfig>
|
||||
variables?: Array<AgentEnvVariableConfig>
|
||||
}
|
||||
|
||||
export type AgentPermissionConfig = {
|
||||
allowed?: boolean | null
|
||||
state?: string | null
|
||||
|
||||
@ -2285,6 +2285,14 @@ export const zAgentSoulEnvConfig = z.object({
|
||||
variables: z.array(zAgentEnvVariableConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentCliToolEnvConfig
|
||||
*/
|
||||
export const zAgentCliToolEnvConfig = z.object({
|
||||
secret_refs: z.array(zAgentSecretRefConfig).optional(),
|
||||
variables: z.array(zAgentEnvVariableConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentCliToolRiskLevel
|
||||
*
|
||||
@ -2305,6 +2313,7 @@ export const zAgentCliToolConfig = z.object({
|
||||
dangerous_command: z.boolean().optional().default(false),
|
||||
description: z.string().nullish(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
env: zAgentCliToolEnvConfig.optional(),
|
||||
install: z.string().nullish(),
|
||||
install_command: z.string().nullish(),
|
||||
install_commands: z.array(z.string()).optional(),
|
||||
|
||||
Reference in New Issue
Block a user