mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-05 06:39:33 +08:00
fix(agent): harden browser session recovery
This commit is contained in:
@@ -651,14 +651,21 @@ class BrowserSessionHelper:
|
||||
"""
|
||||
if tab_index < 0 or tab_index >= len(session.pages):
|
||||
raise ValueError(f"标签页索引不存在: {tab_index}")
|
||||
active_index = session.active_index
|
||||
page = session.pages.pop(tab_index)
|
||||
close_error: Exception | None = None
|
||||
try:
|
||||
page.close()
|
||||
except Exception as err:
|
||||
close_error = err
|
||||
logger.warning(f"关闭浏览器标签页失败: {str(err)}")
|
||||
if not session.pages:
|
||||
session.pages.append(session.context.new_page())
|
||||
session.active_index = min(session.active_index, len(session.pages) - 1)
|
||||
elif tab_index < active_index:
|
||||
active_index -= 1
|
||||
session.active_index = min(active_index, len(session.pages) - 1)
|
||||
if close_error is not None:
|
||||
raise RuntimeError("关闭浏览器标签页失败") from close_error
|
||||
return BrowserSessionHelper.list_tabs(session)
|
||||
|
||||
@staticmethod
|
||||
@@ -962,6 +969,8 @@ class BrowserSessionHelper:
|
||||
script = f"""
|
||||
() => {{
|
||||
const limit = {int(max_elements)};
|
||||
document.querySelectorAll('[{cls.REF_ATTRIBUTE}]').forEach((el) =>
|
||||
el.removeAttribute('{cls.REF_ATTRIBUTE}'));
|
||||
const selector = [
|
||||
'a[href]',
|
||||
'button',
|
||||
|
||||
@@ -12,6 +12,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.adapters.network.browser import BrowserSessionHelper
|
||||
from app.agent.policy.contracts import ExecutionOutcome
|
||||
from app.agent.policy.sanitizer import summarize_error
|
||||
from app.agent.terminal.ownership import current_terminal_scope
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.result import inspect_tool_result
|
||||
@@ -22,6 +23,7 @@ from app.runtime.log import logger
|
||||
MAX_CONTENT_LENGTH = 12_000
|
||||
# 默认超时时间(秒)
|
||||
DEFAULT_TIMEOUT = 30
|
||||
MAX_TIMEOUT = 300
|
||||
# 截图最大宽度
|
||||
SCREENSHOT_MAX_WIDTH = 1280
|
||||
# 截图最大高度
|
||||
@@ -107,7 +109,8 @@ class BrowseWebpageInput(BaseModel):
|
||||
description="Content type for 'get_content' action: 'text' for readable text, 'html' for raw HTML",
|
||||
)
|
||||
timeout: Optional[int] = Field(
|
||||
DEFAULT_TIMEOUT, description="Timeout in seconds for the action (default: 30)"
|
||||
DEFAULT_TIMEOUT, ge=1, le=MAX_TIMEOUT,
|
||||
description="Timeout in seconds for the action (default: 30, range: 1-300)"
|
||||
)
|
||||
cookies: Optional[str] = Field(
|
||||
None,
|
||||
@@ -255,18 +258,26 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
)
|
||||
|
||||
try:
|
||||
if timeout is None or type(timeout) is not int or not 1 <= timeout <= MAX_TIMEOUT:
|
||||
return self._error_response(
|
||||
"invalid_timeout", f"timeout 必须是 1 到 {MAX_TIMEOUT} 秒的整数。",
|
||||
"修正 timeout 后重试,不要重复已经执行的浏览器动作。",
|
||||
)
|
||||
# 验证操作类型
|
||||
try:
|
||||
browser_action = BrowserAction(action)
|
||||
except ValueError:
|
||||
valid_actions = ", ".join([a.value for a in BrowserAction])
|
||||
return f"错误: 不支持的操作类型 '{action}',支持的操作: {valid_actions}"
|
||||
return self._error_response(
|
||||
"invalid_action", f"不支持的操作类型 '{action}',支持的操作: {valid_actions}",
|
||||
"从支持的 action 中选择后重试。",
|
||||
)
|
||||
|
||||
# 参数校验
|
||||
if browser_action == BrowserAction.GOTO and not url:
|
||||
return "错误: 'goto' 操作需要提供 url 参数"
|
||||
return self._error_response("missing_url", "'goto' 操作需要提供 url 参数", "补充 url 后重试。")
|
||||
if browser_action == BrowserAction.OPEN_TAB and not url:
|
||||
return "错误: 'open_tab' 操作需要提供 url 参数"
|
||||
return self._error_response("missing_url", "'open_tab' 操作需要提供 url 参数", "补充 url 后重试。")
|
||||
if (
|
||||
browser_action
|
||||
in (
|
||||
@@ -277,7 +288,7 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
)
|
||||
and not selector
|
||||
):
|
||||
return f"错误: '{action}' 操作需要提供 selector 参数"
|
||||
return self._error_response("missing_selector", f"'{action}' 操作需要提供 selector 参数", "补充 selector 后重试。")
|
||||
if (
|
||||
browser_action
|
||||
in (
|
||||
@@ -287,28 +298,28 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
)
|
||||
and not ref
|
||||
):
|
||||
return f"错误: '{action}' 操作需要提供 ref 参数"
|
||||
return self._error_response("missing_ref", f"'{action}' 操作需要提供 ref 参数", "先获取最新 snapshot,再补充 ref 后重试。")
|
||||
if browser_action == BrowserAction.FILL and value is None:
|
||||
return "错误: 'fill' 操作需要提供 value 参数"
|
||||
return self._error_response("missing_value", "'fill' 操作需要提供 value 参数", "补充 value 后重试。")
|
||||
if browser_action == BrowserAction.FILL_REF and value is None:
|
||||
return "错误: 'fill_ref' 操作需要提供 value 参数"
|
||||
return self._error_response("missing_value", "'fill_ref' 操作需要提供 value 参数", "补充 value 后重试。")
|
||||
if browser_action == BrowserAction.EVALUATE and not script:
|
||||
return "错误: 'evaluate' 操作需要提供 script 参数"
|
||||
return self._error_response("missing_script", "'evaluate' 操作需要提供 script 参数", "补充 script 后重试。")
|
||||
if (
|
||||
browser_action == BrowserAction.EVALUATE
|
||||
and not await self.is_admin_user()
|
||||
):
|
||||
return "错误: 'evaluate' 操作仅允许管理员使用"
|
||||
return self._error_response("admin_required", "'evaluate' 操作仅允许管理员使用", "改用只读浏览器 action 或请求管理员授权。")
|
||||
if (
|
||||
browser_action == BrowserAction.GET_COOKIES
|
||||
and not await self.is_admin_user()
|
||||
):
|
||||
return "错误: 'get_cookies' 操作仅允许管理员使用"
|
||||
return self._error_response("admin_required", "'get_cookies' 操作仅允许管理员使用", "改用非敏感浏览器 action 或请求管理员授权。")
|
||||
if (
|
||||
browser_action in (BrowserAction.FOCUS_TAB, BrowserAction.CLOSE_TAB)
|
||||
and tab_index is None
|
||||
):
|
||||
return f"错误: '{action}' 操作需要提供 tab_index 参数"
|
||||
return self._error_response("missing_tab_index", f"'{action}' 操作需要提供 tab_index 参数", "补充 tab_index 后重试。")
|
||||
|
||||
effective_session_key = session_key or self._session_id
|
||||
|
||||
@@ -332,10 +343,11 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"浏览器操作失败: {e}", exc_info=True)
|
||||
error_summary = summarize_error(e)
|
||||
logger.error(f"浏览器操作失败: {error_summary}", exc_info=True)
|
||||
if action == BrowserAction.SCREENSHOT:
|
||||
return self._screenshot_failure("screenshot_failed", "浏览器截图执行失败")
|
||||
return f"浏览器操作失败: {str(e)}"
|
||||
return self._error_response("browser_operation_failed", f"浏览器操作失败: {error_summary}", "检查当前会话和页面状态后再重试。")
|
||||
|
||||
def _execute_browser_action(
|
||||
self,
|
||||
@@ -402,10 +414,11 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CloakBrowser 执行失败: {e}", exc_info=True)
|
||||
error_summary = summarize_error(e)
|
||||
logger.error(f"CloakBrowser 执行失败: {error_summary}", exc_info=True)
|
||||
if browser_action == BrowserAction.SCREENSHOT:
|
||||
return self._screenshot_failure("screenshot_failed", "浏览器截图执行失败")
|
||||
return f"CloakBrowser 执行失败: {str(e)}"
|
||||
return self._error_response("browser_operation_failed", f"CloakBrowser 执行失败: {error_summary}", "检查当前会话和页面状态后再重试。")
|
||||
|
||||
def _do_action(
|
||||
self,
|
||||
@@ -545,6 +558,14 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
)
|
||||
return json.dumps(normalized, ensure_ascii=False, indent=2)
|
||||
|
||||
@staticmethod
|
||||
def _error_response(code: str, message: str, recovery: str) -> str:
|
||||
"""返回不含敏感细节的结构化浏览器失败回执。"""
|
||||
return BrowseWebpageTool._json_response({
|
||||
"success": False, "execution_outcome": "failed", "error": code,
|
||||
"message": message, "recovery": recovery,
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _action_goto(
|
||||
helper: BrowserSessionHelper,
|
||||
@@ -655,7 +676,7 @@ class BrowseWebpageTool(MoviePilotTool):
|
||||
|
||||
# 等待可能的页面变化
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=5000)
|
||||
page.wait_for_load_state("networkidle", timeout=min(timeout * 1000, 5000))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -114,4 +114,5 @@ uv run --locked --no-sync python -m scripts.evaluation \
|
||||
- Shell 会话恢复阶段:一次性命令和后台会话的失败、未知、作用域拒绝、输出游标错误现在都返回对应恢复动作;未知退出状态优先要求读取现有 session,失败命令要求检查 `output/exit_code` 后修正,避免把输出文字当成功或重复启动副作用命令。文件工具的行号与版本冲突保护已在上一阶段验证;本阶段相关回归共 122 项通过。
|
||||
- 浏览器与子代理状态阶段:浏览器所有成功/失败 JSON 统一带 `execution_outcome`;子代理任务的 running/cancelled 状态带明确的 `pending/failed` 语义和继续观察、取消后重新派发的恢复建议。当前阶段与文件、Shell、浏览器回归共 112 项通过;真实浏览器仍受用户保存权限限制,子代理终端共享仍需原生句柄闭环。
|
||||
- 浏览器观察回执阶段:`goto`、`snapshot`、`get_content` 及交互动作统一显式返回 `success`,从而稳定映射为 `execution_outcome=succeeded/failed`;页面内容为空与浏览器执行失败可以区分。当前跨浏览器、文件、Shell、子代理回归共 125 项通过。
|
||||
- 浏览器会话可靠性阶段:直接 `run()` 的参数/异常现在返回结构化恢复合同并限制 timeout 为 1–300 秒;点击等待遵守调用 deadline;关闭标签页正确调整 active index,关闭异常不再伪报成功;生成新 snapshot 前清理旧 ref,动态页面不会把过期 ref 静默指向新元素。浏览器、文件、Shell、子代理相关回归共 155 项通过。
|
||||
- 浏览器环境复核:启动本机 Chrome 后用同一官方 OAuth 配置重跑 `/tmp/moviepilot-agent-round-luna-oauth-native-browser-chrome-20260912.json`,原生已加载 Browser plugin、node_repl 并选择 Chrome;真实回环导航仍被保存的浏览器权限拒绝。该结果排除了“CLI 未登录或未启动 Chrome”的解释,下一次只有在用户允许对应回环地址后才继续导航、点击和 oracle 配对。
|
||||
|
||||
@@ -14,6 +14,7 @@ import pytest
|
||||
from app.adapters.network.browser import (
|
||||
BrowserSessionHelper,
|
||||
PlaywrightHelper,
|
||||
_BrowserSessionState,
|
||||
launch_browser_context,
|
||||
launch_browser_context_async,
|
||||
)
|
||||
@@ -499,6 +500,15 @@ def test_browse_webpage_returns_snapshot_with_refs_after_goto():
|
||||
assert payload["interactive_elements"][0]["ref"] == "e1"
|
||||
|
||||
|
||||
def test_snapshot_clears_previous_refs_before_assigning_new_refs() -> None:
|
||||
"""新快照应清理旧 ref,避免动态页面把过期引用映射到新元素。"""
|
||||
page = MagicMock()
|
||||
page.evaluate.return_value = [{"ref": "e1", "tag": "button"}]
|
||||
assert BrowserSessionHelper._extract_interactive_elements(page, max_elements=5)
|
||||
script = page.evaluate.call_args.args[0]
|
||||
assert "removeAttribute('data-moviepilot-agent-ref')" in script
|
||||
|
||||
|
||||
def test_browse_webpage_click_ref_uses_snapshot_selector():
|
||||
"""click_ref 应将 ref 转换为快照注入的稳定选择器。"""
|
||||
page = _FakePage()
|
||||
@@ -527,6 +537,19 @@ def test_browse_webpage_click_ref_uses_snapshot_selector():
|
||||
assert page.clicks == ['[data-moviepilot-agent-ref="e1"]']
|
||||
|
||||
|
||||
def test_close_tab_before_active_page_keeps_active_index() -> None:
|
||||
"""关闭活动页之前的标签页不能把活动页错误地指向下一页。"""
|
||||
first_page = _FakePage("first")
|
||||
pages = [first_page, _FakePage("active"), _FakePage("last")]
|
||||
context = _FakeContext(pages)
|
||||
session = _BrowserSessionState("close-index", context, pages, active_index=1)
|
||||
tabs = BrowserSessionHelper.close_tab(session, 0)
|
||||
assert session.active_index == 0
|
||||
assert session.active_page.page_id == "active"
|
||||
assert tabs[0]["active"] is True
|
||||
assert first_page.closed is True
|
||||
|
||||
|
||||
def test_browse_webpage_get_cookies_returns_current_domain_cookie_and_ua():
|
||||
"""管理员 Cookie 动作应只返回当前页面域名的会话字段。"""
|
||||
page = _FakePage()
|
||||
|
||||
Reference in New Issue
Block a user