fix(artifacts): keep a delimited sandbox path whole (#7308)

* fix(artifacts): keep a delimited sandbox path whole

The free-text artifact scan matched `/mnt/user-data/\S+`, so a tool that
quoted a filename with spaces registered a handle for the prefix: `Created
"/mnt/user-data/outputs/quarterly report.pdf" successfully.` became
`/mnt/user-data/outputs/quarterly`, which resolves to nothing after compact or
checkpoint reload. Quotes and inline backticks had the same failure.

The scan now classifies each candidate by how it is delimited. A span opened by
a matching quote or backtick on the same line is one value and keeps its spaces
and punctuation; a span whose delimiter never closes on that line reports
nothing, because a truncated prefix names a file that does not exist; a bare
path keeps the conservative whitespace boundary it always had. Ordering is the
text order, and the shared `seen` set still deduplicates against structured
references.

Pins the three delimiters, both unchanged behaviours (bare path boundary,
punctuation outside the delimiter), the unterminated and next-line cases, two
delimited paths in one result, a mixed delimited/bare result, and the extracted
handle end to end.

* fix(artifacts): reject line breaks and match escaped quotes

* fix(artifacts): validate quoted path boundaries

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
李刚 / Bryan Li 2026-10-23 17:59:44 +08:00 committed by GitHub
parent 6caa5addf3
commit 1c9e962b32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 302 additions and 5 deletions

View File

@ -960,7 +960,7 @@ already received by the browser, without an additional secret-redaction layer.
In plan mode, malformed TODO statuses return normal tool-validation errors without aborting token attribution, so the agent can correct the call.
Tool-produced paths and URLs can be retained as short artifact handles across context compaction (`tool_artifacts` in `config.yaml`). Handles distinguish separate tool-result occurrences, even when a provider reuses call IDs. Standard MCP image URLs are captured alongside file references, including results from stdio and HTTP servers. Detected file URLs preserve their query strings and fragments. Free-text artifact references retain balanced ASCII and CJK brackets in literal paths and URLs while trimming surrounding prose punctuation. When PII redaction is enabled, model-visible artifact labels follow that policy; internal references stay intact for tool argument resolution. The configured registry limit retains the newest artifacts, while checkpointed processing identities prevent evicted results from being recaptured after restart. Resolution runs before authorization and write-safety checks; unknown or expired handles return an error without executing the tool. Small unknown structured results may be retained as complete JSON up to 4096 UTF-8 bytes; empty or oversized payloads are skipped. Handles are agent-local: task arguments resolve parent handles to concrete references, and delegated reports must return concrete references rather than child-local handles. A truncated model projection reports how many handles are omitted.
Tool-produced paths and URLs can be retained as short artifact handles across context compaction (`tool_artifacts` in `config.yaml`). Handles distinguish separate tool-result occurrences, even when a provider reuses call IDs. Standard MCP image URLs are captured alongside file references, including results from stdio and HTTP servers. Detected file URLs preserve their query strings and fragments. Free-text sandbox paths retain spaces inside matching ASCII quotes, backticks, or curly quote pairs, including backslash-escaped wrappers. Unterminated spans and delimited values containing a line break or tab are skipped. Free-text artifact references retain balanced ASCII and CJK brackets in literal paths and URLs while trimming surrounding prose punctuation. When PII redaction is enabled, model-visible artifact labels follow that policy; internal references stay intact for tool argument resolution. The configured registry limit retains the newest artifacts, while checkpointed processing identities prevent evicted results from being recaptured after restart. Resolution runs before authorization and write-safety checks; unknown or expired handles return an error without executing the tool. Small unknown structured results may be retained as complete JSON up to 4096 UTF-8 bytes; empty or oversized payloads are skipped. Handles are agent-local: task arguments resolve parent handles to concrete references, and delegated reports must return concrete references rather than child-local handles. A truncated model projection reports how many handles are omitted.
DeerFlow supports configurable MCP servers and skills to extend its capabilities.
When durable MCP background tasks are enabled, agents can use `list_background_tasks(status="failed")` or `status="input_required"` to find failed tasks or tasks awaiting input in the current chat. Other supported statuses are `submitted`, `working`, `completed`, and `cancelled`; omitting the status preserves existing behavior. The database applies the filter before limiting results to the 20 most recent matching tasks. When combined with `active_only=true`, both filters apply: active statuses are `submitted`, `working`, and `input_required`, so terminal statuses return an empty list. `GET /api/threads/{thread_id}/mcp-tasks` supports the same `status` and `active_only` filters before its `limit` (default 50, range 1–100); for example, `?status=failed&limit=20`. Unknown statuses return 422, and omitting the filters preserves the existing response.

View File

@ -5,6 +5,12 @@ legacy `source.url`/`source.mime_type`. A present top-level `url` selects that
shape, even when invalid; never mix its MIME metadata with a conflicting legacy
source. Both shapes use the existing referenceability gate and per-result deduplication.
Free-text sandbox paths retain spaces inside matching ASCII/curly quote pairs,
backticks or backslash-escaped wrappers. Exclude wrapper escapes without stripping
literal path backslashes. Reject Python line boundaries and tabs; bound delimiter
searches to the same line so later refs survive. Preserve bare-token boundaries
and structured refs. Tests: `tests/test_artifact_registry.py`.
`artifact_registry.py` checks remote file suffixes against the complete parsed
URL path without case sensitivity. Free-text URL and sandbox-path extraction
strips common trailing ASCII/CJK prose punctuation while preserving ASCII

View File

@ -28,8 +28,18 @@ _HANDLE_LENGTH = 8
# Virtual sandbox path prefix (used by local stdio MCP servers whose files live
# inside the mounted user-data tree).
_SANDBOX_PATH_PREFIX = "/mnt/user-data/"
_SANDBOX_PATH_PATTERN = re.compile(r"/mnt/user-data/\S+")
# Delimiters a tool result may wrap a path in. A delimited span is one value, so a
# filename inside it keeps its spaces; the bare token scan below keeps its
# whitespace boundary, because nothing tells it where such a name would end.
_REF_QUOTE_DELIMITERS = {'"': '"', "'": "'", "`": "`", "“": "”", "‘": "’"}
_REF_LINE_BOUNDARY_PATTERN = re.compile(r"[\r\n\v\f\x1c-\x1e\x85\u2028\u2029]")
_SPAN_BARE = "bare"
_SPAN_QUOTED = "quoted"
_SPAN_UNTERMINATED = "unterminated"
# Scan complete URLs before checking the path, so extensions in query strings
# cannot truncate a reference or turn a non-file URL into a file artifact.
_REMOTE_URL_PATTERN = re.compile(r"https?://[^\s\"'`<>]+")
@ -148,20 +158,78 @@ def _strip_ref_trailing_noise(raw: str) -> str:
return raw[:end]
def _classify_span(text: str, start: int) -> tuple[str, int | None]:
"""How the sandbox path at ``start`` is delimited, and where a quoted span ends.
``bare`` paths keep the whitespace boundary. A ``quoted`` span ends at the matching
delimiter on the same line, which is what keeps ``"report final.pdf"`` whole;
``unterminated`` is a span whose delimiter never closes on that line, and it produces
no reference at all rather than a prefix of a filename that does not exist.
"""
opener = text[start - 1] if start else ""
if opener not in _REF_QUOTE_DELIMITERS:
return _SPAN_BARE, None
line_break = _REF_LINE_BOUNDARY_PATTERN.search(text, start)
line_end = line_break.start() if line_break else len(text)
# Odd backslash runs escape the wrapper; even runs leave it unescaped.
# Exclude only the wrapper marker, without decoding backslashes in the path.
escape_start = start - 1
while escape_start and text[escape_start - 1] == "\\":
escape_start -= 1
escape_prefix = text[escape_start : start - 1]
closer_marker = (escape_prefix if len(escape_prefix) % 2 else "") + _REF_QUOTE_DELIMITERS[opener]
closer = text.find(closer_marker, start, line_end)
if closer == -1:
return _SPAN_UNTERMINATED, None
return _SPAN_QUOTED, closer
def _is_referenceable_sandbox_path(raw: str) -> bool:
"""A sandbox path may contain spaces only when the tool delimited it.
The size and single-line bounds still apply: a delimited span can carry prose, and an
unbounded or multi-line value must not become a handle.
"""
if not raw.startswith(_SANDBOX_PATH_PREFIX) or len(raw) == len(_SANDBOX_PATH_PREFIX):
return False
if _REF_LINE_BOUNDARY_PATTERN.search(raw) or "\t" in raw or raw != raw.strip():
return False
try:
return len(raw.encode("utf-8")) <= _STRUCTURED_DATA_MAX_BYTES
except UnicodeError:
return False
def _detect_refs_in_text(text: str) -> list[dict[str, str]]:
"""Conservatively detect file paths and remote file URLs in free text."""
refs: list[dict[str, str]] = []
seen: set[str] = set()
for match in _SANDBOX_PATH_PATTERN.finditer(text):
raw = _strip_ref_trailing_noise(match.group(0))
if raw in seen or not _is_referenceable_url(raw):
cursor = 0
while (start := text.find(_SANDBOX_PATH_PREFIX, cursor)) != -1:
cursor = start + 1
kind, span_end = _classify_span(text, start)
if kind == _SPAN_UNTERMINATED:
# The opener says the path runs past this point or onto another line; a
# whitespace-truncated prefix would name a file that does not exist.
continue
if kind == _SPAN_QUOTED:
assert span_end is not None
raw = text[start:span_end]
cursor = span_end
else:
match = _SANDBOX_PATH_PATTERN.match(text, start)
if match is None:
continue
cursor = match.end()
raw = _strip_ref_trailing_noise(match.group(0))
if raw in seen or not _is_referenceable_sandbox_path(raw):
continue
seen.add(raw)
refs.append(
{
"type": "file",
"ref": raw,
"display": raw.split("/")[-1],
"display": _display_name_for_ref(raw),
}
)
for match in _REMOTE_URL_PATTERN.finditer(text):

View File

@ -673,3 +673,226 @@ def test_merge_tool_artifacts_consumption_update_does_not_grow_count():
consumed = {**entries[0], "consumed_by": ["call-x"]}
merged = merge_tool_artifacts(entries, [consumed])
assert len(merged) == 1000
# --- delimited sandbox paths (issue #7303) ---------------------------------
@pytest.mark.parametrize("delimiter", ['"', "'", "`"])
def test_a_delimited_sandbox_path_keeps_its_spaces(delimiter):
"""A tool that quotes the filename means the whole quoted string is the path."""
path = "/mnt/user-data/outputs/quarterly report (final).pdf"
refs = _detect_refs_in_text(f"Created {delimiter}{path}{delimiter} successfully.")
assert [ref["ref"] for ref in refs] == [path]
assert refs[0]["display"] == "quarterly report (final).pdf"
def test_a_bare_sandbox_path_keeps_the_whitespace_boundary():
"""Nothing tells the free-text scan where an undelimited name with spaces ends, so it
keeps the conservative boundary it always had."""
refs = _detect_refs_in_text("Created /mnt/user-data/outputs/quarterly report.pdf successfully.")
assert [ref["ref"] for ref in refs] == ["/mnt/user-data/outputs/quarterly"]
@pytest.mark.parametrize(
"text",
[
# Never closed on this line.
'Created "/mnt/user-data/outputs/quarterly report.pdf successfully.',
"Created '/mnt/user-data/outputs/quarterly report.pdf",
"Created `/mnt/user-data/outputs/quarterly report.pdf",
# Closed, but only after the line break: a multi-line value is not one path.
'Created "/mnt/user-data/outputs/quarterly report.pdf\nsuccessfully."',
],
)
def test_an_unterminated_delimited_span_produces_no_reference(text):
"""A prefix of a quoted filename names a file that does not exist, so it is not
reported at all rather than reported truncated."""
assert _detect_refs_in_text(text) == []
def test_delimited_paths_keep_their_order_and_deduplicate():
first = "/mnt/user-data/outputs/a report.pdf"
second = "/mnt/user-data/outputs/b report.csv"
refs = _detect_refs_in_text(f'Published `{first}` and "{second}", then again "{first}".')
assert [ref["ref"] for ref in refs] == [first, second]
def test_a_delimited_and_a_bare_path_in_one_result_are_both_captured():
quoted = "/mnt/user-data/outputs/a report.pdf"
refs = _detect_refs_in_text(f'Copied to "{quoted}"; log at /mnt/user-data/workspace/run.log.')
assert [ref["ref"] for ref in refs] == [quoted, "/mnt/user-data/workspace/run.log"]
@pytest.mark.parametrize(
("text", "expected"),
[
('Output: "/mnt/user-data/outputs/report.pdf".', "/mnt/user-data/outputs/report.pdf"),
("Output: `/mnt/user-data/outputs/report.pdf`,", "/mnt/user-data/outputs/report.pdf"),
],
)
def test_punctuation_outside_the_delimiter_is_not_part_of_the_reference(text, expected):
assert [ref["ref"] for ref in _detect_refs_in_text(text)] == [expected]
def test_extraction_registers_the_full_delimited_path_once():
path = "/mnt/user-data/outputs/quarterly report.pdf"
message = ToolMessage(
content=f'Created "{path}" successfully.',
tool_call_id="export-1",
name="export_report",
)
entries = extract_artifacts_from_result(message, thread_id="demo")
assert [entry["real_ref"] for entry in entries] == [path]
assert entries[0]["display_name"] == "quarterly report.pdf"
assert entries[0]["artifact_type"] == "file"
@pytest.mark.parametrize("separator", ["\n", "\r", "\r\n", "\v", "\f", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029", "\t"])
@pytest.mark.parametrize("delimiter", ['"', "'", "`"])
def test_delimited_sandbox_path_rejects_line_breaks_and_tabs(separator, delimiter):
message = ToolMessage(
content=f"Created {delimiter}/mnt/user-data/outputs/quarterly{separator}report.pdf{delimiter}.",
tool_call_id="export-multiline",
name="export_report",
)
assert extract_artifacts_from_result(message, thread_id="demo") == []
@pytest.mark.parametrize("delimiter", ['"', "'", "`"])
@pytest.mark.parametrize("content_block", [False, True])
def test_backslash_escaped_delimiters_keep_the_full_sandbox_path(delimiter, content_block):
path = "/mnt/user-data/outputs/quarterly report (final).pdf"
text = f"log: \\{delimiter}{path}\\{delimiter} done"
message = ToolMessage(
content=[{"type": "text", "text": text}] if content_block else text,
tool_call_id="export-escaped",
name="export_report",
)
entries = extract_artifacts_from_result(message, thread_id="demo")
assert [entry["real_ref"] for entry in entries] == [path]
assert entries[0]["display_name"] == "quarterly report (final).pdf"
@pytest.mark.parametrize("escaped", [False, True])
def test_delimited_sandbox_path_keeps_a_literal_trailing_backslash(escaped):
path = "/mnt/user-data/outputs/report \\"
delimiter = '\\"' if escaped else '"'
assert [ref["ref"] for ref in _detect_refs_in_text(f"Created {delimiter}{path}{delimiter}.")] == [path]
def test_json_escaped_path_deduplicates_against_a_structured_reference():
path = "/mnt/user-data/outputs/quarterly report.pdf"
message = ToolMessage(
content=json.dumps(f'Created "{path}" successfully.'),
tool_call_id="export-json",
name="export_report",
artifact={"structured_content": {"path": path}},
)
entries = extract_artifacts_from_result(message, thread_id="demo")
assert [entry["real_ref"] for entry in entries] == [path]
def test_escaped_sandbox_path_requires_a_matching_escaped_closer():
assert _detect_refs_in_text(r'Created \"/mnt/user-data/outputs/quarterly report.pdf" successfully.') == []
_LINE_BREAKS = ["\n", "\r", "\r\n", "\v", "\f", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029"]
_CJK_QUOTE_PAIRS = [("“", "”"), ("‘", "’")]
@pytest.mark.parametrize("separator", [*_LINE_BREAKS, "\t"])
@pytest.mark.parametrize(("opener", "closer"), _CJK_QUOTE_PAIRS)
def test_delimited_sandbox_paths_reject_line_breaks_and_tabs(separator, opener, closer):
path = f"/mnt/user-data/outputs/quarterly{separator}report.pdf"
assert _detect_refs_in_text(f"Created {opener}{path}{closer}.") == []
@pytest.mark.parametrize("separator", _LINE_BREAKS)
def test_unterminated_path_does_not_swallow_a_reference_on_the_next_line(separator):
valid = "/mnt/user-data/outputs/next.pdf"
text = f'Created "/mnt/user-data/outputs/unfinished report.pdf{separator}Saved {valid}"'
assert [ref["ref"] for ref in _detect_refs_in_text(text)] == [valid]
@pytest.mark.parametrize("delimiter", ['"', "'", "`"])
@pytest.mark.parametrize("escape_count", [3])
@pytest.mark.parametrize(("path", "display"), [("/mnt/user-data/outputs/a b.pdf", "a b.pdf"), (r"/mnt/user-data/outputs/folder\name report.pdf", "name report.pdf")])
def test_escaped_quote_wrappers_do_not_append_backslashes(delimiter, escape_count, path, display):
marker = "\\" * escape_count + delimiter
refs = _detect_refs_in_text(f"log: {marker}{path}{marker} done")
assert [ref["ref"] for ref in refs] == [path]
assert refs[0]["display"] == display
@pytest.mark.parametrize(("opener", "closer"), [("“", "”"), ("‘", "’")])
def test_cjk_quote_pairs_keep_the_entire_sandbox_path(opener, closer):
path = "/mnt/user-data/outputs/输出 报告(最终).pdf"
refs = _detect_refs_in_text(f"已创建 {opener}{path}{closer},完成。")
assert [ref["ref"] for ref in refs] == [path]
assert refs[0]["display"] == "输出 报告(最终).pdf"
@pytest.mark.parametrize("opener", ["“", "‘"])
def test_unterminated_cjk_quotes_do_not_register_prefixes(opener):
assert _detect_refs_in_text(f"已创建 {opener}/mnt/user-data/outputs/输出 报告.pdf") == []
def test_even_backslashes_before_a_quote_preserve_literal_path_backslashes():
prefix = "\\" * 2
path = "/mnt/user-data/outputs/a b\\"
assert [ref["ref"] for ref in _detect_refs_in_text(f'{prefix}"{path}"')] == [path]
@pytest.mark.parametrize(("opener", "closer"), [('\\"', '\\"'), ("“", "”"), ("‘", "’")])
@pytest.mark.parametrize("text_block", [False, True])
def test_review_quote_wrappers_deduplicate_structured_refs_and_keep_order(opener, closer, text_block):
first = "/mnt/user-data/outputs/输出 报告.pdf"
second = "/mnt/user-data/outputs/second report.csv"
text = f"Created {opener}{first}{closer}, then {opener}{second}{closer}, then {opener}{first}{closer}."
message = ToolMessage(
content=[{"type": "text", "text": text}] if text_block else text,
artifact={"structured_content": {"path": first}},
tool_call_id="export-review",
name="export_report",
)
entries = extract_artifacts_from_result(message, thread_id="demo")
assert [entry["real_ref"] for entry in entries] == [first, second]
assert [entry["display_name"] for entry in entries] == ["输出 报告.pdf", "second report.csv"]
assert len({entry["handle"] for entry in entries}) == 2
@pytest.mark.parametrize("text_block", [False, True])
def test_raw_json_text_preserves_a_quoted_sandbox_path(text_block):
path = "/mnt/user-data/outputs/quarterly report.pdf"
text = json.dumps({"message": f'Created "{path}" successfully.'})
message = ToolMessage(content=[{"type": "text", "text": text}] if text_block else text, tool_call_id="export-json", name="export_report")
entries = extract_artifacts_from_result(message, thread_id="demo")
assert [entry["real_ref"] for entry in entries] == [path]
assert entries[0]["display_name"] == "quarterly report.pdf"