diff --git a/README.md b/README.md index ca9668339..b558ae5c0 100644 --- a/README.md +++ b/README.md @@ -373,7 +373,7 @@ collision-resistant directory-safe user IDs before accessing DeerFlow storage. The default DeerFlow service topology remains the Gateway-embedded runtime described above. -Gateway runs automatically enforce native delivery for artifacts created or modified under `/mnt/user-data/outputs`: `present_files` must present at least one output produced by the current run, and the terminal `run.delivery` receipt must be durably recorded. Runs that do not produce output artifacts keep ordinary conversational behavior. +Gateway runs automatically enforce native delivery for artifacts created or modified under `/mnt/user-data/outputs`: `present_files` must present at least one output produced by the current run, and the terminal `run.delivery` receipt must be durably recorded. Virtual artifact paths are resolved within the same authenticated user and thread scope that produced the output before the output-directory boundary is validated. Runs that do not produce output artifacts keep ordinary conversational behavior. DeerFlow's built-in custom events are available through both LangGraph streaming interfaces: native clients can continue subscribing to `stream_mode="custom"`, while callback-based integrations can consume the same payloads as `on_custom_event` records from `astream_events(version="v2")`. The callback event name matches the payload's `type` field. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 03ecc3812..cba186e19 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -793,7 +793,7 @@ that cannot tell sibling branches apart. 1. **Config-defined tools** - Resolved from `config.yaml` via `resolve_variable()` 2. **MCP tools** - From enabled MCP servers (lazy initialized, cached with resolved-path + content-signature invalidation) 3. **Built-in tools**: - - `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`) + - `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`); virtual paths use `resolve_runtime_user_id(runtime)` so validation resolves the same user-scoped outputs directory established by `ThreadDataMiddleware` - `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware, which preserves text fallback and adds `artifact.human_input` for Web UI Human Input Cards). Beyond free text and single choice, the request-side v2 protocol supports `fields` (structured form card collecting several values at once; field types: text/textarea/number/select/multi_select/checkbox/date, validated and normalized server-side in the middleware — invalid entries are dropped, unknown types degrade to `text`; a standalone multi-select question is a one-field form). Replies stay on the v1 response protocol (`text`/`option`): the form card submits a readable text summary - `view_image` - Read image as base64 (added only if model supports vision) - `setup_agent` - Bootstrap-only: persist a brand-new custom agent's `SOUL.md` and `config.yaml`. Bound only when `is_bootstrap=True`. diff --git a/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py b/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py index c091e01df..c69e8990e 100644 --- a/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py @@ -7,7 +7,7 @@ from langgraph.config import get_config from langgraph.types import Command from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths -from deerflow.runtime.user_context import get_effective_user_id +from deerflow.runtime.user_context import resolve_runtime_user_id from deerflow.tools.types import Runtime OUTPUTS_VIRTUAL_PREFIX = f"{VIRTUAL_PATH_PREFIX}/outputs" @@ -66,7 +66,7 @@ def _normalize_presented_filepath( if stripped == virtual_prefix or stripped.startswith(virtual_prefix + "/"): try: - actual_path = get_paths().resolve_virtual_path(thread_id, filepath, user_id=get_effective_user_id()) + actual_path = get_paths().resolve_virtual_path(thread_id, filepath, user_id=resolve_runtime_user_id(runtime)) except TypeError: actual_path = get_paths().resolve_virtual_path(thread_id, filepath) else: diff --git a/backend/tests/test_present_file_tool_core_logic.py b/backend/tests/test_present_file_tool_core_logic.py index 0c064b56b..8dac17ec0 100644 --- a/backend/tests/test_present_file_tool_core_logic.py +++ b/backend/tests/test_present_file_tool_core_logic.py @@ -3,6 +3,10 @@ import importlib from types import SimpleNamespace +import pytest + +from deerflow.config.paths import Paths + present_file_tool_module = importlib.import_module("deerflow.tools.builtins.present_file_tool") @@ -51,6 +55,34 @@ def test_present_files_keeps_virtual_outputs_path(tmp_path, monkeypatch): assert result.update["artifacts"] == ["/mnt/user-data/outputs/summary.json"] +@pytest.mark.no_auto_user +def test_present_files_uses_runtime_user_for_virtual_outputs_path(tmp_path, monkeypatch): + """A runtime user must resolve virtual output paths even without a request ContextVar.""" + paths = Paths(tmp_path) + user_id = "runtime-user" + thread_id = "thread-runtime-user" + outputs_dir = paths.sandbox_outputs_dir(thread_id, user_id=user_id) + outputs_dir.mkdir(parents=True) + (outputs_dir / "report.md").write_text("ok") + + monkeypatch.setattr(present_file_tool_module, "get_paths", lambda: paths) + runtime = SimpleNamespace( + state={"thread_data": {"outputs_path": str(outputs_dir)}}, + context={"thread_id": thread_id, "user_id": user_id}, + config={}, + ) + + result = present_file_tool_module.present_file_tool.func( + runtime=runtime, + filepaths=["/mnt/user-data/outputs/report.md"], + tool_call_id="tc-runtime-user", + ) + + assert result.update["artifacts"] == ["/mnt/user-data/outputs/report.md"] + assert result.update["messages"][0].content == "Successfully presented files" + assert not paths.sandbox_outputs_dir(thread_id, user_id="default").exists() + + def test_present_files_uses_config_thread_id_when_context_missing(tmp_path, monkeypatch): outputs_dir = tmp_path / "threads" / "thread-from-config" / "user-data" / "outputs" outputs_dir.mkdir(parents=True) diff --git a/backend/tests/test_present_files_e2e_user_isolation.py b/backend/tests/test_present_files_e2e_user_isolation.py new file mode 100644 index 000000000..eb6219ff6 --- /dev/null +++ b/backend/tests/test_present_files_e2e_user_isolation.py @@ -0,0 +1,75 @@ +"""End-to-end user-isolation regression coverage for ``present_files``.""" + +from __future__ import annotations + +import importlib +from pathlib import Path + +import pytest +from _agent_e2e_helpers import FakeToolCallingModel +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage +from langgraph.runtime import Runtime + +from deerflow.agents.factory import create_deerflow_agent +from deerflow.agents.middlewares.thread_data_middleware import ThreadDataMiddleware +from deerflow.agents.thread_state import ThreadState +from deerflow.config.paths import Paths +from deerflow.tools.builtins.present_file_tool import present_file_tool + +present_file_tool_module = importlib.import_module("deerflow.tools.builtins.present_file_tool") + + +def _build_present_files_graph(tmp_path: Path): + model = FakeToolCallingModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + { + "name": "present_files", + "args": {"filepaths": ["/mnt/user-data/outputs/report.md"]}, + "id": "call_present_file", + "type": "tool_call", + } + ], + ), + AIMessage(content="Presented the report."), + ] + ) + return create_deerflow_agent( + model, + tools=[present_file_tool], + middleware=[ThreadDataMiddleware(base_dir=str(tmp_path), lazy_init=True)], + state_schema=ThreadState, + system_prompt="Present the report file.", + ) + + +@pytest.mark.no_auto_user +def test_present_files_uses_runtime_user_through_real_agent_graph(tmp_path, monkeypatch): + """The real middleware and ToolNode must keep one user bucket without a ContextVar.""" + paths = Paths(tmp_path) + user_id = "runtime-user" + thread_id = "thread-present-e2e" + outputs_dir = paths.sandbox_outputs_dir(thread_id, user_id=user_id) + outputs_dir.mkdir(parents=True) + (outputs_dir / "report.md").write_text("report body") + + monkeypatch.setattr(present_file_tool_module, "get_paths", lambda: paths) + + runtime = Runtime(context={"thread_id": thread_id, "user_id": user_id}, store=None) + config = { + "configurable": {"thread_id": thread_id, "__pregel_runtime": runtime}, + "recursion_limit": 20, + } + final_state = _build_present_files_graph(tmp_path).invoke( + {"messages": [HumanMessage(content="Present the report")]}, + config=config, + ) + + assert final_state["thread_data"]["outputs_path"] == str(outputs_dir) + tool_messages = [message for message in final_state["messages"] if isinstance(message, ToolMessage)] + assert len(tool_messages) == 1 + assert tool_messages[0].content == "Successfully presented files" + assert final_state["artifacts"] == ["/mnt/user-data/outputs/report.md"] + assert not paths.sandbox_outputs_dir(thread_id, user_id="default").exists()