fix(task-deeplink): 共享任务对话的 md/产物按对话创建者解析路径,修复切换账号后找不到文件
xdfx/rwfx/3qfx 任务深链对话按 metadata.taskId 全局共享、不区分用户,但生成的
md/artifacts 与上传文件物理存在 users/{user_id}/threads/{thread_id}/ 下,user_id
之前取当前登录账号。导致 A 账号生成的文件,B 账号打开同一共享对话时后端去
users/B/ 找 → 404「找不到」。
新增 deerflow/runtime/thread_paths.py(resolve_path_user_id /
aresolve_path_user_id):带 taskId 的共享对话统一解析到对话创建者
(threads_meta.user_id),个人对话仍走当前登录(行为不变)。在所有线程文件路径关口替换:
写入源 ThreadDataMiddleware、present_files、uploads(manager+middleware+router)、
产物读写(path_utils → artifacts/skills router)、嵌入式 client.get_artifact。
正向结果进程内缓存;无 DB/内存态/无行均安全回退到当前登录。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
13ef1942f2
commit
23e6685977
11 files changed
+194
-24
No files matched your search
@@ -459,6 +459,7 @@ Operators upgrading an existing DB run the whole file (`USE <db>;` then execute)
|
|||||||
- Physical: `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/...`, `deer-flow/skills/`
|
- Physical: `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/...`, `deer-flow/skills/`
|
||||||
- Translation: `replace_virtual_path()` / `replace_virtual_paths_in_command()`
|
- Translation: `replace_virtual_path()` / `replace_virtual_paths_in_command()`
|
||||||
- Detection: `is_local_sandbox()` checks `sandbox_id == "local"`
|
- Detection: `is_local_sandbox()` checks `sandbox_id == "local"`
|
||||||
|
- **`{user_id}` for path resolution = `resolve_path_user_id(thread_id)`, NOT the current login.** Task-deeplink conversations (rwfx/3qfx/xdfx, `threads_meta.metadata.taskId`) are shared across all users (see the `taskId` escape branches in `thread_meta/sql.py`), but generated files/uploads live under a *per-user* dir. Resolving by current login meant account B opening a shared task conversation 404'd on the md/artifacts account A generated ("切换账号后生成的文件找不到"). `deerflow/runtime/thread_paths.py` (`resolve_path_user_id` sync / `aresolve_path_user_id` async) resolves a task-shared thread to its **creator** (`threads_meta.user_id`), so every account reads/writes the same physical directory; personal threads resolve to the current login unchanged (no behavior change). Wired at every thread-path seam: write source `ThreadDataMiddleware.before_agent` (drives all sandbox writes via `thread_data` paths), `present_file_tool`, `uploads/manager.get_uploads_dir` + `uploads_middleware` + uploads router, artifact serving (`path_utils.{a,}resolve_thread_virtual_path` → artifacts/skills routers), embedded `client.get_artifact`. Positive (taskId→creator) results are cached process-wide (immutable mapping); any failure / memory-mode store / missing row falls back to the current login. **Caveat**: files a *non-creator* wrote into a shared thread before this fix stay orphaned under that writer's dir (no data loss); all new writes consolidate under the creator. Tests: `tests/test_thread_paths_owner.py`.
|
||||||
|
|
||||||
**Sandbox Tools** (in `packages/harness/deerflow/sandbox/tools.py`):
|
**Sandbox Tools** (in `packages/harness/deerflow/sandbox/tools.py`):
|
||||||
- `bash` - Execute commands with path translation and error handling
|
- `bash` - Execute commands with path translation and error handling
|
||||||
|
|||||||
@@ -5,16 +5,21 @@ from pathlib import Path
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.thread_paths import aresolve_path_user_id, resolve_path_user_id
|
||||||
|
|
||||||
|
|
||||||
def resolve_thread_virtual_path(thread_id: str, virtual_path: str) -> Path:
|
def resolve_thread_virtual_path(thread_id: str, virtual_path: str, *, user_id: str | None = None) -> Path:
|
||||||
"""Resolve a virtual path to the actual filesystem path under thread user-data.
|
"""Resolve a virtual path to the actual filesystem path under thread user-data.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
thread_id: The thread ID.
|
thread_id: The thread ID.
|
||||||
virtual_path: The virtual path as seen inside the sandbox
|
virtual_path: The virtual path as seen inside the sandbox
|
||||||
(e.g., /mnt/user-data/outputs/file.txt).
|
(e.g., /mnt/user-data/outputs/file.txt).
|
||||||
|
user_id: Pre-resolved canonical user bucket. When ``None`` it is
|
||||||
|
resolved synchronously via :func:`resolve_path_user_id` (the
|
||||||
|
thread creator for task-shared threads, else the current
|
||||||
|
login). Async callers should pass the result of
|
||||||
|
:func:`aresolve_path_user_id` to avoid a blocking DB read.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The resolved filesystem path.
|
The resolved filesystem path.
|
||||||
@@ -22,8 +27,21 @@ def resolve_thread_virtual_path(thread_id: str, virtual_path: str) -> Path:
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: If the path is invalid or outside allowed directories.
|
HTTPException: If the path is invalid or outside allowed directories.
|
||||||
"""
|
"""
|
||||||
|
if user_id is None:
|
||||||
|
user_id = resolve_path_user_id(thread_id)
|
||||||
try:
|
try:
|
||||||
return get_paths().resolve_virtual_path(thread_id, virtual_path, user_id=get_effective_user_id())
|
return get_paths().resolve_virtual_path(thread_id, virtual_path, user_id=user_id)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
status = 403 if "traversal" in str(e) else 400
|
status = 403 if "traversal" in str(e) else 400
|
||||||
raise HTTPException(status_code=status, detail=str(e))
|
raise HTTPException(status_code=status, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def aresolve_thread_virtual_path(thread_id: str, virtual_path: str) -> Path:
|
||||||
|
"""Async variant of :func:`resolve_thread_virtual_path` for FastAPI handlers.
|
||||||
|
|
||||||
|
Resolves the canonical user bucket without blocking the event loop, so a
|
||||||
|
task-shared thread's artifacts resolve to the thread creator's directory
|
||||||
|
regardless of which account is currently logged in.
|
||||||
|
"""
|
||||||
|
user_id = await aresolve_path_user_id(thread_id)
|
||||||
|
return resolve_thread_virtual_path(thread_id, virtual_path, user_id=user_id)
|
||||||
@@ -10,9 +10,9 @@ from fastapi.responses import FileResponse, PlainTextResponse, Response
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.gateway.authz import require_permission
|
from app.gateway.authz import require_permission
|
||||||
from app.gateway.path_utils import resolve_thread_virtual_path
|
from app.gateway.path_utils import aresolve_thread_virtual_path
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.thread_paths import aresolve_path_user_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ async def list_artifacts(thread_id: str, request: Request) -> dict:
|
|||||||
返回 ``{ "thread_id", "files": [{ path, name, size_bytes, mime_type, modified_at }] }``,
|
返回 ``{ "thread_id", "files": [{ path, name, size_bytes, mime_type, modified_at }] }``,
|
||||||
目录不存在时 ``files`` 为空数组(非 404)。
|
目录不存在时 ``files`` 为空数组(非 404)。
|
||||||
"""
|
"""
|
||||||
outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=get_effective_user_id())
|
outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=await aresolve_path_user_id(thread_id))
|
||||||
return {"thread_id": thread_id, "files": collect_outputs_listing(outputs_dir)}
|
return {"thread_id": thread_id, "files": collect_outputs_listing(outputs_dir)}
|
||||||
|
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
|
|||||||
skill_file_path = path[: marker_pos + len(".skill")] # e.g., "mnt/user-data/outputs/my-skill.skill"
|
skill_file_path = path[: marker_pos + len(".skill")] # e.g., "mnt/user-data/outputs/my-skill.skill"
|
||||||
internal_path = path[marker_pos + len(skill_marker) :] # e.g., "SKILL.md"
|
internal_path = path[marker_pos + len(skill_marker) :] # e.g., "SKILL.md"
|
||||||
|
|
||||||
actual_skill_path = resolve_thread_virtual_path(thread_id, skill_file_path)
|
actual_skill_path = await aresolve_thread_virtual_path(thread_id, skill_file_path)
|
||||||
|
|
||||||
if not actual_skill_path.exists():
|
if not actual_skill_path.exists():
|
||||||
raise HTTPException(status_code=404, detail=f"Skill file not found: {skill_file_path}")
|
raise HTTPException(status_code=404, detail=f"Skill file not found: {skill_file_path}")
|
||||||
@@ -236,7 +236,7 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
|
|||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
return Response(content=content, media_type=mime_type or "application/octet-stream", headers=cache_headers)
|
return Response(content=content, media_type=mime_type or "application/octet-stream", headers=cache_headers)
|
||||||
|
|
||||||
actual_path = resolve_thread_virtual_path(thread_id, path)
|
actual_path = await aresolve_thread_virtual_path(thread_id, path)
|
||||||
|
|
||||||
logger.info(f"Resolving artifact path: thread_id={thread_id}, requested_path={path}, actual_path={actual_path}")
|
logger.info(f"Resolving artifact path: thread_id={thread_id}, requested_path={path}, actual_path={actual_path}")
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@ async def update_artifact(
|
|||||||
if ".skill/" in path:
|
if ".skill/" in path:
|
||||||
raise HTTPException(status_code=400, detail="Skill archive contents are read-only.")
|
raise HTTPException(status_code=400, detail="Skill archive contents are read-only.")
|
||||||
|
|
||||||
actual_path = resolve_thread_virtual_path(thread_id, path)
|
actual_path = await aresolve_thread_virtual_path(thread_id, path)
|
||||||
logger.info(f"Updating artifact path: thread_id={thread_id}, requested_path={path}, actual_path={actual_path}")
|
logger.info(f"Updating artifact path: thread_id={thread_id}, requested_path={path}, actual_path={actual_path}")
|
||||||
|
|
||||||
if not actual_path.exists():
|
if not actual_path.exists():
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from pydantic import BaseModel, Field
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
|
from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
|
||||||
from app.gateway.path_utils import resolve_thread_virtual_path
|
from app.gateway.path_utils import aresolve_thread_virtual_path
|
||||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.config.extensions_config import ExtensionsConfig, SkillDisplayConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
from deerflow.config.extensions_config import ExtensionsConfig, SkillDisplayConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
||||||
@@ -1058,7 +1058,7 @@ async def install_skill(
|
|||||||
target_category = body.target_category
|
target_category = body.target_category
|
||||||
if target_category == SkillCategory.PUBLIC and not is_admin:
|
if target_category == SkillCategory.PUBLIC and not is_admin:
|
||||||
raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
|
raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
|
||||||
skill_file_path = resolve_thread_virtual_path(body.thread_id, body.path)
|
skill_file_path = await aresolve_thread_virtual_path(body.thread_id, body.path)
|
||||||
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path, target_category=target_category)
|
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path, target_category=target_category)
|
||||||
try:
|
try:
|
||||||
from deerflow.skills.usage import mark_uploaded
|
from deerflow.skills.usage import mark_uploaded
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from app.gateway.authz import require_permission
|
|||||||
from app.gateway.deps import get_config
|
from app.gateway.deps import get_config
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.thread_paths import aresolve_path_user_id
|
||||||
from deerflow.sandbox.sandbox_provider import SandboxProvider, get_sandbox_provider
|
from deerflow.sandbox.sandbox_provider import SandboxProvider, get_sandbox_provider
|
||||||
from deerflow.uploads.manager import (
|
from deerflow.uploads.manager import (
|
||||||
PathTraversalError,
|
PathTraversalError,
|
||||||
@@ -186,7 +186,7 @@ async def upload_files(
|
|||||||
uploads_dir = ensure_uploads_dir(thread_id)
|
uploads_dir = ensure_uploads_dir(thread_id)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
sandbox_uploads = get_paths().sandbox_uploads_dir(thread_id, user_id=get_effective_user_id())
|
sandbox_uploads = get_paths().sandbox_uploads_dir(thread_id, user_id=await aresolve_path_user_id(thread_id))
|
||||||
uploaded_files = []
|
uploaded_files = []
|
||||||
written_paths = []
|
written_paths = []
|
||||||
sandbox_sync_targets = []
|
sandbox_sync_targets = []
|
||||||
@@ -308,7 +308,7 @@ async def list_uploaded_files(thread_id: str, request: Request) -> dict:
|
|||||||
enrich_file_listing(result, thread_id)
|
enrich_file_listing(result, thread_id)
|
||||||
|
|
||||||
# Gateway additionally includes the sandbox-relative path.
|
# Gateway additionally includes the sandbox-relative path.
|
||||||
sandbox_uploads = get_paths().sandbox_uploads_dir(thread_id, user_id=get_effective_user_id())
|
sandbox_uploads = get_paths().sandbox_uploads_dir(thread_id, user_id=await aresolve_path_user_id(thread_id))
|
||||||
for f in result["files"]:
|
for f in result["files"]:
|
||||||
f["path"] = str(sandbox_uploads / f["filename"])
|
f["path"] = str(sandbox_uploads / f["filename"])
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -10,7 +10,7 @@ from langgraph.runtime import Runtime
|
|||||||
|
|
||||||
from deerflow.agents.thread_state import ThreadDataState
|
from deerflow.agents.thread_state import ThreadDataState
|
||||||
from deerflow.config.paths import Paths, get_paths
|
from deerflow.config.paths import Paths, get_paths
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.thread_paths import resolve_path_user_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -89,7 +89,11 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]):
|
|||||||
if thread_id is None:
|
if thread_id is None:
|
||||||
raise ValueError("Thread ID is required in runtime context or config.configurable")
|
raise ValueError("Thread ID is required in runtime context or config.configurable")
|
||||||
|
|
||||||
user_id = get_effective_user_id()
|
# Task-shared threads (rwfx/3qfx/xdfx, metadata.taskId) resolve to the
|
||||||
|
# thread creator so every account that shares the conversation writes to
|
||||||
|
# the same physical user-data directory. Personal threads resolve to the
|
||||||
|
# current login unchanged.
|
||||||
|
user_id = resolve_path_user_id(thread_id)
|
||||||
|
|
||||||
if self._lazy_init:
|
if self._lazy_init:
|
||||||
# Lazy initialization: only compute paths, don't create directories
|
# Lazy initialization: only compute paths, don't create directories
|
||||||
|
|||||||
+3
-3
@@ -12,7 +12,7 @@ from langchain_core.messages import HumanMessage
|
|||||||
from langgraph.runtime import Runtime
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
from deerflow.config.paths import Paths, get_paths
|
from deerflow.config.paths import Paths, get_paths
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.thread_paths import resolve_path_user_id
|
||||||
from deerflow.utils.file_conversion import extract_outline
|
from deerflow.utils.file_conversion import extract_outline
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -198,7 +198,7 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
|||||||
source_path = self._paths.resolve_virtual_path(
|
source_path = self._paths.resolve_virtual_path(
|
||||||
source_thread_id,
|
source_thread_id,
|
||||||
virtual_path,
|
virtual_path,
|
||||||
user_id=get_effective_user_id(),
|
user_id=resolve_path_user_id(source_thread_id),
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.debug("Failed to resolve uploaded artifact path: %s", artifact_url, exc_info=True)
|
logger.debug("Failed to resolve uploaded artifact path: %s", artifact_url, exc_info=True)
|
||||||
@@ -294,7 +294,7 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
|||||||
thread_id = get_config().get("configurable", {}).get("thread_id")
|
thread_id = get_config().get("configurable", {}).get("thread_id")
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass # get_config() raises outside a runnable context (e.g. unit tests)
|
pass # get_config() raises outside a runnable context (e.g. unit tests)
|
||||||
uploads_dir = self._paths.sandbox_uploads_dir(thread_id, user_id=get_effective_user_id()) if thread_id else None
|
uploads_dir = self._paths.sandbox_uploads_dir(thread_id, user_id=resolve_path_user_id(thread_id)) if thread_id else None
|
||||||
|
|
||||||
# Get newly uploaded files from the current message's additional_kwargs.files
|
# Get newly uploaded files from the current message's additional_kwargs.files
|
||||||
new_files = self._files_from_kwargs(last_message, uploads_dir) or []
|
new_files = self._files_from_kwargs(last_message, uploads_dir) or []
|
||||||
|
|||||||
@@ -1178,7 +1178,9 @@ class DeerFlowClient:
|
|||||||
ValueError: If the path is invalid.
|
ValueError: If the path is invalid.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
actual = get_paths().resolve_virtual_path(thread_id, path, user_id=get_effective_user_id())
|
from deerflow.runtime.thread_paths import resolve_path_user_id
|
||||||
|
|
||||||
|
actual = get_paths().resolve_virtual_path(thread_id, path, user_id=resolve_path_user_id(thread_id))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
if "traversal" in str(exc):
|
if "traversal" in str(exc):
|
||||||
from deerflow.uploads.manager import PathTraversalError
|
from deerflow.uploads.manager import PathTraversalError
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Canonical owner resolution for a thread's filesystem paths.
|
||||||
|
|
||||||
|
Task-deeplink conversations (those whose ``threads_meta.metadata.taskId`` is
|
||||||
|
set — rwfx / 3qfx / xdfx) are **shared across all users**: any logged-in
|
||||||
|
account may open the same conversation (enforced by the ``taskId`` escape
|
||||||
|
branches in :mod:`deerflow.persistence.thread_meta.sql`). But the agent's
|
||||||
|
generated files (md / artifacts) and uploads live under a *per-user* directory::
|
||||||
|
|
||||||
|
{base_dir}/users/{user_id}/threads/{thread_id}/user-data/...
|
||||||
|
|
||||||
|
If that ``{user_id}`` were the **current login**, account B opening a shared
|
||||||
|
task conversation would resolve to an empty ``users/B/...`` directory and 404
|
||||||
|
on every file account A generated — which is exactly the "切换账号后生成的 md
|
||||||
|
文件找不到" bug.
|
||||||
|
|
||||||
|
To keep the *file* layer consistent with the *conversation-sharing* layer, all
|
||||||
|
path resolution for a task-shared thread must use one stable user bucket: the
|
||||||
|
thread's **creator** (``threads_meta.user_id``). That value is fixed at thread
|
||||||
|
creation and never changes, so every account resolves to the same physical
|
||||||
|
directory.
|
||||||
|
|
||||||
|
Personal (non-task) threads are unaffected: their creator is always the only /
|
||||||
|
current user, so :func:`resolve_path_user_id` returns exactly what
|
||||||
|
``get_effective_user_id`` would have. On any failure (no DB, missing row,
|
||||||
|
memory-mode store) it falls back to the current login, so behaviour degrades
|
||||||
|
safely to the pre-fix semantics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# thread_id -> creator user_id, populated only for task-shared threads. The
|
||||||
|
# (taskId, creator) pair is immutable for a thread, so positive results are
|
||||||
|
# cached forever; the fallback (current login) is never cached because a row may
|
||||||
|
# appear after the first lookup.
|
||||||
|
_owner_cache: dict[str, str] = {}
|
||||||
|
_owner_cache_lock = threading.Lock()
|
||||||
|
_OWNER_CACHE_MAX = 4096
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_get(thread_id: str) -> str | None:
|
||||||
|
with _owner_cache_lock:
|
||||||
|
return _owner_cache.get(thread_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_put(thread_id: str, owner: str) -> None:
|
||||||
|
with _owner_cache_lock:
|
||||||
|
# Crude bound: clear wholesale rather than track LRU order. The mapping
|
||||||
|
# is tiny and immutable, so a rare full clear just re-warms lazily.
|
||||||
|
if len(_owner_cache) >= _OWNER_CACHE_MAX:
|
||||||
|
_owner_cache.clear()
|
||||||
|
_owner_cache[thread_id] = owner
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_task_owner(thread_id: str) -> str | None:
|
||||||
|
"""Return the creator user_id iff *thread_id* is a task-shared thread.
|
||||||
|
|
||||||
|
Returns ``None`` for personal threads, untracked threads, the memory-mode
|
||||||
|
store, or any DB error — callers then fall back to the current login.
|
||||||
|
"""
|
||||||
|
from deerflow.persistence.engine import get_session_factory
|
||||||
|
from deerflow.persistence.thread_meta import ThreadMetaRepository
|
||||||
|
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
if session_factory is None:
|
||||||
|
return None
|
||||||
|
store = ThreadMetaRepository(session_factory)
|
||||||
|
# user_id=None bypasses the owner filter so we can read any thread's row.
|
||||||
|
row = await store.get(thread_id, user_id=None)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
metadata = row.get("metadata") or {}
|
||||||
|
owner = row.get("user_id")
|
||||||
|
if metadata.get("taskId") and owner:
|
||||||
|
return str(owner)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_coro_blocking(coro):
|
||||||
|
"""Run an async coroutine to completion from sync code.
|
||||||
|
|
||||||
|
Mirrors ``lead_agent.prompt._run_coro_blocking``: when already inside a
|
||||||
|
running event loop, hand the coroutine to a fresh loop in a worker thread;
|
||||||
|
otherwise run it directly.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop()
|
||||||
|
in_loop = True
|
||||||
|
except RuntimeError:
|
||||||
|
in_loop = False
|
||||||
|
|
||||||
|
if in_loop:
|
||||||
|
import concurrent.futures
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||||
|
return pool.submit(asyncio.run, coro).result()
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_path_user_id(thread_id: str | None) -> str:
|
||||||
|
"""Canonical user bucket for *thread_id*'s files (sync).
|
||||||
|
|
||||||
|
For a task-shared thread this is the thread creator, so all accounts that
|
||||||
|
share the conversation read/write the same physical directory. For every
|
||||||
|
other case it is the current login (``get_effective_user_id``).
|
||||||
|
"""
|
||||||
|
if not thread_id:
|
||||||
|
return get_effective_user_id()
|
||||||
|
cached = _cache_get(thread_id)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
try:
|
||||||
|
owner = _run_coro_blocking(_fetch_task_owner(thread_id))
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to resolve task-shared owner for thread %s", thread_id, exc_info=True)
|
||||||
|
owner = None
|
||||||
|
if owner:
|
||||||
|
_cache_put(thread_id, owner)
|
||||||
|
return owner
|
||||||
|
return get_effective_user_id()
|
||||||
|
|
||||||
|
|
||||||
|
async def aresolve_path_user_id(thread_id: str | None) -> str:
|
||||||
|
"""Async variant of :func:`resolve_path_user_id` for FastAPI handlers."""
|
||||||
|
if not thread_id:
|
||||||
|
return get_effective_user_id()
|
||||||
|
cached = _cache_get(thread_id)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
try:
|
||||||
|
owner = await _fetch_task_owner(thread_id)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to resolve task-shared owner for thread %s", thread_id, exc_info=True)
|
||||||
|
owner = None
|
||||||
|
if owner:
|
||||||
|
_cache_put(thread_id, owner)
|
||||||
|
return owner
|
||||||
|
return get_effective_user_id()
|
||||||
+2
-2
@@ -10,7 +10,7 @@ from langgraph.typing import ContextT
|
|||||||
|
|
||||||
from deerflow.agents.thread_state import ThreadState
|
from deerflow.agents.thread_state import ThreadState
|
||||||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
|
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.thread_paths import resolve_path_user_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ def _normalize_presented_filepath(
|
|||||||
|
|
||||||
if stripped == virtual_prefix or stripped.startswith(virtual_prefix + "/"):
|
if stripped == virtual_prefix or stripped.startswith(virtual_prefix + "/"):
|
||||||
try:
|
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_path_user_id(thread_id))
|
||||||
except TypeError:
|
except TypeError:
|
||||||
actual_path = get_paths().resolve_virtual_path(thread_id, filepath)
|
actual_path = get_paths().resolve_virtual_path(thread_id, filepath)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
|||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
|
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.thread_paths import resolve_path_user_id
|
||||||
|
|
||||||
|
|
||||||
class PathTraversalError(ValueError):
|
class PathTraversalError(ValueError):
|
||||||
@@ -40,7 +40,7 @@ def validate_thread_id(thread_id: str) -> None:
|
|||||||
def get_uploads_dir(thread_id: str) -> Path:
|
def get_uploads_dir(thread_id: str) -> Path:
|
||||||
"""Return the uploads directory path for a thread (no side effects)."""
|
"""Return the uploads directory path for a thread (no side effects)."""
|
||||||
validate_thread_id(thread_id)
|
validate_thread_id(thread_id)
|
||||||
return get_paths().sandbox_uploads_dir(thread_id, user_id=get_effective_user_id())
|
return get_paths().sandbox_uploads_dir(thread_id, user_id=resolve_path_user_id(thread_id))
|
||||||
|
|
||||||
|
|
||||||
def ensure_uploads_dir(thread_id: str) -> Path:
|
def ensure_uploads_dir(thread_id: str) -> Path:
|
||||||
|
|||||||
Reference in new issue
Block a user