deer-flow/backend/tests/test_sandbox_search_tools.py
Hyeonsang Cho ed986a10ef
fix(sandbox): report truncated remote glob and grep results (#5427)
* fix(sandbox): report truncated remote glob and grep results

BoxLite, Tenki, E2B, and OpenSandbox run find/grep in the sandbox, cap
the raw output with `| head`, and then filter those lines in Python:
ignored directories such as node_modules are dropped and grep's glob
scope is applied. They reported truncated only when max_results matches
survived the filter. When the capped lines were mostly filtered out, a
search with real matches past the cap came back short or empty with
truncated=False, and glob_tool/grep_tool rendered it as "No files
matched" / "No matches found". With the default max_results=200 and
1,200 files under node_modules, glob("**/*.py") reported no matches for
a workspace that has src/app.py.

remote_search_command now lets one line past its limit through, and
parse_remote_search_output(..., limit=) returns RemoteSearchOutput(text,
truncated): the first `limit` lines and whether the extra line arrived.
Exactly `limit` lines stays a complete result. Each provider passes the
cap it already computed to both calls and returns that truncated from
glob and grep when fewer than max_results results survive filtering.

The glob and grep tools now describe an empty truncated result as
incomplete instead of reporting no matches, which also covers AIO grep's
forwarded truncated flag. Sandbox.glob/grep document truncated as "the
matches may be incomplete".

* docs(changelog): reference #5427 in the remote search truncation entry

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-14 15:38:34 +08:00

887 lines
34 KiB
Python

import json
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from support.symlinks import symlink_or_skip
from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox
from deerflow.config.paths import Paths
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_accepts_single_file_path(tmp_path, monkeypatch) -> None:
runtime = _make_runtime(tmp_path)
uploads = tmp_path / "uploads"
report = uploads / "report.md"
report.write_text("Revenue grew 20%\n", encoding="utf-8")
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
result = grep_tool.func(
runtime=runtime,
description="find revenue in the uploaded report",
pattern="Revenue",
path="/mnt/user-data/uploads/report.md",
)
assert "/mnt/user-data/uploads/report.md:1: Revenue grew 20%" in result
assert "Path is not a directory" not in result
assert str(uploads) not in result
def _remote_search_runtime():
return SimpleNamespace(state={"sandbox": {"sandbox_id": "remote-1"}}, context={"thread_id": "thread-1"})
@pytest.mark.parametrize(
("tool", "arguments"),
[
(glob_tool, {"pattern": "src/*.py"}),
(grep_tool, {"pattern": "needle", "glob": "src/*.py"}),
],
)
def test_search_tools_do_not_report_a_truncated_empty_result_as_no_matches(monkeypatch, tool, arguments) -> None:
"""A remote search whose output hit its cap before any line survived the glob
filter has no results to show, but it has not proven there are none."""
sandbox = SimpleNamespace(glob=lambda *args, **kwargs: ([], True), grep=lambda *args, **kwargs: ([], True))
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: sandbox)
result = tool.func(runtime=_remote_search_runtime(), description="scoped search", path="/mnt/user-data/workspace", **arguments)
assert not result.startswith(("No files matched", "No matches found"))
assert "incomplete" in result
assert "/mnt/user-data/workspace" in result
@pytest.mark.parametrize(("tool", "arguments", "expected"), [(glob_tool, {"pattern": "*.py"}, "No files matched under"), (grep_tool, {"pattern": "needle"}, "No matches found under")])
def test_search_tools_keep_the_no_match_message_for_a_complete_empty_result(monkeypatch, tool, arguments, expected) -> None:
sandbox = SimpleNamespace(glob=lambda *args, **kwargs: ([], False), grep=lambda *args, **kwargs: ([], False))
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: sandbox)
result = tool.func(runtime=_remote_search_runtime(), description="search", path="/mnt/user-data/workspace", **arguments)
assert result == f"{expected} /mnt/user-data/workspace"
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,
"grep_files",
lambda **kwargs: SimpleNamespace(
data=SimpleNamespace(
matches=[
SimpleNamespace(
file="/mnt/user-data/workspace/app.py",
line_number=7,
line_content="TODO = True",
)
],
truncated=False,
)
),
)
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_aio_sandbox_grep_accepts_single_file_path(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,
"grep_files",
lambda **kwargs: SimpleNamespace(
data=SimpleNamespace(
matches=[
SimpleNamespace(
file="/mnt/user-data/uploads/report.md",
line_number=3,
line_content="Revenue grew 20%",
)
],
truncated=False,
)
),
)
monkeypatch.setattr(
sandbox._client.file,
"list_path",
lambda **kwargs: (_ for _ in ()).throw(AssertionError("single-file grep must not list the path as a directory")),
)
matches, truncated = sandbox.grep("/mnt/user-data/uploads/report.md", "Revenue")
assert matches == [GrepMatch(path="/mnt/user-data/uploads/report.md", line_number=3, line="Revenue grew 20%")]
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_accepts_single_file(tmp_path) -> None:
file_path = tmp_path / "file.txt"
file_path.write_text("TODO\n", encoding="utf-8")
matches, truncated = find_grep_matches(file_path, "TODO")
assert matches == [GrepMatch(path=str(file_path), line_number=1, line="TODO")]
assert truncated is False
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")
symlink_or_skip(workspace / "outside-link.txt", 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_drops_matches_outside_requested_root(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,
"grep_files",
lambda **kwargs: SimpleNamespace(
data=SimpleNamespace(
matches=[
SimpleNamespace(
file="/mnt/user-data/workspace/app.py",
line_number=7,
line_content="TODO = True",
),
SimpleNamespace(
file="/mnt/user-data/workspace-sibling/leak.py",
line_number=9,
line_content="TODO = False",
),
],
truncated=False,
)
),
)
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)
# Listing a category root descends into the skills below it, so the
# disabled-skill gate now resolves each one's enabled state. That lookup
# needs app config; without it the gate fails closed (see PR #3889) and the
# listing would be empty for a reason unrelated to what this test asserts.
skills_root = tmp_path / "skills"
(skills_root / "custom").mkdir(parents=True)
app_config = SimpleNamespace(
skills=SimpleNamespace(
get_skills_path=lambda: skills_root,
container_path="/mnt/skills",
use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
),
skill_evolution=SimpleNamespace(enabled=False),
)
# 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:
with patch("deerflow.config.paths.get_paths", return_value=Paths(base_dir=base_dir)):
with patch("deerflow.config.get_app_config", return_value=app_config):
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
def _make_skills_sandbox(tmp_path, monkeypatch, *, disabled: str):
"""Skills tree with one disabled and one enabled public skill.
Drives the real `_is_disabled_skill_path` gate through a real
extensions_config.json rather than stubbing the gate out.
"""
skills_dir = tmp_path / "skills"
for name, body in [(disabled, "SECRET_PROCEDURE = step-1-step-2\n"), ("open-skill", "PUBLIC_PROCEDURE = hello\n")]:
(skills_dir / "public" / name).mkdir(parents=True)
(skills_dir / "public" / name / "SKILL.md").write_text(f"---\nname: {name}\n---\n\n{body}", encoding="utf-8")
ext = tmp_path / "extensions_config.json"
ext.write_text(
json.dumps({"mcpServers": {}, "skills": {disabled: {"enabled": False}, "open-skill": {"enabled": True}}}),
encoding="utf-8",
)
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(ext))
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)
return sandbox
def test_glob_tool_blocks_disabled_skill_root(tmp_path, monkeypatch) -> None:
"""glob must refuse a disabled skill's own directory, like ls and read_file do."""
runtime = _make_runtime(tmp_path)
_make_skills_sandbox(tmp_path, monkeypatch, disabled="secret-skill")
result = glob_tool.func(
runtime=runtime,
description="list skill files",
pattern="**/*.md",
path="/mnt/skills/public/secret-skill",
)
assert "Skill 'secret-skill' is disabled" in result
assert "SKILL.md" not in result
def test_grep_tool_blocks_disabled_skill_root(tmp_path, monkeypatch) -> None:
"""grep must refuse a disabled skill's own directory, like ls and read_file do."""
runtime = _make_runtime(tmp_path)
_make_skills_sandbox(tmp_path, monkeypatch, disabled="secret-skill")
result = grep_tool.func(
runtime=runtime,
description="search skill files",
pattern="SECRET_PROCEDURE",
path="/mnt/skills/public/secret-skill",
)
assert "Skill 'secret-skill' is disabled" in result
assert "SECRET_PROCEDURE = step-1-step-2" not in result
def test_glob_tool_does_not_surface_disabled_skill_from_ancestor_root(tmp_path, monkeypatch) -> None:
"""A root above the skill must not surface it: glob descends past the path gate."""
runtime = _make_runtime(tmp_path)
_make_skills_sandbox(tmp_path, monkeypatch, disabled="secret-skill")
result = glob_tool.func(
runtime=runtime,
description="find skills",
pattern="**/SKILL.md",
path="/mnt/skills",
)
assert "secret-skill" not in result
# ...while the enabled sibling is still returned.
assert "/mnt/skills/public/open-skill/SKILL.md" in result
def test_grep_tool_does_not_surface_disabled_skill_content_from_ancestor_root(tmp_path, monkeypatch) -> None:
"""The strongest leak: grep from /mnt/skills printed a disabled skill's file contents."""
runtime = _make_runtime(tmp_path)
_make_skills_sandbox(tmp_path, monkeypatch, disabled="secret-skill")
result = grep_tool.func(
runtime=runtime,
description="search skills",
pattern="PROCEDURE",
path="/mnt/skills",
)
assert "SECRET_PROCEDURE = step-1-step-2" not in result
assert "secret-skill" not in result
# ...while the enabled sibling still matches.
assert "PUBLIC_PROCEDURE = hello" in result
def test_ls_tool_does_not_surface_disabled_skill_from_category_root(tmp_path, monkeypatch) -> None:
"""ls gates the requested path but descends two levels, so the category root leaked."""
runtime = _make_runtime(tmp_path)
_make_skills_sandbox(tmp_path, monkeypatch, disabled="secret-skill")
result = ls_tool.func(
runtime=runtime,
description="list public skills",
path="/mnt/skills/public",
)
assert "secret-skill" not in result
# ...while the enabled sibling is still listed.
assert "open-skill" in result
def test_ls_tool_keeps_category_dirs_when_listing_skills_root(tmp_path, monkeypatch) -> None:
"""`ls /mnt/skills` lists dirs with a trailing slash ("public/"), which the
skill-name extractor must read as a category root, not as a skill named "".
An empty name skips the `skill_name is None` short-circuit and falls through
to a config read; it currently lands on "keep" only because unknown skills
default to enabled. This pins the intended outcome directly: category dirs
stay visible while the disabled skill below them does not.
"""
runtime = _make_runtime(tmp_path)
_make_skills_sandbox(tmp_path, monkeypatch, disabled="secret-skill")
result = ls_tool.func(
runtime=runtime,
description="list skills root",
path="/mnt/skills",
)
assert "/mnt/skills/public" in result
assert "open-skill" in result
assert "secret-skill" not in result
def test_extract_skill_name_treats_category_dir_with_trailing_slash_as_root() -> None:
"""LocalSandbox.list_dir appends "/" to directories, so the gate sees
"/mnt/skills/public/" — which must resolve to None (category root), not "".
"""
from deerflow.sandbox.tools import _extract_skill_name_from_skills_path as extract
# Changed direction: trailing-slash category roots used to yield "".
assert extract("/mnt/skills/public/") is None
assert extract("/mnt/skills/custom/") is None
assert extract("/mnt/skills/legacy/") is None
# Unchanged directions: real skills still resolve, with or without the slash.
assert extract("/mnt/skills/public") is None
assert extract("/mnt/skills/public/bootstrap") == "bootstrap"
assert extract("/mnt/skills/public/bootstrap/") == "bootstrap"
assert extract("/mnt/skills/public/bootstrap/SKILL.md") == "bootstrap"
assert extract("/mnt/skills/my-skill/") == "my-skill"
assert extract("/mnt/user-data/workspace/file.md") is None
def _make_custom_skills_sandbox(tmp_path, monkeypatch, *, user_id: str, disabled: str):
"""Per-user CUSTOM skills tree with one disabled and one enabled skill.
CUSTOM/LEGACY enabled state lives in the per-user ``_skill_states.json``
(``UserScopedSkillStorage``), a different store from the public skills'
``extensions_config.json`` — so the public fixture above does not exercise
this branch of ``_is_disabled_skill_path``.
"""
from deerflow.skills.storage import reset_skill_storage
base_dir = tmp_path / ".deer-flow"
user_skills = base_dir / "users" / user_id / "skills"
user_custom = user_skills / "custom"
for name, body in [(disabled, "SECRET_PROCEDURE = step-1-step-2\n"), ("open-custom", "PUBLIC_PROCEDURE = hello\n")]:
(user_custom / name).mkdir(parents=True)
(user_custom / name / "SKILL.md").write_text(f"---\nname: {name}\n---\n\n{body}", encoding="utf-8")
(user_skills / "_skill_states.json").write_text(
json.dumps({disabled: {"enabled": False}, "open-custom": {"enabled": True}}),
encoding="utf-8",
)
skills_root = tmp_path / "skills"
(skills_root / "public").mkdir(parents=True)
(skills_root / "custom").mkdir(parents=True)
app_config = SimpleNamespace(
skills=SimpleNamespace(
get_skills_path=lambda: skills_root,
container_path="/mnt/skills",
use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
),
skill_evolution=SimpleNamespace(enabled=False),
)
sandbox = LocalSandbox(
id=f"local:{user_id}:thread-1",
path_mappings=[PathMapping(container_path="/mnt/skills/custom", local_path=str(user_custom), read_only=True)],
)
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: sandbox)
# The storage cache is keyed by user id, not by base_dir: a cached instance
# from another test would read the wrong _skill_states.json.
reset_skill_storage()
monkeypatch.setattr("deerflow.sandbox.tools.resolve_runtime_user_id", lambda runtime: user_id)
return base_dir, app_config
def test_grep_tool_does_not_surface_disabled_custom_skill(tmp_path, monkeypatch) -> None:
"""CUSTOM skills resolve enabled state through the per-user _skill_states.json,
not extensions_config.json — the store the public-skill tests never touch."""
from deerflow.skills.storage import reset_skill_storage
runtime = _make_runtime(tmp_path)
base_dir, app_config = _make_custom_skills_sandbox(tmp_path, monkeypatch, user_id="user-abc", disabled="secret-custom")
try:
with patch("deerflow.config.paths.get_paths", return_value=Paths(base_dir=base_dir)):
with patch("deerflow.config.get_app_config", return_value=app_config):
result = grep_tool.func(
runtime=runtime,
description="search custom skills",
pattern="PROCEDURE",
path="/mnt/skills/custom",
)
finally:
reset_skill_storage()
assert "SECRET_PROCEDURE = step-1-step-2" not in result
assert "secret-custom" not in result
# ...while the enabled sibling still matches.
assert "PUBLIC_PROCEDURE = hello" in result
def test_ls_tool_does_not_surface_disabled_custom_skill(tmp_path, monkeypatch) -> None:
"""Same per-user store, via the descending ls listing."""
from deerflow.skills.storage import reset_skill_storage
runtime = _make_runtime(tmp_path)
base_dir, app_config = _make_custom_skills_sandbox(tmp_path, monkeypatch, user_id="user-abc", disabled="secret-custom")
try:
with patch("deerflow.config.paths.get_paths", return_value=Paths(base_dir=base_dir)):
with patch("deerflow.config.get_app_config", return_value=app_config):
result = ls_tool.func(
runtime=runtime,
description="list custom skills",
path="/mnt/skills/custom",
)
finally:
reset_skill_storage()
assert "secret-custom" not in result
assert "open-custom" in result