346 lines
11 KiB
Python
346 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from yuxi.agents.backends.sandbox import (
|
|
ensure_thread_dirs,
|
|
sandbox_outputs_dir,
|
|
sandbox_uploads_dir,
|
|
sandbox_user_data_dir,
|
|
sandbox_workspace_dir,
|
|
)
|
|
|
|
pytestmark = [pytest.mark.asyncio, pytest.mark.e2e, pytest.mark.slow]
|
|
|
|
|
|
async def _create_thread(client: httpx.AsyncClient, headers: dict[str, str], agent_id: str) -> str:
|
|
response = await client.post(
|
|
"/api/chat/thread",
|
|
json={"agent_id": agent_id, "title": f"viewer-fs-e2e-{uuid.uuid4().hex[:8]}", "metadata": {}},
|
|
headers=headers,
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
thread_id = payload.get("thread_id") or payload.get("id")
|
|
assert thread_id, payload
|
|
return str(thread_id)
|
|
|
|
|
|
async def _tree(
|
|
client: httpx.AsyncClient,
|
|
headers: dict[str, str],
|
|
*,
|
|
agent_id: str,
|
|
thread_id: str,
|
|
path: str,
|
|
) -> list[dict]:
|
|
response = await client.get(
|
|
"/api/viewer/filesystem/tree",
|
|
params={"thread_id": thread_id, "path": path, "agent_id": agent_id},
|
|
headers=headers,
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
return list(response.json().get("entries") or [])
|
|
|
|
|
|
async def _file(
|
|
client: httpx.AsyncClient,
|
|
headers: dict[str, str],
|
|
*,
|
|
agent_id: str,
|
|
thread_id: str,
|
|
path: str,
|
|
) -> dict:
|
|
response = await client.get(
|
|
"/api/viewer/filesystem/file",
|
|
params={"thread_id": thread_id, "path": path, "agent_id": agent_id},
|
|
headers=headers,
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
return dict(response.json())
|
|
|
|
|
|
async def _download(
|
|
client: httpx.AsyncClient,
|
|
headers: dict[str, str],
|
|
*,
|
|
agent_id: str,
|
|
thread_id: str,
|
|
path: str,
|
|
) -> tuple[str, bytes]:
|
|
response = await client.get(
|
|
"/api/viewer/filesystem/download",
|
|
params={"thread_id": thread_id, "path": path, "agent_id": agent_id},
|
|
headers=headers,
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
return response.headers.get("content-disposition", ""), response.content
|
|
|
|
|
|
async def _delete(
|
|
client: httpx.AsyncClient,
|
|
headers: dict[str, str],
|
|
*,
|
|
agent_id: str,
|
|
thread_id: str,
|
|
path: str,
|
|
) -> dict:
|
|
response = await client.delete(
|
|
"/api/viewer/filesystem/file",
|
|
params={"thread_id": thread_id, "path": path, "agent_id": agent_id},
|
|
headers=headers,
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
return dict(response.json())
|
|
|
|
|
|
async def _create_directory(
|
|
client: httpx.AsyncClient,
|
|
headers: dict[str, str],
|
|
*,
|
|
agent_id: str,
|
|
thread_id: str,
|
|
parent_path: str,
|
|
name: str,
|
|
) -> dict:
|
|
response = await client.post(
|
|
"/api/viewer/filesystem/directory",
|
|
json={"thread_id": thread_id, "parent_path": parent_path, "name": name, "agent_id": agent_id},
|
|
headers=headers,
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
return dict(response.json())
|
|
|
|
|
|
async def _upload(
|
|
client: httpx.AsyncClient,
|
|
headers: dict[str, str],
|
|
*,
|
|
agent_id: str,
|
|
thread_id: str,
|
|
parent_path: str,
|
|
file_name: str,
|
|
content: bytes,
|
|
) -> dict:
|
|
response = await client.post(
|
|
"/api/viewer/filesystem/upload",
|
|
data={"thread_id": thread_id, "parent_path": parent_path, "agent_id": agent_id},
|
|
files={"files": (file_name, content, "text/plain")},
|
|
headers=headers,
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
return dict(response.json())
|
|
|
|
|
|
async def test_viewer_filesystem_e2e_respects_workspace_sharing_and_thread_local_uploads(
|
|
e2e_client: httpx.AsyncClient,
|
|
e2e_headers: dict[str, str],
|
|
e2e_agent_context: dict[str, str | int],
|
|
):
|
|
agent_id = str(e2e_agent_context["agent_id"])
|
|
uid = str(e2e_agent_context["uid"])
|
|
thread_id = await _create_thread(e2e_client, e2e_headers, agent_id)
|
|
other_thread_id = await _create_thread(e2e_client, e2e_headers, agent_id)
|
|
|
|
ensure_thread_dirs(thread_id, uid)
|
|
ensure_thread_dirs(other_thread_id, uid)
|
|
|
|
(sandbox_user_data_dir(thread_id) / "root-note.txt").write_text("root-visible\n", encoding="utf-8")
|
|
(sandbox_workspace_dir(thread_id, uid) / "demo.py").write_text("print(42)\n", encoding="utf-8")
|
|
|
|
uploads_dir = sandbox_uploads_dir(thread_id) / "attachments"
|
|
uploads_dir.mkdir(parents=True, exist_ok=True)
|
|
(uploads_dir / "thread1.txt").write_text("thread-one-upload\n", encoding="utf-8")
|
|
(sandbox_outputs_dir(thread_id) / "result.txt").write_text("viewer-output\n", encoding="utf-8")
|
|
|
|
root_entries = await _tree(
|
|
e2e_client,
|
|
e2e_headers,
|
|
agent_id=agent_id,
|
|
thread_id=thread_id,
|
|
path="/",
|
|
)
|
|
root_paths = {str(entry.get("path", "")) for entry in root_entries}
|
|
assert "/home/gem/user-data/" in root_paths, sorted(root_paths)
|
|
|
|
user_data_paths = {
|
|
str(entry.get("path", ""))
|
|
for entry in await _tree(
|
|
e2e_client,
|
|
e2e_headers,
|
|
agent_id=agent_id,
|
|
thread_id=thread_id,
|
|
path="/home/gem/user-data",
|
|
)
|
|
}
|
|
expected_root_paths = {
|
|
"/home/gem/user-data/workspace/",
|
|
"/home/gem/user-data/uploads/",
|
|
"/home/gem/user-data/outputs/",
|
|
"/home/gem/user-data/root-note.txt",
|
|
}
|
|
assert expected_root_paths.issubset(user_data_paths), sorted(user_data_paths)
|
|
|
|
workspace_paths = {
|
|
str(entry.get("path", ""))
|
|
for entry in await _tree(
|
|
e2e_client,
|
|
e2e_headers,
|
|
agent_id=agent_id,
|
|
thread_id=thread_id,
|
|
path="/home/gem/user-data/workspace",
|
|
)
|
|
}
|
|
assert "/home/gem/user-data/workspace/demo.py" in workspace_paths, sorted(workspace_paths)
|
|
|
|
other_workspace_paths = {
|
|
str(entry.get("path", ""))
|
|
for entry in await _tree(
|
|
e2e_client,
|
|
e2e_headers,
|
|
agent_id=agent_id,
|
|
thread_id=other_thread_id,
|
|
path="/home/gem/user-data/workspace",
|
|
)
|
|
}
|
|
assert "/home/gem/user-data/workspace/demo.py" in other_workspace_paths, sorted(other_workspace_paths)
|
|
|
|
other_upload_paths = {
|
|
str(entry.get("path", ""))
|
|
for entry in await _tree(
|
|
e2e_client,
|
|
e2e_headers,
|
|
agent_id=agent_id,
|
|
thread_id=other_thread_id,
|
|
path="/home/gem/user-data/uploads",
|
|
)
|
|
}
|
|
assert "/home/gem/user-data/uploads/attachments/" not in other_upload_paths, sorted(other_upload_paths)
|
|
|
|
file_payload = await _file(
|
|
e2e_client,
|
|
e2e_headers,
|
|
agent_id=agent_id,
|
|
thread_id=thread_id,
|
|
path="/home/gem/user-data/workspace/demo.py",
|
|
)
|
|
assert file_payload.get("content") == "print(42)\n", file_payload
|
|
assert file_payload.get("preview_type") == "text", file_payload
|
|
assert file_payload.get("supported") is True, file_payload
|
|
|
|
other_file_payload = await _file(
|
|
e2e_client,
|
|
e2e_headers,
|
|
agent_id=agent_id,
|
|
thread_id=other_thread_id,
|
|
path="/home/gem/user-data/workspace/demo.py",
|
|
)
|
|
assert other_file_payload.get("content") == "print(42)\n", other_file_payload
|
|
|
|
content_disposition, payload = await _download(
|
|
e2e_client,
|
|
e2e_headers,
|
|
agent_id=agent_id,
|
|
thread_id=thread_id,
|
|
path="/home/gem/user-data/outputs/result.txt",
|
|
)
|
|
assert "result.txt" in content_disposition, content_disposition
|
|
assert payload == b"viewer-output\n", payload
|
|
|
|
|
|
async def test_viewer_filesystem_e2e_deletes_workspace_directory_recursively(
|
|
e2e_client: httpx.AsyncClient,
|
|
e2e_headers: dict[str, str],
|
|
e2e_agent_context: dict[str, str | int],
|
|
):
|
|
agent_id = str(e2e_agent_context["agent_id"])
|
|
uid = str(e2e_agent_context["uid"])
|
|
thread_id = await _create_thread(e2e_client, e2e_headers, agent_id)
|
|
|
|
ensure_thread_dirs(thread_id, uid)
|
|
target_dir = sandbox_workspace_dir(thread_id, uid) / "delete-dir"
|
|
nested_dir = target_dir / "deep"
|
|
nested_dir.mkdir(parents=True)
|
|
(nested_dir / "artifact.txt").write_text("delete me\n", encoding="utf-8")
|
|
|
|
delete_payload = await _delete(
|
|
e2e_client,
|
|
e2e_headers,
|
|
agent_id=agent_id,
|
|
thread_id=thread_id,
|
|
path="/home/gem/user-data/workspace/delete-dir",
|
|
)
|
|
assert delete_payload.get("success") is True, delete_payload
|
|
assert not target_dir.exists()
|
|
|
|
workspace_paths = {
|
|
str(entry.get("path", ""))
|
|
for entry in await _tree(
|
|
e2e_client,
|
|
e2e_headers,
|
|
agent_id=agent_id,
|
|
thread_id=thread_id,
|
|
path="/home/gem/user-data/workspace",
|
|
)
|
|
}
|
|
assert "/home/gem/user-data/workspace/delete-dir/" not in workspace_paths, sorted(workspace_paths)
|
|
|
|
|
|
async def test_viewer_filesystem_e2e_creates_directory_and_uploads_file(
|
|
e2e_client: httpx.AsyncClient,
|
|
e2e_headers: dict[str, str],
|
|
e2e_agent_context: dict[str, str | int],
|
|
):
|
|
agent_id = str(e2e_agent_context["agent_id"])
|
|
uid = str(e2e_agent_context["uid"])
|
|
thread_id = await _create_thread(e2e_client, e2e_headers, agent_id)
|
|
directory_name = f"viewer-created-{uuid.uuid4().hex[:8]}"
|
|
|
|
ensure_thread_dirs(thread_id, uid)
|
|
|
|
directory_payload = await _create_directory(
|
|
e2e_client,
|
|
e2e_headers,
|
|
agent_id=agent_id,
|
|
thread_id=thread_id,
|
|
parent_path="/home/gem/user-data/workspace",
|
|
name=directory_name,
|
|
)
|
|
assert directory_payload.get("success") is True, directory_payload
|
|
assert (sandbox_workspace_dir(thread_id, uid) / directory_name).is_dir()
|
|
|
|
upload_payload = await _upload(
|
|
e2e_client,
|
|
e2e_headers,
|
|
agent_id=agent_id,
|
|
thread_id=thread_id,
|
|
parent_path=f"/home/gem/user-data/workspace/{directory_name}",
|
|
file_name="note.txt",
|
|
content=b"created by viewer\n",
|
|
)
|
|
assert upload_payload.get("success") is True, upload_payload
|
|
|
|
created_paths = {
|
|
str(entry.get("path", ""))
|
|
for entry in await _tree(
|
|
e2e_client,
|
|
e2e_headers,
|
|
agent_id=agent_id,
|
|
thread_id=thread_id,
|
|
path=f"/home/gem/user-data/workspace/{directory_name}",
|
|
)
|
|
}
|
|
assert f"/home/gem/user-data/workspace/{directory_name}/note.txt" in created_paths, sorted(created_paths)
|
|
|
|
file_payload = await _file(
|
|
e2e_client,
|
|
e2e_headers,
|
|
agent_id=agent_id,
|
|
thread_id=thread_id,
|
|
path=f"/home/gem/user-data/workspace/{directory_name}/note.txt",
|
|
)
|
|
assert file_payload.get("content") == "created by viewer\n", file_payload
|