deer-flow/backend/tests/test_local_sandbox_content_path_translation.py
Starchaser1024 228913b6b7
fix(sandbox): keep document bytes when write_file translates paths (#7462)
* fix(sandbox): keep document bytes when write_file translates paths

LocalSandbox.write_file rewrote container paths inside every file it stored,
so a dictated README.md ended up holding a host path while read_file masked
the rewrite for agent-written files: the read-back showed the virtual path,
and the agent confirmed content the file did not contain (#7461).

Translation now applies only to executable content, classified with the
shared is_code_file rule from deerflow.skills.package_files that the skill
installer, the export guard, and skill_manage_tool already use. Code suffixes
and extensionless files that open with a shebang keep the host-path rewrite
they need to run under a host interpreter (PR #1935); documents, data files,
and configs are stored verbatim and read back as stored.

* fix(sandbox): translate paths for interpreter scripts and stop reading pipes

The classifier added for #7461 asked only the skill-package rule, which
does not list `.awk` (nor `.sed`, `.lua`, `.r`, `.tcl`), so a written AWK
program kept container paths that the host awk cannot open when it runs
`awk -f`. Those suffixes are now the sandbox's own set: the shared rule
also decides what SkillScan and the installer treat as package code, and
widening it there is a different call.

Classification no longer touches the destination unless the path is
extensionless. A suffix answers the question by itself, so an append to
`events.py` no longer opens the file first, and the extensionless
shebang sniff goes through `os.open(O_NONBLOCK)` plus an `fstat` regular
file check, which is what keeps a FIFO append from blocking.

* test(sandbox): pin the non-blocking sniff with a suffixless pipe append
2026-10-25 15:13:41 +08:00

207 lines
8.5 KiB
Python

"""Issue #7461 — only executable content gets container paths translated.
``write_file`` rewrites container paths inside the bytes it stores so a script
the agent wrote can run under a host interpreter (PR #1935). Applying that to
documents put host paths into deliverables, and ``read_file`` masked the
rewrite for agent-written files, so the agent read back the virtual path and
reported content it had not stored. Documents, data, and configs are now
stored verbatim; code files keep the translation.
"""
from __future__ import annotations
import builtins
import os
import shutil
import threading
from pathlib import Path
import pytest
from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping
def _sandbox(tmp_path: Path) -> LocalSandbox:
outputs = tmp_path / "outputs"
workspace = tmp_path / "workspace"
outputs.mkdir()
workspace.mkdir()
return LocalSandbox(
id="test",
path_mappings=[
PathMapping(container_path="/mnt/user-data/outputs", local_path=str(outputs)),
PathMapping(container_path="/mnt/user-data/workspace", local_path=str(workspace)),
],
)
@pytest.mark.parametrize(
"name,content",
[
("README.md", "# Run notes\nThe results table is saved at /mnt/user-data/outputs/results.csv\n"),
("notes.txt", "see /mnt/user-data/outputs/results.csv and /mnt/user-data/workspace/plot.png\n"),
("data.csv", "label,path\nrows,/mnt/user-data/outputs/results.csv\n"),
("settings.json", '{"output": "/mnt/user-data/outputs/results.csv"}\n'),
("export.yaml", "output: /mnt/user-data/outputs/results.csv\n"),
],
ids=["markdown", "text", "csv", "json", "yaml"],
)
def test_non_executable_content_is_stored_verbatim(tmp_path, name, content):
"""A dictation of exact bytes must survive the write and the read-back."""
sandbox = _sandbox(tmp_path)
virtual = f"/mnt/user-data/outputs/{name}"
sandbox.write_file(virtual, content)
stored = (tmp_path / "outputs" / name).read_bytes().decode("utf-8")
assert stored == content
# read_file returns the stored bytes, so the agent cannot claim a rewrite it did not see.
assert sandbox.read_file(virtual) == content
def test_extensionless_document_without_shebang_is_stored_verbatim(tmp_path):
"""``README`` with no suffix is a document, not a program (the shared rule's negative case)."""
sandbox = _sandbox(tmp_path)
content = "Install notes: /mnt/user-data/workspace/setup.sh\n"
sandbox.write_file("/mnt/user-data/outputs/README", content)
assert (tmp_path / "outputs" / "README").read_text(encoding="utf-8") == content
def test_append_keeps_document_paths_virtual(tmp_path):
sandbox = _sandbox(tmp_path)
sandbox.write_file("/mnt/user-data/outputs/README.md", "# Run notes\n")
sandbox.write_file("/mnt/user-data/outputs/README.md", "saved at /mnt/user-data/outputs/results.csv\n", append=True)
assert (tmp_path / "outputs" / "README.md").read_text(encoding="utf-8") == ("# Run notes\nsaved at /mnt/user-data/outputs/results.csv\n")
def test_append_to_shebang_script_still_translates_paths(tmp_path):
"""An extensionless script's interpreter line stays at the top of the file."""
sandbox = _sandbox(tmp_path)
virtual = "/mnt/user-data/workspace/collect"
sandbox.write_file(virtual, "#!/bin/bash\nset -eu\n")
sandbox.write_file(virtual, "cat /mnt/user-data/outputs/results.csv\n", append=True)
stored = (tmp_path / "workspace" / "collect").read_text(encoding="utf-8")
assert stored.startswith("#!/bin/bash\n")
assert "/mnt/user-data" not in stored
@pytest.mark.parametrize(
"name,content",
[
("plot.py", 'OUTPUT = "/mnt/user-data/outputs/results.csv"\n'),
("run.sh", 'export PATH="/mnt/user-data/workspace/bin:/mnt/user-data/outputs/bin"\n'),
("run", "#!/bin/bash\ncat /mnt/user-data/outputs/results.csv\n"),
],
ids=["python", "shell", "shebang-without-suffix"],
)
def test_executable_content_still_translates_paths(tmp_path, name, content):
"""PR #1935: a script must see host paths, or ``bash``/``python`` cannot open them."""
sandbox = _sandbox(tmp_path)
virtual = f"/mnt/user-data/workspace/{name}"
sandbox.write_file(virtual, content)
stored = (tmp_path / "workspace" / name).read_text(encoding="utf-8")
assert "/mnt/user-data" not in stored
assert Path(os.path.realpath(tmp_path)).as_posix() in stored
# The reverse translation keeps the agent's view consistent with what it wrote.
assert sandbox.read_file(virtual) == content
@pytest.mark.parametrize("name", ["report.awk", "cleanup.sed", "plugin.lua", "plot.r", "run.tcl", "REPORT.AWK"])
def test_interpreter_script_suffixes_translate_container_paths(tmp_path, name):
"""``awk -f`` and friends open the script itself, so its container paths need host paths."""
sandbox = _sandbox(tmp_path)
content = 'BEGIN { while ((getline line < "/mnt/user-data/outputs/data.txt") > 0) print line }\n'
virtual = f"/mnt/user-data/workspace/{name}"
sandbox.write_file(virtual, content)
stored = (tmp_path / "workspace" / name).read_text(encoding="utf-8")
assert "/mnt/user-data" not in stored
assert Path(os.path.realpath(tmp_path)).as_posix() in stored
assert sandbox.read_file(virtual) == content
@pytest.mark.skipif(shutil.which("awk") is None, reason="awk is not installed")
def test_awk_script_written_through_the_sandbox_still_runs(tmp_path):
"""An AWK program that opens a container path must run through ``execute_command``."""
sandbox = _sandbox(tmp_path)
(tmp_path / "outputs" / "data.txt").write_text("alpha\nbeta\n", encoding="utf-8")
sandbox.write_file(
"/mnt/user-data/workspace/report.awk",
'BEGIN { while ((getline line < "/mnt/user-data/outputs/data.txt") > 0) print line }\n',
)
output = sandbox.execute_command("awk -f /mnt/user-data/workspace/report.awk /dev/null", timeout=20)
assert "alpha" in output
assert "beta" in output
def test_append_does_not_read_the_destination_before_classifying(tmp_path, monkeypatch):
"""A ``.py`` suffix already settles it, so the append must not open the file for reading."""
sandbox = _sandbox(tmp_path)
virtual = "/mnt/user-data/outputs/events.py"
sandbox.write_file(virtual, "print('ready')\n")
destination = os.path.realpath(tmp_path / "outputs" / "events.py")
real_open = builtins.open
def guard_reads(file, mode="r", *args, **kwargs):
reads_only = not set(mode) & set("wa+")
if isinstance(file, str) and reads_only and os.path.realpath(file) == destination:
raise AssertionError("append opened the destination for reading")
return real_open(file, mode, *args, **kwargs)
monkeypatch.setattr(builtins, "open", guard_reads)
sandbox.write_file(virtual, "print('appended')\n", append=True)
assert (tmp_path / "outputs" / "events.py").read_text(encoding="utf-8") == "print('ready')\nprint('appended')\n"
@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="named pipes are a POSIX primitive")
@pytest.mark.parametrize("name", ["events.py", "events"], ids=["suffix-settles-the-answer", "extensionless-shebang-sniff"])
def test_append_to_a_named_pipe_does_not_block_while_classifying(tmp_path, name):
"""Classification must not wait on the destination, whichever shape settles it.
A ``.py`` suffix settles the answer with no I/O; an extensionless name
reaches the shebang sniff, which has to open the destination without
waiting and reject the non-regular handle before reading. Reading a FIFO
waits for the other end and for data; the append used to complete as soon
as a reader was connected.
"""
sandbox = _sandbox(tmp_path)
fifo = tmp_path / "outputs" / name
os.mkfifo(fifo)
virtual = f"/mnt/user-data/outputs/{name}"
received: list[str] = []
def read_pipe() -> None:
with open(fifo, encoding="utf-8") as reader:
received.append(reader.read())
reader_thread = threading.Thread(target=read_pipe, daemon=True)
reader_thread.start()
errors: list[BaseException] = []
def append_pipe() -> None:
try:
sandbox.write_file(virtual, "ready\n", append=True)
except BaseException as exc: # noqa: BLE001 - surfaced by the assertion below
errors.append(exc)
writer_thread = threading.Thread(target=append_pipe, daemon=True)
writer_thread.start()
writer_thread.join(5)
assert not writer_thread.is_alive(), "append blocked while classifying the destination"
assert errors == []
reader_thread.join(5)
assert received == ["ready\n"]