"""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