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:
zyssyz123
2026-06-11 15:14:39 +08:00
committed by GitHub
parent c4a8d79be9
commit fb39df49c8
18 changed files with 406 additions and 91 deletions

View File

@ -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(),
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"}],
}
]

View File

@ -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"}]}

View File

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

View File

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

View File

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

View File

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

View File

@ -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"),

View File

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

View File

@ -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(),

View File

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

View File

@ -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(),