mirror of
https://github.com/TencentCloud/Octop.git
synced 2026-10-20 06:19:17 +08:00
Apply the remaining working-tree changes: connectors empty-state layout, plus the knowledge, HITL, captcha, workspace, user-cache, and SSRF adjustments with matching tests. Co-authored-by: Cursor <cursoragent@cursor.com>
531 lines
16 KiB
Python
531 lines
16 KiB
Python
"""tests/integration/test_workspace_api.py — workspace endpoints.
|
|
|
|
Requires a running harness agent (``env_with_agent``); workspace I/O goes
|
|
through ``agent.workspace`` backed by ``local_shell`` on the agent dir.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from io import BytesIO
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from docx import Document
|
|
|
|
# Workspace UI semantics: leading '/' is relative to agent workspace.
|
|
FROM_WORKSPACE = {"from_workspace": "true"}
|
|
|
|
|
|
def _sample_docx_bytes() -> bytes:
|
|
doc = Document()
|
|
doc.add_heading("Report Title", level=1)
|
|
doc.add_paragraph("Intro paragraph")
|
|
buffer = BytesIO()
|
|
doc.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
@pytest.fixture
|
|
async def env(env_with_agent):
|
|
yield env_with_agent
|
|
|
|
|
|
# --- listing ---------------------------------------------------------------
|
|
|
|
|
|
async def test_tree_returns_empty_for_fresh_workspace(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
r = await c.get(f"/api/agents/{aid}/workspace/tree?from_workspace=true", headers=auth)
|
|
assert r.status_code == 200, r.text
|
|
rows = r.json()
|
|
assert isinstance(rows, list)
|
|
# Fresh workspace may contain a SOUL.md (written at agent boot) or
|
|
# be empty — we don't pin the exact contents, just the shape.
|
|
for row in rows:
|
|
assert "path" in row
|
|
|
|
|
|
async def test_tree_lists_root_files(env: Any) -> None:
|
|
c, srv, auth, aid = env
|
|
agent = srv.app_runtime.agent_registry.get_agent(aid)
|
|
await agent.workspace.aupload_bytes("notes.md", b"hello")
|
|
|
|
r = await c.get(f"/api/agents/{aid}/workspace/tree?path=/&from_workspace=true", headers=auth)
|
|
assert r.status_code == 200, r.text
|
|
rows = r.json()
|
|
paths = {row["path"] for row in rows}
|
|
assert any("notes.md" in p for p in paths)
|
|
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/file?path=%2Fnotes.md&from_workspace=true",
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["content"] == "hello"
|
|
|
|
|
|
async def test_tree_lists_subdirectory(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
await c.put(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/sub/nested.txt"},
|
|
headers=auth,
|
|
json={"content": "nested content"},
|
|
)
|
|
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/tree",
|
|
params={**FROM_WORKSPACE, "path": "/sub"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
rows = r.json()
|
|
assert any("nested.txt" in row["path"] for row in rows)
|
|
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/sub/nested.txt"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["content"] == "nested content"
|
|
|
|
|
|
async def test_tree_for_unknown_agent_404(env: Any) -> None:
|
|
c, _srv, auth, _aid = env
|
|
r = await c.get("/api/agents/no-such-agent/workspace/tree?from_workspace=true", headers=auth)
|
|
assert r.status_code == 404
|
|
|
|
|
|
# --- write + read round-trip ------------------------------------------------
|
|
|
|
|
|
async def test_write_then_read_roundtrip(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
payload = "hello from workspace test\n"
|
|
r = await c.put(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/notes.md"},
|
|
headers=auth,
|
|
json={"content": payload},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["path"] == "/notes.md"
|
|
assert r.json()["size"] == len(payload.encode("utf-8"))
|
|
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/notes.md"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["path"] == "/notes.md"
|
|
assert body["content"] == payload
|
|
|
|
|
|
async def test_read_missing_file_404(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/no-such-file.txt"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
|
|
# --- upload + download ------------------------------------------------------
|
|
|
|
|
|
async def test_upload_then_download_binary(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
blob = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 # fake PNG header
|
|
files = {"file": ("logo.png", blob, "image/png")}
|
|
r = await c.post(
|
|
f"/api/agents/{aid}/workspace/upload",
|
|
params={**FROM_WORKSPACE},
|
|
headers=auth,
|
|
files=files,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["size"] == len(blob)
|
|
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/download",
|
|
params={**FROM_WORKSPACE, "path": "/logo.png"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.content.startswith(b"\x89PNG\r\n\x1a\n")
|
|
cd = r.headers.get("content-disposition", "")
|
|
assert "logo.png" in cd
|
|
|
|
|
|
async def test_download_non_ascii_filename(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
fname = "1783510288_地球介绍.pptx"
|
|
path = f"/outbound/{fname}"
|
|
r = await c.post(
|
|
f"/api/agents/{aid}/workspace/upload",
|
|
params={**FROM_WORKSPACE, "path": path},
|
|
headers=auth,
|
|
files={"file": (fname, b"PK\x03\x04fake", "application/vnd.ms-powerpoint")},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/download",
|
|
params={**FROM_WORKSPACE, "path": path},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.content.startswith(b"PK\x03\x04")
|
|
cd = r.headers.get("content-disposition", "")
|
|
assert 'filename="download.pptx"' in cd
|
|
assert "filename*" in cd
|
|
assert "%E5%9C%B0%E7%90%83" in cd
|
|
|
|
|
|
async def test_upload_with_explicit_path_query(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
r = await c.post(
|
|
f"/api/agents/{aid}/workspace/upload",
|
|
params={**FROM_WORKSPACE, "path": "/sub/dir/named.txt"},
|
|
headers=auth,
|
|
files={"file": ("ignored.txt", b"x", "text/plain")},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["path"] == "/sub/dir/named.txt"
|
|
|
|
|
|
# --- glob + grep ------------------------------------------------------------
|
|
|
|
|
|
async def test_glob_after_seeding(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
for fname in ("a.md", "b.md", "c.txt"):
|
|
await c.put(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": f"/{fname}"},
|
|
headers=auth,
|
|
json={"content": "x"},
|
|
)
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/glob",
|
|
params={**FROM_WORKSPACE, "pattern": "*.md", "path": "/"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
paths = {row["path"] for row in r.json()}
|
|
# Glob may return absolute or relative paths depending on backend.
|
|
matched = {p.rsplit("/", 1)[-1] for p in paths}
|
|
assert "a.md" in matched
|
|
assert "b.md" in matched
|
|
|
|
|
|
async def test_grep_after_seeding(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
await c.put(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/needle.txt"},
|
|
headers=auth,
|
|
json={"content": "alpha\nNEEDLE here\ngamma\n"},
|
|
)
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/grep",
|
|
params={**FROM_WORKSPACE, "pattern": "NEEDLE", "path": "/"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
rows = r.json()
|
|
assert any("NEEDLE" in str(row) for row in rows)
|
|
|
|
|
|
# --- cross-user isolation ---------------------------------------------------
|
|
|
|
|
|
async def test_non_owner_cannot_access_workspace(env: Any) -> None:
|
|
"""Non-owners cannot read another user's agent workspace."""
|
|
c, _srv, admin_auth, _aid = env
|
|
await c.post(
|
|
"/api/users",
|
|
headers=admin_auth,
|
|
json={"username": "bob", "password": "TestPass12", "role": "user"},
|
|
)
|
|
bob_tok = (
|
|
await c.post(
|
|
"/api/auth/login",
|
|
json={"username": "bob", "password": "TestPass12"},
|
|
)
|
|
).json()["access_token"]
|
|
bob_auth = {"Authorization": f"Bearer {bob_tok}"}
|
|
|
|
admin_agent_id = (await c.get("/api/agents", headers=admin_auth)).json()[0]["agent_id"]
|
|
|
|
r = await c.get(
|
|
f"/api/agents/{admin_agent_id}/workspace/tree",
|
|
headers=bob_auth,
|
|
)
|
|
assert r.status_code == 403
|
|
|
|
|
|
# --- delete + move ----------------------------------------------------------
|
|
|
|
|
|
async def test_delete_file(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
await c.put(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/trash-me.txt"},
|
|
headers=auth,
|
|
json={"content": "bye"},
|
|
)
|
|
r = await c.delete(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/trash-me.txt"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 204, r.text
|
|
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/trash-me.txt"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
|
|
async def test_move_file(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
await c.put(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/src.txt"},
|
|
headers=auth,
|
|
json={"content": "payload"},
|
|
)
|
|
r = await c.post(
|
|
f"/api/agents/{aid}/workspace/move",
|
|
params={**FROM_WORKSPACE, "path": "/src.txt"},
|
|
headers=auth,
|
|
json={"destination": "/moved/src.txt"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["path"] == "/moved/src.txt"
|
|
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/moved/src.txt"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["content"] == "payload"
|
|
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/src.txt"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
|
|
async def test_rename_file(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
await c.put(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/old-name.md"},
|
|
headers=auth,
|
|
json={"content": "x"},
|
|
)
|
|
r = await c.post(
|
|
f"/api/agents/{aid}/workspace/move",
|
|
params={**FROM_WORKSPACE, "path": "/old-name.md"},
|
|
headers=auth,
|
|
json={"destination": "/new-name.md"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/tree",
|
|
params={**FROM_WORKSPACE, "path": "/"},
|
|
headers=auth,
|
|
)
|
|
paths = {row["path"].rsplit("/", 1)[-1] for row in r.json()}
|
|
assert "new-name.md" in paths
|
|
assert "old-name.md" not in paths
|
|
|
|
|
|
async def test_mkdir_creates_directory(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
r = await c.post(
|
|
f"/api/agents/{aid}/workspace/mkdir",
|
|
params={**FROM_WORKSPACE, "path": "/projects/demo"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
body = r.json()
|
|
assert body["path"] == "/projects/demo"
|
|
assert body["is_dir"] is True
|
|
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/tree",
|
|
params={**FROM_WORKSPACE, "path": "/projects"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
names = {row["path"].rsplit("/", 1)[-1] for row in r.json()}
|
|
assert "demo" in names
|
|
|
|
|
|
async def test_delete_directory(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
await c.put(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/box/a.txt"},
|
|
headers=auth,
|
|
json={"content": "a"},
|
|
)
|
|
r = await c.delete(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/box"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 204, r.text
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/tree",
|
|
params={**FROM_WORKSPACE, "path": "/"},
|
|
headers=auth,
|
|
)
|
|
names = {row["path"].rsplit("/", 1)[-1] for row in r.json()}
|
|
assert "box" not in names
|
|
|
|
|
|
async def test_delete_builtin_skills_forbidden(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
r = await c.delete(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/_builtin_skills/foo/SKILL.md"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 403
|
|
|
|
|
|
# --- editable document (Markdown round-trip) --------------------------------
|
|
|
|
|
|
async def test_doc_read_and_write_roundtrip(env: Any) -> None:
|
|
c, srv, auth, aid = env
|
|
agent = srv.app_runtime.agent_registry.get_agent(aid)
|
|
await agent.workspace.aupload_bytes("report.docx", _sample_docx_bytes())
|
|
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/doc",
|
|
params={**FROM_WORKSPACE, "path": "/report.docx"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["path"] == "/report.docx"
|
|
assert "# Report Title" in body["content"]
|
|
assert "Intro paragraph" in body["content"]
|
|
|
|
r = await c.put(
|
|
f"/api/agents/{aid}/workspace/doc",
|
|
params={**FROM_WORKSPACE, "path": "/report.docx"},
|
|
headers=auth,
|
|
json={"content": "# Updated Title\n\nNew **bold** body\n"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["path"] == "/report.docx"
|
|
assert r.json()["size"] > 0
|
|
|
|
# Stored bytes parse back into a valid docx.
|
|
blob = await agent.workspace.adownload_bytes("report.docx")
|
|
assert blob is not None
|
|
parsed = Document(BytesIO(blob))
|
|
texts = [p.text for p in parsed.paragraphs if p.text]
|
|
assert "Updated Title" in texts
|
|
assert "New bold body" in texts
|
|
|
|
# Round-trip back through the API keeps the Markdown structure.
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/doc",
|
|
params={**FROM_WORKSPACE, "path": "/report.docx"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
content = r.json()["content"]
|
|
assert "# Updated Title" in content
|
|
assert "**bold**" in content
|
|
|
|
|
|
async def test_doc_unsupported_extension_400(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/doc",
|
|
params={**FROM_WORKSPACE, "path": "/notes.md"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
async def test_create_empty_docx_via_text_endpoint_is_previewable(env: Any) -> None:
|
|
"""Workspace "new file" creates .docx through the text endpoint; it must be
|
|
stored as a valid document package so preview/edit work immediately."""
|
|
c, srv, auth, aid = env
|
|
r = await c.put(
|
|
f"/api/agents/{aid}/workspace/file",
|
|
params={**FROM_WORKSPACE, "path": "/fresh.docx"},
|
|
headers=auth,
|
|
json={"content": ""},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["size"] > 0 # not a 0-byte file
|
|
|
|
agent = srv.app_runtime.agent_registry.get_agent(aid)
|
|
blob = await agent.workspace.adownload_bytes("fresh.docx")
|
|
assert blob is not None
|
|
parsed = Document(BytesIO(blob))
|
|
assert len(parsed.paragraphs) == 0 # valid empty document
|
|
|
|
# It can be opened for editing as an empty Markdown document.
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/doc",
|
|
params={**FROM_WORKSPACE, "path": "/fresh.docx"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["content"] == ""
|
|
|
|
|
|
async def test_doc_missing_file_404(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/doc",
|
|
params={**FROM_WORKSPACE, "path": "/no-such.docx"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
|
|
async def test_doc_write_root_forbidden(env: Any) -> None:
|
|
c, _srv, auth, aid = env
|
|
r = await c.put(
|
|
f"/api/agents/{aid}/workspace/doc",
|
|
params={**FROM_WORKSPACE, "path": "/"},
|
|
headers=auth,
|
|
json={"content": "# hi\n"},
|
|
)
|
|
assert r.status_code == 403
|
|
|
|
|
|
async def test_doc_write_invalid_content_400(env: Any) -> None:
|
|
c, srv, auth, aid = env
|
|
agent = srv.app_runtime.agent_registry.get_agent(aid)
|
|
await agent.workspace.aupload_bytes("broken.docx", b"not a real docx zip")
|
|
|
|
r = await c.get(
|
|
f"/api/agents/{aid}/workspace/doc",
|
|
params={**FROM_WORKSPACE, "path": "/broken.docx"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 400
|