mirror of
https://github.com/langgenius/dify.git
synced 2026-07-31 09:19:29 +08:00
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Yunlu Wen <wylswz@163.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joel <iamjoel007@gmail.com> Co-authored-by: Yanli 盐粒 <yanli@dify.ai> Co-authored-by: 盐粒 Yanli <beautyyuyanli@gmail.com> Co-authored-by: zyssyz123 <916125788@qq.com> Co-authored-by: 盐粒 Yanli <mail@yanli.one>
295 lines
11 KiB
Python
295 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
from collections.abc import Callable, Mapping
|
|
from dataclasses import dataclass
|
|
from typing import Literal
|
|
|
|
import pytest
|
|
from agenton.compositor import CompositorSessionSnapshot, LayerProvider
|
|
from agenton.compositor.schemas import LayerSessionSnapshot
|
|
from agenton.layers.base import LifecycleState
|
|
from dify_agent.adapters.shell.shellctl import ShellctlProvider
|
|
from dify_agent.agent_stub.server.shell_agent_stub_env import (
|
|
AGENT_STUB_API_BASE_URL_ENV_VAR,
|
|
AGENT_STUB_AUTH_JWE_ENV_VAR,
|
|
AGENT_STUB_DRIVE_BASE_ENV_VAR,
|
|
)
|
|
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
|
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
|
from dify_agent.layers.shell import DifyShellLayerConfig
|
|
from dify_agent.layers.shell.layer import CompleteRemoteCommandResult, DifyShellLayer
|
|
from dify_agent.protocol import (
|
|
CreateRunRequest,
|
|
RunComposition,
|
|
RunLayerSpec,
|
|
SandboxListRequest,
|
|
SandboxLocator,
|
|
SandboxReadRequest,
|
|
SandboxUploadRequest,
|
|
build_sandbox_locator_from_run_request,
|
|
)
|
|
from dify_agent.server.sandbox_files import (
|
|
SandboxFileError,
|
|
SandboxFileService,
|
|
_OUTPUT_BEGIN,
|
|
_OUTPUT_END,
|
|
_decode_sandbox_payload,
|
|
_shell_result_details,
|
|
)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _Job:
|
|
job_id: str
|
|
status: str = "exited"
|
|
done: bool = True
|
|
exit_code: int | None = 0
|
|
output: str = ""
|
|
offset: int = 0
|
|
truncated: bool = False
|
|
output_path: str | None = "/tmp/sandbox-job.out"
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class RunCall:
|
|
script: str
|
|
cwd: str | None
|
|
env: Mapping[str, str] | None
|
|
timeout: float
|
|
|
|
|
|
class FakeShellctlClient:
|
|
def __init__(self, *, run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], _Job]) -> None:
|
|
self.run_handler = run_handler
|
|
self.run_calls: list[RunCall] = []
|
|
self.delete_calls: list[str] = []
|
|
|
|
async def run(
|
|
self, script: str, *, cwd: str | None = None, env: Mapping[str, str] | None = None, timeout: float = 10.0
|
|
):
|
|
self.run_calls.append(RunCall(script=script, cwd=cwd, env=env, timeout=timeout))
|
|
return self.run_handler(script, cwd, env, timeout)
|
|
|
|
async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0):
|
|
raise AssertionError(f"Unexpected wait() call for {job_id} offset={offset} timeout={timeout}")
|
|
|
|
async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0):
|
|
raise AssertionError(f"Unexpected input() call for {job_id} text={text!r}")
|
|
|
|
async def tail(self, job_id: str):
|
|
raise AssertionError(f"Unexpected tail() call for {job_id}")
|
|
|
|
async def terminate(self, job_id: str, grace_seconds: float = 10.0):
|
|
raise AssertionError(f"Unexpected terminate() call for {job_id} grace={grace_seconds}")
|
|
|
|
async def delete(self, job_id: str, *, force: bool = False, grace_seconds: float | None = None):
|
|
del force, grace_seconds
|
|
self.delete_calls.append(job_id)
|
|
return None
|
|
|
|
async def close(self) -> None:
|
|
return None
|
|
|
|
|
|
def _wrap(payload: dict[str, object], *, pty_wrap: int = 0, noise: bool = False) -> str:
|
|
blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
|
|
if pty_wrap:
|
|
blob = "\n".join(blob[index : index + pty_wrap] for index in range(0, len(blob), pty_wrap))
|
|
framed = f"{_OUTPUT_BEGIN}{blob}{_OUTPUT_END}\n"
|
|
if noise:
|
|
framed = f"user@host$ python3 - ...\r\n{framed}user@host$ \r\n"
|
|
return framed
|
|
|
|
|
|
def _complete_result(
|
|
*,
|
|
output: str,
|
|
exit_code: int | None = 0,
|
|
output_complete: bool = True,
|
|
incomplete_reason: Literal["output_limit", "timeout"] | None = None,
|
|
job_id: str = "sandbox-job",
|
|
) -> CompleteRemoteCommandResult:
|
|
return CompleteRemoteCommandResult(
|
|
job_id=job_id,
|
|
status="exited",
|
|
done=True,
|
|
exit_code=exit_code,
|
|
output=output,
|
|
output_complete=output_complete,
|
|
incomplete_reason=incomplete_reason,
|
|
offset=len(output),
|
|
output_path="/tmp/sandbox-job.out",
|
|
)
|
|
|
|
|
|
def _execution_context() -> DifyExecutionContextLayerConfig:
|
|
return DifyExecutionContextLayerConfig(
|
|
tenant_id="tenant-1",
|
|
user_id="user-1",
|
|
user_from="account",
|
|
app_id="app-1",
|
|
conversation_id="conv-1",
|
|
agent_id="agent-1",
|
|
agent_config_version_id="snapshot-1",
|
|
agent_mode="agent_app",
|
|
invoke_from="service-api",
|
|
)
|
|
|
|
|
|
def _locator() -> SandboxLocator:
|
|
request = CreateRunRequest(
|
|
composition=RunComposition(
|
|
layers=[
|
|
RunLayerSpec(name="execution_context", type="dify.execution_context", config=_execution_context()),
|
|
RunLayerSpec(
|
|
name="shell",
|
|
type="dify.shell",
|
|
deps={"execution_context": "execution_context"},
|
|
config=DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
|
|
),
|
|
]
|
|
),
|
|
session_snapshot=CompositorSessionSnapshot(
|
|
layers=[
|
|
LayerSessionSnapshot(
|
|
name="execution_context", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}
|
|
),
|
|
LayerSessionSnapshot(
|
|
name="shell",
|
|
lifecycle_state=LifecycleState.SUSPENDED,
|
|
runtime_state={"session_id": "abc12ff", "workspace_cwd": "~/workspace/abc12ff"},
|
|
),
|
|
]
|
|
),
|
|
)
|
|
return build_sandbox_locator_from_run_request(request)
|
|
|
|
|
|
def _service(
|
|
run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], _Job],
|
|
) -> tuple[SandboxFileService, FakeShellctlClient]:
|
|
client = FakeShellctlClient(run_handler=run_handler)
|
|
execution_context_provider = LayerProvider.from_factory(
|
|
layer_type=DifyExecutionContextLayer,
|
|
create=lambda config: DifyExecutionContextLayer.from_config_with_settings(
|
|
DifyExecutionContextLayerConfig.model_validate(config),
|
|
daemon_url="http://plugin-daemon",
|
|
daemon_api_key="daemon-secret",
|
|
),
|
|
)
|
|
shell_provider = LayerProvider.from_factory(
|
|
layer_type=DifyShellLayer,
|
|
create=lambda config: DifyShellLayer.from_config_with_settings(
|
|
DifyShellLayerConfig.model_validate(config),
|
|
shell_provider=ShellctlProvider(
|
|
entrypoint="http://shellctl",
|
|
token="",
|
|
client_factory=lambda: client,
|
|
),
|
|
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
|
agent_stub_token_factory=lambda execution_context, *, session_id: (
|
|
f"token-for:{execution_context.tenant_id}:{session_id}"
|
|
),
|
|
),
|
|
)
|
|
return SandboxFileService(layer_providers=(execution_context_provider, shell_provider)), client
|
|
|
|
|
|
def test_list_files_runs_fixed_script_and_parses_response() -> None:
|
|
service, client = _service(
|
|
lambda script, cwd, env, timeout: _Job(
|
|
job_id="sandbox-job",
|
|
output=_wrap(
|
|
{
|
|
"path": ".",
|
|
"entries": [{"name": "notes.txt", "type": "file", "size": 5, "mtime": 1}],
|
|
"truncated": False,
|
|
}
|
|
),
|
|
)
|
|
)
|
|
|
|
result = asyncio.run(service.list_files(SandboxListRequest(locator=_locator(), path=".")))
|
|
|
|
assert result.entries[0].name == "notes.txt"
|
|
assert client.run_calls[0].cwd == "~/workspace/abc12ff"
|
|
assert client.run_calls[0].env is None
|
|
assert "python3 - . 1000 <<'PY'" in client.run_calls[0].script
|
|
assert client.delete_calls == ["sandbox-job"]
|
|
|
|
|
|
@pytest.mark.parametrize("bad_path", ["/etc/passwd", "~/secret-dir", "bad\x00path"])
|
|
def test_list_files_rejects_invalid_paths_before_shell_execution(bad_path: str) -> None:
|
|
service, client = _service(lambda script, cwd, env, timeout: _Job(job_id="sandbox-job", output="unused"))
|
|
|
|
with pytest.raises(SandboxFileError, match="path"):
|
|
asyncio.run(service.list_files(SandboxListRequest(locator=_locator(), path=bad_path)))
|
|
|
|
assert client.run_calls == []
|
|
|
|
|
|
def test_decode_payload_reports_incomplete_capture_when_frame_is_missing() -> None:
|
|
with pytest.raises(SandboxFileError, match="incomplete before framed payload was captured"):
|
|
_decode_sandbox_payload(
|
|
_complete_result(output="partial", output_complete=False, incomplete_reason="output_limit")
|
|
)
|
|
|
|
|
|
def test_decode_payload_reports_incomplete_capture_when_frame_is_corrupt() -> None:
|
|
broken = f"{_OUTPUT_BEGIN}%%%%{_OUTPUT_END}"
|
|
with pytest.raises(SandboxFileError, match="incomplete while decoding framed payload"):
|
|
_decode_sandbox_payload(_complete_result(output=broken, output_complete=False, incomplete_reason="timeout"))
|
|
|
|
|
|
def test_upload_injects_agent_stub_env_and_returns_mapping() -> None:
|
|
service, client = _service(
|
|
lambda script, cwd, env, timeout: _Job(
|
|
job_id="sandbox-job",
|
|
output=_wrap(
|
|
{
|
|
"path": "report.txt",
|
|
"file": {"transfer_method": "tool_file", "reference": "file-ref"},
|
|
},
|
|
noise=True,
|
|
),
|
|
)
|
|
)
|
|
|
|
result = asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path="report.txt")))
|
|
|
|
assert result.file.transfer_method == "tool_file"
|
|
assert result.file.reference == "file-ref"
|
|
assert client.run_calls[0].cwd == "~/workspace/abc12ff"
|
|
assert client.run_calls[0].env == {
|
|
AGENT_STUB_API_BASE_URL_ENV_VAR: "https://agent.example.com/agent-stub",
|
|
AGENT_STUB_AUTH_JWE_ENV_VAR: "token-for:tenant-1:abc12ff",
|
|
AGENT_STUB_DRIVE_BASE_ENV_VAR: "/mnt/drive/agent-1",
|
|
}
|
|
|
|
|
|
def test_shell_result_details_include_output_metadata_and_tail() -> None:
|
|
details = _shell_result_details(
|
|
_complete_result(output="hello", output_complete=False, incomplete_reason="output_limit")
|
|
)
|
|
assert "output_complete=False" in details
|
|
assert "incomplete_reason=output_limit" in details
|
|
assert "output_path=/tmp/sandbox-job.out" in details
|
|
assert details.endswith("hello")
|
|
|
|
|
|
def test_read_file_uses_complete_mode_and_parses_response() -> None:
|
|
service, client = _service(
|
|
lambda script, cwd, env, timeout: _Job(
|
|
job_id="sandbox-job",
|
|
output=_wrap({"path": "notes.txt", "size": 5, "truncated": False, "binary": False, "text": "hello"}),
|
|
)
|
|
)
|
|
|
|
result = asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="notes.txt", max_bytes=8)))
|
|
|
|
assert result.text == "hello"
|
|
assert "python3 - notes.txt 8 <<'PY'" in client.run_calls[0].script
|