fix: 按用户隔离 Web 智能助手临时附件

This commit is contained in:
qingyukb
2026-10-31 17:04:21 +08:00
parent 142f41cd3d
commit aea8cf11c4
8 changed files with 127 additions and 26 deletions

View File

@@ -235,15 +235,20 @@ def _build_web_agent_sse(
)
async def download_web_agent_file(
file_id: str,
_: _SchemaTokenPayload = Depends(verify_resource_token),
token_payload: _SchemaTokenPayload = Depends(verify_resource_token),
) -> FileResponse:
"""
下载 Web 智能助手本轮生成的临时附件。
:param file_id: 附件随机标识
:param token_payload: 当前资源令牌中的登录用户身份
:return: 附件文件响应
"""
file_info = web_agent_application.get_web_agent_registered_file(f"message/agent/file/{file_id}")
owner_id = str(token_payload.sub) if token_payload.sub is not None else ""
file_info = web_agent_application.get_web_agent_registered_file(
f"message/agent/file/{file_id}",
owner_id=owner_id,
)
if not file_info:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在或已过期")
@@ -299,6 +304,7 @@ async def upload_web_agent_file(
file_name=safe_name,
kind=web_agent_application.guess_web_agent_attachment_kind(mime_type),
mime_type=mime_type,
owner_id=str(current_user.id),
)
if not attachment:
target_path.unlink(missing_ok=True)

View File

