assistant/backend/browser/preflight_gate.py

229 lines
14 KiB
Python

"""Program-owned navigation scope for explicit no-save relationship preflight."""
from __future__ import annotations
import re
import uuid
import asyncio
from datetime import datetime, timezone
from urllib.parse import urljoin, urlsplit
from backend.browser.preflight_policy import PreflightPolicy, note_id_from_url
STATUSES = {"entered_note", "revisited_note", "excluded_note_blocked", "note_limit_blocked", "unknown_click_blocked",
"action_not_allowed", "unexpected_note_rejected", "scope_history_untracked"}
ALLOWED = {"navigate", "click", "input", "send_keys", "scroll", "go_back", "wait", "done", "ask_user",
"inspect_reply_relations", "advance_comments", "find_elements", "find_text", "search_page", "screenshot"}
def valid_navigation(row: object) -> bool:
return bool(isinstance(row, dict) and set(row) == {"id", "version", "run_id", "goal_version", "status", "action", "note_id", "observed_at"}
and row.get("version") == 1 and type(row.get("version")) is int
and isinstance(row.get("id"), str) and re.fullmatch(r"preflight_nav_[a-f0-9]{32}", row["id"])
and isinstance(row.get("run_id"), str) and re.fullmatch(r"[a-f0-9]{32}", row["run_id"])
and type(row.get("goal_version")) is int and row["goal_version"] > 0 and row.get("status") in STATUSES
and isinstance(row.get("action"), str) and row["action"] in ALLOWED | {"observe", "other"}
and (row.get("note_id") is None or isinstance(row["note_id"], str) and re.fullmatch(r"[a-f0-9]{24}", row["note_id"]))
and isinstance(row.get("observed_at"), str) and len(row["observed_at"]) <= 40)
class PreflightScopeError(RuntimeError):
def __init__(self, code):
self.code = code
super().__init__("只读预检的实际页面范围未能核对(" + code + "),已停止继续读取,保留当前范围及未读部分。")
class PreflightGate:
def __init__(self, task_id: str, browser, policy: PreflightPolicy, run_id: str, goal_version: int):
from backend.browser import tools
self.task_id, self.browser, self.policy = task_id, browser, policy
self.run_id, self.goal_version, self.failure = run_id, goal_version, None
artifacts = tools._task_artifacts.setdefault(task_id, {})
rows = artifacts.get("preflight_navigation", [])
if not isinstance(rows, list) or any(not valid_navigation(row) for row in rows):
raise PreflightScopeError("navigation_history_invalid")
self.visited = {row["note_id"] for row in rows if row["status"] in {"entered_note", "revisited_note"} and row["note_id"]}
self.seen = set()
history = artifacts.get("browser_history") or {}
marker = artifacts.get("preflight_run")
tracked = bool(isinstance(marker, dict) and set(marker) == {"run_id", "goal_version"}
and isinstance(marker.get("run_id"), str) and re.fullmatch(r"[a-f0-9]{32}", marker["run_id"])
and type(marker.get("goal_version")) is int and marker["goal_version"] > 0)
self.prior_violation = any(row["status"] == "unexpected_note_rejected" for row in rows)
self.untracked = bool(any(row["status"] == "scope_history_untracked" for row in rows)
or not rows and not tracked and (history.get("steps_this_run", history.get("steps", 0)) or history.get("urls")))
if self.untracked:
# Persist the refusal before publishing any new run marker. A
# cancellation or target failure before observe cannot erase the
# fact that the preceding browser scope was never recorded.
self._record("scope_history_untracked")
self.failure = PreflightScopeError("prior_scope_untracked")
raise self.failure
artifacts["preflight_run"] = {"run_id": run_id, "goal_version": goal_version}
tools._persist_evidence(task_id)
def _record(self, status, action="observe", note_id=None):
from backend.browser import tools
rows = tools._task_artifacts.setdefault(self.task_id, {}).setdefault("preflight_navigation", [])
row = {"id": "preflight_nav_" + uuid.uuid4().hex, "version": 1, "run_id": self.run_id,
"goal_version": self.goal_version, "status": status, "action": action if action in ALLOWED else "other",
"note_id": note_id, "observed_at": datetime.now(timezone.utc).isoformat(timespec="milliseconds")}
if action == "observe":
row["action"] = action
rows.append(row)
tools._persist_evidence(self.task_id)
async def current_url(self):
try:
page = await asyncio.wait_for(self.browser.get_current_page(), timeout=10)
if page is None:
raise PreflightScopeError("current_page_unavailable")
return await asyncio.wait_for(page.get_url(), timeout=10)
except PreflightScopeError:
raise
except Exception:
raise PreflightScopeError("current_page_unavailable") from None
async def observe(self):
if self.prior_violation:
self.failure = PreflightScopeError("prior_scope_violation")
raise self.failure
if self.untracked:
self._record("scope_history_untracked")
self.failure = PreflightScopeError("prior_scope_untracked")
raise self.failure
note_id = note_id_from_url(await self.current_url())
if not note_id:
return
if note_id in self.policy.excluded_note_ids or (note_id not in self.visited
and self.policy.max_notes is not None and len(self.visited) >= self.policy.max_notes):
self._record("unexpected_note_rejected", note_id=note_id)
self.failure = PreflightScopeError("excluded_or_extra_note")
raise self.failure
if note_id not in self.seen:
self._record("revisited_note" if note_id in self.visited else "entered_note", note_id=note_id)
self.visited.add(note_id)
self.seen.add(note_id)
def _target(self, actual_url, action):
note_id = note_id_from_url(actual_url)
try:
parts = urlsplit(actual_url)
if parts.path.startswith('/user/profile/'):
return self._deny(action, "本轮只读预检不打开用户主页或点击回复对象名字,未执行该入口。")
if (parts.scheme != 'https' or parts.hostname not in {'www.xiaohongshu.com', 'xiaohongshu.com'}
or parts.username or parts.password or parts.port not in {None, 443}
or not note_id and parts.path.rstrip('/') not in {'', '/explore', '/search_result', '/search_result_ai'}):
return self._deny(action, "该实际入口无法确认是笔记、首页或搜索页,未执行未知目标。", unknown=True)
except (ValueError, TypeError):
return self._deny(action, "实际入口地址无法核对,未执行。", unknown=True)
if note_id in self.policy.excluded_note_ids:
self._record("excluded_note_blocked", action, note_id)
return "该实际入口指向本轮明确排除的笔记,未执行;请换另一个可核对入口。"
if note_id and note_id not in self.visited and self.policy.max_notes is not None and len(self.visited) >= self.policy.max_notes:
self._record("note_limit_blocked", action, note_id)
return "已达到用户明确的实际笔记数量上限,没有打开额外笔记;请交付当前预检结果及未读范围。"
return None
async def _current_anchor(self, node):
"""Read only the live ancestor anchor of the stable clicked DOM node."""
backend_id, session_id = getattr(node, 'backend_node_id', None), getattr(node, 'session_id', None)
if type(backend_id) is not int or backend_id <= 0 or not isinstance(session_id, str) or not session_id:
return None
try:
# Resolve the stable backend node directly. Unlike frontend node
# IDs this does not require a separate DOM.getDocument traversal.
client = self.browser.cdp_client.send
resolved = await asyncio.wait_for(client.DOM.resolveNode(params={'backendNodeId': backend_id}, session_id=session_id), timeout=10)
object_id = resolved.get('object', {}).get('objectId')
if not isinstance(object_id, str) or not object_id:
return None
raw = await asyncio.wait_for(client.Runtime.callFunctionOn(params={
'objectId': object_id, 'returnByValue': True,
'functionDeclaration': r"""function() {
if(!this.isConnected || this.ownerDocument !== document) return null;
const anchor=this.closest('a[href]');
if(!anchor || !anchor.isConnected) return null;
const href=anchor.getAttribute('href');
return typeof href==='string'?{href,absolute_href:anchor.href}:null;
}"""}, session_id=session_id), timeout=10)
if not isinstance(raw, dict) or raw.get('exceptionDetails'):
return None
value = raw.get('result', {}).get('value')
if (not isinstance(value, dict) or set(value) != {'href', 'absolute_href'}
or any(not isinstance(value[key], str) or not value[key].strip() for key in value)):
return None
return value
except Exception:
return None
async def before_action(self, action=None):
await self.observe()
if action is None:
return None
payload = action.model_dump(exclude_none=True)
if not isinstance(payload, dict) or len(payload) != 1:
return self._deny("other", "无法核对本次预检动作。")
name, params = next(iter(payload.items()))
if not isinstance(params, dict):
return self._deny(name, "无法核对本次预检动作参数。")
if name not in ALLOWED:
return self._deny(name, "本轮只读预检不保存来源或知识、不展开、不核主页、不触达;该动作未执行。")
if name == "navigate":
return self._target(params.get("url", ""), name)
if name == "input":
if urlsplit(await self.current_url()).path.rstrip('/') not in {"", "/explore", "/search_result", "/search_result_ai"}:
return self._deny(name, "本轮只在真实搜索入口输入检索词,不在笔记、评论或私信输入框填写内容。")
if name == "click":
index = params.get("index")
if type(index) is not int:
return self._deny(name, "本轮预检无法从坐标确认入口目标,未点击;请使用当前可核对的真实链接。", unknown=True)
node = await self.browser.get_dom_element_by_index(index)
clicked_node = node
seen = set()
for _ in range(32):
if node is None or id(node) in seen:
break
seen.add(id(node))
if str(getattr(node, "node_name", "")).upper() == "A":
href = (getattr(node, "attributes", None) or {}).get("href")
if isinstance(href, str) and href.strip():
if href.strip().startswith('#'):
break
cached = urljoin(await self.current_url(), href)
current = await self._current_anchor(clicked_node)
if not current or current['href'].strip().startswith('#'):
break
actual = current['absolute_href']
# A changed link is not the target the model observed,
# even if its new destination would otherwise be legal.
if actual != cached or current['href'] != href:
rejection = self._target(actual, name)
if rejection:
return rejection
return self._deny(name, "入口链接已变化,本次旧点击未执行;请重新观察并确认实际目标。", unknown=True)
return self._target(actual, name)
node = getattr(node, "parent_node", None)
return self._deny(name, "当前点击入口没有可核对的真实目标链接,无法事前排除旧笔记或额外笔记,因此未点击;可使用实际评论入口推进或另一个真实链接。", unknown=True)
if name == "go_back":
try:
session = await self.browser.get_or_create_cdp_session(self.browser.agent_focus_target_id, focus=False)
history = await session.cdp_client.send.Page.getNavigationHistory(session_id=session.session_id)
current = history["currentIndex"]
actual = history["entries"][current - 1]["url"] if type(current) is int and current > 0 else None
except Exception:
actual = None
if not actual:
return self._deny(name, "当前原生返回目标无法核对,未返回;请保留当前页面并说明未读范围。", unknown=True)
return self._target(actual, name)
if name == "send_keys" and str(params.get("keys", "")).lower() not in {
"enter", "pagedown", "pageup", "home", "end", "arrowdown", "arrowup", "escape"}:
return self._deny(name, "本轮预检不使用可能切换未知页面的快捷键。")
if name == "send_keys" and str(params.get("keys", "")).lower() == "enter" and urlsplit(await self.current_url()).path.rstrip('/') not in {"", "/explore", "/search_result", "/search_result_ai"}:
return self._deny(name, "本轮不在笔记或聊天页面按发送/提交键,只观察当前可见讨论。")
return None
def _deny(self, name, message, *, unknown=False):
self._record("unknown_click_blocked" if unknown else "action_not_allowed", name)
return message