feat: 修复 @ mention 搜索漏洞并重构优化 AgentPanel 自动打开策略
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 文档,更新相关单元测试。
This commit is contained in:
parent
d0c60d9d82
commit
6b29f659e5
@ -1,12 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import ormsgpack
|
import ormsgpack
|
||||||
from yuxi.agents.backends.sandbox.paths import (
|
from yuxi.agents.backends.sandbox.paths import (
|
||||||
ensure_thread_dirs,
|
|
||||||
sandbox_outputs_dir,
|
sandbox_outputs_dir,
|
||||||
sandbox_uploads_dir,
|
sandbox_uploads_dir,
|
||||||
sandbox_workspace_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 深入)
|
# 1. 剪枝黑名单和隐藏目录 (直接在 dirnames 中修改,阻止 os.walk 深入)
|
||||||
dirnames[:] = [d for d in dirnames if d not in MENTION_EXCLUDE_DIRS and not d.startswith(".")]
|
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:
|
try:
|
||||||
rel = Path(dirpath).relative_to(root)
|
rel = Path(dirpath).relative_to(root)
|
||||||
if len(rel.parts) > MAX_SEARCH_DEPTH:
|
if len(rel.parts) >= MAX_SEARCH_DEPTH:
|
||||||
dirnames.clear()
|
dirnames.clear()
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -97,8 +97,6 @@ async def get_or_build_file_index(
|
|||||||
"""
|
"""
|
||||||
获取或构建当前 Workspace 和 Thread 的提及文件索引缓存 (使用 ormsgpack 二进制序列化)
|
获取或构建当前 Workspace 和 Thread 的提及文件索引缓存 (使用 ormsgpack 二进制序列化)
|
||||||
"""
|
"""
|
||||||
ensure_thread_dirs(thread_id, user_id)
|
|
||||||
|
|
||||||
redis = await get_redis_client()
|
redis = await get_redis_client()
|
||||||
redis_key = f"{REDIS_KEY_PREFIX}{thread_id}"
|
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)
|
cached_str = await redis.get(redis_key)
|
||||||
if cached_str:
|
if cached_str:
|
||||||
try:
|
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)
|
return ormsgpack.unpackb(packed_bytes)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to unpack mention cache for thread {thread_id}: {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 缓存
|
# 写入 Redis 缓存
|
||||||
try:
|
try:
|
||||||
packed_bytes = ormsgpack.packb(entries)
|
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)
|
await redis.set(redis_key, packed_str, ex=CACHE_TTL)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to write mention cache for thread {thread_id}: {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:
|
if not index:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# NOTE: query 为空时不执行搜索,避免空字符串匹配所有条目(空串是任何字符串的子串)
|
||||||
|
if not query:
|
||||||
|
return []
|
||||||
|
|
||||||
query_lower = query.lower()
|
query_lower = query.lower()
|
||||||
|
|
||||||
prefix = (config.sandbox_virtual_path_prefix or "/home/gem/user-data").rstrip("/")
|
prefix = (config.sandbox_virtual_path_prefix or "/home/gem/user-data").rstrip("/")
|
||||||
|
|||||||
@ -1,23 +1,43 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from server.utils.auth_middleware import get_required_user
|
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.services.mention_search_service import search_mention_files_in_index
|
||||||
from yuxi.storage.postgres.models_business import User
|
from yuxi.storage.postgres.models_business import User
|
||||||
|
|
||||||
mention_router = APIRouter(prefix="/mention", tags=["mention"])
|
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(
|
async def search_mention_files(
|
||||||
thread_id: str = Query(..., description="当前聊天会话 ID"),
|
thread_id: str = Query(..., description="当前聊天会话 ID"),
|
||||||
query: str = Query("", description="模糊搜索关键字"),
|
query: str = Query("", description="模糊搜索关键字"),
|
||||||
current_user: User = Depends(get_required_user),
|
current_user: User = Depends(get_required_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
提及文件模糊搜索接口:使用 Redis 二进制缓存进行极速过滤,防止大文件递归卡死。
|
提及文件模糊搜索接口:使用 Redis 二进制缓存进行极速过滤,防止大文件递归卡死。
|
||||||
|
调用前校验 thread 归属权,防止 IDOR 越权访问他人会话文件。
|
||||||
"""
|
"""
|
||||||
user_id = str(current_user.id)
|
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(
|
return await search_mention_files_in_index(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import pytest
|
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_workspace_dir", lambda t, u: workspace_dir)
|
||||||
monkeypatch.setattr(mention_service, "sandbox_uploads_dir", lambda t: uploads_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, "sandbox_outputs_dir", lambda t: outputs_dir)
|
||||||
monkeypatch.setattr(mention_service, "ensure_thread_dirs", lambda t, u: None)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"workspace": workspace_dir,
|
"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
|
assert cached_str is not None
|
||||||
|
|
||||||
# 反序列化校验
|
# 反序列化校验
|
||||||
packed_bytes = cached_str.encode("latin1")
|
packed_bytes = base64.b64decode(cached_str)
|
||||||
decoded_entries = ormsgpack.unpackb(packed_bytes)
|
decoded_entries = ormsgpack.unpackb(packed_bytes)
|
||||||
assert len(decoded_entries) == 2
|
assert len(decoded_entries) == 2
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { apiGet } from './base'
|
import { apiGet } from './base'
|
||||||
|
|
||||||
export const searchMentionFiles = (threadId, query, signal) => {
|
export const searchMentionFiles = (threadId, query, signal) => {
|
||||||
|
// NOTE: threadId 是后端必填参数,为空时直接返回空数组,不发请求
|
||||||
|
if (!threadId) return Promise.resolve([])
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (threadId) params.set('thread_id', threadId)
|
params.set('thread_id', threadId)
|
||||||
if (query) params.set('query', query)
|
if (query) params.set('query', query)
|
||||||
return apiGet(`/api/mention/search?${params.toString()}`, { signal })
|
return apiGet(`/api/mention/search?${params.toString()}`, { signal })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -255,7 +255,6 @@ import { useAgentRunStream } from '@/composables/useAgentRunStream'
|
|||||||
import { useAgentStreamHandler } from '@/composables/useAgentStreamHandler'
|
import { useAgentStreamHandler } from '@/composables/useAgentStreamHandler'
|
||||||
import { useStreamSmoother } from '@/composables/useStreamSmoother'
|
import { useStreamSmoother } from '@/composables/useStreamSmoother'
|
||||||
import { useAgentMentionConfig } from '@/composables/useAgentMentionConfig'
|
import { useAgentMentionConfig } from '@/composables/useAgentMentionConfig'
|
||||||
import { shouldAutoOpenAgentPanel } from '@/utils/agentPanelAutoOpen'
|
|
||||||
import AgentArtifactsCard from '@/components/AgentArtifactsCard.vue'
|
import AgentArtifactsCard from '@/components/AgentArtifactsCard.vue'
|
||||||
import AgentPanel from '@/components/AgentPanel.vue'
|
import AgentPanel from '@/components/AgentPanel.vue'
|
||||||
|
|
||||||
@ -432,17 +431,7 @@ const currentTodos = computed(() => {
|
|||||||
return Array.isArray(todos) ? todos : []
|
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({
|
const { mentionConfig } = useAgentMentionConfig({
|
||||||
currentAgentState,
|
currentAgentState,
|
||||||
currentThreadAttachments,
|
currentThreadAttachments,
|
||||||
@ -1014,6 +1003,7 @@ const refreshThreadFilesAndAttachments = async (threadId) => {
|
|||||||
const handleArtifactSaved = async () => {
|
const handleArtifactSaved = async () => {
|
||||||
if (!currentChatId.value) return
|
if (!currentChatId.value) return
|
||||||
await refreshThreadFilesAndAttachments(currentChatId.value)
|
await refreshThreadFilesAndAttachments(currentChatId.value)
|
||||||
|
isAgentPanelOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchAgentState = async (agentId, threadId) => {
|
const fetchAgentState = async (agentId, threadId) => {
|
||||||
@ -1084,6 +1074,7 @@ const handleAttachmentUpload = async (files) => {
|
|||||||
fetchAgentState(currentAgentId.value, threadId),
|
fetchAgentState(currentAgentId.value, threadId),
|
||||||
refreshThreadFilesAndAttachments(threadId)
|
refreshThreadFilesAndAttachments(threadId)
|
||||||
])
|
])
|
||||||
|
isAgentPanelOpen.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.destroy('upload-attachment')
|
message.destroy('upload-attachment')
|
||||||
handleChatError(error, 'upload')
|
handleChatError(error, 'upload')
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user