From fb39df49c80cd5f07e93ebd37544aa38640544b7 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Thu, 11 Jun 2026 15:14:39 +0800 Subject: [PATCH] feat(agent): support cli tool scoped env (#37324) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/clients/agent_backend/request_builder.py | 41 ++++++++-- .../nodes/agent_v2/runtime_request_builder.py | 27 ++++++- .../workflow/nodes/agent_v2/validators.py | 48 ++++++++++-- api/models/agent_config_entities.py | 58 +++++++------- api/openapi/markdown/console-swagger.md | 8 ++ api/services/agent/composer_validator.py | 77 +++++++++++++------ .../agent_backend/test_request_builder.py | 7 +- .../agent_v2/test_runtime_request_builder.py | 53 +++++++++++-- .../nodes/agent_v2/test_validators.py | 54 +++++++++++++ .../services/agent/test_agent_services.py | 33 +++++++- .../src/dify_agent/layers/shell/configs.py | 30 ++++---- .../src/dify_agent/layers/shell/layer.py | 12 ++- .../dify_agent/layers/shell/test_configs.py | 7 +- .../dify_agent/layers/shell/test_layer.py | 12 ++- .../generated/api/console/agents/types.gen.ts | 6 ++ .../generated/api/console/agents/zod.gen.ts | 9 +++ .../generated/api/console/apps/types.gen.ts | 6 ++ .../generated/api/console/apps/zod.gen.ts | 9 +++ 18 files changed, 406 insertions(+), 91 deletions(-) diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index e315a989985..c9f042db496 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -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(), ) diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index 1a85b14d286..98bc644f7c5 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -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: diff --git a/api/core/workflow/nodes/agent_v2/validators.py b/api/core/workflow/nodes/agent_v2/validators.py index 832a1a5e152..be0e6549add 100644 --- a/api/core/workflow/nodes/agent_v2/validators.py +++ b/api/core/workflow/nodes/agent_v2/validators.py @@ -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 "" + 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) diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index 095108bbc7d..abc67123132 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -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 diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index 8fd1a57e200..2f30adccb12 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -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. diff --git a/api/services/agent/composer_validator.py b/api/services/agent/composer_validator.py index d98c37796f2..f7bd70ac267 100644 --- a/api/services/agent/composer_validator.py +++ b/api/services/agent/composer_validator.py @@ -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 "" + 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: diff --git a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py index 87257475f21..833c400db44 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py +++ b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py @@ -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" diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 80ef5cadfad..65571a9383b 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -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"}], + } ] diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py index 6280d6d2895..61876c8ac1e 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py @@ -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"}]} diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index ed7240128d4..c1e868b62d5 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -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: diff --git a/dify-agent/src/dify_agent/layers/shell/configs.py b/dify-agent/src/dify_agent/layers/shell/configs.py index b2255bb9fd5..fafab6a3bd9 100644 --- a/dify-agent/src/dify_agent/layers/shell/configs.py +++ b/dify-agent/src/dify_agent/layers/shell/configs.py @@ -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.""" diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py index b51174d972d..31391ce2c24 100644 --- a/dify-agent/src/dify_agent/layers/shell/layer.py +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -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: diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py b/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py index 10ada98149a..c15b15ee284 100644 --- a/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_configs.py @@ -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 diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py index c1459b8df2c..ff8a857ae2e 100644 --- a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py @@ -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"), diff --git a/packages/contracts/generated/api/console/agents/types.gen.ts b/packages/contracts/generated/api/console/agents/types.gen.ts index dc2b712fe54..906e98a795d 100644 --- a/packages/contracts/generated/api/console/agents/types.gen.ts +++ b/packages/contracts/generated/api/console/agents/types.gen.ts @@ -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 @@ -447,6 +448,11 @@ export type AgentCliToolAuthorizationStatus | 'pre_authorized' | 'unauthorized' +export type AgentCliToolEnvConfig = { + secret_refs?: Array + variables?: Array +} + export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown' export type AgentSoulDifyToolCredentialRef = { diff --git a/packages/contracts/generated/api/console/agents/zod.gen.ts b/packages/contracts/generated/api/console/agents/zod.gen.ts index b777fd35b9b..af804e9d544 100644 --- a/packages/contracts/generated/api/console/agents/zod.gen.ts +++ b/packages/contracts/generated/api/console/agents/zod.gen.ts @@ -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(), diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 6f61e1cb0a9..8fbc81b13fe 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -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 @@ -2015,6 +2016,11 @@ export type AgentCliToolAuthorizationStatus | 'pre_authorized' | 'unauthorized' +export type AgentCliToolEnvConfig = { + secret_refs?: Array + variables?: Array +} + export type AgentPermissionConfig = { allowed?: boolean | null state?: string | null diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index a19377cba14..96baed59795 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -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(),