@@ -12,6 +12,7 @@ import uuid
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime
from functools import partial
from pathlib import Path
from queue import Empty, Queue
from typing import (
@@ -796,6 +797,8 @@ def register_web_agent_file(
file_name: Optional[str] = None,
kind: Optional[str] = None,
mime_type: Optional[str] = None,
*,
owner_id: str,
) -> Optional[dict[str, Any]]:
"""
注册 Web Agent 本地附件并返回前端可访问的短期下载地址。
@@ -804,9 +807,11 @@ def register_web_agent_file(
:param file_name: 前端展示文件名
:param kind: 附件展示类型
:param mime_type: 已知 MIME 类型
:param owner_id: 创建附件的用户 ID
:return: 前端附件描述,文件不可访问时返回 None
"""
if not file_path:
normalized_owner_id = str(owner_id).strip()
if not file_path or not normalized_owner_id:
return None
try:
resolved_path = Path(file_path).expanduser().resolve(strict=True)
@@ -824,6 +829,7 @@ def register_web_agent_file(
"path": resolved_path,
"name": display_name,
"mime_type": resolved_mime_type or "application/octet-stream",
"owner_id": normalized_owner_id,
"created_at": time.time(),
}
return {
@@ -979,22 +985,24 @@ async def prepare_web_agent_audio_attachment_path_async(voice_path: str) -> Path
return source_path
def get_web_agent_registered_file(ref: str) -> Optional[dict[str, Any]]:
def get_web_agent_registered_file(ref: str, *, owner_id: str) -> Optional[dict[str, Any]]:
"""
根据前端附件引用读取 WebAgent 临时文件登记信息。
根据前端附件引用读取当前用户登记的 WebAgent 临时文件。
:param ref: message/agent/file/{file_id} 形式的短期引用
:return: 文件登记信息,引用无效或过期时返回 None
:param owner_id: 请求读取附件的用户 ID
:return: 文件登记信息,引用无效、过期或不属于当前用户时返回 None
"""
normalized_owner_id = str(owner_id).strip()
normalized_ref = (ref or "").strip()
prefix = "message/agent/file/"
if not normalized_ref.startswith(prefix):
if not normalized_owner_id or not normalized_ref.startswith(prefix):
return None
cleanup_web_agent_file_registry()
file_id = normalized_ref[len(prefix) :].split("/", 1)[0]
file_info = _WEB_AGENT_FILE_REGISTRY.get(file_id)
if not file_info:
if not file_info or file_info.get("owner_id") != normalized_owner_id:
return None
file_path = Path(file_info["path"])
if not file_path.exists() or not file_path.is_file():
@@ -1005,13 +1013,19 @@ def get_web_agent_registered_file(ref: str) -> Optional[dict[str, Any]]:
def resolve_web_agent_audio_refs(
audio_refs: list[str],
*,
owner_id: str,
) -> list[tuple[str, Path, str]]:
"""
在调用协程中解析音频引用,返回不依赖登记表的文件快照。
在调用协程中解析当前用户的音频引用,返回不依赖登记表的文件快照。
:param audio_refs: 前端提交的临时音频引用
:param owner_id: 请求转写音频的用户 ID
:return: 已解析的引用、文件路径和文件名
"""
audio_files = []
for audio_ref in audio_refs:
file_info = get_web_agent_registered_file(audio_ref)
file_info = get_web_agent_registered_file(audio_ref, owner_id=owner_id)
if not file_info:
logger.warning("WebAgent 语音引用不存在或已过期: ref=%s", audio_ref)
continue
@@ -1054,11 +1068,19 @@ def transcribe_web_agent_audio_files(
return "\n".join(transcripts).strip() if transcripts else None
async def transcribe_web_agent_audio_input(audio_refs: list[str]) -> Optional[str]:
async def transcribe_web_agent_audio_input(
audio_refs: list[str],
*,
owner_id: str,
) -> Optional[str]:
"""
解析并在线程池中转写 WebAgent 音频引用。
解析并在线程池中转写当前用户的 WebAgent 音频引用。
:param audio_refs: 前端提交的临时音频引用
:param owner_id: 请求转写音频的用户 ID
:return: 合并后的转写文本,没有可转写内容时返回 None
"""
audio_files = resolve_web_agent_audio_refs(audio_refs)
audio_files = resolve_web_agent_audio_refs(audio_refs, owner_id=owner_id)
if not audio_files:
return None
return await asyncio.to_thread(transcribe_web_agent_audio_files, audio_files)
@@ -1162,12 +1184,15 @@ def resolve_web_agent_choice_payload(callback_data: str, user_id: str) -> Option
def build_web_agent_message_events(
message: Message,
*,
owner_id: str,
prepared_audio_path: Optional[Path] = None,
) -> list[dict[str, Any]]:
"""
将 Agent 工具通知转换为 Web SSE 事件。
将 Agent 工具通知转换为当前用户的 Web SSE 事件。
:param message: 工具产生的通知消息
:param owner_id: 当前接收通知的用户 ID
:param prepared_audio_path: 已准备好的语音文件路径
:return: 前端可直接应用到当前助手消息的事件列表
"""
events = []
@@ -1184,7 +1209,12 @@ def build_web_agent_message_events(
image_path = Path(image_ref).expanduser()
attachment = None
if not image_ref.startswith(("http://", "https://", "data:", "blob:")):
attachment = register_web_agent_file(image_ref, file_name=Path(image_ref).name, kind="image")
attachment = register_web_agent_file(
image_ref,
file_name=Path(image_ref).name,
kind="image",
owner_id=owner_id,
)
if not attachment:
attachment = build_web_agent_url_attachment(
image_ref,
@@ -1200,6 +1230,7 @@ def build_web_agent_message_events(
file_name=audio_path.name,
kind="audio",
mime_type=get_web_agent_audio_mime_type(audio_path),
owner_id=owner_id,
)
if attachment:
events.append({"type": "attachment", "attachment": attachment})
@@ -1208,6 +1239,7 @@ def build_web_agent_message_events(
attachment = register_web_agent_file(
message.file_path,
file_name=message.file_name or Path(message.file_path).name,
owner_id=owner_id,
)
if attachment:
events.append({"type": "attachment", "attachment": attachment})
@@ -1217,15 +1249,22 @@ def build_web_agent_message_events(
async def build_web_agent_message_events_async(
message: Message,
*,
owner_id: str,
) -> list[dict[str, Any]]:
"""
异步构造 WebAgent 通知,确保语音转码不占用事件循环。
异步构造当前用户的 WebAgent 通知,确保语音转码不占用事件循环。
:param message: 工具产生的通知消息
:param owner_id: 当前接收通知的用户 ID
:return: 前端可直接应用到当前助手消息的事件列表
"""
prepared_audio_path = None
if message.voice_path:
prepared_audio_path = await prepare_web_agent_audio_attachment_path_async(message.voice_path)
return build_web_agent_message_events(
message,
owner_id=owner_id,
prepared_audio_path=prepared_audio_path,
)
@@ -1460,7 +1499,7 @@ async def collect_web_agent_traditional_events(
if not web_agent_events.is_web_agent_message_for_user(message, user_id):
continue
events.extend(await build_web_agent_message_events_async(message))
events.extend(await build_web_agent_message_events_async(message, owner_id=user_id))
idle_deadline = time.monotonic() + WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS
return events
finally:
@@ -1684,7 +1723,10 @@ async def build_web_agent_stream(
submit_web_agent_steering,
)
transcript = await transcribe_web_agent_audio_input(command.audio_refs)
transcript = await transcribe_web_agent_audio_input(
command.audio_refs,
owner_id=str(current_user.id),
)
prompt = merge_web_agent_prompt_with_transcript(prompt, transcript)
display_prompt = merge_web_agent_prompt_with_transcript(display_prompt, transcript)
if not prompt and command.audio_refs and not command.images and not command.files:
@@ -1722,7 +1764,10 @@ async def build_web_agent_stream(
apply_display_event=apply_web_agent_display_event,
build_display_message=build_web_agent_display_message,
build_input_attachments=build_web_agent_input_attachments,
build_message_events_async=build_web_agent_message_events_async,
build_message_events_async=partial(
build_web_agent_message_events_async,
owner_id=str(current_user.id),
),
save_display_snapshot=save_web_agent_display_snapshot,
split_output=split_web_agent_output,
create_background_task=create_web_agent_background_task,

View File

@@ -449,6 +449,7 @@ Emby、Jellyfin、Plex、Navidrome 的目录、条目、搜索、季集和活动
- `agent_task` 更新的 `enabled:null` 保留原启用状态,仅重命名暂停任务不会恢复调度。MCP 工具发现和 Google 模型发现会读取全部分页;重复游标或超过分页上限时报告失败。
- `agent_task` 创建时固定真实执行账号;渠道入口须唯一绑定启用账号。后台查询与管理员操作沿用同一账号权限,身份失效记录为失败。升级前的任务须从原会话 `update` 确认绑定或重新创建,详见[自主任务执行账号](agent-execution.md#自主任务执行账号)。
- 传统消息会话按渠道、配置接入源与原始用户 ID 隔离,停止、清除、状态和确认操作使用同一范围。WebAgent 后台完成追加本轮展示历史,前端完整快照仍按原合同替换。
- Web 智能助手的临时附件按创建者账号隔离;下载、语音转写和附件事件只接受同一账号登记的引用,跨账号访问按不存在处理。
- Agent 文件工具的禁止覆盖创建使用排他提交;同目标并发版本修改只允许仍匹配原 SHA 的请求提交。冲突后重新读取当前文件再编辑。`view_image` 使用只读权限,普通用户可读取允许范围内的公共技能图片。
- 安装市场技能保留用户明确选择的 GitHub 分支,该分支不可用时不改装默认分支。Agent 运行时人格文件变化使缓存失效;关闭 Agent 配置会通过能力 owner 停止服务。
- 订阅身份包含用户名、媒体类型、来源、媒体 ID、季号、剧集组和音乐实体类型。同编号的电影与电视剧可分别订阅;按来源定位时保留剧集组。同用户并发创建同一目标会复用真实订阅 ID。

View File

@@ -373,6 +373,7 @@ token exchange downloads retain their existing credential rules.
- `agent_task` pins a real execution account at creation. Channel senders must uniquely bind to an active MoviePilot user. Background API calls retain that account's permissions, and invalid identity fails the run. Confirm legacy tasks with `update` in their original conversation or recreate them; never substitute a same-name account or fall back to the superuser.
- Traditional message sessions are scoped by channel, configured source, and user ID. A stop, clear, status query, or confirmation targets the current source's session.
- Background WebAgent completion appends the current turn to display history. Frontend complete snapshots keep their existing replacement contract.
- WebAgent temporary attachments are scoped to their creating account. Download, voice transcription, and attachment events accept only references registered to the current account; cross-account access is treated as missing.
- File creation with `overwrite=false` rejects a concurrent creator. Versioned edits reject a peer's intervening write; reread the file and use its current SHA before retrying.
- Market skill installation preserves an explicitly selected GitHub branch. If that branch is unavailable, treat installation as failed instead of silently switching branches.

View File

@@ -12,6 +12,7 @@ import sys
import zipfile
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from functools import partial
from pathlib import Path
from threading import Barrier
from types import MethodType, SimpleNamespace
@@ -687,7 +688,7 @@ async def test_webagent_background_turns_preserve_display_history(tmp_path, monk
bind_user_session=lambda *_args: None, apply_display_event=application.apply_web_agent_display_event,
build_display_message=application.build_web_agent_display_message,
build_input_attachments=application.build_web_agent_input_attachments,
build_message_events_async=application.build_web_agent_message_events_async,
build_message_events_async=partial(application.build_web_agent_message_events_async, owner_id="1"),
save_display_snapshot=application.save_web_agent_display_snapshot,
split_output=application.split_web_agent_output, create_background_task=create_task,
source="web-agent", heartbeat_seconds=1)

View File

@@ -206,7 +206,7 @@ def test_global_mutations_and_agent_file_access_keep_privilege_boundaries():
assert _dependency_of(transfer_endpoint.remove_queue, "_") is get_current_active_manage_user
assert _dependency_of(history_endpoint.delete_download_history, "_") is get_current_active_manage_user
assert _dependency_of(message_endpoint.clear_notification_message, "_") is get_current_active_superuser
assert _dependency_of(agent_endpoint.download_web_agent_file, "_") is verify_resource_token
assert _dependency_of(agent_endpoint.download_web_agent_file, "token_payload") is verify_resource_token
def test_manage_page_endpoints_accept_manage_permission():

View File

@@ -248,6 +248,37 @@ def test_upload_denies_foreign_session_without_saving_file(monkeypatch):
save.assert_not_awaited()
def test_web_agent_file_download_is_scoped_to_owner(tmp_path):
"""
Web Agent 临时附件只能由登记它的用户下载,旧无归属记录同样不可访问。
"""
private_file = tmp_path / "private.txt"
private_file.write_text("private attachment", encoding="utf-8")
attachment = web_agent.register_web_agent_file(str(private_file), owner_id="1")
assert attachment is not None
file_id = attachment["url"].rsplit("/", 1)[-1]
app = FastAPI()
app.include_router(agent_endpoint.router, prefix="/agent")
app.dependency_overrides[agent_endpoint.verify_resource_token] = lambda: SimpleNamespace(sub=1)
try:
with TestClient(app) as client:
own_response = client.get(f"/agent/file/{file_id}")
app.dependency_overrides[agent_endpoint.verify_resource_token] = lambda: SimpleNamespace(sub=2)
foreign_response = client.get(f"/agent/file/{file_id}")
web_agent._WEB_AGENT_FILE_REGISTRY[file_id].pop("owner_id")
app.dependency_overrides[agent_endpoint.verify_resource_token] = lambda: SimpleNamespace(sub=1)
legacy_response = client.get(f"/agent/file/{file_id}")
finally:
web_agent._WEB_AGENT_FILE_REGISTRY.pop(file_id, None)
assert own_response.status_code == 200
assert own_response.content == b"private attachment"
assert foreign_response.status_code == 404
assert legacy_response.status_code == 404
@pytest.mark.parametrize("method,path", [
("GET", "/agent/sessions/web-agent:victim"),
("POST", "/agent/sessions/web-agent:victim/stop"),

View File

@@ -867,7 +867,8 @@ def test_build_web_agent_message_events_extracts_image():
title="海报",
text="已找到图片",
image="https://example.com/poster.jpg",
)
),
owner_id="1",
)
assert events == [
@@ -969,7 +970,8 @@ def test_build_web_agent_message_events_registers_local_file(tmp_path):
mtype=MessageType.Agent,
file_path=str(file_path),
file_name="report.txt",
)
),
owner_id="1",
)
assert len(events) == 1
@@ -980,6 +982,8 @@ def test_build_web_agent_message_events_registers_local_file(tmp_path):
assert attachment["mime_type"] == "text/plain"
assert attachment["size"] == 5
assert attachment["url"].startswith("message/agent/file/")
assert agent_application.get_web_agent_registered_file(attachment["url"], owner_id="1")
assert agent_application.get_web_agent_registered_file(attachment["url"], owner_id="2") is None
def test_build_web_agent_message_events_registers_voice_attachment(tmp_path):
@@ -995,7 +999,8 @@ def test_build_web_agent_message_events_registers_voice_attachment(tmp_path):
mtype=MessageType.Agent,
text="你好",
voice_path=str(voice_path),
)
),
owner_id="1",
)
assert len(events) == 2
@@ -1348,6 +1353,7 @@ def test_transcribe_web_agent_audio_files_reads_registered_upload(tmp_path):
"path": voice_path,
"name": "recording.webm",
"mime_type": "audio/webm",
"owner_id": "1",
"created_at": time.time(),
}
@@ -1362,12 +1368,21 @@ def test_transcribe_web_agent_audio_files_reads_registered_upload(tmp_path):
return_value="帮我推荐一部电影",
) as transcribe_audio,
):
audio_files = _resolve_web_agent_audio_refs(["message/agent/file/audio-test"])
audio_files = _resolve_web_agent_audio_refs(
["message/agent/file/audio-test"],
owner_id="1",
)
foreign_audio_files = _resolve_web_agent_audio_refs(
["message/agent/file/audio-test"],
owner_id="2",
)
_WEB_AGENT_FILE_REGISTRY.pop("audio-test", None)
transcript = _transcribe_web_agent_audio_files(audio_files)
finally:
_WEB_AGENT_FILE_REGISTRY.pop("audio-test", None)
assert len(audio_files) == 1
assert foreign_audio_files == []
assert transcript == "帮我推荐一部电影"
transcribe_audio.assert_called_once_with(
content=b"webm-bytes",
@@ -2603,7 +2618,8 @@ def test_build_web_agent_message_events_extracts_choice_card():
}
],
],
)
),
owner_id="1",
)
assert events == [