Files
deer-flow/backend/tests/test_paths_user_isolation.py
T
yangzheli 59c4a3f0a4
Unit Tests / backend-unit-tests (push) Waiting to run
E2E Tests / e2e-tests (push) Waiting to run
Frontend Unit Tests / frontend-unit-tests (push) Waiting to run
Lint Check / lint (push) Waiting to run
Lint Check / lint-frontend (push) Waiting to run
feat(agent): add custom-agent self-updates with user isolation (#2713)
* feat(agent): add update_agent tool for in-chat custom-agent self-updates (#2616)

Custom agents had no built-in way to persist updates to their own SOUL.md /
config.yaml from a normal chat — `setup_agent` was only bound during the
bootstrap flow, so when the user asked the agent to refine its description
or personality, the agent would shell out via bash/write_file and the edits
landed in a temporary sandbox/tool workspace instead of
`{base_dir}/agents/{agent_name}/`.

Changes:
- New `update_agent` builtin tool with partial-update semantics (only the
  fields you pass are written) and atomic temp-file + os.replace writes so
  a failed update never corrupts existing SOUL.md / config.yaml.
- Lead agent now binds `update_agent` in the non-bootstrap path whenever
  `agent_name` is set in the runtime context. Default agent (no
  agent_name) and bootstrap flow are unchanged.
- New `<self_update>` system-prompt section is injected for custom agents,
  instructing them to use `update_agent` — and explicitly NOT bash /
  write_file — to persist self-updates.
- Tests: 11 new cases in `tests/test_update_agent_tool.py` covering
  validation (missing/invalid agent_name, unknown agent, no fields),
  partial updates (soul-only, description-only, skills=[] vs omitted),
  no-op detection, atomic-write safety, and AgentConfig round-tripping;
  plus 2 new cases in `tests/test_lead_agent_prompt.py` covering the
  self-update prompt section.
- Docs: updated backend/CLAUDE.md builtin tools list and tools.mdx
  (en/zh) with the new tool description.

* feat(agent): isolate custom agents per user

Store custom agent definitions under the effective user, keep legacy agents readable until migration, and cover API/tool/migration behavior with tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: consistent write/delete targets & add --user-id to migration

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 23:17:42 +08:00

185 lines
7.6 KiB
Python

"""Tests for user-scoped path resolution in Paths."""
from pathlib import Path
import pytest
from deerflow.config.paths import Paths
@pytest.fixture
def paths(tmp_path: Path) -> Paths:
return Paths(tmp_path)
class TestValidateUserId:
def test_valid_user_id(self, paths: Paths):
d = paths.user_dir("u-abc-123")
assert d == paths.base_dir / "users" / "u-abc-123"
def test_rejects_path_traversal(self, paths: Paths):
with pytest.raises(ValueError, match="Invalid user_id"):
paths.user_dir("../escape")
def test_rejects_slash(self, paths: Paths):
with pytest.raises(ValueError, match="Invalid user_id"):
paths.user_dir("foo/bar")
def test_rejects_empty(self, paths: Paths):
with pytest.raises(ValueError, match="Invalid user_id"):
paths.user_dir("")
class TestUserDir:
def test_user_dir(self, paths: Paths):
assert paths.user_dir("alice") == paths.base_dir / "users" / "alice"
class TestUserMemoryFile:
def test_user_memory_file(self, paths: Paths):
assert paths.user_memory_file("bob") == paths.base_dir / "users" / "bob" / "memory.json"
class TestUserAgentMemoryFile:
def test_user_agent_memory_file(self, paths: Paths):
expected = paths.base_dir / "users" / "bob" / "agents" / "myagent" / "memory.json"
assert paths.user_agent_memory_file("bob", "myagent") == expected
def test_user_agent_memory_file_lowercases_name(self, paths: Paths):
expected = paths.base_dir / "users" / "bob" / "agents" / "myagent" / "memory.json"
assert paths.user_agent_memory_file("bob", "MyAgent") == expected
class TestUserAgentDir:
def test_user_agents_dir(self, paths: Paths):
assert paths.user_agents_dir("alice") == paths.base_dir / "users" / "alice" / "agents"
def test_user_agent_dir(self, paths: Paths):
assert paths.user_agent_dir("alice", "code-reviewer") == paths.base_dir / "users" / "alice" / "agents" / "code-reviewer"
def test_user_agent_dir_lowercases_name(self, paths: Paths):
assert paths.user_agent_dir("alice", "CodeReviewer") == paths.base_dir / "users" / "alice" / "agents" / "codereviewer"
def test_user_agent_dir_validates_user_id(self, paths: Paths):
with pytest.raises(ValueError, match="Invalid user_id"):
paths.user_agent_dir("../escape", "myagent")
class TestUserThreadDir:
def test_user_thread_dir(self, paths: Paths):
expected = paths.base_dir / "users" / "u1" / "threads" / "t1"
assert paths.thread_dir("t1", user_id="u1") == expected
def test_thread_dir_no_user_id_falls_back_to_legacy(self, paths: Paths):
expected = paths.base_dir / "threads" / "t1"
assert paths.thread_dir("t1") == expected
class TestUserSandboxDirs:
def test_sandbox_work_dir(self, paths: Paths):
expected = paths.base_dir / "users" / "u1" / "threads" / "t1" / "user-data" / "workspace"
assert paths.sandbox_work_dir("t1", user_id="u1") == expected
def test_sandbox_uploads_dir(self, paths: Paths):
expected = paths.base_dir / "users" / "u1" / "threads" / "t1" / "user-data" / "uploads"
assert paths.sandbox_uploads_dir("t1", user_id="u1") == expected
def test_sandbox_outputs_dir(self, paths: Paths):
expected = paths.base_dir / "users" / "u1" / "threads" / "t1" / "user-data" / "outputs"
assert paths.sandbox_outputs_dir("t1", user_id="u1") == expected
def test_sandbox_user_data_dir(self, paths: Paths):
expected = paths.base_dir / "users" / "u1" / "threads" / "t1" / "user-data"
assert paths.sandbox_user_data_dir("t1", user_id="u1") == expected
def test_acp_workspace_dir(self, paths: Paths):
expected = paths.base_dir / "users" / "u1" / "threads" / "t1" / "acp-workspace"
assert paths.acp_workspace_dir("t1", user_id="u1") == expected
def test_legacy_sandbox_work_dir(self, paths: Paths):
expected = paths.base_dir / "threads" / "t1" / "user-data" / "workspace"
assert paths.sandbox_work_dir("t1") == expected
class TestHostPathsWithUserId:
def test_host_thread_dir_with_user_id(self, paths: Paths):
result = paths.host_thread_dir("t1", user_id="u1")
assert "users" in result
assert "u1" in result
assert "threads" in result
assert "t1" in result
def test_host_thread_dir_legacy(self, paths: Paths):
result = paths.host_thread_dir("t1")
assert "threads" in result
assert "t1" in result
assert "users" not in result
def test_host_sandbox_user_data_dir_with_user_id(self, paths: Paths):
result = paths.host_sandbox_user_data_dir("t1", user_id="u1")
assert "users" in result
assert "user-data" in result
def test_host_sandbox_work_dir_with_user_id(self, paths: Paths):
result = paths.host_sandbox_work_dir("t1", user_id="u1")
assert "workspace" in result
def test_host_sandbox_uploads_dir_with_user_id(self, paths: Paths):
result = paths.host_sandbox_uploads_dir("t1", user_id="u1")
assert "uploads" in result
def test_host_sandbox_outputs_dir_with_user_id(self, paths: Paths):
result = paths.host_sandbox_outputs_dir("t1", user_id="u1")
assert "outputs" in result
def test_host_acp_workspace_dir_with_user_id(self, paths: Paths):
result = paths.host_acp_workspace_dir("t1", user_id="u1")
assert "acp-workspace" in result
class TestEnsureAndDeleteWithUserId:
def test_ensure_thread_dirs_creates_user_scoped(self, paths: Paths):
paths.ensure_thread_dirs("t1", user_id="u1")
assert paths.sandbox_work_dir("t1", user_id="u1").is_dir()
assert paths.sandbox_uploads_dir("t1", user_id="u1").is_dir()
assert paths.sandbox_outputs_dir("t1", user_id="u1").is_dir()
assert paths.acp_workspace_dir("t1", user_id="u1").is_dir()
def test_delete_thread_dir_removes_user_scoped(self, paths: Paths):
paths.ensure_thread_dirs("t1", user_id="u1")
assert paths.thread_dir("t1", user_id="u1").exists()
paths.delete_thread_dir("t1", user_id="u1")
assert not paths.thread_dir("t1", user_id="u1").exists()
def test_delete_thread_dir_idempotent(self, paths: Paths):
paths.delete_thread_dir("nonexistent", user_id="u1") # should not raise
def test_ensure_thread_dirs_legacy_still_works(self, paths: Paths):
paths.ensure_thread_dirs("t1")
assert paths.sandbox_work_dir("t1").is_dir()
def test_user_scoped_and_legacy_are_independent(self, paths: Paths):
paths.ensure_thread_dirs("t1", user_id="u1")
paths.ensure_thread_dirs("t1")
# Both exist independently
assert paths.thread_dir("t1", user_id="u1").exists()
assert paths.thread_dir("t1").exists()
# Delete one doesn't affect the other
paths.delete_thread_dir("t1", user_id="u1")
assert not paths.thread_dir("t1", user_id="u1").exists()
assert paths.thread_dir("t1").exists()
class TestResolveVirtualPathWithUserId:
def test_resolve_virtual_path_with_user_id(self, paths: Paths):
paths.ensure_thread_dirs("t1", user_id="u1")
result = paths.resolve_virtual_path("t1", "/mnt/user-data/workspace/file.txt", user_id="u1")
expected_base = paths.sandbox_user_data_dir("t1", user_id="u1").resolve()
assert str(result).startswith(str(expected_base))
def test_resolve_virtual_path_legacy(self, paths: Paths):
paths.ensure_thread_dirs("t1")
result = paths.resolve_virtual_path("t1", "/mnt/user-data/workspace/file.txt")
expected_base = paths.sandbox_user_data_dir("t1").resolve()
assert str(result).startswith(str(expected_base))