refactor(dify-agent): use Workspace as shell temp space (#41023)

This commit is contained in:
盐粒 Yanli 2026-08-20 10:16:05 +00:00 committed by GitHub
parent 005edb7d47
commit 97a3039a2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 213 additions and 51 deletions

View File

@ -74,12 +74,12 @@ Each agent job runs inside a Landlock sandbox that restricts filesystem access:
| Access | Paths (defaults) |
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Read-Write** | `$HOME` (always, includes `$CWD/.tmp` as `TMPDIR`) |
| **Read-Write** | `$HOME` and the job's `cwd` (also used directly as `TMPDIR`, `TMP`, and `TEMP`) |
| **Read-Write (dev)** | `/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/random`, `/dev/tty` |
| **Read-Only + Exec** | `/usr`, `/bin`, `/sbin`, `/lib`, `/lib64`, `/etc`, `/proc`, `/opt/dify-agent-tools`, `/opt/homebrew`, `/snap` |
| **Denied** | Everything else (`/tmp`, other agents' homes, `/var`, `/srv`, etc.) |
The runner automatically creates `$CWD/.tmp` and sets `TMPDIR`, `TMP`, `TEMP` to it, so temp files stay isolated per workspace.
The runner sets `TMPDIR`, `TMP`, and `TEMP` directly to the job's `cwd`. It does not create a separate temp directory, so the active Workspace is both the working directory and temp space.
### Environment Variables

View File

@ -84,6 +84,11 @@ func parentMode() {
envOverlay := loadEnvJSON(envPath)
env = mergeEnv(env, envOverlay)
env = mergeEnv(env, map[string]string{
"TMPDIR": cwd,
"TMP": cwd,
"TEMP": cwd,
})
// Ensure HOME exists.
home := envGet(env, "HOME")
@ -91,14 +96,6 @@ func parentMode() {
cmdutil.HandleError(os.MkdirAll(home, 0755), 125, "mkdir HOME %s", home)
}
// Create a per-workspace temp directory under cwd and inject TMPDIR.
// This avoids granting RW access to the shared /tmp.
agentTmp := filepath.Join(cwd, ".tmp")
cmdutil.HandleError(os.MkdirAll(agentTmp, 0755), 125, "mkdir TMPDIR %s", agentTmp)
env = setEnvIfEmpty(env, "TMPDIR", agentTmp)
env = setEnvIfEmpty(env, "TMP", agentTmp)
env = setEnvIfEmpty(env, "TEMP", agentTmp)
// Determine if path isolation is enabled.
enableIsolation := envvar.PathIsolationEnabled()
@ -399,14 +396,6 @@ func envGet(env []string, key string) string {
return ""
}
// setEnvIfEmpty sets key=value in the env slice only if the key is not already present.
func setEnvIfEmpty(env []string, key, value string) []string {
if envGet(env, key) != "" {
return env
}
return append(env, key+"="+value)
}
// writeAtomic writes value to dest via a temp file + rename.
func writeAtomic(dest, value string) {
tmp := fmt.Sprintf("%s.tmp.%d", dest, os.Getpid())

View File

@ -71,8 +71,9 @@ COPY --from=go-builder /bin/shellctl-runner /usr/local/bin/shellctl-runner
COPY --from=go-builder /bin/dify-agent /usr/local/bin/dify-agent
RUN useradd --create-home --shell /bin/sh dify \
&& mkdir -p /workspace \
&& chown dify:dify /home \
&& chown -R dify:dify /home/dify
&& chown -R dify:dify /home/dify /workspace
USER dify
WORKDIR /home/dify

View File

@ -631,19 +631,27 @@ func TestLandlockCanReadSystemBinaries(t *testing.T) {
}
}
func TestLandlockCanWriteTmpdir(t *testing.T) {
func TestLandlockUsesWorkspaceAsTempSpace(t *testing.T) {
for _, tgt := range targets() {
t.Run(tgt.name, func(t *testing.T) {
// TMPDIR ($CWD/.tmp) should be writable; /tmp should be denied.
// The workspace itself is cwd and temp space; shared /tmp remains denied.
result := runJob(t, tgt, map[string]any{
"script": "echo TMPDIR=$TMPDIR && touch $TMPDIR/landlock-tmp-test && echo tmpdir_ok && touch /tmp/landlock-denied 2>&1; echo tmp_exit=$?",
"env": map[string]string{"HOME": "/home/dify"},
"script": "test \"$PWD\" = /workspace && test \"$TMPDIR\" = /workspace && test \"$TMP\" = /workspace && test \"$TEMP\" = /workspace && " +
"touch \"$TMPDIR/landlock-tmp-test\" && echo workspace_temp_ok; " +
"touch /tmp/landlock-denied 2>&1; echo tmp_exit=$?",
"cwd": "/workspace",
"env": map[string]string{
"HOME": "/home/dify",
"TMPDIR": "/tmp",
"TMP": "/tmp",
"TEMP": "/tmp",
},
"timeout": 10,
})
assertJobDone(t, result)
output := result["output"].(string)
if !strings.Contains(output, "tmpdir_ok") {
t.Errorf("expected write to $TMPDIR to succeed, got %q", output)
if !strings.Contains(output, "workspace_temp_ok") {
t.Errorf("expected workspace temp checks to pass, got %q", output)
}
if !strings.Contains(output, "tmp_exit=1") && !strings.Contains(output, "Permission denied") {
t.Errorf("expected write to /tmp to be denied, got %q", output)
@ -652,6 +660,48 @@ func TestLandlockCanWriteTmpdir(t *testing.T) {
}
}
func TestRunnerDoesNotCreateCwdTmpDirectory(t *testing.T) {
for _, tgt := range targets() {
t.Run(tgt.name, func(t *testing.T) {
freshCwd := fmt.Sprintf("/workspace/no-auto-tmp-%s-%d", tgt.name, time.Now().UnixNano())
setup := runJob(t, tgt, map[string]any{
"script": "mkdir -p -- \"$FRESH_CWD\"",
"cwd": "/workspace",
"env": map[string]string{
"HOME": "/home/dify",
"FRESH_CWD": freshCwd,
},
"timeout": 10,
})
assertJobDone(t, setup)
assertExitCode(t, setup, 0)
t.Cleanup(func() {
cleanup := runJob(t, tgt, map[string]any{
"script": "rm -rf -- \"$FRESH_CWD\"",
"cwd": "/workspace",
"env": map[string]string{
"HOME": "/home/dify",
"FRESH_CWD": freshCwd,
},
"timeout": 10,
})
assertJobDone(t, cleanup)
assertExitCode(t, cleanup, 0)
})
result := runJob(t, tgt, map[string]any{
"script": "test ! -e \"$PWD/.tmp\"",
"cwd": freshCwd,
"env": map[string]string{"HOME": "/home/dify"},
"timeout": 10,
})
assertJobDone(t, result)
assertExitCode(t, result, 0)
})
}
}
func TestLandlockCannotWriteOutsideHome(t *testing.T) {
for _, tgt := range targets() {
t.Run(tgt.name, func(t *testing.T) {

View File

@ -190,9 +190,11 @@ Dify API signs a browser URL.
`RuntimeLayout.home_dir` and `RuntimeLayout.workspace_dir` are canonical paths
inside the backend execution namespace. They are not host paths, product ids,
or request configuration. Shell commands start in `workspace_dir`, and `HOME`
is forced to `home_dir`. On Local, sibling materialized Homes may exist in the
same shellctl namespace, while path isolation restricts the active lease to its
own Home plus the shared Workspace.
is forced to `home_dir`. The standard temp variables `TMPDIR`, `TMP`, and `TEMP`
also point directly to `workspace_dir`, so the Workspace is both the command
`cwd` and temp space. On Local, sibling materialized Homes may exist in the same
shellctl namespace, while path isolation restricts the active lease to its own
Home plus the shared Workspace.
## Backend support

View File

@ -44,9 +44,9 @@ also reads `.env` and `dify-agent/.env` when present.
| `DIFY_AGENT_RUNTIME_BACKEND` | `local` | Selects one coherent `local`, `enterprise`, or `e2b` Home Snapshot + Execution Binding backend profile. |
| `DIFY_AGENT_LOCAL_SANDBOX_ENDPOINT` | empty | Local shellctl data-plane URL. With the default Local selection, leaving it empty disables `dify.runtime` and resource endpoints. |
| `DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN` | empty | Optional bearer token sent to Local shellctl. |
| `DIFY_AGENT_LOCAL_SANDBOX_MATERIALIZED_HOME_ROOT` | `/home/dify/.dify-agent-materialized-homes` | Root directory, on the Local shellctl filesystem, for per-Binding materialized Homes. |
| `DIFY_AGENT_LOCAL_SANDBOX_WORKSPACE_ROOT` | `/home/dify/.dify-agent-workspaces` | Root directory, on the Local shellctl filesystem, for mutable Workspaces. |
| `DIFY_AGENT_LOCAL_SANDBOX_HOME_SNAPSHOT_ROOT` | `/home/dify/.dify-agent-home-snapshots` | Root directory, on the Local shellctl filesystem, for immutable Home Snapshots. |
| `DIFY_AGENT_LOCAL_SANDBOX_MATERIALIZED_HOME_ROOT` | `/home/dify` | Root directory, on the Local shellctl filesystem, for per-Binding materialized Homes. |
| `DIFY_AGENT_LOCAL_SANDBOX_WORKSPACE_ROOT` | `/workspace` | Root directory, on the Local shellctl filesystem, for mutable Workspaces. |
| `DIFY_AGENT_LOCAL_SANDBOX_HOME_SNAPSHOT_ROOT` | `/home/dify/.snapshots` | Root directory, on the Local shellctl filesystem, for immutable Home Snapshots. |
| `DIFY_AGENT_ENTERPRISE_SANDBOX_GATEWAY_ENDPOINT` | empty | Enterprise Gateway endpoint required by configuration. Default-Home Bindings are supported; immutable Home Snapshot operations remain unsupported. |
| `DIFY_AGENT_ENTERPRISE_SANDBOX_GATEWAY_AUTH_TOKEN` | empty | Optional `X-Inner-Api-Key` sent to the Enterprise Gateway. |
| `DIFY_AGENT_ENTERPRISE_SANDBOX_GATEWAY_TIMEOUT` | `30` | Enterprise control-plane timeout in seconds. |

View File

@ -257,7 +257,9 @@ The resource part serializes as:
backend execution namespace. They are not host filesystem paths and are not
sent in the run request. Shell commands start in `workspace_dir`, while `HOME`
is forced to `home_dir`; `~` therefore resolves to the current Binding's
materialized Home.
materialized Home. The runner also sets `TMPDIR`, `TMP`, and `TEMP` directly to
`workspace_dir`, making the active Workspace both the default `cwd` and the
temporary working space.
Workspace content persists with the Workspace until Dify API retires and
collects it. Releasing a RuntimeLease ends only the current operation. Dify API can later

View File

@ -108,9 +108,10 @@ Installed CLI:
Filesystem spaces:
- `$HOME` is the system space.
- The current working directory (`cwd`) is the temporary working space. Relative paths resolve from here.
- Store temporary files under `<cwd>/.tmp` (normally `./.tmp`). Do not use `/tmp`.
- `$HOME` is the system space for reusable tools and state.
- The current working directory (`cwd`) is the active Workspace and temporary working space.
- Relative paths and the standard temp environment variables (`TMPDIR`, `TMP`, and `TEMP`) resolve directly to `cwd`.
- Do not use `/tmp`.
shell_run script rules:

View File

@ -49,11 +49,17 @@ class _E2BControlPlaneNotFoundError(RuntimeError):
"""Typed boundary error for SDK resources that no longer exist."""
class _E2BFileEntry(Protocol):
path: str
class _E2BFileSystem(Protocol):
async def make_dir(self, path: str) -> bool: ...
async def exists(self, path: str) -> bool: ...
async def list(self, path: str) -> list[_E2BFileEntry]: ...
async def remove(self, path: str) -> None: ...
@ -212,7 +218,7 @@ class E2BExecutionBindingBackend:
active_timeout_seconds: int
shellctl_port: int = 5004
layout: RuntimeLayout = field(
default_factory=lambda: RuntimeLayout(home_dir="/home/dify", workspace_dir="/home/dify/workspace")
default_factory=lambda: RuntimeLayout(home_dir="/home/dify", workspace_dir="/workspace")
)
async def create_binding(self, spec: ExecutionBindingCreateSpec) -> ExecutionBindingAllocation:
@ -233,9 +239,9 @@ class E2BExecutionBindingBackend:
},
on_timeout="pause",
)
if await sandbox.files.exists(self.layout.workspace_dir):
await sandbox.files.remove(self.layout.workspace_dir)
_ = await sandbox.files.make_dir(self.layout.workspace_dir)
for entry in await sandbox.files.list(self.layout.workspace_dir):
await sandbox.files.remove(entry.path)
sandbox_id = sandbox.sandbox_id
_ = await sandbox.pause(keep_memory=True)
return ExecutionBindingAllocation(binding_ref=sandbox_id, workspace_ref=sandbox_id)

View File

@ -70,7 +70,7 @@ class EnterpriseExecutionBindingBackend:
gateway_timeout: float = 30.0
proxy_timeout: float = 60.0
layout: RuntimeLayout = field(
default_factory=lambda: RuntimeLayout(home_dir="/home/dify", workspace_dir="/home/dify/workspace")
default_factory=lambda: RuntimeLayout(home_dir="/home/dify", workspace_dir="/workspace")
)
async def create_binding(self, spec: ExecutionBindingCreateSpec) -> ExecutionBindingAllocation:
@ -104,8 +104,8 @@ class EnterpriseExecutionBindingBackend:
[
"set -eu",
f"mkdir -p {shlex.quote(self.layout.home_dir)}",
f"rm -rf -- {shlex.quote(self.layout.workspace_dir)}",
f"mkdir -p {shlex.quote(self.layout.workspace_dir)}",
f"find {shlex.quote(self.layout.workspace_dir)} -mindepth 1 -maxdepth 1 -exec rm -rf -- {{}} +",
f"chmod 700 {shlex.quote(self.layout.home_dir)} {shlex.quote(self.layout.workspace_dir)}",
]
),

View File

@ -112,7 +112,7 @@ class LocalExecutionBindingBackend:
endpoint: str
auth_token: str
materialized_home_root: str = "/home/dify"
workspace_root: str = "/home/dify/.dify-agent-workspaces"
workspace_root: str = "/workspace"
snapshot_root: str = "/home/dify/.snapshots"
client_factory: ShellctlClientFactory | None = None
@ -238,9 +238,17 @@ class LocalExecutionBindingBackend:
def _control_lease(self, handle: str) -> ShellctlRuntimeLease:
control_root = _control_root((self.materialized_home_root, self.workspace_root, self.snapshot_root))
layout = RuntimeLayout(home_dir=control_root, workspace_dir=control_root)
if control_root == "/":
# Keep the canonical root-level Workspace separate instead of
# broadening the control job's writable layout to the whole filesystem.
layout = RuntimeLayout(
home_dir=posixpath.commonpath((self.materialized_home_root, self.snapshot_root)),
workspace_dir=self.workspace_root,
)
return create_shellctl_lease(
handle=handle,
layout=RuntimeLayout(home_dir=control_root, workspace_dir=control_root),
layout=layout,
entrypoint=self.endpoint,
token=self.auth_token,
client_factory=self.client_factory,

View File

@ -21,7 +21,7 @@ from dify_agent.runtime_backend.protocols import RuntimeBackendProfile
DEFAULT_E2B_TEMPLATE = "difys-default-team/dify-agent-local-sandbox"
DEFAULT_LOCAL_MATERIALIZED_HOME_ROOT = "/home/dify"
DEFAULT_LOCAL_WORKSPACE_ROOT = "/home/dify/.dify-agent-workspaces"
DEFAULT_LOCAL_WORKSPACE_ROOT = "/workspace"
DEFAULT_LOCAL_HOME_SNAPSHOT_ROOT = "/home/dify/.snapshots"

View File

@ -303,6 +303,15 @@ def test_shell_type_id_constant_matches_implementation_class() -> None:
assert DIFY_SHELL_LAYER_TYPE_ID == DifyShellLayer.type_id
def test_shell_prefix_prompt_describes_workspace_as_temp_space() -> None:
prompt = shell_layer_module._SHELL_LAYER_PREFIX_PROMPT
assert "`cwd`) is the active Workspace and temporary working space" in prompt
assert "`TMPDIR`, `TMP`, and `TEMP`) resolve directly to `cwd`" in prompt
assert "`$HOME` is the system space for reusable tools and state" in prompt
assert "<cwd>/.tmp" not in prompt
def test_shell_layer_create_bootstraps_inside_sandbox_workspace() -> None:
expected_home = "/home/agent-1"
expected_workspace_cwd = "/home/agent-1/workspace/abc12ff"

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import posixpath
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import cast
@ -28,9 +29,15 @@ from dify_agent.runtime_backend.e2b import (
from dify_agent.runtime_backend.shellctl import ShellctlRuntimeLease
@dataclass(frozen=True, slots=True)
class _FileEntry:
path: str
@dataclass(slots=True)
class _Files:
paths: set[str] = field(default_factory=set)
removed: list[str] = field(default_factory=list)
async def make_dir(self, path: str) -> bool:
self.paths.add(path)
@ -39,8 +46,17 @@ class _Files:
async def exists(self, path: str) -> bool:
return path in self.paths
async def list(self, path: str) -> list[_FileEntry]:
prefix = f"{path.rstrip('/')}/"
return [
_FileEntry(path=entry)
for entry in sorted(self.paths)
if entry.startswith(prefix) and "/" not in entry.removeprefix(prefix)
]
async def remove(self, path: str) -> None:
self.paths.discard(path)
self.removed.append(path)
self.paths = {entry for entry in self.paths if entry != path and posixpath.commonpath((entry, path)) != path}
@dataclass(slots=True)
@ -90,6 +106,14 @@ class _ControlPlane:
del timeout
sandbox_id = f"sandbox-{len(self.sandboxes) + 1}"
sandbox = _Sandbox(sandbox_id=sandbox_id, pause_error=self.pause_error)
sandbox.files.paths.update(
{
"/workspace",
"/workspace/stale-dir",
"/workspace/stale-dir/nested.txt",
"/workspace/stale.txt",
}
)
self.sandboxes[sandbox_id] = sandbox
self.created.append((template, on_timeout))
assert metadata["dify.resource"] == "runtime-sandbox"
@ -129,7 +153,7 @@ def _mock_http(
def _connected_backend(*, pause_error: Exception | None = None) -> tuple[E2BExecutionBindingBackend, _Sandbox]:
control = _ControlPlane()
sandbox = _Sandbox(sandbox_id="sandbox-1", pause_error=pause_error)
sandbox.files.paths.add("/home/dify/workspace")
sandbox.files.paths.add("/workspace")
control.sandboxes[sandbox.sandbox_id] = sandbox
return (
E2BExecutionBindingBackend(
@ -207,9 +231,12 @@ async def test_e2b_binding_uses_default_template_or_exact_snapshot_and_couples_r
assert control.created == [("prepared-template", "pause"), ("snapshot-1", "pause")]
assert default_allocation.binding_ref == default_allocation.workspace_ref
assert snapshot_allocation.binding_ref == snapshot_allocation.workspace_ref
runtime = control.sandboxes[default_allocation.binding_ref]
assert runtime.files.paths == {"/home/dify/workspace"}
assert runtime.pauses == [True]
assert control.sandboxes[default_allocation.binding_ref].pauses == [True]
for allocation in (default_allocation, snapshot_allocation):
runtime = control.sandboxes[allocation.binding_ref]
assert runtime.files.paths == {"/workspace"}
assert "/workspace" not in runtime.files.removed
for allocation in (default_allocation, snapshot_allocation):
await bindings.destroy_binding(
@ -358,6 +385,8 @@ async def test_e2b_acquire_retries_transient_shellctl_failures_until_ready(
assert attempts == 3
assert sleeps == [0.5, 0.5]
assert lease.layout.home_dir == "/home/dify"
assert lease.layout.workspace_dir == "/workspace"
assert not clients[0].is_closed
await backend.release(lease)
assert clients[0].is_closed

View File

@ -80,7 +80,7 @@ async def test_enterprise_acquire_exposes_canonical_layout_through_gateway_proxy
script = payload["script"]
assert isinstance(script, str)
assert "test -d /home/dify" in script
assert "test -d /home/dify/workspace" in script
assert "test -d /workspace" in script
return _job_response()
return httpx.Response(200, json={"job_id": "job-1"})
@ -94,7 +94,7 @@ async def test_enterprise_acquire_exposes_canonical_layout_through_gateway_proxy
lease = await backend.acquire("sandbox-1")
assert lease.layout.home_dir == "/home/dify"
assert lease.layout.workspace_dir == "/home/dify/workspace"
assert lease.layout.workspace_dir == "/workspace"
assert [request.url.path for request in requests] == [
"/proxy/v1/jobs/run",
"/proxy/v1/jobs/job-1",
@ -299,7 +299,8 @@ async def test_enterprise_default_binding_creates_gateway_sandbox_and_layout(
script = payload["script"]
assert isinstance(script, str)
assert "mkdir -p /home/dify" in script
assert "rm -rf -- /home/dify/workspace" in script
assert "mkdir -p /workspace" in script
assert "find /workspace -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +" in script
return _job_response()
return httpx.Response(200, json={"job_id": "job-1"})

View File

@ -198,6 +198,57 @@ async def test_local_binding_create_uses_empty_default_home_without_snapshot_acc
assert all("/snapshots" not in part for command in factory.commands for part in command)
@pytest.mark.anyio
async def test_local_binding_bootstraps_custom_roots_from_common_root() -> None:
factory = _Factory()
backend = LocalExecutionBindingBackend(
endpoint="http://shellctl",
auth_token="",
materialized_home_root="/tmp/dify-agent/homes",
workspace_root="/tmp/dify-agent/workspaces",
snapshot_root="/tmp/dify-agent/snapshots",
client_factory=factory, # pyright: ignore[reportArgumentType]
)
await backend.create_binding(
ExecutionBindingCreateSpec(
tenant_id="tenant-1",
agent_id="agent-1",
binding_id="binding-1",
workspace_id="workspace-1",
existing_workspace_ref=None,
home_snapshot_ref=None,
)
)
assert factory.runs[0].cwd == "/tmp/dify-agent"
assert factory.runs[0].env == {"HOME": "/tmp/dify-agent"}
@pytest.mark.anyio
async def test_local_binding_separates_root_workspace_from_home_control_scope() -> None:
factory = _Factory()
backend = LocalExecutionBindingBackend(
endpoint="http://shellctl",
auth_token="",
client_factory=factory, # pyright: ignore[reportArgumentType]
)
await backend.create_binding(
ExecutionBindingCreateSpec(
tenant_id="tenant-1",
agent_id="agent-1",
binding_id="binding-1",
workspace_id="workspace-1",
existing_workspace_ref=None,
home_snapshot_ref=None,
)
)
assert factory.runs[0].cwd == "/workspace"
assert factory.runs[0].env == {"HOME": "/home/dify"}
@pytest.mark.anyio
async def test_local_binding_create_failure_removes_partial_home_and_workspace() -> None:
factory = _FailThenSucceedFactory()

View File

@ -40,6 +40,19 @@ def test_local_backend_requires_shellctl_endpoint() -> None:
_ = RuntimeBackendSettings(runtime_backend="local")
def test_local_backend_uses_root_workspace_directory_by_default() -> None:
settings = RuntimeBackendSettings(
runtime_backend="local",
local_sandbox_endpoint="http://shellctl.example",
)
profile = create_runtime_backend_profile(settings)
assert settings.local_sandbox_workspace_root == "/workspace"
assert isinstance(profile.execution_bindings, LocalExecutionBindingBackend)
assert profile.execution_bindings.workspace_root == "/workspace"
def test_local_backend_passes_configured_roots_to_drivers() -> None:
settings = RuntimeBackendSettings(
runtime_backend="local",