feat: allow user specific home dir (#38903)

This commit is contained in:
wangxiaolei
2026-07-14 16:07:47 +08:00
committed by GitHub
parent 2c4d8ef098
commit f71a30bebf
10 changed files with 113 additions and 7 deletions

View File

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

View File

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

View File

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

View File

@ -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/<agent_id>`` and a home-rooted workspace path. The
commands with ``HOME=<shell_home_root>/<agent_id>`` and a home-rooted workspace path. The
persisted runtime state intentionally keeps the historical
``~/workspace/<session>`` 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:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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