Files
Octop/tests/integration/test_workspace_api.py
jubaoliangandCursor 5e34c8a001 fix: connectors empty layout and restore prior control-plane behaviors
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>
2026-09-24 22:40:47 +08:00

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