mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-11 15:28:37 +00:00
* feat(skills): per-user skill isolation (#2905) Implement user-scoped skill storage that isolates custom skills between users while sharing public skills globally. Key changes: - Add UserScopedSkillStorage class for per-user custom skill directories - Introduce get_or_new_user_skill_storage() factory with user_id context - Auth middleware sets effective_user_id for request-scoped storage - Agent/prompt/middleware now use user-scoped storage and prompt cache - Sandbox mounts user-scoped skill directories for search/read tools - Add validate_skill_file_path() to SkillStorage for path security - Migration script supports --all-users bulk migration - Frontend: add editable field to Skill type, error check in enableSkill - All skill categories can be toggled (custom skills default to enabled) - Update skill-creator SKILL.md with isolation-aware instructions Tests: - Add test_user_scoped_skill_storage.py (new) - Update all existing skill tests for user-scoped storage - Update sandbox, client, and router tests * fix(skills): address second-round PR review feedback (#3889) - P1-1: restrict legacy skill mount to users without custom skills - P1-2: fail-closed for _is_disabled_skill_path (OSError → return True) - P2-1: AND-merge global extensions_config skill disabled state - P2-2: atomic write for _skill_states.json (mkstemp + replace) - P2-3: normalize X-DeerFlow-Owner-User-Id in trusted boundary - P2-4: LRU-bounded _enabled_skills_by_config_cache (OrderedDict, maxsize=256) - P2-5: clear global prompt cache on PUBLIC skill toggle - P2-6: invalidate skill caches on client.update_skill * fix(tests): correct tool policy test after merge * fix(skills): use DEFAULT_SKILLS_CONTAINER_PATH in UserScopedSkillStorage The "/mnt/skills" literal in UserScopedSkillStorage.__init__ triggers test_skill_container_path_defaults::test_mnt_skills_literal_is_owned_by_skill_constants_module on CI. Migrate the default to the existing deerflow.constants constant, matching the pattern already used by LocalSkillStorage, SkillStorage, and the durable/tool_error middlewares. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
539 lines
20 KiB
Python
539 lines
20 KiB
Python
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox
|
|
from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping
|
|
from deerflow.sandbox.search import GrepMatch, find_glob_matches, find_grep_matches
|
|
from deerflow.sandbox.tools import glob_tool, grep_tool, ls_tool
|
|
|
|
|
|
def _make_runtime(tmp_path):
|
|
workspace = tmp_path / "workspace"
|
|
uploads = tmp_path / "uploads"
|
|
outputs = tmp_path / "outputs"
|
|
workspace.mkdir()
|
|
uploads.mkdir()
|
|
outputs.mkdir()
|
|
return SimpleNamespace(
|
|
state={
|
|
"sandbox": {"sandbox_id": "local"},
|
|
"thread_data": {
|
|
"workspace_path": str(workspace),
|
|
"uploads_path": str(uploads),
|
|
"outputs_path": str(outputs),
|
|
},
|
|
},
|
|
context={"thread_id": "thread-1"},
|
|
)
|
|
|
|
|
|
def test_glob_tool_returns_virtual_paths_and_ignores_common_dirs(tmp_path, monkeypatch) -> None:
|
|
runtime = _make_runtime(tmp_path)
|
|
workspace = tmp_path / "workspace"
|
|
(workspace / "app.py").write_text("print('hi')\n", encoding="utf-8")
|
|
(workspace / "pkg").mkdir()
|
|
(workspace / "pkg" / "util.py").write_text("print('util')\n", encoding="utf-8")
|
|
(workspace / "node_modules").mkdir()
|
|
(workspace / "node_modules" / "skip.py").write_text("ignored\n", encoding="utf-8")
|
|
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
|
|
|
|
result = glob_tool.func(
|
|
runtime=runtime,
|
|
description="find python files",
|
|
pattern="**/*.py",
|
|
path="/mnt/user-data/workspace",
|
|
)
|
|
|
|
assert "/mnt/user-data/workspace/app.py" in result
|
|
assert "/mnt/user-data/workspace/pkg/util.py" in result
|
|
assert "node_modules" not in result
|
|
assert str(workspace) not in result
|
|
|
|
|
|
def test_glob_tool_supports_skills_virtual_paths(tmp_path, monkeypatch) -> None:
|
|
runtime = _make_runtime(tmp_path)
|
|
skills_dir = tmp_path / "skills"
|
|
(skills_dir / "public" / "demo").mkdir(parents=True)
|
|
(skills_dir / "public" / "demo" / "SKILL.md").write_text("# Demo\n", encoding="utf-8")
|
|
|
|
sandbox = LocalSandbox(
|
|
id="local",
|
|
path_mappings=[
|
|
PathMapping(container_path="/mnt/skills", local_path=str(skills_dir), read_only=True),
|
|
],
|
|
)
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: sandbox)
|
|
|
|
result = glob_tool.func(
|
|
runtime=runtime,
|
|
description="find skills",
|
|
pattern="**/SKILL.md",
|
|
path="/mnt/skills",
|
|
)
|
|
|
|
assert "/mnt/skills/public/demo/SKILL.md" in result
|
|
assert str(skills_dir) not in result
|
|
|
|
|
|
def test_grep_tool_filters_by_glob_and_skips_binary_files(tmp_path, monkeypatch) -> None:
|
|
runtime = _make_runtime(tmp_path)
|
|
workspace = tmp_path / "workspace"
|
|
(workspace / "main.py").write_text("TODO = 'ship it'\nprint(TODO)\n", encoding="utf-8")
|
|
(workspace / "notes.txt").write_text("TODO in txt should be filtered\n", encoding="utf-8")
|
|
(workspace / "image.bin").write_bytes(b"\0binary TODO")
|
|
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
|
|
|
|
result = grep_tool.func(
|
|
runtime=runtime,
|
|
description="find todo references",
|
|
pattern="TODO",
|
|
path="/mnt/user-data/workspace",
|
|
glob="**/*.py",
|
|
)
|
|
|
|
assert "/mnt/user-data/workspace/main.py:1: TODO = 'ship it'" in result
|
|
assert "notes.txt" not in result
|
|
assert "image.bin" not in result
|
|
assert str(workspace) not in result
|
|
|
|
|
|
def test_grep_tool_truncates_results(tmp_path, monkeypatch) -> None:
|
|
runtime = _make_runtime(tmp_path)
|
|
workspace = tmp_path / "workspace"
|
|
(workspace / "main.py").write_text("TODO one\nTODO two\nTODO three\n", encoding="utf-8")
|
|
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
|
|
# Prevent config.yaml tool config from overriding the caller-supplied max_results=2.
|
|
monkeypatch.setattr("deerflow.sandbox.tools.get_app_config", lambda: SimpleNamespace(get_tool_config=lambda name: None))
|
|
|
|
result = grep_tool.func(
|
|
runtime=runtime,
|
|
description="limit matches",
|
|
pattern="TODO",
|
|
path="/mnt/user-data/workspace",
|
|
max_results=2,
|
|
)
|
|
|
|
assert "Found 2 matches under /mnt/user-data/workspace (showing first 2)" in result
|
|
assert "TODO one" in result
|
|
assert "TODO two" in result
|
|
assert "TODO three" not in result
|
|
assert "Results truncated." in result
|
|
|
|
|
|
def test_glob_tool_include_dirs_filters_nested_ignored_paths(tmp_path, monkeypatch) -> None:
|
|
runtime = _make_runtime(tmp_path)
|
|
workspace = tmp_path / "workspace"
|
|
(workspace / "src").mkdir()
|
|
(workspace / "src" / "main.py").write_text("x\n", encoding="utf-8")
|
|
(workspace / "node_modules").mkdir()
|
|
(workspace / "node_modules" / "lib").mkdir()
|
|
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
|
|
|
|
result = glob_tool.func(
|
|
runtime=runtime,
|
|
description="find dirs",
|
|
pattern="**",
|
|
path="/mnt/user-data/workspace",
|
|
include_dirs=True,
|
|
)
|
|
|
|
assert "src" in result
|
|
assert "node_modules" not in result
|
|
|
|
|
|
def test_grep_tool_literal_mode(tmp_path, monkeypatch) -> None:
|
|
runtime = _make_runtime(tmp_path)
|
|
workspace = tmp_path / "workspace"
|
|
(workspace / "file.py").write_text("price = (a+b)\nresult = a+b\n", encoding="utf-8")
|
|
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
|
|
|
|
# literal=True should treat (a+b) as a plain string, not a regex group
|
|
result = grep_tool.func(
|
|
runtime=runtime,
|
|
description="literal search",
|
|
pattern="(a+b)",
|
|
path="/mnt/user-data/workspace",
|
|
literal=True,
|
|
)
|
|
|
|
assert "price = (a+b)" in result
|
|
assert "result = a+b" not in result
|
|
|
|
|
|
def test_grep_tool_case_sensitive(tmp_path, monkeypatch) -> None:
|
|
runtime = _make_runtime(tmp_path)
|
|
workspace = tmp_path / "workspace"
|
|
(workspace / "file.py").write_text("TODO: fix\ntodo: also fix\n", encoding="utf-8")
|
|
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
|
|
|
|
result = grep_tool.func(
|
|
runtime=runtime,
|
|
description="case sensitive search",
|
|
pattern="TODO",
|
|
path="/mnt/user-data/workspace",
|
|
case_sensitive=True,
|
|
)
|
|
|
|
assert "TODO: fix" in result
|
|
assert "todo: also fix" not in result
|
|
|
|
|
|
def test_grep_tool_invalid_regex_returns_error(tmp_path, monkeypatch) -> None:
|
|
runtime = _make_runtime(tmp_path)
|
|
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
|
|
|
|
result = grep_tool.func(
|
|
runtime=runtime,
|
|
description="bad pattern",
|
|
pattern="[invalid",
|
|
path="/mnt/user-data/workspace",
|
|
)
|
|
|
|
assert "Invalid regex pattern" in result
|
|
|
|
|
|
def test_aio_sandbox_glob_include_dirs_filters_nested_ignored(monkeypatch) -> None:
|
|
with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"):
|
|
sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080")
|
|
monkeypatch.setattr(
|
|
sandbox._client.file,
|
|
"list_path",
|
|
lambda **kwargs: SimpleNamespace(
|
|
data=SimpleNamespace(
|
|
files=[
|
|
SimpleNamespace(name="src", path="/mnt/workspace/src"),
|
|
SimpleNamespace(name="node_modules", path="/mnt/workspace/node_modules"),
|
|
# child of node_modules — should be filtered via should_ignore_path
|
|
SimpleNamespace(name="lib", path="/mnt/workspace/node_modules/lib"),
|
|
]
|
|
)
|
|
),
|
|
)
|
|
|
|
matches, truncated = sandbox.glob("/mnt/workspace", "**", include_dirs=True)
|
|
|
|
assert "/mnt/workspace/src" in matches
|
|
assert "/mnt/workspace/node_modules" not in matches
|
|
assert "/mnt/workspace/node_modules/lib" not in matches
|
|
assert truncated is False
|
|
|
|
|
|
def test_aio_sandbox_grep_invalid_regex_raises() -> None:
|
|
with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"):
|
|
sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080")
|
|
|
|
import re
|
|
|
|
try:
|
|
sandbox.grep("/mnt/workspace", "[invalid")
|
|
assert False, "Expected re.error"
|
|
except re.error:
|
|
pass
|
|
|
|
|
|
def test_aio_sandbox_glob_parses_json(monkeypatch) -> None:
|
|
with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"):
|
|
sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080")
|
|
monkeypatch.setattr(
|
|
sandbox._client.file,
|
|
"find_files",
|
|
lambda **kwargs: SimpleNamespace(data=SimpleNamespace(files=["/mnt/user-data/workspace/app.py", "/mnt/user-data/workspace/node_modules/skip.py"])),
|
|
)
|
|
|
|
matches, truncated = sandbox.glob("/mnt/user-data/workspace", "**/*.py")
|
|
|
|
assert matches == ["/mnt/user-data/workspace/app.py"]
|
|
assert truncated is False
|
|
|
|
|
|
def test_aio_sandbox_grep_parses_json(monkeypatch) -> None:
|
|
with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"):
|
|
sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080")
|
|
monkeypatch.setattr(
|
|
sandbox._client.file,
|
|
"list_path",
|
|
lambda **kwargs: SimpleNamespace(
|
|
data=SimpleNamespace(
|
|
files=[
|
|
SimpleNamespace(
|
|
name="app.py",
|
|
path="/mnt/user-data/workspace/app.py",
|
|
is_directory=False,
|
|
)
|
|
]
|
|
)
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
sandbox._client.file,
|
|
"search_in_file",
|
|
lambda **kwargs: SimpleNamespace(data=SimpleNamespace(line_numbers=[7], matches=["TODO = True"])),
|
|
)
|
|
|
|
matches, truncated = sandbox.grep("/mnt/user-data/workspace", "TODO")
|
|
|
|
assert matches == [GrepMatch(path="/mnt/user-data/workspace/app.py", line_number=7, line="TODO = True")]
|
|
assert truncated is False
|
|
|
|
|
|
def test_find_glob_matches_raises_not_a_directory(tmp_path) -> None:
|
|
file_path = tmp_path / "file.txt"
|
|
file_path.write_text("x\n", encoding="utf-8")
|
|
|
|
try:
|
|
find_glob_matches(file_path, "**/*.py")
|
|
assert False, "Expected NotADirectoryError"
|
|
except NotADirectoryError:
|
|
pass
|
|
|
|
|
|
def test_find_grep_matches_raises_not_a_directory(tmp_path) -> None:
|
|
file_path = tmp_path / "file.txt"
|
|
file_path.write_text("TODO\n", encoding="utf-8")
|
|
|
|
try:
|
|
find_grep_matches(file_path, "TODO")
|
|
assert False, "Expected NotADirectoryError"
|
|
except NotADirectoryError:
|
|
pass
|
|
|
|
|
|
def test_find_grep_matches_skips_symlink_outside_root(tmp_path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
outside = tmp_path / "outside.txt"
|
|
outside.write_text("TODO outside\n", encoding="utf-8")
|
|
(workspace / "outside-link.txt").symlink_to(outside)
|
|
|
|
matches, truncated = find_grep_matches(workspace, "TODO")
|
|
|
|
assert matches == []
|
|
assert truncated is False
|
|
|
|
|
|
def test_glob_tool_honors_smaller_requested_max_results(tmp_path, monkeypatch) -> None:
|
|
runtime = _make_runtime(tmp_path)
|
|
workspace = tmp_path / "workspace"
|
|
(workspace / "a.py").write_text("print('a')\n", encoding="utf-8")
|
|
(workspace / "b.py").write_text("print('b')\n", encoding="utf-8")
|
|
(workspace / "c.py").write_text("print('c')\n", encoding="utf-8")
|
|
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
|
|
monkeypatch.setattr(
|
|
"deerflow.sandbox.tools.get_app_config",
|
|
lambda: SimpleNamespace(get_tool_config=lambda name: SimpleNamespace(model_extra={"max_results": 50})),
|
|
)
|
|
|
|
result = glob_tool.func(
|
|
runtime=runtime,
|
|
description="limit glob matches",
|
|
pattern="**/*.py",
|
|
path="/mnt/user-data/workspace",
|
|
max_results=2,
|
|
)
|
|
|
|
assert "Found 2 paths under /mnt/user-data/workspace (showing first 2)" in result
|
|
assert "Results truncated." in result
|
|
|
|
|
|
def test_aio_sandbox_glob_include_dirs_enforces_root_boundary(monkeypatch) -> None:
|
|
with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"):
|
|
sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080")
|
|
monkeypatch.setattr(
|
|
sandbox._client.file,
|
|
"list_path",
|
|
lambda **kwargs: SimpleNamespace(
|
|
data=SimpleNamespace(
|
|
files=[
|
|
SimpleNamespace(name="src", path="/mnt/workspace/src"),
|
|
SimpleNamespace(name="src2", path="/mnt/workspace2/src2"),
|
|
]
|
|
)
|
|
),
|
|
)
|
|
|
|
matches, truncated = sandbox.glob("/mnt/workspace", "**", include_dirs=True)
|
|
|
|
assert matches == ["/mnt/workspace/src"]
|
|
assert truncated is False
|
|
|
|
|
|
def test_aio_sandbox_grep_skips_mismatched_line_number_payloads(monkeypatch) -> None:
|
|
with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"):
|
|
sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080")
|
|
monkeypatch.setattr(
|
|
sandbox._client.file,
|
|
"list_path",
|
|
lambda **kwargs: SimpleNamespace(
|
|
data=SimpleNamespace(
|
|
files=[
|
|
SimpleNamespace(
|
|
name="app.py",
|
|
path="/mnt/user-data/workspace/app.py",
|
|
is_directory=False,
|
|
)
|
|
]
|
|
)
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
sandbox._client.file,
|
|
"search_in_file",
|
|
lambda **kwargs: SimpleNamespace(data=SimpleNamespace(line_numbers=[7], matches=["TODO = True", "extra"])),
|
|
)
|
|
|
|
matches, truncated = sandbox.grep("/mnt/user-data/workspace", "TODO")
|
|
|
|
assert matches == [GrepMatch(path="/mnt/user-data/workspace/app.py", line_number=7, line="TODO = True")]
|
|
assert truncated is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ls_tool — path masking
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_ls_tool_masks_user_data_host_paths(tmp_path, monkeypatch) -> None:
|
|
"""ls_tool output must not leak host user-data paths; they should be virtual."""
|
|
runtime = _make_runtime(tmp_path)
|
|
workspace = tmp_path / "workspace"
|
|
(workspace / "report.txt").write_text("hello\n", encoding="utf-8")
|
|
(workspace / "subdir").mkdir()
|
|
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
|
|
|
|
result = ls_tool.func(
|
|
runtime=runtime,
|
|
description="list workspace",
|
|
path="/mnt/user-data/workspace",
|
|
)
|
|
|
|
# Virtual paths must be present
|
|
assert "/mnt/user-data/workspace" in result
|
|
# Host paths must NOT leak
|
|
assert str(workspace) not in result
|
|
assert str(tmp_path) not in result
|
|
|
|
|
|
def test_ls_tool_masks_skills_host_paths(tmp_path, monkeypatch) -> None:
|
|
"""ls_tool output must not leak host skills paths; they should be virtual."""
|
|
runtime = _make_runtime(tmp_path)
|
|
skills_dir = tmp_path / "skills"
|
|
(skills_dir / "public").mkdir(parents=True)
|
|
(skills_dir / "public" / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
|
|
|
|
sandbox = LocalSandbox(
|
|
id="local",
|
|
path_mappings=[
|
|
PathMapping(container_path="/mnt/skills", local_path=str(skills_dir), read_only=True),
|
|
],
|
|
)
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: sandbox)
|
|
|
|
result = ls_tool.func(
|
|
runtime=runtime,
|
|
description="list skills",
|
|
path="/mnt/skills",
|
|
)
|
|
|
|
# Virtual paths must be present
|
|
assert "/mnt/skills" in result
|
|
# Host paths must NOT leak
|
|
assert str(skills_dir) not in result
|
|
assert str(tmp_path) not in result
|
|
|
|
|
|
def test_ls_tool_returns_empty_for_empty_directory(tmp_path, monkeypatch) -> None:
|
|
"""ls_tool should return '(empty)' for an empty directory."""
|
|
runtime = _make_runtime(tmp_path)
|
|
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
|
|
|
|
result = ls_tool.func(
|
|
runtime=runtime,
|
|
description="list empty dir",
|
|
path="/mnt/user-data/workspace",
|
|
)
|
|
|
|
assert result == "(empty)"
|
|
|
|
|
|
def test_ls_tool_skills_path_uses_sandbox_mapping_user_id_not_contextvar(tmp_path, monkeypatch) -> None:
|
|
"""ls_tool must resolve /mnt/skills/custom via the sandbox PathMapping
|
|
(which uses the user_id from acquire time), not via _resolve_skills_path
|
|
(which uses get_effective_user_id() from contextvar).
|
|
|
|
Regression: when the contextvar user_id differs from the sandbox mapping's
|
|
user_id (e.g., contextvar unset → "default", but sandbox uses authenticated
|
|
"user-abc"), _resolve_skills_path would resolve to the wrong directory,
|
|
making /mnt/skills/custom appear empty. The fix delegates resolution to the
|
|
sandbox's PathMapping which always uses the acquire-time user_id.
|
|
"""
|
|
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
|
|
|
# Create two user-specific custom skill directories:
|
|
# - user-abc: has a skill "my-skill"
|
|
# - default: empty (the fallback when contextvar is unset)
|
|
base_dir = tmp_path / ".deer-flow"
|
|
user_abc_custom = base_dir / "users" / "user-abc" / "skills" / "custom"
|
|
user_abc_custom.mkdir(parents=True)
|
|
(user_abc_custom / "my-skill").mkdir()
|
|
(user_abc_custom / "my-skill" / "SKILL.md").write_text("# My Skill\n", encoding="utf-8")
|
|
|
|
default_custom = base_dir / "users" / "default" / "skills" / "custom"
|
|
default_custom.mkdir(parents=True) # exists but empty
|
|
|
|
# Create a sandbox with PathMappings that use user-abc's directory
|
|
# (simulating a sandbox acquired for user-abc)
|
|
sandbox = LocalSandbox(
|
|
id="local:user-abc:thread-1",
|
|
path_mappings=[
|
|
PathMapping(container_path="/mnt/skills/custom", local_path=str(user_abc_custom), read_only=True),
|
|
],
|
|
)
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: sandbox)
|
|
|
|
# Leave contextvar unset → get_effective_user_id() returns "default"
|
|
# Before the fix, _resolve_skills_path would resolve to default_custom (empty)
|
|
# After the fix, the sandbox PathMapping resolves to user-abc_custom (has my-skill)
|
|
token = set_current_user(SimpleNamespace(id="default")) # contextvar says "default"
|
|
try:
|
|
result = ls_tool.func(
|
|
runtime=_make_runtime(tmp_path),
|
|
description="list custom skills",
|
|
path="/mnt/skills/custom",
|
|
)
|
|
|
|
# Must show user-abc's skill (sandbox mapping), NOT default's empty dir (contextvar)
|
|
assert "my-skill" in result
|
|
assert str(user_abc_custom) not in result # host paths must not leak
|
|
finally:
|
|
reset_current_user(token)
|
|
|
|
|
|
def test_ls_tool_filters_upload_staging_files(tmp_path, monkeypatch) -> None:
|
|
runtime = _make_runtime(tmp_path)
|
|
uploads = tmp_path / "uploads"
|
|
(uploads / "report.txt").write_text("ready\n", encoding="utf-8")
|
|
(uploads / ".upload-active.part").write_text("partial\n", encoding="utf-8")
|
|
(uploads / ".upload-note.txt").write_text("intentional\n", encoding="utf-8")
|
|
|
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
|
|
|
|
result = ls_tool.func(
|
|
runtime=runtime,
|
|
description="list uploads",
|
|
path="/mnt/user-data/uploads",
|
|
)
|
|
|
|
assert "/mnt/user-data/uploads/report.txt" in result
|
|
assert "/mnt/user-data/uploads/.upload-note.txt" in result
|
|
assert ".upload-active.part" not in result
|