diff --git a/dify-agent/.example.env b/dify-agent/.example.env index 630595616f2..26f25293efd 100644 --- a/dify-agent/.example.env +++ b/dify-agent/.example.env @@ -33,6 +33,9 @@ DIFY_AGENT_SHELL_PROVIDER=shellctl DIFY_AGENT_SHELLCTL_ENTRYPOINT= # Optional bearer token sent to the shellctl server. DIFY_AGENT_SHELLCTL_AUTH_TOKEN= +# Root directory for per-Agent shell HOME directories. Use a writable path such +# as /tmp/dify-agent-home for local macOS development. +DIFY_AGENT_SHELL_HOME_ROOT=/home # enterprise provider: sandbox gateway endpoint. Leave empty to disable shell layer use. DIFY_AGENT_ENTERPRISE_SANDBOX_GATEWAY_ENDPOINT= # Optional X-Inner-Api-Key sent to the enterprise sandbox gateway. diff --git a/dify-agent/docs/dify-agent/guide/index.md b/dify-agent/docs/dify-agent/guide/index.md index d3a41ef7d19..d588e5b7960 100644 --- a/dify-agent/docs/dify-agent/guide/index.md +++ b/dify-agent/docs/dify-agent/guide/index.md @@ -40,6 +40,7 @@ also reads `.env` and `dify-agent/.env` when present. | `DIFY_AGENT_INNER_API_KEY` | empty | API key sent to Dify API inner plugin endpoints. Set this to Dify API `INNER_API_KEY_FOR_PLUGIN` (Docker: `PLUGIN_DIFY_INNER_API_KEY`). | | `DIFY_AGENT_SHELLCTL_ENTRYPOINT` | empty | Base URL for the shellctl server used by `dify.shell`; required when runs include the shell layer. | | `DIFY_AGENT_SHELLCTL_AUTH_TOKEN` | empty | Optional bearer token sent to the shellctl server. | +| `DIFY_AGENT_SHELL_HOME_ROOT` | `/home` | Root for per-Agent shell HOME directories. Set this to a writable path such as `/tmp/dify-agent-home` for local macOS development. | | `DIFY_AGENT_STUB_API_BASE_URL` | empty | Public Agent Stub API base URL reachable from shellctl-managed remote machines. HTTP may be the service root or `/agent-stub`; gRPC must be `grpc://host:port`. Enables `DIFY_AGENT_STUB_*` env injection for user `shell.run` jobs. | | `DIFY_AGENT_STUB_GRPC_BIND_ADDRESS` | empty | Optional `host:port` bind override used only when `DIFY_AGENT_STUB_API_BASE_URL` uses `grpc://`. | | `DIFY_AGENT_SERVER_SECRET_KEY` | empty | Security-sensitive server-wide root secret used to derive the JWE encryption key for Agent Stub bearer tokens; required when `DIFY_AGENT_STUB_API_BASE_URL` is set. The supplied default config uses a development value; set a unique unpadded base64url 32-byte secret in production. | @@ -64,6 +65,7 @@ DIFY_AGENT_INNER_API_URL=http://localhost:5001 DIFY_AGENT_INNER_API_KEY=replace-with-dify-inner-api-key-for-plugin DIFY_AGENT_SHELLCTL_ENTRYPOINT=http://127.0.0.1:5004 DIFY_AGENT_SHELLCTL_AUTH_TOKEN=replace-with-shellctl-token +DIFY_AGENT_SHELL_HOME_ROOT=/tmp/dify-agent-home DIFY_AGENT_STUB_API_BASE_URL=https://agent.example.com/agent-stub # This is security-sensitive: it derives the JWE encryption key for Agent Stub bearer tokens. # Replace this development default in production. diff --git a/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md b/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md index fa7f0fc22ae..f3f6a77c4de 100644 --- a/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md +++ b/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md @@ -48,12 +48,18 @@ In the FastAPI server, these values are read from environment-backed ```env DIFY_AGENT_SHELLCTL_ENTRYPOINT=http://127.0.0.1:5004 DIFY_AGENT_SHELLCTL_AUTH_TOKEN=replace-with-shellctl-token +DIFY_AGENT_SHELL_HOME_ROOT=/tmp/dify-agent-home ``` `DIFY_AGENT_SHELLCTL_AUTH_TOKEN` defaults to `None`/empty, which keeps the shell client on the no-token path. Set it only when the shellctl server is started with bearer authentication. +`DIFY_AGENT_SHELL_HOME_ROOT` defaults to `/home`, matching Linux and container +deployments. For local macOS development, set it to a writable directory such as +`/tmp/dify-agent-home`; the shell layer creates per-Agent home directories under +that root. + To let commands inside user-visible shell jobs call back to the Dify Agent server with `dify-agent ...`, also enable the Agent Stub: diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py index 686047ab78c..902d5ddc0fa 100644 --- a/dify-agent/src/dify_agent/layers/shell/layer.py +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -2,7 +2,7 @@ Shell command execution requires a bound execution-context layer with a safe ``agent_id``. The layer uses the current bound execution context to run -commands with ``HOME=/home/`` and a home-rooted workspace path. The +commands with ``HOME=/`` and a home-rooted workspace path. The persisted runtime state intentionally keeps the historical ``~/workspace/`` identity so existing session snapshots stay compatible while live command execution no longer depends on the sandbox user's @@ -250,6 +250,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC config: DifyShellLayerConfig shell_provider: ShellProviderProtocol + shell_home_root: str = "/home" agent_stub_api_base_url: str | None = None agent_stub_token_factory: ShellAgentStubTokenFactory | None = None _shell_resource: ShellResourceProtocol | None = None @@ -267,6 +268,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC config: DifyShellLayerConfig, *, shell_provider: ShellProviderProtocol | None, + shell_home_root: str = "/home", agent_stub_api_base_url: str | None = None, agent_stub_token_factory: ShellAgentStubTokenFactory | None = None, ) -> Self: @@ -275,6 +277,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC layer = cls( config=config, shell_provider=shell_provider, + shell_home_root=_normalize_shell_home_root(shell_home_root), agent_stub_api_base_url=agent_stub_api_base_url, agent_stub_token_factory=agent_stub_token_factory, ) @@ -673,7 +676,10 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC self.runtime_state.job_ids = [] def _shell_home_dir(self) -> str: - return _shell_home_dir_for_agent_id(self._require_current_execution_agent_id()) + return _shell_home_dir_for_agent_id( + self._require_current_execution_agent_id(), + shell_home_root=self.shell_home_root, + ) def _current_execution_agent_id(self) -> str | None: execution_context_layer = self.deps.execution_context @@ -890,10 +896,19 @@ def _workspace_cwd(session_id: str) -> str: return f"{_WORKSPACE_ROOT}/{_validated_session_id(session_id)}" -def _shell_home_dir_for_agent_id(agent_id: str | None) -> str: +def _normalize_shell_home_root(shell_home_root: str) -> str: + stripped = shell_home_root.strip().rstrip("/") + if not stripped: + raise ValueError("shell_home_root must not be empty") + if not stripped.startswith("/"): + raise ValueError("shell_home_root must be an absolute path") + return stripped + + +def _shell_home_dir_for_agent_id(agent_id: str | None, *, shell_home_root: str = "/home") -> str: if agent_id is None: raise ValueError("ShellLayer command execution requires execution_context.agent_id.") - return f"/home/{_validated_agent_home_segment(agent_id)}" + return f"{_normalize_shell_home_root(shell_home_root)}/{_validated_agent_home_segment(agent_id)}" def _validated_agent_home_segment(agent_id: str) -> str: diff --git a/dify-agent/src/dify_agent/runtime/compositor_factory.py b/dify-agent/src/dify_agent/runtime/compositor_factory.py index cecdd848bdd..a341195bdff 100644 --- a/dify-agent/src/dify_agent/runtime/compositor_factory.py +++ b/dify-agent/src/dify_agent/runtime/compositor_factory.py @@ -68,6 +68,7 @@ def create_default_layer_providers( inner_api_url: str = "http://localhost:5001", inner_api_key: str = "", shell_provider: ShellProviderProtocol | None = None, + shell_home_root: str = "/home", agent_stub_api_base_url: str | None = None, agent_stub_token_factory: ShellAgentStubTokenFactory | None = None, ) -> tuple[DifyAgentLayerProvider, ...]: @@ -92,6 +93,7 @@ def create_default_layer_providers( create=lambda config: DifyShellLayer.from_config_with_settings( DifyShellLayerConfig.model_validate(config), shell_provider=shell_provider, + shell_home_root=shell_home_root, agent_stub_api_base_url=agent_stub_api_base_url, agent_stub_token_factory=agent_stub_token_factory, ), diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py index 45302042d9a..918a9292416 100644 --- a/dify-agent/src/dify_agent/server/app.py +++ b/dify-agent/src/dify_agent/server/app.py @@ -66,6 +66,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: inner_api_url=resolved_settings.inner_api_url, inner_api_key=resolved_settings.inner_api_key or "", shell_provider=shell_provider, + shell_home_root=resolved_settings.shell_home_root, agent_stub_api_base_url=resolved_settings.agent_stub_api_base_url, agent_stub_token_factory=agent_stub_token_factory, ) diff --git a/dify-agent/src/dify_agent/server/settings.py b/dify-agent/src/dify_agent/server/settings.py index db482aa5ec2..05174cb2637 100644 --- a/dify-agent/src/dify_agent/server/settings.py +++ b/dify-agent/src/dify_agent/server/settings.py @@ -44,6 +44,7 @@ class ServerSettings(BaseSettings): shell_provider: Literal["shellctl", "enterprise"] = "shellctl" shellctl_entrypoint: str | None = None shellctl_auth_token: str | None = None + shell_home_root: str = "/home" enterprise_sandbox_gateway_endpoint: str | None = None enterprise_sandbox_gateway_auth_token: str | None = None enterprise_sandbox_gateway_timeout: float = Field(default=30.0, gt=0) @@ -125,6 +126,17 @@ class ServerSettings(BaseSettings): stripped = value.strip() return stripped or None + @field_validator("shell_home_root") + @classmethod + def normalize_shell_home_root(cls, value: str) -> str: + """Normalize the root used for per-Agent shell HOME directories.""" + stripped = value.strip().rstrip("/") + if not stripped: + raise ValueError("DIFY_AGENT_SHELL_HOME_ROOT must not be empty") + if not stripped.startswith("/"): + raise ValueError("DIFY_AGENT_SHELL_HOME_ROOT must be an absolute path") + return stripped + @model_validator(mode="after") def validate_agent_stub_requirements(self) -> "ServerSettings": """Require Agent Stub settings while allowing deployments without inner API calls.""" 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 2a328c7770f..274e779c2c4 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 @@ -4,6 +4,7 @@ import asyncio import json from collections.abc import Callable, Mapping from dataclasses import dataclass, field +from pathlib import Path from typing import cast import pytest @@ -245,10 +246,17 @@ class FakeProvider(ShellProviderProtocol): def _layer( - *, commands: FakeCommands, config: DifyShellLayerConfig | None = None + *, + commands: FakeCommands, + config: DifyShellLayerConfig | None = None, + shell_home_root: str = "/home", ) -> tuple[DifyShellLayer, FakeProvider]: provider = FakeProvider(resource=FakeResource(commands=commands)) - layer = DifyShellLayer.from_config_with_settings(config or DifyShellLayerConfig(), shell_provider=provider) + layer = DifyShellLayer.from_config_with_settings( + config or DifyShellLayerConfig(), + shell_provider=provider, + shell_home_root=shell_home_root, + ) return layer, provider @@ -387,6 +395,44 @@ def test_shell_layer_uses_agent_specific_home_and_workspace_cwd( assert [call.job_id for call in commands.delete_calls] == ["mkdir-job"] +def test_shell_layer_uses_configured_home_root_for_local_development( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(shell_layer_module.time, "time", lambda: int("abc12", 16)) + monkeypatch.setattr(shell_layer_module.secrets, "token_hex", lambda _nbytes: "ff") + + shell_home_root = tmp_path / "shell-home" + expected_home = f"{shell_home_root}/agent-1" + expected_workspace_cwd = f"{expected_home}/workspace/abc12ff" + + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: + del timeout + if script.startswith('mkdir -p "$HOME/workspace";'): + assert cwd is None + assert env == {"HOME": expected_home} + return _command_result("mkdir-job", status="exited", done=True, exit_code=0) + if script == "pwd": + assert cwd == expected_workspace_cwd + assert env == {"HOME": expected_home} + return _command_result("user-job", status="exited", done=True, exit_code=0, output=expected_home, offset=13) + raise AssertionError(f"Unexpected script: {script!r}") + + layer, _provider = _layer(commands=FakeCommands(run_handler=run_handler), shell_home_root=f"{shell_home_root}/") + _bind_execution_context(layer) + tools = {tool.name: tool for tool in layer.tools} + + async def scenario() -> None: + async with layer.resource_context(): + await layer.on_context_create() + await tools["shell_run"].function_schema.call({"script": "pwd"}, None) # pyright: ignore[reportArgumentType] + + asyncio.run(scenario()) + + assert layer.shell_home_root == str(shell_home_root) + assert layer.runtime_state.workspace_cwd == "~/workspace/abc12ff" + + def test_shell_layer_suspend_does_not_close_before_resource_context_exits() -> None: layer, provider = _layer(commands=FakeCommands()) layer.runtime_state = _runtime_state() diff --git a/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py b/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py index 620c4e2acc5..e0ce4e7a50f 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_compositor_factory.py @@ -103,12 +103,16 @@ class FakeProvider: def test_default_layer_providers_wire_provided_shell_provider() -> None: fake_provider = FakeProvider() - providers = create_default_layer_providers(shell_provider=cast(ShellProviderProtocol, fake_provider)) + providers = create_default_layer_providers( + shell_provider=cast(ShellProviderProtocol, fake_provider), + shell_home_root="/tmp/dify-agent-home", + ) shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID) shell_layer = shell_provider.create_layer(DifyShellLayerConfig()) assert isinstance(shell_layer, DifyShellLayer) assert shell_layer.shell_provider is fake_provider + assert shell_layer.shell_home_root == "/tmp/dify-agent-home" def test_default_layer_providers_forward_agent_stub_token_factory() -> None: diff --git a/dify-agent/tests/local/dify_agent/server/test_settings.py b/dify-agent/tests/local/dify_agent/server/test_settings.py index c523eef909a..3233ab32cb5 100644 --- a/dify-agent/tests/local/dify_agent/server/test_settings.py +++ b/dify-agent/tests/local/dify_agent/server/test_settings.py @@ -38,6 +38,21 @@ def test_server_settings_reads_shellctl_auth_token_from_env(monkeypatch: pytest. assert settings.shellctl_auth_token == "shell-secret" +def test_server_settings_reads_shell_home_root_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DIFY_AGENT_SHELL_HOME_ROOT", "/tmp/dify-agent-home/") + + settings = ServerSettings() + + assert settings.shell_home_root == "/tmp/dify-agent-home" + + +def test_server_settings_rejects_relative_shell_home_root(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DIFY_AGENT_SHELL_HOME_ROOT", "relative/path") + + with pytest.raises(ValidationError, match="DIFY_AGENT_SHELL_HOME_ROOT must be an absolute path"): + ServerSettings() + + def test_server_settings_reads_enterprise_timeouts_from_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DIFY_AGENT_ENTERPRISE_SANDBOX_GATEWAY_TIMEOUT", "45") monkeypatch.setenv("DIFY_AGENT_ENTERPRISE_SANDBOX_PROXY_TIMEOUT", "90")