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

This commit is contained in:
盐粒 Yanli authored and GitHub committed 2026-08-20 10:16:05 +00:00
1 parent 005edb7d47
commit 97a3039a2d
17 files changed
+213 -51

No files matched your search

+2 -2
View File
@@ -74,12 +74,12 @@ Each agent job runs inside a Landlock sandbox that restricts filesystem access:
| Access | Paths (defaults) | | 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-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` | | **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.) | | **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 ### Environment Variables
+5 -16
View File
@@ -84,6 +84,11 @@ func parentMode() {
envOverlay := loadEnvJSON(envPath) envOverlay := loadEnvJSON(envPath)
env = mergeEnv(env, envOverlay) env = mergeEnv(env, envOverlay)
env = mergeEnv(env, map[string]string{
"TMPDIR": cwd,
"TMP": cwd,
"TEMP": cwd,
})
// Ensure HOME exists. // Ensure HOME exists.
home := envGet(env, "HOME") home := envGet(env, "HOME")
@@ -91,14 +96,6 @@ func parentMode() {
cmdutil.HandleError(os.MkdirAll(home, 0755), 125, "mkdir HOME %s", home) 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. // Determine if path isolation is enabled.
enableIsolation := envvar.PathIsolationEnabled() enableIsolation := envvar.PathIsolationEnabled()
@@ -399,14 +396,6 @@ func envGet(env []string, key string) string {
return "" 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. // writeAtomic writes value to dest via a temp file + rename.
func writeAtomic(dest, value string) { func writeAtomic(dest, value string) {
tmp := fmt.Sprintf("%s.tmp.%d", dest, os.Getpid()) tmp := fmt.Sprintf("%s.tmp.%d", dest, os.Getpid())
+2 -1
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 COPY --from=go-builder /bin/dify-agent /usr/local/bin/dify-agent
RUN useradd --create-home --shell /bin/sh dify \ RUN useradd --create-home --shell /bin/sh dify \
&& mkdir -p /workspace \
&& chown dify:dify /home \ && chown dify:dify /home \
&& chown -R dify:dify /home/dify && chown -R dify:dify /home/dify /workspace
USER dify USER dify
WORKDIR /home/dify WORKDIR /home/dify
+56 -6
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() { for _, tgt := range targets() {
t.Run(tgt.name, func(t *testing.T) { 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{ 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=$?", "script": "test \"$PWD\" = /workspace && test \"$TMPDIR\" = /workspace && test \"$TMP\" = /workspace && test \"$TEMP\" = /workspace && " +
"env": map[string]string{"HOME": "/home/dify"}, "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, "timeout": 10,
}) })
assertJobDone(t, result) assertJobDone(t, result)
output := result["output"].(string) output := result["output"].(string)
if !strings.Contains(output, "tmpdir_ok") { if !strings.Contains(output, "workspace_temp_ok") {
t.Errorf("expected write to $TMPDIR to succeed, got %q", output) t.Errorf("expected workspace temp checks to pass, got %q", output)
} }
if !strings.Contains(output, "tmp_exit=1") && !strings.Contains(output, "Permission denied") { if !strings.Contains(output, "tmp_exit=1") && !strings.Contains(output, "Permission denied") {
t.Errorf("expected write to /tmp to be denied, got %q", output) 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) { func TestLandlockCannotWriteOutsideHome(t *testing.T) {
for _, tgt := range targets() { for _, tgt := range targets() {
t.Run(tgt.name, func(t *testing.T) { t.Run(tgt.name, func(t *testing.T) {
@@ -190,9 +190,11 @@ Dify API signs a browser URL.
`RuntimeLayout.home_dir` and `RuntimeLayout.workspace_dir` are canonical paths `RuntimeLayout.home_dir` and `RuntimeLayout.workspace_dir` are canonical paths
inside the backend execution namespace. They are not host paths, product ids, inside the backend execution namespace. They are not host paths, product ids,
or request configuration. Shell commands start in `workspace_dir`, and `HOME` 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 is forced to `home_dir`. The standard temp variables `TMPDIR`, `TMP`, and `TEMP`
same shellctl namespace, while path isolation restricts the active lease to its also point directly to `workspace_dir`, so the Workspace is both the command
own Home plus the shared Workspace. `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 ## Backend support
+3 -3
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_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_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_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_MATERIALIZED_HOME_ROOT` | `/home/dify` | 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_WORKSPACE_ROOT` | `/workspace` | 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_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_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_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. | | `DIFY_AGENT_ENTERPRISE_SANDBOX_GATEWAY_TIMEOUT` | `30` | Enterprise control-plane timeout in seconds. |
@@ -257,7 +257,9 @@ The resource part serializes as:
backend execution namespace. They are not host filesystem paths and are not 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` 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 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 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 collects it. Releasing a RuntimeLease ends only the current operation. Dify API can later
@@ -108,9 +108,10 @@ Installed CLI:
Filesystem spaces: Filesystem spaces:
- `$HOME` is the system space. - `$HOME` is the system space for reusable tools and state.
- The current working directory (`cwd`) is the temporary working space. Relative paths resolve from here. - The current working directory (`cwd`) is the active Workspace and temporary working space.
- Store temporary files under `<cwd>/.tmp` (normally `./.tmp`). Do not use `/tmp`. - Relative paths and the standard temp environment variables (`TMPDIR`, `TMP`, and `TEMP`) resolve directly to `cwd`.
- Do not use `/tmp`.
shell_run script rules: shell_run script rules:
@@ -49,11 +49,17 @@ class _E2BControlPlaneNotFoundError(RuntimeError):
"""Typed boundary error for SDK resources that no longer exist.""" """Typed boundary error for SDK resources that no longer exist."""
class _E2BFileEntry(Protocol):
path: str
class _E2BFileSystem(Protocol): class _E2BFileSystem(Protocol):
async def make_dir(self, path: str) -> bool: ... async def make_dir(self, path: str) -> bool: ...
async def exists(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: ... async def remove(self, path: str) -> None: ...
@@ -212,7 +218,7 @@ class E2BExecutionBindingBackend:
active_timeout_seconds: int active_timeout_seconds: int
shellctl_port: int = 5004 shellctl_port: int = 5004
layout: RuntimeLayout = field( 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: async def create_binding(self, spec: ExecutionBindingCreateSpec) -> ExecutionBindingAllocation:
@@ -233,9 +239,9 @@ class E2BExecutionBindingBackend:
}, },
on_timeout="pause", 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) _ = 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 sandbox_id = sandbox.sandbox_id
_ = await sandbox.pause(keep_memory=True) _ = await sandbox.pause(keep_memory=True)
return ExecutionBindingAllocation(binding_ref=sandbox_id, workspace_ref=sandbox_id) return ExecutionBindingAllocation(binding_ref=sandbox_id, workspace_ref=sandbox_id)
@@ -70,7 +70,7 @@ class EnterpriseExecutionBindingBackend:
gateway_timeout: float = 30.0 gateway_timeout: float = 30.0
proxy_timeout: float = 60.0 proxy_timeout: float = 60.0
layout: RuntimeLayout = field( 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: async def create_binding(self, spec: ExecutionBindingCreateSpec) -> ExecutionBindingAllocation:
@@ -104,8 +104,8 @@ class EnterpriseExecutionBindingBackend:
[ [
"set -eu", "set -eu",
f"mkdir -p {shlex.quote(self.layout.home_dir)}", 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"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)}", f"chmod 700 {shlex.quote(self.layout.home_dir)} {shlex.quote(self.layout.workspace_dir)}",
] ]
), ),
@@ -112,7 +112,7 @@ class LocalExecutionBindingBackend:
endpoint: str endpoint: str
auth_token: str auth_token: str
materialized_home_root: str = "/home/dify" materialized_home_root: str = "/home/dify"
workspace_root: str = "/home/dify/.dify-agent-workspaces" workspace_root: str = "/workspace"
snapshot_root: str = "/home/dify/.snapshots" snapshot_root: str = "/home/dify/.snapshots"
client_factory: ShellctlClientFactory | None = None client_factory: ShellctlClientFactory | None = None
@@ -238,9 +238,17 @@ class LocalExecutionBindingBackend:
def _control_lease(self, handle: str) -> ShellctlRuntimeLease: def _control_lease(self, handle: str) -> ShellctlRuntimeLease:
control_root = _control_root((self.materialized_home_root, self.workspace_root, self.snapshot_root)) 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( return create_shellctl_lease(
handle=handle, handle=handle,
layout=RuntimeLayout(home_dir=control_root, workspace_dir=control_root), layout=layout,
entrypoint=self.endpoint, entrypoint=self.endpoint,
token=self.auth_token, token=self.auth_token,
client_factory=self.client_factory, client_factory=self.client_factory,
@@ -21,7 +21,7 @@ from dify_agent.runtime_backend.protocols import RuntimeBackendProfile
DEFAULT_E2B_TEMPLATE = "difys-default-team/dify-agent-local-sandbox" DEFAULT_E2B_TEMPLATE = "difys-default-team/dify-agent-local-sandbox"
DEFAULT_LOCAL_MATERIALIZED_HOME_ROOT = "/home/dify" 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" DEFAULT_LOCAL_HOME_SNAPSHOT_ROOT = "/home/dify/.snapshots"
@@ -303,6 +303,15 @@ def test_shell_type_id_constant_matches_implementation_class() -> None:
assert DIFY_SHELL_LAYER_TYPE_ID == DifyShellLayer.type_id 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: def test_shell_layer_create_bootstraps_inside_sandbox_workspace() -> None:
expected_home = "/home/agent-1" expected_home = "/home/agent-1"
expected_workspace_cwd = "/home/agent-1/workspace/abc12ff" expected_workspace_cwd = "/home/agent-1/workspace/abc12ff"
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import posixpath
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import cast from typing import cast
@@ -28,9 +29,15 @@ from dify_agent.runtime_backend.e2b import (
from dify_agent.runtime_backend.shellctl import ShellctlRuntimeLease from dify_agent.runtime_backend.shellctl import ShellctlRuntimeLease
@dataclass(frozen=True, slots=True)
class _FileEntry:
path: str
@dataclass(slots=True) @dataclass(slots=True)
class _Files: class _Files:
paths: set[str] = field(default_factory=set) paths: set[str] = field(default_factory=set)
removed: list[str] = field(default_factory=list)
async def make_dir(self, path: str) -> bool: async def make_dir(self, path: str) -> bool:
self.paths.add(path) self.paths.add(path)
@@ -39,8 +46,17 @@ class _Files:
async def exists(self, path: str) -> bool: async def exists(self, path: str) -> bool:
return path in self.paths 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: 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) @dataclass(slots=True)
@@ -90,6 +106,14 @@ class _ControlPlane:
del timeout del timeout
sandbox_id = f"sandbox-{len(self.sandboxes) + 1}" sandbox_id = f"sandbox-{len(self.sandboxes) + 1}"
sandbox = _Sandbox(sandbox_id=sandbox_id, pause_error=self.pause_error) 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.sandboxes[sandbox_id] = sandbox
self.created.append((template, on_timeout)) self.created.append((template, on_timeout))
assert metadata["dify.resource"] == "runtime-sandbox" assert metadata["dify.resource"] == "runtime-sandbox"
@@ -129,7 +153,7 @@ def _mock_http(
def _connected_backend(*, pause_error: Exception | None = None) -> tuple[E2BExecutionBindingBackend, _Sandbox]: def _connected_backend(*, pause_error: Exception | None = None) -> tuple[E2BExecutionBindingBackend, _Sandbox]:
control = _ControlPlane() control = _ControlPlane()
sandbox = _Sandbox(sandbox_id="sandbox-1", pause_error=pause_error) 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 control.sandboxes[sandbox.sandbox_id] = sandbox
return ( return (
E2BExecutionBindingBackend( 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 control.created == [("prepared-template", "pause"), ("snapshot-1", "pause")]
assert default_allocation.binding_ref == default_allocation.workspace_ref assert default_allocation.binding_ref == default_allocation.workspace_ref
assert snapshot_allocation.binding_ref == snapshot_allocation.workspace_ref assert snapshot_allocation.binding_ref == snapshot_allocation.workspace_ref
runtime = control.sandboxes[default_allocation.binding_ref] assert control.sandboxes[default_allocation.binding_ref].pauses == [True]
assert runtime.files.paths == {"/home/dify/workspace"}
assert runtime.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): for allocation in (default_allocation, snapshot_allocation):
await bindings.destroy_binding( await bindings.destroy_binding(
@@ -358,6 +385,8 @@ async def test_e2b_acquire_retries_transient_shellctl_failures_until_ready(
assert attempts == 3 assert attempts == 3
assert sleeps == [0.5, 0.5] 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 assert not clients[0].is_closed
await backend.release(lease) await backend.release(lease)
assert clients[0].is_closed assert clients[0].is_closed
@@ -80,7 +80,7 @@ async def test_enterprise_acquire_exposes_canonical_layout_through_gateway_proxy
script = payload["script"] script = payload["script"]
assert isinstance(script, str) assert isinstance(script, str)
assert "test -d /home/dify" in script 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 _job_response()
return httpx.Response(200, json={"job_id": "job-1"}) 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") lease = await backend.acquire("sandbox-1")
assert lease.layout.home_dir == "/home/dify" 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] == [ assert [request.url.path for request in requests] == [
"/proxy/v1/jobs/run", "/proxy/v1/jobs/run",
"/proxy/v1/jobs/job-1", "/proxy/v1/jobs/job-1",
@@ -299,7 +299,8 @@ async def test_enterprise_default_binding_creates_gateway_sandbox_and_layout(
script = payload["script"] script = payload["script"]
assert isinstance(script, str) assert isinstance(script, str)
assert "mkdir -p /home/dify" in script 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 _job_response()
return httpx.Response(200, json={"job_id": "job-1"}) return httpx.Response(200, json={"job_id": "job-1"})
@@ -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) 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 @pytest.mark.anyio
async def test_local_binding_create_failure_removes_partial_home_and_workspace() -> None: async def test_local_binding_create_failure_removes_partial_home_and_workspace() -> None:
factory = _FailThenSucceedFactory() factory = _FailThenSucceedFactory()
@@ -40,6 +40,19 @@ def test_local_backend_requires_shellctl_endpoint() -> None:
_ = RuntimeBackendSettings(runtime_backend="local") _ = 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: def test_local_backend_passes_configured_roots_to_drivers() -> None:
settings = RuntimeBackendSettings( settings = RuntimeBackendSettings(
runtime_backend="local", runtime_backend="local",