mirror of
https://github.com/langgenius/dify.git
synced 2026-09-03 23:47:16 +08:00
refactor(dify-agent): use Workspace as shell temp space (#41023)
This commit is contained in:
parent
005edb7d47
commit
97a3039a2d
@ -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
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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. |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)}",
|
||||
]
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"})
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user