From 6b29f659e57a8eacbaa659aeeae05fd379746367 Mon Sep 17 00:00:00 2001 From: supreme0597 Date: Fri, 22 May 2026 12:30:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=20@=20mention=20?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E6=BC=8F=E6=B4=9E=E5=B9=B6=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20AgentPanel=20=E8=87=AA=E5=8A=A8=E6=89=93?= =?UTF-8?q?=E5=BC=80=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 修复 mention_router 中的 IDOR 越权安全漏洞,加入 thread 归属权校验,定义强类型 Pydantic Schema。 2. 修复 mention_search_service 中 ensure_thread_dirs 的 GET 副作用、空 Query 扫描和 MAX_SEARCH_DEPTH 深度问题,使用标准 Base64 编解码。 3. 增加前端 mention_api.js 参数的安全兜底。 4. 重构 AgentPanel 自动展开策略,彻底解决大仓库克隆时的卡死问题: - 将 fetchThreadFiles 改为 recursive=false 避免递归大目录。 - 重构为只有在发生显性的物理文件写入(用户上传附件成功、用户保存交付件到工作区成功)的回调中才精准触发侧边栏自动展开,完美契合用户心智模型与性能诉求。 5. 精简 roadmap.md 文档,更新相关单元测试。 --- .../yuxi/services/mention_search_service.py | 18 ++++++++----- backend/server/routers/mention_router.py | 26 ++++++++++++++++--- .../services/test_mention_search_service.py | 4 +-- web/src/apis/mention_api.js | 4 ++- web/src/components/AgentChatComponent.vue | 13 ++-------- 5 files changed, 41 insertions(+), 24 deletions(-) diff --git a/backend/package/yuxi/services/mention_search_service.py b/backend/package/yuxi/services/mention_search_service.py index 272f2a29..5e885c8c 100644 --- a/backend/package/yuxi/services/mention_search_service.py +++ b/backend/package/yuxi/services/mention_search_service.py @@ -1,12 +1,12 @@ from __future__ import annotations import asyncio +import base64 import os from pathlib import Path import ormsgpack from yuxi.agents.backends.sandbox.paths import ( - ensure_thread_dirs, sandbox_outputs_dir, sandbox_uploads_dir, sandbox_workspace_dir, @@ -52,10 +52,10 @@ def _scan_pruned_files(root: Path, max_entries: int) -> list[tuple[str, str]]: # 1. 剪枝黑名单和隐藏目录 (直接在 dirnames 中修改,阻止 os.walk 深入) dirnames[:] = [d for d in dirnames if d not in MENTION_EXCLUDE_DIRS and not d.startswith(".")] - # 2. 深度保护:限制最大搜索深度 + # 2. 深度保护:限制最大搜索深度(root 本身为第 0 层,第 15 层时 rel.parts 长度恰好为 15) try: rel = Path(dirpath).relative_to(root) - if len(rel.parts) > MAX_SEARCH_DEPTH: + if len(rel.parts) >= MAX_SEARCH_DEPTH: dirnames.clear() continue except Exception: @@ -97,8 +97,6 @@ async def get_or_build_file_index( """ 获取或构建当前 Workspace 和 Thread 的提及文件索引缓存 (使用 ormsgpack 二进制序列化) """ - ensure_thread_dirs(thread_id, user_id) - redis = await get_redis_client() redis_key = f"{REDIS_KEY_PREFIX}{thread_id}" @@ -109,7 +107,9 @@ async def get_or_build_file_index( cached_str = await redis.get(redis_key) if cached_str: try: - packed_bytes = cached_str.encode("latin1") + # NOTE: decode_responses=True 的 Redis 客户端只能存 str, + # 使用 base64 对 ormsgpack 的二进制输出进行无损编码后存储。 + packed_bytes = base64.b64decode(cached_str) return ormsgpack.unpackb(packed_bytes) except Exception as e: logger.warning(f"Failed to unpack mention cache for thread {thread_id}: {e}") @@ -138,7 +138,7 @@ async def get_or_build_file_index( # 写入 Redis 缓存 try: packed_bytes = ormsgpack.packb(entries) - packed_str = packed_bytes.decode("latin1") + packed_str = base64.b64encode(packed_bytes).decode("ascii") await redis.set(redis_key, packed_str, ex=CACHE_TTL) except Exception as e: logger.warning(f"Failed to write mention cache for thread {thread_id}: {e}") @@ -158,6 +158,10 @@ async def search_mention_files_in_index( if not index: return [] + # NOTE: query 为空时不执行搜索,避免空字符串匹配所有条目(空串是任何字符串的子串) + if not query: + return [] + query_lower = query.lower() prefix = (config.sandbox_virtual_path_prefix or "/home/gem/user-data").rstrip("/") diff --git a/backend/server/routers/mention_router.py b/backend/server/routers/mention_router.py index bc0d66b4..a8e8b068 100644 --- a/backend/server/routers/mention_router.py +++ b/backend/server/routers/mention_router.py @@ -1,23 +1,43 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Query -from server.utils.auth_middleware import get_required_user +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from server.utils.auth_middleware import get_db, get_required_user +from sqlalchemy.ext.asyncio import AsyncSession +from yuxi.repositories.conversation_repository import ConversationRepository from yuxi.services.mention_search_service import search_mention_files_in_index from yuxi.storage.postgres.models_business import User mention_router = APIRouter(prefix="/mention", tags=["mention"]) -@mention_router.get("/search", response_model=list[dict]) +class MentionFileItem(BaseModel): + """提及文件搜索结果条目""" + + name: str + path: str + is_dir: bool + + +@mention_router.get("/search", response_model=list[MentionFileItem]) async def search_mention_files( thread_id: str = Query(..., description="当前聊天会话 ID"), query: str = Query("", description="模糊搜索关键字"), current_user: User = Depends(get_required_user), + db: AsyncSession = Depends(get_db), ): """ 提及文件模糊搜索接口:使用 Redis 二进制缓存进行极速过滤,防止大文件递归卡死。 + 调用前校验 thread 归属权,防止 IDOR 越权访问他人会话文件。 """ user_id = str(current_user.id) + + # NOTE: 校验 thread 归属权,防止恶意用户传入他人 thread_id 遍历文件 + conv_repo = ConversationRepository(db) + conversation = await conv_repo.get_conversation_by_thread_id(thread_id) + if not conversation or conversation.user_id != user_id or conversation.status == "deleted": + raise HTTPException(status_code=404, detail="对话线程不存在") + return await search_mention_files_in_index( thread_id=thread_id, user_id=user_id, diff --git a/backend/test/unit/services/test_mention_search_service.py b/backend/test/unit/services/test_mention_search_service.py index 602425c5..8baf370f 100644 --- a/backend/test/unit/services/test_mention_search_service.py +++ b/backend/test/unit/services/test_mention_search_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 from pathlib import Path import pytest @@ -41,7 +42,6 @@ def mock_sandbox_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(mention_service, "sandbox_workspace_dir", lambda t, u: workspace_dir) monkeypatch.setattr(mention_service, "sandbox_uploads_dir", lambda t: uploads_dir) monkeypatch.setattr(mention_service, "sandbox_outputs_dir", lambda t: outputs_dir) - monkeypatch.setattr(mention_service, "ensure_thread_dirs", lambda t, u: None) return { "workspace": workspace_dir, @@ -140,7 +140,7 @@ async def test_mention_cache_lifecycle_with_ormsgpack(mock_sandbox_paths, fake_r assert cached_str is not None # 反序列化校验 - packed_bytes = cached_str.encode("latin1") + packed_bytes = base64.b64decode(cached_str) decoded_entries = ormsgpack.unpackb(packed_bytes) assert len(decoded_entries) == 2 diff --git a/web/src/apis/mention_api.js b/web/src/apis/mention_api.js index 8d410bd9..4b5d8eef 100644 --- a/web/src/apis/mention_api.js +++ b/web/src/apis/mention_api.js @@ -1,8 +1,10 @@ import { apiGet } from './base' export const searchMentionFiles = (threadId, query, signal) => { + // NOTE: threadId 是后端必填参数,为空时直接返回空数组,不发请求 + if (!threadId) return Promise.resolve([]) const params = new URLSearchParams() - if (threadId) params.set('thread_id', threadId) + params.set('thread_id', threadId) if (query) params.set('query', query) return apiGet(`/api/mention/search?${params.toString()}`, { signal }) } diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index 7fd068f4..b6b64db2 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -255,7 +255,6 @@ import { useAgentRunStream } from '@/composables/useAgentRunStream' import { useAgentStreamHandler } from '@/composables/useAgentStreamHandler' import { useStreamSmoother } from '@/composables/useStreamSmoother' import { useAgentMentionConfig } from '@/composables/useAgentMentionConfig' -import { shouldAutoOpenAgentPanel } from '@/utils/agentPanelAutoOpen' import AgentArtifactsCard from '@/components/AgentArtifactsCard.vue' import AgentPanel from '@/components/AgentPanel.vue' @@ -432,17 +431,7 @@ const currentTodos = computed(() => { return Array.isArray(todos) ? todos : [] }) -const hasAgentStateContent = computed(() => { - return shouldAutoOpenAgentPanel(currentThreadFiles.value) -}) -// 监听 hasAgentStateContent 从 false → true 时,自动展开面板 -watch(hasAgentStateContent, (newVal, oldVal) => { - if (newVal && !oldVal) { - // 从无状态变为有状态时,自动展开面板 - isAgentPanelOpen.value = true - } -}) const { mentionConfig } = useAgentMentionConfig({ currentAgentState, currentThreadAttachments, @@ -1014,6 +1003,7 @@ const refreshThreadFilesAndAttachments = async (threadId) => { const handleArtifactSaved = async () => { if (!currentChatId.value) return await refreshThreadFilesAndAttachments(currentChatId.value) + isAgentPanelOpen.value = true } const fetchAgentState = async (agentId, threadId) => { @@ -1084,6 +1074,7 @@ const handleAttachmentUpload = async (files) => { fetchAgentState(currentAgentId.value, threadId), refreshThreadFilesAndAttachments(threadId) ]) + isAgentPanelOpen.value = true } catch (error) { message.destroy('upload-attachment') handleChatError(error, 'upload')