diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py index 5db17d68499..a8f46d628a6 100644 --- a/dify-agent/src/dify_agent/layers/shell/layer.py +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -18,9 +18,11 @@ side-effecting ``on_context_resume`` attempt fails after issuing shellctl jobs, Agenton still exits ``resource_context()`` but never transitions the layer to ``ACTIVE``. In that failed-enter path, normal suspend/delete hooks do not run, so the enter hook itself must perform best-effort business compensation before -re-raising the failure. Agent Stub env injection uses shellctl's native per-run -``env`` argument for user-visible ``shell.run`` and for trusted server-owned -fixed scripts executed through ``run_remote_script()``. +re-raising the failure. Agent Soul shell env is injected into user-visible +commands and CLI bootstrap commands without persisting a workspace env file. +Agent Stub env injection uses shellctl's native per-run ``env`` argument for +user-visible ``shell.run`` and for trusted server-owned fixed scripts executed +through ``run_remote_script()``. """ from __future__ import annotations @@ -475,7 +477,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC try: client = self._require_client() result = await client.run( - _wrap_user_script(script), + _wrap_user_script(script, self.config), cwd=self._require_workspace_cwd(), env=self._build_user_shell_run_env(), timeout=timeout, @@ -536,9 +538,9 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC and optional Agent Stub env injection. Unlike model-visible ``shell.run``, this server-owned boundary does not - source ``.dify/env.sh``. That file is user-controlled shell config, so - sourcing it here would let sandbox code clobber trusted Agent Stub env - values before ``dify-agent file upload`` executes. + inject Agent Soul shell env. Keeping the user-controlled shell env out + of this path prevents sandbox code from clobbering trusted Agent Stub + env values before ``dify-agent file upload`` executes. """ env = None if inject_agent_stub_env: @@ -833,16 +835,18 @@ 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 the workspace bootstrap script for CLI tool declarations.""" + install_commands = [command for tool in config.cli_tools for command in tool.install_commands] + if not install_commands: return "" - lines: list[str] = [ - "set -eu", - 'mkdir -p ".dify"', - "cat > \".dify/env.sh\" <<'DIFY_ENV_EOF'", - ] + lines: list[str] = ["set -eu", *_shell_config_export_lines(config), *install_commands] + return "\n".join(lines) + + +def _shell_config_export_lines(config: DifyShellLayerConfig) -> list[str]: + """Return ephemeral Agent Soul shell exports for one shellctl command.""" + lines: list[str] = [] for env_var in config.env: lines.append(f"export {env_var.name}={_shquote(env_var.value)}") for secret_ref in config.secret_refs: @@ -860,32 +864,15 @@ def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str: if config.sandbox.config: sandbox_config = json.dumps(config.sandbox.config, ensure_ascii=True, sort_keys=True) lines.append(f"export DIFY_SANDBOX_CONFIG_JSON={_shquote(sandbox_config)}") - lines.extend( - [ - "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) + return lines -def _wrap_user_script(script: str) -> str: - """Source Agent Soul env before executing a model-requested shell command.""" - # TODO: refactor - return "\n".join( - [ - 'if [ -f ".dify/env.sh" ]; then', - " set -a", - ' . ".dify/env.sh"', - " set +a", - "fi", - script, - ] - ) +def _wrap_user_script(script: str, config: DifyShellLayerConfig) -> str: + """Inject Agent Soul env before executing a model-requested shell command.""" + lines = _shell_config_export_lines(config) + if not lines: + return script + return "\n".join([*lines, script]) def _workspace_mkdir_script(*, session_id: str) -> str: 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 30352d87c5e..c7d2599b63c 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 @@ -3,6 +3,7 @@ from collections.abc import Callable, Mapping import secrets import time from dataclasses import dataclass +from typing import cast import pytest @@ -454,7 +455,6 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte 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) @@ -489,10 +489,60 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte assert layer.runtime_state.job_ids == ["mkdir-job", "bootstrap-job"] +def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(time, "time", lambda: 0xABC12) + + def token_hex(_nbytes: int) -> str: + return "ff" + + monkeypatch.setattr(secrets, "token_hex", token_hex) + + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: + del timeout + assert env is None + if cwd is None: + return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0) + + assert cwd == "~/workspace/abc12ff" + assert "export PROJECT_NAME='demo project'" in script + assert 'export OPENAI_API_KEY="${OPENAI_API_KEY:-}"' in script + assert "export DIFY_SANDBOX_PROVIDER='independent'" in script + assert "export DIFY_SANDBOX_CONFIG_JSON='{\"cpu\": 2}'" in script + assert script.endswith("\npwd") + return _job_result("user-job", status=JobStatusName.EXITED, done=True, exit_code=0) + + client = FakeShellctlClient(run_handler=run_handler) + layer = _shell_layer( + client_factory=lambda _entrypoint: client, + config=DifyShellLayerConfig( + env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo project")], + secret_refs=[DifyShellSecretRefConfig(name="OPENAI_API_KEY", ref="secret-1")], + sandbox=DifyShellSandboxConfig(provider="independent", config={"cpu": 2}), + ), + ) + tools = {tool.name: tool for tool in layer.tools} + + async def scenario() -> None: + async with layer.resource_context(): + await layer.on_context_create() + run_result = cast( + Mapping[str, object], + await tools["shell_run"].function_schema.call( + {"script": "pwd"}, + None, # pyright: ignore[reportArgumentType] + ), + ) + assert run_result["job_id"] == "user-job" + + asyncio.run(scenario()) + + assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ff"] + assert layer.runtime_state.job_ids == ["mkdir-job", "user-job"] + + def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -> None: def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: - assert script.endswith("\npwd") - assert '. ".dify/env.sh"' in script + assert script == "pwd" assert cwd == "~/workspace/abc12ff" assert env is None assert timeout == 2.5 @@ -608,8 +658,7 @@ def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() - def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() -> None: def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: del cwd, timeout - if script.endswith("\npwd"): - assert '. ".dify/env.sh"' in script + if script == "pwd": assert env is not None return _job_result("user-job", status=JobStatusName.EXITED, done=True, exit_code=0) assert env is None @@ -639,8 +688,8 @@ def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() -> asyncio.run(scenario()) - user_run_call = next(call for call in client.run_calls if call.script.endswith("\npwd")) - internal_run_calls = [call for call in client.run_calls if not call.script.endswith("\npwd")] + user_run_call = next(call for call in client.run_calls if call.script == "pwd") + internal_run_calls = [call for call in client.run_calls if call.script != "pwd"] assert user_run_call.env == { AGENT_STUB_API_BASE_URL_ENV_VAR: "https://agent.example.com/agent-stub",