diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserPrivacyGuard.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserPrivacyGuard.java
new file mode 100644
index 00000000..5255e2e9
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserPrivacyGuard.java
@@ -0,0 +1,142 @@
+package vip.mate.tool.browser;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import vip.mate.tool.guard.model.GuardDecision;
+import vip.mate.tool.guard.model.GuardSeverity;
+import vip.mate.tool.guard.model.ToolGuardAuditLogEntity;
+import vip.mate.tool.guard.repository.ToolGuardAuditLogMapper;
+
+import java.net.URI;
+import java.util.List;
+
+/**
+ * Privacy guard for browser sessions attached to a user's own logged-in browser.
+ *
+ *
When the browser tool connects to a Chrome the user is running themselves
+ * (via the DevTools Protocol, process not spawned by us), that browser may have
+ * banking, webmail or internal-admin tabs open with live sessions. Reading such
+ * a page in full — screenshot, arbitrary JS eval, or a text/accessibility dump —
+ * would funnel private content into the model and possibly into persisted
+ * transcripts. This guard classifies the current page and refuses those
+ * content-reading actions on pages that look sensitive, while leaving plain
+ * navigation untouched. It never applies to headless or self-spawned browsers.
+ *
+ *
Classification precedence: configured trusted hosts (always safe) →
+ * configured sensitive hosts → a built-in keyword heuristic over host + path.
+ * Every block is overridable by adding the host to
+ * {@code mateclaw.browser.privacy.trusted-hosts}.
+ */
+@Slf4j
+@Component
+public class BrowserPrivacyGuard {
+
+ /** Keyword signals that a page handles money, identity, or privileged access. */
+ private static final List SENSITIVE_SIGNALS = List.of(
+ "bank", "banking", "pay", "payment", "wallet", "checkout", "billing", "invoice",
+ "mail", "webmail", "signin", "login", "logon", "account", "admin", "console",
+ "secure", "oauth", "authorize", "password", "passport", "identity");
+
+ private final BrowserProperties properties;
+ private final ToolGuardAuditLogMapper auditMapper;
+
+ public BrowserPrivacyGuard(BrowserProperties properties, ToolGuardAuditLogMapper auditMapper) {
+ this.properties = properties;
+ this.auditMapper = auditMapper;
+ }
+
+ /**
+ * Decide whether a content-reading action must be refused. Returns a
+ * human-readable block reason, or {@code null} when the action may proceed.
+ *
+ * @param userManagedBrowser true only when attached to a Chrome the user runs themselves
+ * @param url the current page URL
+ * @param action the action being attempted (screenshot / eval / snapshot)
+ */
+ public String blockReason(boolean userManagedBrowser, String url, String action) {
+ if (!properties.getPrivacy().isEnabled() || !userManagedBrowser) {
+ return null;
+ }
+ if (!isSensitive(url)) {
+ return null;
+ }
+ return "Refusing action=" + action + " on what looks like a sensitive page (" + safeHost(url)
+ + ") inside your own logged-in browser, to avoid exposing private content to the"
+ + " model. Plain navigation is still allowed. If this page is safe to read, add its"
+ + " host to mateclaw.browser.privacy.trusted-hosts.";
+ }
+
+ /** True when the URL matches a configured/heuristic sensitive signal and is not trusted. */
+ public boolean isSensitive(String url) {
+ if (url == null || url.isBlank()) {
+ return false;
+ }
+ String host;
+ String path;
+ try {
+ URI uri = URI.create(url);
+ host = uri.getHost() == null ? "" : uri.getHost().toLowerCase();
+ path = uri.getPath() == null ? "" : uri.getPath().toLowerCase();
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ if (host.isEmpty()) {
+ return false;
+ }
+ if (hostMatches(host, properties.getPrivacy().getTrustedHosts())) {
+ return false;
+ }
+ if (hostMatches(host, properties.getPrivacy().getSensitiveHosts())) {
+ return true;
+ }
+ String hostAndPath = host + path;
+ for (String sig : SENSITIVE_SIGNALS) {
+ if (hostAndPath.contains(sig)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Record a blocked read into the shared tool-guard audit log so it shows up in the audit panel. */
+ public void audit(String conversationId, String action, String url, String reason) {
+ try {
+ ToolGuardAuditLogEntity entity = new ToolGuardAuditLogEntity();
+ entity.setConversationId(conversationId);
+ entity.setToolName("browser_use");
+ entity.setDecision(GuardDecision.BLOCK.name());
+ entity.setMaxSeverity(GuardSeverity.HIGH.name());
+ entity.setToolParamsJson("{\"action\":\"" + action + "\",\"host\":\"" + safeHost(url) + "\"}");
+ entity.setFindingsJson("[{\"type\":\"sensitive-page\",\"reason\":\""
+ + reason.replace("\"", "'") + "\"}]");
+ auditMapper.insert(entity);
+ } catch (Exception e) {
+ log.warn("[BrowserPrivacyGuard] Failed to record audit entry: {}", e.getMessage());
+ }
+ }
+
+ private static boolean hostMatches(String host, List patterns) {
+ if (patterns == null) {
+ return false;
+ }
+ for (String p : patterns) {
+ if (p == null || p.isBlank()) {
+ continue;
+ }
+ String pat = p.trim().toLowerCase();
+ if (host.equals(pat) || host.endsWith("." + pat)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static String safeHost(String url) {
+ try {
+ String h = URI.create(url).getHost();
+ return h == null ? "unknown" : h;
+ } catch (Exception e) {
+ return "unknown";
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java
index 6836fd19..5d110a46 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java
@@ -116,5 +116,69 @@ public class BrowserProperties {
* to avoid forcing every snapshot through the spill-and-preview path.
*/
private int snapshotMaxLength = 20_000;
+
+ /**
+ * Whether {@code action=snapshot} includes non-interactive structural nodes
+ * (headings, list items, navigation, images) in the accessibility tree.
+ * Interactive elements always get a reference handle; structural nodes are
+ * emitted without one, purely to give the model page context. Turn off to
+ * produce a terser tree of only actionable elements.
+ */
+ private boolean snapshotIncludeNonInteractive = true;
+
+ /** Privacy guard for sessions attached to a user's own logged-in browser (action=connect_cdp). */
+ private Privacy privacy = new Privacy();
+
+ /** Raw DevTools Protocol escape hatch (action=cdp) configuration. */
+ private Cdp cdp = new Cdp();
+
+ /**
+ * Controls {@code action=cdp}, which forwards a raw Chrome DevTools Protocol
+ * command. Constrained by a method allowlist; content-reading methods are
+ * additionally subject to {@link Privacy} on user-managed browsers.
+ */
+ @Data
+ public static class Cdp {
+ /** Master switch for action=cdp. */
+ private boolean enabled = true;
+
+ /**
+ * Allowed CDP methods. An entry is either an exact method
+ * ({@code "Page.navigate"}) or a domain wildcard ({@code "Input.*"}).
+ * Defaults to safe actuation methods; extend for advanced automation.
+ * Content-reading methods stay guarded by {@link Privacy} even if added.
+ */
+ private java.util.List allowedMethods = new java.util.ArrayList<>(java.util.List.of(
+ "Input.*",
+ "Page.navigate", "Page.reload", "Page.bringToFront",
+ "Page.getNavigationHistory", "Page.navigateToHistoryEntry"));
+ }
+
+ /**
+ * When the browser tool is attached to a user-managed Chrome (connected via
+ * CDP, process not spawned by us), that Chrome may have banking / email /
+ * internal-admin tabs open. This guard refuses content-reading actions
+ * (screenshot / eval / full snapshot) on pages that look sensitive, so
+ * private content is not funnelled into the model / persisted. It never
+ * affects headless or self-spawned browsers.
+ */
+ @Data
+ public static class Privacy {
+ /** Master switch. When false, no sensitive-page blocking happens. */
+ private boolean enabled = true;
+
+ /**
+ * Extra hosts to always treat as sensitive (exact host or any subdomain),
+ * on top of the built-in heuristic. E.g. {@code intranet.corp.example}.
+ */
+ private java.util.List sensitiveHosts = new java.util.ArrayList<>();
+
+ /**
+ * Hosts to always treat as safe (exact host or any subdomain). Overrides
+ * both the heuristic and {@link #sensitiveHosts}. Use to un-block a page
+ * the heuristic flagged that you know is fine to read.
+ */
+ private java.util.List trustedHosts = new java.util.ArrayList<>();
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/PageSnapshotScript.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/PageSnapshotScript.java
new file mode 100644
index 00000000..fbf871a1
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/PageSnapshotScript.java
@@ -0,0 +1,230 @@
+package vip.mate.tool.browser;
+
+import cn.hutool.json.JSONArray;
+import cn.hutool.json.JSONObject;
+import cn.hutool.json.JSONUtil;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Accessibility-tree page snapshot for browser automation.
+ *
+ *
Replaces the older visible-text dump with a compact accessibility tree in
+ * which every interactive element is tagged with a stable reference handle
+ * ({@code @e1}, {@code @e2}, ...). The reference is materialised as a
+ * {@code data-mate-ref} attribute on the live DOM node, so a follow-up
+ * click/type action can address the element by {@code [data-mate-ref='eN']}
+ * instead of forcing the model to guess a brittle CSS selector.
+ *
+ *
Why an injected attribute rather than a framework-native snapshot: the
+ * attribute approach is independent of the browser-driver version, survives
+ * driver upgrades, and produces a selector that plugs straight into the
+ * existing click/type plumbing. References stay valid only for the snapshot
+ * that produced them — a navigation wipes the attributes, so a stale reference
+ * naturally resolves to "not found" and the caller is told to re-snapshot.
+ */
+public final class PageSnapshotScript {
+
+ private PageSnapshotScript() {
+ }
+
+ /**
+ * Injected snapshot function. Runs as {@code root.evaluate(SNAPSHOT_JS, opts)}.
+ * Playwright's {@code ElementHandle.evaluate} invokes the function as
+ * {@code fn(element, arg)} — the scoped root element is the FIRST positional
+ * parameter (NOT {@code this}, which Playwright never binds to the element),
+ * and the caller's {@code opts} object is the second. {@code opts} is
+ * {@code {maxLen, includeNonInteractive}}.
+ *
+ *
{@code tree} — indented accessibility tree text for the model;
+ *
{@code truncated} — true when output was cut at the length budget;
+ *
{@code refs} — the list of reference ids assigned this snapshot.
+ *
+ */
+ public static final String SNAPSHOT_JS = """
+ (rootEl, opts) => {
+ const maxLen = opts.maxLen;
+ const includeNon = opts.includeNonInteractive;
+ const budget = { remaining: maxLen, truncated: false };
+ let counter = 0;
+ const refs = [];
+
+ // Wipe references from a prior snapshot so ids never collide
+ // across generations and a navigated-away page leaves nothing behind.
+ document.querySelectorAll('[data-mate-ref]').forEach(function (n) {
+ n.removeAttribute('data-mate-ref');
+ });
+
+ function isVisible(el) {
+ const style = window.getComputedStyle(el);
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
+ return el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0;
+ }
+
+ function isInteractive(el) {
+ const tag = el.tagName.toLowerCase();
+ if (['a', 'button', 'input', 'select', 'textarea', 'summary'].includes(tag)) return true;
+ const role = el.getAttribute('role');
+ if (role && ['button', 'link', 'checkbox', 'radio', 'tab', 'menuitem',
+ 'switch', 'textbox', 'combobox', 'option', 'searchbox', 'slider'].includes(role)) return true;
+ if (el.hasAttribute('onclick')) return true;
+ if (el.isContentEditable) return true;
+ const ti = el.getAttribute('tabindex');
+ if (ti !== null && ti !== '-1') return true;
+ return false;
+ }
+
+ function roleOf(el) {
+ const explicit = el.getAttribute('role');
+ if (explicit) return explicit;
+ const tag = el.tagName.toLowerCase();
+ switch (tag) {
+ case 'a': return el.hasAttribute('href') ? 'link' : 'generic';
+ case 'button': return 'button';
+ case 'select': return 'combobox';
+ case 'textarea': return 'textbox';
+ case 'summary': return 'button';
+ case 'input': {
+ const t = (el.getAttribute('type') || 'text').toLowerCase();
+ if (t === 'checkbox') return 'checkbox';
+ if (t === 'radio') return 'radio';
+ if (t === 'submit' || t === 'button' || t === 'reset') return 'button';
+ if (t === 'search') return 'searchbox';
+ if (t === 'hidden') return null;
+ return 'textbox';
+ }
+ case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': return 'heading';
+ case 'li': return 'listitem';
+ case 'ul': case 'ol': return 'list';
+ case 'nav': return 'navigation';
+ case 'img': return 'img';
+ default: return null;
+ }
+ }
+
+ function nameOf(el) {
+ const aria = el.getAttribute('aria-label');
+ if (aria) return aria.trim();
+ const labelledby = el.getAttribute('aria-labelledby');
+ if (labelledby) {
+ const target = document.getElementById(labelledby);
+ if (target) return (target.textContent || '').trim();
+ }
+ const tag = el.tagName.toLowerCase();
+ if (tag === 'input' || tag === 'textarea') {
+ const ph = el.getAttribute('placeholder');
+ if (ph) return ph.trim();
+ if (el.value) return String(el.value).trim();
+ if (el.id) {
+ const lab = document.querySelector('label[for="' + (window.CSS ? CSS.escape(el.id) : el.id) + '"]');
+ if (lab) return (lab.textContent || '').trim();
+ }
+ // Wrapping label: — common
+ // and has no for= link, so climb to the nearest label ancestor.
+ const wrap = el.closest('label');
+ if (wrap) {
+ const wt = (wrap.textContent || '').trim().replace(/\\s+/g, ' ');
+ if (wt) return wt;
+ }
+ return '';
+ }
+ if (tag === 'img') {
+ const alt = el.getAttribute('alt');
+ if (alt) return alt.trim();
+ }
+ const title = el.getAttribute('title');
+ if (title) return title.trim();
+ const txt = el.textContent ? el.textContent.trim().replace(/\\s+/g, ' ') : '';
+ return txt;
+ }
+
+ function clip(s, n) {
+ if (!s) return '';
+ return s.length > n ? s.substring(0, n) + '…' : s;
+ }
+
+ const lines = [];
+
+ function emit(text) {
+ if (budget.remaining <= 0) { budget.truncated = true; return false; }
+ if (text.length + 1 > budget.remaining) {
+ budget.truncated = true;
+ budget.remaining = 0;
+ return false;
+ }
+ lines.push(text);
+ budget.remaining -= (text.length + 1);
+ return true;
+ }
+
+ function walk(el, depth) {
+ if (depth > 20 || budget.remaining <= 0) return;
+ if (!isVisible(el)) return;
+
+ const role = roleOf(el);
+ const interactive = isInteractive(el);
+ let line = null;
+
+ if (interactive && role !== 'generic' && role !== null) {
+ counter += 1;
+ const ref = 'e' + counter;
+ el.setAttribute('data-mate-ref', ref);
+ refs.push(ref);
+ const nm = clip(nameOf(el), 100);
+ line = role + (nm ? ' "' + nm + '"' : '') + ' @' + ref;
+ } else if (includeNon && role && role !== 'generic') {
+ const nm = clip(nameOf(el), 100);
+ if (nm || role === 'list' || role === 'navigation') {
+ let extra = '';
+ if (role === 'heading') {
+ const lvl = el.getAttribute('aria-level')
+ || (el.tagName.length === 2 ? el.tagName.charAt(1) : '');
+ if (lvl) extra = ' [level=' + lvl + ']';
+ }
+ line = role + (nm ? ' "' + nm + '"' : '') + extra;
+ }
+ }
+
+ if (line !== null) {
+ if (!emit(' '.repeat(Math.min(depth, 10)) + '- ' + line)) return;
+ }
+
+ const childDepth = line !== null ? depth + 1 : depth;
+ for (const child of el.children) {
+ if (budget.remaining <= 0) break;
+ walk(child, childDepth);
+ }
+ }
+
+ walk(rootEl, 0);
+ return JSON.stringify({ tree: lines.join('\\n'), truncated: budget.truncated, refs: refs });
+ }
+ """;
+
+ /** Parsed result of a snapshot evaluation. */
+ public record Result(String tree, boolean truncated, List refs) {
+ public static Result fromJson(String json) {
+ JSONObject obj = JSONUtil.parseObj(json);
+ String tree = obj.getStr("tree", "");
+ boolean truncated = obj.getBool("truncated", false);
+ List refs = new ArrayList<>();
+ JSONArray arr = obj.getJSONArray("refs");
+ if (arr != null) {
+ for (Object o : arr) {
+ if (o != null) {
+ refs.add(o.toString());
+ }
+ }
+ }
+ return new Result(tree, truncated, refs);
+ }
+ }
+
+ /** Build the deterministic attribute selector for a reference id. */
+ public static String selectorForRef(String ref) {
+ return "[data-mate-ref='" + ref + "']";
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java
index 6f80d092..3f03255d 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java
@@ -4,8 +4,11 @@ import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
+import com.microsoft.playwright.CDPSession;
import com.microsoft.playwright.ElementHandle;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
@@ -20,7 +23,9 @@ import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.tool.browser.BrowserDiagnosticsService;
import vip.mate.tool.browser.BrowserLauncher;
+import vip.mate.tool.browser.BrowserPrivacyGuard;
import vip.mate.common.net.SsrfProperties;
+import vip.mate.tool.browser.PageSnapshotScript;
import vip.mate.tool.browser.UrlSafetyChecker;
import java.net.Socket;
@@ -44,14 +49,16 @@ public class BrowserUseTool {
private static final int CDP_SCAN_PORT_MAX = 10000;
/**
- * Snapshot extractor — runs as an ElementHandle.evaluate so {@code this}
- * is the scoped root (document.body when no selector is passed). Uses a
- * budget object so truncation stops at element boundaries rather than
- * mid-TEXT_NODE, and surfaces a {@code truncated:true} flag to the caller
- * so the LLM can be told to retry with a narrower selector.
+ * Legacy visible-text extractor kept as a fallback: {@link #doSnapshot}
+ * prefers the accessibility-tree snapshot ({@link PageSnapshotScript}) and
+ * only falls back to this plain-text dump if the tree script throws on an
+ * unusual page. Runs as an ElementHandle.evaluate so {@code this} is the
+ * scoped root (document.body when no selector is passed). Uses a budget
+ * object so truncation stops at element boundaries rather than
+ * mid-TEXT_NODE, and surfaces a {@code truncated:true} flag.
*/
- private static final String SNAPSHOT_JS = """
- (maxLen) => {
+ private static final String TEXT_SNAPSHOT_JS_FALLBACK = """
+ (rootEl, maxLen) => {
const budget = { remaining: maxLen, truncated: false };
function getVisibleText(node, depth) {
if (depth > 10 || budget.remaining <= 0) return '';
@@ -104,7 +111,7 @@ public class BrowserUseTool {
}
return results.join('\\n');
}
- const text = getVisibleText(this, 0);
+ const text = getVisibleText(rootEl, 0);
return JSON.stringify({ text: text, truncated: budget.truncated });
}
""";
@@ -114,15 +121,35 @@ public class BrowserUseTool {
private final BrowserLauncher launcher;
private final BrowserDiagnosticsService diagnostics;
private final SsrfProperties ssrfProperties;
+ private final BrowserPrivacyGuard privacyGuard;
public BrowserUseTool(vip.mate.channel.web.ChatStreamTracker streamTracker,
BrowserLauncher launcher,
BrowserDiagnosticsService diagnostics,
- SsrfProperties ssrfProperties) {
+ SsrfProperties ssrfProperties,
+ BrowserPrivacyGuard privacyGuard) {
this.streamTracker = streamTracker;
this.launcher = launcher;
this.diagnostics = diagnostics;
this.ssrfProperties = ssrfProperties;
+ this.privacyGuard = privacyGuard;
+ }
+
+ /**
+ * Enforce the privacy guard for a content-reading action. Returns an error
+ * JSON string to return to the caller when the action is refused on a
+ * sensitive page of a user-managed browser, or {@code null} to proceed.
+ */
+ private String guardReadOrNull(BrowserSession session, String action) {
+ String reason = privacyGuard.blockReason(session.isUserManagedBrowser(),
+ session.page.url(), action);
+ if (reason == null) {
+ return null;
+ }
+ String conversationId = ToolExecutionContext.conversationId(currentToolContext);
+ privacyGuard.audit(conversationId, action, session.page.url(), reason);
+ log.info("[BrowserUse] Privacy guard blocked action={} on {}", action, session.page.url());
+ return error(reason);
}
/**
@@ -165,25 +192,34 @@ public class BrowserUseTool {
- start: Launch a new browser (tries system Chrome, system Edge, then Playwright bundled). Optional headed=true.
- stop: Close browser. If connected via CDP, only disconnects (Chrome keeps running).
- open: Navigate to a URL. Requires url parameter. Auto-starts browser if not running.
- - snapshot: Get page text content, interactive elements, and title. Optional `selector`
- scopes to a subtree — USE IT when the page is large (big tables, long lists) to avoid
- truncation. Without selector, content is capped and a `truncated:true` flag is returned
- with a hint to retry using selector.
+ - snapshot: Get the page as an accessibility tree. Every interactive element (link, button,
+ input, ...) is tagged with a stable reference like `@e1`, `@e2`. Read the tree, then act on
+ an element by passing ref= to action=click/type — no CSS selector guessing needed.
+ References belong to the returned `generation`; re-snapshot if the page changes. Optional
+ `selector` scopes to a subtree — USE IT when the page is large to avoid truncation
+ (`truncated:true` is flagged with a hint).
- screenshot: Take a screenshot. Optional path to save file; returns base64 if no path.
- - click: Click an element. Requires selector (CSS selector).
- - type: Type text into an element. Requires selector and text.
+ - click: Click an element. Pass ref= from a snapshot (preferred) or a CSS selector.
+ - type: Type text into an element. Pass ref= (preferred) or selector, plus text.
+ - hover: Hover over an element (reveals menus/tooltips). Pass ref= or selector.
+ - select: Choose an option in a dropdown. Pass ref= or selector, plus value.
- eval: Execute JavaScript on the page. Requires code parameter. Top-level await is supported; use `return` to surface a value.
+ - cdp: Send a raw Chrome DevTools Protocol command. Requires method (e.g. 'Page.navigate'), optional params (JSON). Constrained by an allowlist; use only when the higher-level actions cannot express what you need.
- connect_cdp: Connect to an existing Chrome via CDP. Requires url (e.g. "http://localhost:9222").
- list_cdp_targets: Scan local ports (9000-10000) for CDP endpoints. Optional cdpPort for single port.
- navigate_back: Go back in browser history.
- diagnose: Run a self-check — reports which launch strategies are available and what to install if none are.
""")
public String browser_use(
- @ToolParam(description = "Action: start|stop|open|snapshot|screenshot|click|type|eval|connect_cdp|list_cdp_targets|navigate_back|diagnose") String action,
+ @ToolParam(description = "Action: start|stop|open|snapshot|screenshot|click|type|hover|select|eval|connect_cdp|list_cdp_targets|navigate_back|diagnose") String action,
@ToolParam(description = "URL to navigate to (for open), or CDP base URL (for connect_cdp, e.g. http://localhost:9222)", required = false) String url,
- @ToolParam(description = "CSS selector. REQUIRED for click/type. OPTIONAL for snapshot: pass to scope to a subtree when previous snapshot returned truncated:true.", required = false) String selector,
+ @ToolParam(description = "CSS selector. Alternative to ref for click/type/hover/select. OPTIONAL for snapshot: pass to scope to a subtree when previous snapshot returned truncated:true.", required = false) String selector,
+ @ToolParam(description = "Element reference from a snapshot (e.g. 'e4'). PREFERRED for click/type/hover/select — takes priority over selector. Re-snapshot if it reports stale.", required = false) String ref,
@ToolParam(description = "Text to type (for action=type)", required = false) String text,
+ @ToolParam(description = "Option value or visible label to choose (for action=select)", required = false) String value,
@ToolParam(description = "JavaScript code to execute (for action=eval). Top-level await is allowed; add `return` to return a value when the snippet uses await.", required = false) String code,
+ @ToolParam(description = "CDP method for action=cdp (e.g. 'Page.navigate', 'Input.dispatchMouseEvent'). Must be in the allowlist.", required = false) String method,
+ @ToolParam(description = "JSON object of params for action=cdp (e.g. {\"url\":\"https://example.com\"})", required = false) String params,
@ToolParam(description = "File path to save screenshot (for action=screenshot)", required = false) String path,
@ToolParam(description = "Launch visible browser window (for action=start, default false)", required = false) Boolean headed,
@ToolParam(description = "Single CDP port to scan (for action=list_cdp_targets)", required = false) Integer cdpPort,
@@ -199,8 +235,14 @@ public class BrowserUseTool {
return error("action is required");
}
- String sessionKey = "default";
- log.info("[BrowserUse] action={}, url={}, selector={}, headed={}, cdpPort={}", action, url, selector, headed, cdpPort);
+ // Isolate browser state per conversation so concurrent chats don't drive
+ // (and navigate) each other's page. Falls back to a shared key when no
+ // conversation context is present (e.g. internal/system invocations).
+ String conversationId = ToolExecutionContext.conversationId(ctx);
+ String sessionKey = (conversationId != null && !conversationId.isBlank())
+ ? conversationId : "default";
+ log.info("[BrowserUse] action={}, session={}, url={}, selector={}, headed={}, cdpPort={}",
+ action, sessionKey, url, selector, headed, cdpPort);
try {
return switch (action.toLowerCase().trim()) {
@@ -209,14 +251,17 @@ public class BrowserUseTool {
case "open" -> doOpen(sessionKey, url);
case "snapshot" -> doSnapshot(sessionKey, selector);
case "screenshot" -> doScreenshot(sessionKey, path);
- case "click" -> doClick(sessionKey, selector);
- case "type" -> doType(sessionKey, selector, text);
+ case "click" -> doClick(sessionKey, ref, selector);
+ case "type" -> doType(sessionKey, ref, selector, text);
+ case "hover" -> doHover(sessionKey, ref, selector);
+ case "select" -> doSelect(sessionKey, ref, selector, value);
case "eval" -> doEval(sessionKey, code);
+ case "cdp" -> doCdp(sessionKey, method, params);
case "connect_cdp" -> doConnectCdp(sessionKey, url);
case "list_cdp_targets" -> doListCdpTargets(cdpPort);
case "navigate_back" -> doNavigateBack(sessionKey);
case "diagnose" -> doDiagnose();
- default -> error("Unknown action: " + action + ". Supported: start, stop, open, snapshot, screenshot, click, type, eval, connect_cdp, list_cdp_targets, navigate_back, diagnose");
+ default -> error("Unknown action: " + action + ". Supported: start, stop, open, snapshot, screenshot, click, type, hover, select, eval, cdp, connect_cdp, list_cdp_targets, navigate_back, diagnose");
};
} catch (PlaywrightException e) {
log.error("[BrowserUse] Playwright error: {}", e.getMessage());
@@ -520,6 +565,7 @@ public class BrowserUseTool {
}
session.touch();
+ session.invalidateRefs();
Page page = session.page;
page.navigate(normalizedUrl);
@@ -546,6 +592,7 @@ public class BrowserUseTool {
}
session.touch();
+ session.invalidateRefs();
session.page.goBack();
String title = session.page.title();
@@ -567,6 +614,11 @@ public class BrowserUseTool {
return error("No browser running. Use action=start first.");
}
+ String blocked = guardReadOrNull(session, "snapshot");
+ if (blocked != null) {
+ return blocked;
+ }
+
session.touch();
Page page = session.page;
@@ -590,31 +642,70 @@ public class BrowserUseTool {
}
int maxLen = launcher.properties().getSnapshotMaxLength();
- String jsResult = (String) root.evaluate(SNAPSHOT_JS, maxLen);
-
- // JS returns { text: "...", truncated: true/false }
- JSONObject parsed = JSONUtil.parseObj(jsResult);
- String textContent = parsed.getStr("text");
- boolean truncated = parsed.getBool("truncated", false);
+ boolean includeNon = launcher.properties().isSnapshotIncludeNonInteractive();
JSONObject result = new JSONObject();
result.set("ok", true);
result.set("title", title);
result.set("url", url);
- // IMPORTANT: truncated + hint MUST come before content. The framework's
- // spill-preview keeps only the head ~800 chars of the JSON, so placing
- // these flags first ensures the LLM still sees them after a spill.
- result.set("truncated", truncated);
- if (truncated) {
- result.set("hint", "Content truncated. Re-call browser_use with action=snapshot"
- + " and selector= to scope to a subtree (e.g. selector='#main',"
- + " selector='table tbody tr').");
+
+ try {
+ // Accessibility-tree snapshot: assigns stable @eN references to
+ // interactive elements (materialised as data-mate-ref attributes)
+ // so click/type can address them precisely instead of guessing a
+ // CSS selector. Bumps the session generation so a later action on a
+ // stale reference (page changed / navigated) resolves to not-found.
+ JSONObject opts = new JSONObject();
+ opts.set("maxLen", maxLen);
+ opts.set("includeNonInteractive", includeNon);
+ String jsResult = (String) root.evaluate(PageSnapshotScript.SNAPSHOT_JS, opts);
+ PageSnapshotScript.Result snap = PageSnapshotScript.Result.fromJson(jsResult);
+
+ int generation = session.nextSnapshotGeneration(snap.refs());
+
+ result.set("snapshotMode", "accessibility-tree");
+ result.set("generation", generation);
+ // IMPORTANT: flags/hints MUST precede the (large) tree. The framework's
+ // spill-preview keeps only the head of the JSON, so ordering these
+ // first ensures the LLM still sees them after a spill.
+ result.set("truncated", snap.truncated());
+ result.set("hint", "Interactive elements are tagged @eN. To act on one, call"
+ + " action=click or action=type with ref= (e.g. ref='e4') — no CSS"
+ + " selector needed. References are valid only for generation "
+ + generation + "; re-snapshot if the page changes."
+ + (snap.truncated() ? " Content truncated — pass selector= to scope"
+ + " to a subtree (e.g. selector='#main')." : ""));
+ if (selector != null && !selector.isBlank()) {
+ result.set("scopedTo", selector);
+ }
+ result.set("refCount", snap.refs().size());
+ result.set("content", snap.tree());
+ return JSONUtil.toJsonPrettyStr(result);
+ } catch (PlaywrightException e) {
+ // Rare pages break the tree walk (exotic custom elements, CSP on
+ // attribute writes). Fall back to the plain visible-text dump so the
+ // model still gets *something* readable, flagged so it knows refs
+ // are unavailable and it must use CSS selectors for this page.
+ log.warn("[BrowserUse] Accessibility snapshot failed, falling back to text dump: {}", e.getMessage());
+ // The tree script wipes data-mate-ref attributes before it walks, so a
+ // mid-walk failure leaves the DOM with no refs. Drop currentRefs too,
+ // otherwise a later ref action would pass the stale-check but resolve
+ // to nothing (bare timeout) instead of a clean re-snapshot prompt.
+ session.invalidateRefs();
+ String jsResult = (String) root.evaluate(TEXT_SNAPSHOT_JS_FALLBACK, maxLen);
+ JSONObject parsed = JSONUtil.parseObj(jsResult);
+ boolean truncated = parsed.getBool("truncated", false);
+ result.set("snapshotMode", "text-fallback");
+ result.set("truncated", truncated);
+ result.set("hint", "Accessibility tree unavailable on this page — no @eN refs."
+ + " Use action=click/type with selector=."
+ + (truncated ? " Content truncated — pass selector= to scope." : ""));
+ if (selector != null && !selector.isBlank()) {
+ result.set("scopedTo", selector);
+ }
+ result.set("content", parsed.getStr("text"));
+ return JSONUtil.toJsonPrettyStr(result);
}
- if (selector != null && !selector.isBlank()) {
- result.set("scopedTo", selector);
- }
- result.set("content", textContent);
- return JSONUtil.toJsonPrettyStr(result);
}
private String doScreenshot(String sessionKey, String path) {
@@ -623,6 +714,11 @@ public class BrowserUseTool {
return error("No browser running. Use action=start first.");
}
+ String blocked = guardReadOrNull(session, "screenshot");
+ if (blocked != null) {
+ return blocked;
+ }
+
session.touch();
Page page = session.page;
@@ -654,63 +750,159 @@ public class BrowserUseTool {
}
}
- private String doClick(String sessionKey, String selector) {
- if (selector == null || selector.isBlank()) {
- return error("selector is required for action=click");
+ /** Result of resolving a click/type/hover/select target: a selector, or an error to return. */
+ private record TargetResolution(String selector, String label, String error) {
+ static TargetResolution ok(String selector, String label) {
+ return new TargetResolution(selector, label, null);
}
+ static TargetResolution fail(String error) {
+ return new TargetResolution(null, null, error);
+ }
+ }
+ /**
+ * Resolve an action target to a CSS selector. A snapshot {@code ref} takes
+ * priority over an explicit CSS {@code selector}; a ref that is not part of
+ * the current snapshot is reported as stale so the caller re-snapshots
+ * instead of getting a bare element-not-found timeout.
+ */
+ private TargetResolution resolveTarget(BrowserSession session, String ref, String selector) {
+ if (ref != null && !ref.isBlank()) {
+ String r = ref.trim();
+ if (!session.currentRefs.contains(r)) {
+ return TargetResolution.fail("ref '" + r + "' is not part of the current snapshot"
+ + " (generation " + session.snapshotGeneration + "). The page likely changed,"
+ + " or you have not snapshotted since it did. Call action=snapshot first,"
+ + " then use a ref from that fresh result.");
+ }
+ return TargetResolution.ok(PageSnapshotScript.selectorForRef(r), r);
+ }
+ if (selector != null && !selector.isBlank()) {
+ return TargetResolution.ok(selector, selector);
+ }
+ return TargetResolution.fail("Either ref (from a snapshot, e.g. ref='e4') or a CSS selector is required.");
+ }
+
+ private String doClick(String sessionKey, String ref, String selector) {
BrowserSession session = requireSession(sessionKey);
if (session == null) {
return error("No browser running. Use action=start first.");
}
+ TargetResolution t = resolveTarget(session, ref, selector);
+ if (t.error() != null) {
+ return error(t.error());
+ }
session.touch();
Page page = session.page;
- page.click(selector);
+ String before = page.url();
+ page.click(t.selector());
page.waitForLoadState(LoadState.DOMCONTENTLOADED);
String title = page.title();
String url = page.url();
+ // A navigation invalidates the snapshot references; a same-page click
+ // (toggle, expand) keeps them so the model can act on more refs.
+ if (!url.equals(before)) {
+ session.invalidateRefs();
+ }
- log.info("[BrowserUse] Clicked: {} (page now: {})", selector, url);
+ log.info("[BrowserUse] Clicked: {} (page now: {})", t.label(), url);
broadcastBrowserEvent("click", true, url, title, null, 0);
JSONObject result = new JSONObject();
result.set("ok", true);
- result.set("selector", selector);
+ result.set("target", t.label());
result.set("currentUrl", url);
result.set("currentTitle", title);
- result.set("message", "Clicked element: " + selector);
+ if (!url.equals(before)) {
+ result.set("navigated", true);
+ result.set("hint", "The page navigated — previous @eN refs are stale. Re-snapshot before acting.");
+ }
+ result.set("message", "Clicked element: " + t.label());
return JSONUtil.toJsonPrettyStr(result);
}
- private String doType(String sessionKey, String selector, String text) {
- if (selector == null || selector.isBlank()) {
- return error("selector is required for action=type");
- }
+ private String doType(String sessionKey, String ref, String selector, String text) {
if (text == null) {
return error("text is required for action=type");
}
-
BrowserSession session = requireSession(sessionKey);
if (session == null) {
return error("No browser running. Use action=start first.");
}
+ TargetResolution t = resolveTarget(session, ref, selector);
+ if (t.error() != null) {
+ return error(t.error());
+ }
session.touch();
- Page page = session.page;
+ session.page.fill(t.selector(), text);
- page.fill(selector, text);
-
- log.info("[BrowserUse] Typed into: {} ({} chars)", selector, text.length());
+ log.info("[BrowserUse] Typed into: {} ({} chars)", t.label(), text.length());
broadcastBrowserEvent("type", true, null, null, null, 0);
JSONObject result = new JSONObject();
result.set("ok", true);
- result.set("selector", selector);
+ result.set("target", t.label());
result.set("textLength", text.length());
- result.set("message", "Typed " + text.length() + " characters into " + selector);
+ result.set("message", "Typed " + text.length() + " characters into " + t.label());
+ return JSONUtil.toJsonPrettyStr(result);
+ }
+
+ private String doHover(String sessionKey, String ref, String selector) {
+ BrowserSession session = requireSession(sessionKey);
+ if (session == null) {
+ return error("No browser running. Use action=start first.");
+ }
+ TargetResolution t = resolveTarget(session, ref, selector);
+ if (t.error() != null) {
+ return error(t.error());
+ }
+
+ session.touch();
+ session.page.hover(t.selector());
+
+ log.info("[BrowserUse] Hovered: {}", t.label());
+ broadcastBrowserEvent("hover", true, null, null, null, 0);
+
+ JSONObject result = new JSONObject();
+ result.set("ok", true);
+ result.set("target", t.label());
+ result.set("message", "Hovered element: " + t.label()
+ + ". Re-snapshot to capture any menu/tooltip it revealed.");
+ return JSONUtil.toJsonPrettyStr(result);
+ }
+
+ private String doSelect(String sessionKey, String ref, String selector, String value) {
+ if (value == null || value.isBlank()) {
+ return error("value is required for action=select");
+ }
+ BrowserSession session = requireSession(sessionKey);
+ if (session == null) {
+ return error("No browser running. Use action=start first.");
+ }
+ TargetResolution t = resolveTarget(session, ref, selector);
+ if (t.error() != null) {
+ return error(t.error());
+ }
+
+ session.touch();
+ // Playwright matches by option value, label, or visible text, so a
+ // human-readable value from the model works without extra hints.
+ List chosen = session.page.selectOption(t.selector(), value);
+
+ log.info("[BrowserUse] Selected {} in {} -> {}", value, t.label(), chosen);
+ broadcastBrowserEvent("select", true, null, null, null, 0);
+
+ JSONObject result = new JSONObject();
+ result.set("ok", true);
+ result.set("target", t.label());
+ result.set("selected", chosen);
+ result.set("message", chosen.isEmpty()
+ ? "No option matched '" + value + "'. Re-snapshot and check the option labels."
+ : "Selected '" + value + "' in " + t.label());
return JSONUtil.toJsonPrettyStr(result);
}
@@ -737,6 +929,11 @@ public class BrowserUseTool {
return error("No browser running. Use action=start first.");
}
+ String blocked = guardReadOrNull(session, "eval");
+ if (blocked != null) {
+ return blocked;
+ }
+
session.touch();
Page page = session.page;
@@ -777,6 +974,110 @@ public class BrowserUseTool {
return JSONUtil.toJsonPrettyStr(result);
}
+ /**
+ * CDP methods that read page/network/storage content. Even when allowlisted,
+ * these are refused by the privacy guard on a sensitive page of a
+ * user-managed browser. Entries ending in {@code .} match a whole domain.
+ */
+ private static final List CDP_CONTENT_READ = List.of(
+ "Network.getResponseBody", "Network.getResponseBodyForInterception",
+ "Network.getRequestPostData", "Network.getAllCookies", "Network.getCookies",
+ "Storage.", "DOMStorage.", "IndexedDB.", "CacheStorage.",
+ "Page.captureScreenshot", "Page.captureSnapshot", "Page.printToPDF",
+ "Page.getResourceContent", "Page.getResourceTree",
+ "DOM.getOuterHTML", "DOM.getDocument", "Runtime.evaluate", "Runtime.getProperties");
+
+ private boolean isCdpMethodAllowed(String method) {
+ for (String entry : launcher.properties().getCdp().getAllowedMethods()) {
+ if (entry == null || entry.isBlank()) {
+ continue;
+ }
+ String e = entry.trim();
+ if (e.endsWith(".*")) {
+ if (method.startsWith(e.substring(0, e.length() - 1))) { // "Input." prefix
+ return true;
+ }
+ } else if (e.equals(method)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean isCdpContentReading(String method) {
+ for (String p : CDP_CONTENT_READ) {
+ if (p.endsWith(".") ? method.startsWith(p) : method.equals(p)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private String doCdp(String sessionKey, String method, String paramsJson) {
+ if (!launcher.properties().getCdp().isEnabled()) {
+ return error("action=cdp is disabled (mateclaw.browser.cdp.enabled=false).");
+ }
+ if (method == null || method.isBlank()) {
+ return error("method is required for action=cdp (e.g. 'Page.navigate').");
+ }
+ BrowserSession session = requireSession(sessionKey);
+ if (session == null) {
+ return error("No browser running. Use action=start first.");
+ }
+ String m = method.trim();
+ if (!isCdpMethodAllowed(m)) {
+ return error("CDP method '" + m + "' is not allowed. Allowlist: "
+ + launcher.properties().getCdp().getAllowedMethods()
+ + ". Add it to mateclaw.browser.cdp.allowed-methods if you trust it.");
+ }
+ if (isCdpContentReading(m)) {
+ String reason = privacyGuard.blockReason(session.isUserManagedBrowser(),
+ session.page.url(), "cdp:" + m);
+ if (reason != null) {
+ privacyGuard.audit(ToolExecutionContext.conversationId(currentToolContext),
+ "cdp:" + m, session.page.url(), reason);
+ return error(reason);
+ }
+ }
+
+ JsonObject parsed = null;
+ if (paramsJson != null && !paramsJson.isBlank()) {
+ try {
+ parsed = JsonParser.parseString(paramsJson).getAsJsonObject();
+ } catch (RuntimeException e) {
+ return error("params must be a JSON object: " + e.getMessage());
+ }
+ }
+
+ session.touch();
+ // A fresh CDP session per call keeps the escape hatch stateless and avoids
+ // leaking listeners; navigation-triggering methods invalidate references.
+ CDPSession cdp = session.context.newCDPSession(session.page);
+ try {
+ JsonObject res = parsed != null ? cdp.send(m, parsed) : cdp.send(m);
+ if (m.startsWith("Page.navigate") || m.equals("Page.navigateToHistoryEntry")) {
+ session.invalidateRefs();
+ }
+ String out = res != null ? res.toString() : "{}";
+ if (out.length() > 10_000) {
+ out = out.substring(0, 10_000) + "\n... [truncated]";
+ }
+ log.info("[BrowserUse] CDP {} -> {} chars", m, out.length());
+ broadcastBrowserEvent("cdp", true, session.page.url(), null, null, 0);
+ JSONObject result = new JSONObject();
+ result.set("ok", true);
+ result.set("method", m);
+ result.set("result", out);
+ return JSONUtil.toJsonPrettyStr(result);
+ } finally {
+ try {
+ cdp.detach();
+ } catch (Exception ignored) {
+ // best-effort; the session is discarded either way
+ }
+ }
+ }
+
// ==================== CDP Helpers ====================
private boolean isPortOpen(int port) {
@@ -917,6 +1218,35 @@ public class BrowserUseTool {
/** 空闲看门狗定时任务(stop 时取消,避免泄漏) */
volatile ScheduledFuture> idleWatchdog;
+ /**
+ * Monotonic snapshot generation. Each {@code action=snapshot} bumps it and
+ * replaces {@link #currentRefs} with the references assigned that pass.
+ * A click/type by a reference not in {@link #currentRefs} is reported as
+ * stale, prompting the caller to re-snapshot. Safe as plain volatile
+ * because tool calls are serialized per executor.
+ */
+ volatile int snapshotGeneration;
+ volatile java.util.Set currentRefs = java.util.Set.of();
+
+ int nextSnapshotGeneration(java.util.List refs) {
+ this.currentRefs = java.util.Set.copyOf(refs);
+ return ++this.snapshotGeneration;
+ }
+
+ /** Drop all references — the DOM they pointed at is gone (navigation). */
+ void invalidateRefs() {
+ this.currentRefs = java.util.Set.of();
+ }
+
+ /**
+ * True when this session is attached to a Chrome the user runs themselves:
+ * connected over CDP and NOT spawned by us. Only these carry the user's
+ * live logins, so the privacy guard applies only here.
+ */
+ boolean isUserManagedBrowser() {
+ return connectedViaCdp && ownedProcess == null;
+ }
+
BrowserSession(Browser browser, BrowserContext context, Page page,
boolean headed, boolean connectedViaCdp, String cdpUrl,
java.nio.file.Path userDataDir, Process ownedProcess) {
diff --git a/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserPrivacyGuardTest.java b/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserPrivacyGuardTest.java
new file mode 100644
index 00000000..65a31c04
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserPrivacyGuardTest.java
@@ -0,0 +1,86 @@
+package vip.mate.tool.browser;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for {@link BrowserPrivacyGuard} classification and blocking.
+ * The audit mapper is never touched by {@code isSensitive}/{@code blockReason},
+ * so a null mapper is fine here.
+ */
+class BrowserPrivacyGuardTest {
+
+ private BrowserPrivacyGuard guardWith(List sensitive, List trusted) {
+ BrowserProperties props = new BrowserProperties();
+ props.getPrivacy().setSensitiveHosts(sensitive);
+ props.getPrivacy().setTrustedHosts(trusted);
+ return new BrowserPrivacyGuard(props, null);
+ }
+
+ @Test
+ @DisplayName("Heuristic flags banking / webmail / login / admin pages")
+ void heuristicFlagsSensitivePages() {
+ BrowserPrivacyGuard g = guardWith(List.of(), List.of());
+ assertTrue(g.isSensitive("https://www.chase.com/banking"));
+ assertTrue(g.isSensitive("https://mail.google.com/mail/u/0"));
+ assertTrue(g.isSensitive("https://github.com/login"));
+ assertTrue(g.isSensitive("https://example.com/account/settings"));
+ assertTrue(g.isSensitive("https://admin.example.com/"));
+ }
+
+ @Test
+ @DisplayName("Ordinary content pages are not flagged")
+ void ordinaryPagesNotFlagged() {
+ BrowserPrivacyGuard g = guardWith(List.of(), List.of());
+ assertFalse(g.isSensitive("https://example.com/products/42"));
+ assertFalse(g.isSensitive("https://news.example.com/article/hello-world"));
+ assertFalse(g.isSensitive(""));
+ assertFalse(g.isSensitive(null));
+ }
+
+ @Test
+ @DisplayName("trustedHosts overrides both the heuristic and sensitiveHosts")
+ void trustedOverridesEverything() {
+ BrowserPrivacyGuard g = guardWith(List.of("internal.corp"), List.of("mail.google.com"));
+ assertFalse(g.isSensitive("https://mail.google.com/mail"));
+ // subdomain of a trusted host is also trusted
+ BrowserPrivacyGuard g2 = guardWith(List.of(), List.of("example.com"));
+ assertFalse(g2.isSensitive("https://admin.example.com/login"));
+ }
+
+ @Test
+ @DisplayName("sensitiveHosts adds hosts the heuristic would miss")
+ void sensitiveHostsExtendHeuristic() {
+ BrowserPrivacyGuard g = guardWith(List.of("intranet.corp"), List.of());
+ assertTrue(g.isSensitive("https://intranet.corp/dashboard"));
+ assertTrue(g.isSensitive("https://hr.intranet.corp/"));
+ }
+
+ @Test
+ @DisplayName("blockReason only fires for a user-managed browser on a sensitive page")
+ void blockReasonScope() {
+ BrowserPrivacyGuard g = guardWith(List.of(), List.of());
+ // sensitive page but self-spawned browser → allowed
+ assertNull(g.blockReason(false, "https://github.com/login", "screenshot"));
+ // user-managed browser but ordinary page → allowed
+ assertNull(g.blockReason(true, "https://example.com/products", "eval"));
+ // user-managed browser on a sensitive page → blocked
+ assertNotNull(g.blockReason(true, "https://github.com/login", "eval"));
+ }
+
+ @Test
+ @DisplayName("Disabling the guard allows everything")
+ void disabledGuardAllows() {
+ BrowserProperties props = new BrowserProperties();
+ props.getPrivacy().setEnabled(false);
+ BrowserPrivacyGuard g = new BrowserPrivacyGuard(props, null);
+ assertNull(g.blockReason(true, "https://www.chase.com/banking", "screenshot"));
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/tool/browser/PageSnapshotProbe.java b/mateclaw-server/src/test/java/vip/mate/tool/browser/PageSnapshotProbe.java
new file mode 100644
index 00000000..4fed6a64
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/tool/browser/PageSnapshotProbe.java
@@ -0,0 +1,116 @@
+package vip.mate.tool.browser;
+
+import cn.hutool.json.JSONObject;
+import com.microsoft.playwright.Browser;
+import com.microsoft.playwright.ElementHandle;
+import com.microsoft.playwright.Page;
+import com.microsoft.playwright.Playwright;
+import vip.mate.common.net.SsrfProperties;
+
+import java.util.List;
+
+/**
+ * Manual end-to-end probe for {@link PageSnapshotScript}. Not a JUnit test — run via
+ * {@code mvn -q test-compile exec:java -Dexec.mainClass=vip.mate.tool.browser.PageSnapshotProbe
+ * -Dexec.classpathScope=test} (use {@code test-compile}, not {@code compile}, so this
+ * test-scoped class is built).
+ *
+ *
Launches a real browser and drives the EXACT path the tool uses —
+ * {@code page.querySelector("body").evaluate(PageSnapshotScript.SNAPSHOT_JS, opts)} —
+ * against a known page. It exists to lock the most subtle failure mode found in
+ * review: Playwright's {@code ElementHandle.evaluate} passes the element as the
+ * FIRST POSITIONAL ARGUMENT (never as {@code this}). If the snapshot function is
+ * ever reverted to read {@code this}, the walk starts from the wrong root and the
+ * tree comes back empty (or throws) — this probe then fails loudly instead of the
+ * regression shipping silently, the way the original {@code this}-based version did.
+ *
+ *
Asserts, on a fixed HTML page:
+ *
+ *
references are assigned (non-empty) and materialised as {@code data-mate-ref};
+ *
interactive elements appear with the right role + accessible name
+ * (incl. wrapping-label resolution and the {@code display:none} filter);
+ *
{@code [data-mate-ref='e1']} resolves back to the expected element.
+ *
+ * Exits non-zero on any failed assertion.
+ */
+public final class PageSnapshotProbe {
+
+ private static final String HTML = """
+
+
Probe Form
+
+ Learn more
+
+
+
+
+ """;
+
+ private static int failures = 0;
+
+ public static void main(String[] args) {
+ System.out.println("=== PageSnapshotScript probe ===");
+ BrowserProperties props = new BrowserProperties();
+ BrowserLauncher launcher = new BrowserLauncher(props, new SsrfProperties());
+
+ int exit = 0;
+ try (Playwright pw = Playwright.create()) {
+ BrowserLauncher.Result r = launcher.launch(pw, false);
+ if (!r.isSuccess()) {
+ System.err.println("FAIL: could not launch a browser: " + r.getFailureSummary());
+ System.exit(1);
+ }
+ try (Browser browser = r.getBrowser()) {
+ Page page = r.getPage();
+ page.setContent(HTML);
+
+ // EXACT tool path: element handle + Hutool JSONObject opts.
+ ElementHandle root = page.querySelector("body");
+ JSONObject opts = new JSONObject();
+ opts.set("maxLen", 20_000);
+ opts.set("includeNonInteractive", true);
+ String json = (String) root.evaluate(PageSnapshotScript.SNAPSHOT_JS, opts);
+ PageSnapshotScript.Result snap = PageSnapshotScript.Result.fromJson(json);
+
+ System.out.println("tree:\n" + snap.tree());
+ System.out.println("refs: " + snap.refs());
+
+ List refs = snap.refs();
+ String tree = snap.tree();
+
+ // The regression guard: the this-vs-first-arg bug makes this empty.
+ check(!refs.isEmpty(), "references assigned (element passed as first arg, not `this`)");
+ check(refs.size() >= 4, "at least 4 interactive refs (input, link, select, button), got " + refs.size());
+
+ check(tree.contains("textbox \"Customer name:\""), "input resolves wrapping-label name");
+ check(tree.contains("link \"Learn more\""), "anchor rendered as link with text");
+ check(tree.contains("combobox"), "select rendered as combobox");
+ check(tree.contains("button \"Submit\""), "button rendered with text");
+ check(tree.contains("heading \"Probe Form\" [level=1]"), "h1 rendered as heading level 1");
+ check(!tree.contains("Hidden Button"), "display:none subtree is filtered out");
+
+ boolean e1Resolves = page.querySelector("[data-mate-ref='e1']") != null;
+ check(e1Resolves, "[data-mate-ref='e1'] resolves back to a live element");
+
+ if (failures == 0) {
+ System.out.println("\nALL CHECKS PASSED (" + refs.size() + " refs) via " + r.getStrategy());
+ } else {
+ System.err.println("\n" + failures + " CHECK(S) FAILED");
+ exit = 1;
+ }
+ }
+ } catch (Exception e) {
+ System.err.println("FAIL: probe threw: " + e);
+ e.printStackTrace();
+ exit = 1;
+ }
+ System.exit(exit);
+ }
+
+ private static void check(boolean ok, String what) {
+ System.out.println((ok ? " [PASS] " : " [FAIL] ") + what);
+ if (!ok) {
+ failures++;
+ }
+ }
+}