diff --git a/dify-agent-runtime/README.md b/dify-agent-runtime/README.md index b7fd4b67333..00b976c7742 100644 --- a/dify-agent-runtime/README.md +++ b/dify-agent-runtime/README.md @@ -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 diff --git a/dify-agent-runtime/cmd/runner/main.go b/dify-agent-runtime/cmd/runner/main.go index 7a479b38831..9d9a669f885 100644 --- a/dify-agent-runtime/cmd/runner/main.go +++ b/dify-agent-runtime/cmd/runner/main.go @@ -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()) diff --git a/dify-agent-runtime/docker/Dockerfile b/dify-agent-runtime/docker/Dockerfile index b0ed2e499fa..f8d38413e94 100644 --- a/dify-agent-runtime/docker/Dockerfile +++ b/dify-agent-runtime/docker/Dockerfile @@ -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 diff --git a/dify-agent-runtime/tests/acceptance_test.go b/dify-agent-runtime/tests/acceptance_test.go index 15530583a89..192d75729fa 100644 --- a/dify-agent-runtime/tests/acceptance_test.go +++ b/dify-agent-runtime/tests/acceptance_test.go @@ -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) { diff --git a/dify-agent/docs/dify-agent/concepts/runtime-resources/index.md b/dify-agent/docs/dify-agent/concepts/runtime-resources/index.md index 50d47929951..ef33c9cfe7f 100644 --- a/dify-agent/docs/dify-agent/concepts/runtime-resources/index.md +++ b/dify-agent/docs/dify-agent/concepts/runtime-resources/index.md @@ -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 diff --git a/dify-agent/docs/dify-agent/guide/index.md b/dify-agent/docs/dify-agent/guide/index.md index 3090dd6e0cb..826f181478c 100644 --- a/dify-agent/docs/dify-agent/guide/index.md +++ b/dify-agent/docs/dify-agent/guide/index.md @@ -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. | 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 63f76ecce1d..b04d2f7360c 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 @@ -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 diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py index 40e29276e5e..2e39185aa64 100644 --- a/dify-agent/src/dify_agent/layers/shell/layer.py +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -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 `/.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: diff --git a/dify-agent/src/dify_agent/runtime_backend/e2b.py b/dify-agent/src/dify_agent/runtime_backend/e2b.py index 3140391eb13..4dad6ff13bc 100644 --- a/dify-agent/src/dify_agent/runtime_backend/e2b.py +++ b/dify-agent/src/dify_agent/runtime_backend/e2b.py @@ -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) diff --git a/dify-agent/src/dify_agent/runtime_backend/enterprise.py b/dify-agent/src/dify_agent/runtime_backend/enterprise.py index f86867d778e..c3675c3f6d4 100644 --- a/dify-agent/src/dify_agent/runtime_backend/enterprise.py +++ b/dify-agent/src/dify_agent/runtime_backend/enterprise.py @@ -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)}", ] ), diff --git a/dify-agent/src/dify_agent/runtime_backend/local.py b/dify-agent/src/dify_agent/runtime_backend/local.py index 4a0cc209b36..7ea883e2ec6 100644 --- a/dify-agent/src/dify_agent/runtime_backend/local.py +++ b/dify-agent/src/dify_agent/runtime_backend/local.py @@ -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, diff --git a/dify-agent/src/dify_agent/runtime_backend/profile.py b/dify-agent/src/dify_agent/runtime_backend/profile.py index d78dbc00ef1..583afe54d4e 100644 --- a/dify-agent/src/dify_agent/runtime_backend/profile.py +++ b/dify-agent/src/dify_agent/runtime_backend/profile.py @@ -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" 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 85cf5bb5f78..0ffc47c9bd8 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 @@ -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 "/.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" diff --git a/dify-agent/tests/local/dify_agent/runtime_backend/test_e2b.py b/dify-agent/tests/local/dify_agent/runtime_backend/test_e2b.py index 9dcddff2b8c..62963e7066a 100644 --- a/dify-agent/tests/local/dify_agent/runtime_backend/test_e2b.py +++ b/dify-agent/tests/local/dify_agent/runtime_backend/test_e2b.py @@ -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 diff --git a/dify-agent/tests/local/dify_agent/runtime_backend/test_enterprise_backend.py b/dify-agent/tests/local/dify_agent/runtime_backend/test_enterprise_backend.py index 841e6d63a91..049d6fb65e6 100644 --- a/dify-agent/tests/local/dify_agent/runtime_backend/test_enterprise_backend.py +++ b/dify-agent/tests/local/dify_agent/runtime_backend/test_enterprise_backend.py @@ -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"}) diff --git a/dify-agent/tests/local/dify_agent/runtime_backend/test_local.py b/dify-agent/tests/local/dify_agent/runtime_backend/test_local.py index 615f0504df5..5127ebd3c69 100644 --- a/dify-agent/tests/local/dify_agent/runtime_backend/test_local.py +++ b/dify-agent/tests/local/dify_agent/runtime_backend/test_local.py @@ -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() diff --git a/dify-agent/tests/local/dify_agent/runtime_backend/test_profile.py b/dify-agent/tests/local/dify_agent/runtime_backend/test_profile.py index fe6704ac969..1ae8b70005a 100644 --- a/dify-agent/tests/local/dify_agent/runtime_backend/test_profile.py +++ b/dify-agent/tests/local/dify_agent/runtime_backend/test_profile.py @@ -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",