fix(browser): multi-strategy launcher + self-diagnostics for win/linux

This commit is contained in:
matevip 2026-04-24 21:37:46 +08:00
parent a3289d2780
commit 83567e95f0
7 changed files with 1137 additions and 118 deletions

View File

@ -8,6 +8,7 @@ import vip.mate.exception.MateClawException;
import vip.mate.llm.model.ProviderInfoDTO;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.llm.service.ModelProviderService;
import vip.mate.tool.browser.BrowserDiagnosticsService;
import vip.mate.tool.mcp.model.McpServerEntity;
import vip.mate.tool.mcp.runtime.McpClientManager;
import vip.mate.tool.mcp.runtime.McpClientManager.ConnectionResult;
@ -34,6 +35,7 @@ public class SystemHealthService {
private final McpClientManager mcpClientManager;
private final McpServerService mcpServerService;
private final DatabaseBootstrapRunner bootstrapRunner;
private final BrowserDiagnosticsService browserDiagnostics;
public HealthResponse check() {
List<HealthCheck> checks = new ArrayList<>();
@ -50,6 +52,9 @@ public class SystemHealthService {
// 4. Database initialization check
checks.add(checkDatabase());
// 5. Browser launch pre-flight (common failure source on fresh win/linux hosts)
checks.add(checkBrowser());
// Determine overall status
String overall = "healthy";
for (HealthCheck c : checks) {
@ -161,6 +166,28 @@ public class SystemHealthService {
);
}
private HealthCheck checkBrowser() {
try {
BrowserDiagnosticsService.Report report = browserDiagnostics.run();
String status = switch (report.overall()) {
case "healthy" -> "healthy";
case "warning" -> "warning";
default -> "error";
};
String message = "healthy".equals(report.overall())
? "Browser launch ready"
: String.join(" | ", report.advice());
HealthAction action = "healthy".equals(report.overall())
? null
: new HealthAction("Diagnose", "/api/v1/system/browser-health");
return new HealthCheck("browser", status, message, action);
} catch (Exception e) {
log.warn("Browser diagnostics failed: {}", e.getMessage());
return new HealthCheck("browser", "warning",
"Browser diagnostics failed: " + e.getMessage(), null);
}
}
// ==================== Response Records ====================
public record HealthResponse(String overall, List<HealthCheck> checks) {}

View File

@ -0,0 +1,320 @@
package vip.mate.tool.browser;
import cn.hutool.http.HttpUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* Diagnoses why the browser tool might fail to launch on this host.
*
* <p>Runs a dry inventory detecting system browsers, Playwright cache, Node runtime,
* required shared libraries on Linux, container / root context without actually
* launching a session. Produces a structured report with actionable next steps.
*/
@Slf4j
@Service
public class BrowserDiagnosticsService {
private static final boolean IS_WINDOWS = System.getProperty("os.name", "")
.toLowerCase(Locale.ROOT).contains("win");
private static final boolean IS_LINUX = System.getProperty("os.name", "")
.toLowerCase(Locale.ROOT).contains("linux");
/** Shared libraries Chromium needs on Linux. Missing any is a hard block. */
private static final List<String> REQUIRED_LINUX_LIBS = List.of(
"libnss3", "libgbm", "libasound", "libxkbcommon", "libx11", "libxcomposite",
"libxdamage", "libxrandr", "libxfixes", "libatk", "libcups", "libpango"
);
private final BrowserProperties props;
public BrowserDiagnosticsService(BrowserProperties props) {
this.props = props;
}
public Report run() {
List<Finding> findings = new ArrayList<>();
findings.add(inspectEnvironment());
findings.add(inspectConfiguredCdp());
findings.add(inspectConfiguredPath());
findings.add(inspectEnvPath());
findings.add(inspectSystemBrowsers());
findings.add(inspectPlaywrightCache());
if (IS_LINUX) {
findings.add(inspectLinuxLibs());
}
String overall = deriveOverall(findings);
List<String> advice = deriveAdvice(findings);
return new Report(overall, findings, advice);
}
// ==================== Individual probes ====================
private Finding inspectEnvironment() {
Map<String, Object> data = new LinkedHashMap<>();
data.put("os", System.getProperty("os.name"));
data.put("arch", System.getProperty("os.arch"));
data.put("user", System.getProperty("user.name"));
data.put("container", BrowserLauncher.isRunningInContainer());
data.put("root", BrowserLauncher.isRunningAsRoot());
return new Finding("environment", Status.INFO, "Runtime environment", data, null);
}
private Finding inspectConfiguredCdp() {
String url = props.getCdpUrl();
if (url == null || url.isBlank()) {
return new Finding("config.cdp-url", Status.INFO, "mateclaw.browser.cdp-url not set", Map.of(), null);
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("url", url);
try {
String resp = HttpUtil.get(stripTrailing(url) + "/json/version", 2000);
if (resp != null && resp.contains("webSocketDebuggerUrl")) {
data.put("reachable", true);
return new Finding("config.cdp-url", Status.OK,
"CDP endpoint reachable", data, null);
}
data.put("reachable", false);
data.put("response", resp);
return new Finding("config.cdp-url", Status.ERROR,
"CDP endpoint did not return a valid /json/version payload", data,
"Ensure Chrome was started with --remote-debugging-port=" + port(url) + " and /json/version is reachable.");
} catch (Exception e) {
data.put("error", e.getMessage());
return new Finding("config.cdp-url", Status.ERROR,
"CDP endpoint unreachable: " + e.getMessage(), data,
"Start Chrome with --remote-debugging-port or clear mateclaw.browser.cdp-url.");
}
}
private Finding inspectConfiguredPath() {
String path = props.getChromePath();
if (path == null || path.isBlank()) {
return new Finding("config.chrome-path", Status.INFO, "mateclaw.browser.chrome-path not set", Map.of(), null);
}
Path p = Path.of(path);
if (!Files.exists(p)) {
return new Finding("config.chrome-path", Status.ERROR,
"Configured chrome-path does not exist: " + path, Map.of("path", path),
"Install Chrome at that path, or clear mateclaw.browser.chrome-path.");
}
if (!Files.isExecutable(p)) {
return new Finding("config.chrome-path", Status.ERROR,
"Configured chrome-path is not executable: " + path, Map.of("path", path),
"chmod +x the binary, or point to the real chrome executable.");
}
return new Finding("config.chrome-path", Status.OK, "Configured chrome-path is valid",
Map.of("path", path), null);
}
private Finding inspectEnvPath() {
String env = System.getenv("CHROME_PATH");
if (env == null || env.isBlank()) {
return new Finding("env.CHROME_PATH", Status.INFO, "CHROME_PATH not set", Map.of(), null);
}
Path p = Path.of(env);
if (!Files.exists(p)) {
return new Finding("env.CHROME_PATH", Status.WARN,
"CHROME_PATH points to a missing file: " + env, Map.of("path", env),
"Fix CHROME_PATH or unset it to let auto-detection run.");
}
return new Finding("env.CHROME_PATH", Status.OK, "CHROME_PATH resolves to a real file",
Map.of("path", env), null);
}
private Finding inspectSystemBrowsers() {
List<Map<String, Object>> found = new ArrayList<>();
for (Path candidate : BrowserLauncher.systemBrowserCandidates()) {
if (Files.exists(candidate)) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("path", candidate.toString());
entry.put("executable", Files.isExecutable(candidate));
found.add(entry);
}
}
if (found.isEmpty()) {
return new Finding("system.browsers", Status.WARN,
"No system Chrome / Edge / Brave found on well-known paths",
Map.of("scanned", BrowserLauncher.systemBrowserCandidates().stream().map(Path::toString).toList()),
installBrowserAdvice());
}
return new Finding("system.browsers", Status.OK,
"Found " + found.size() + " system browser(s)", Map.of("found", found), null);
}
private Finding inspectPlaywrightCache() {
Path cacheDir = playwrightCacheDir();
Map<String, Object> data = new LinkedHashMap<>();
data.put("cacheDir", cacheDir.toString());
if (!Files.isDirectory(cacheDir)) {
return new Finding("playwright.cache", Status.WARN,
"Playwright browser cache not found (bundled chromium unavailable)", data,
"Run `mvn exec:java -e -Dexec.mainClass=\"com.microsoft.playwright.CLI\" -Dexec.args=\"install chromium\"` " +
"or rely on system Chrome (recommended).");
}
try (var stream = Files.list(cacheDir)) {
List<String> entries = stream.map(p -> p.getFileName().toString()).filter(n -> n.contains("chromium")).toList();
data.put("chromiumBuilds", entries);
if (entries.isEmpty()) {
return new Finding("playwright.cache", Status.WARN,
"Playwright cache has no chromium build", data,
"Run playwright install chromium or use system Chrome.");
}
return new Finding("playwright.cache", Status.OK,
"Playwright bundled chromium available (" + entries.size() + " build(s))", data, null);
} catch (IOException e) {
data.put("error", e.getMessage());
return new Finding("playwright.cache", Status.WARN,
"Failed to read Playwright cache: " + e.getMessage(), data, null);
}
}
private Finding inspectLinuxLibs() {
// Pick the first available system browser to ldd-check.
Path binary = BrowserLauncher.systemBrowserCandidates().stream()
.filter(Files::exists).findFirst().orElse(null);
if (binary == null) {
return new Finding("linux.libs", Status.INFO, "No system browser to ldd-check", Map.of(), null);
}
try {
Process p = new ProcessBuilder("ldd", binary.toString())
.redirectErrorStream(true).start();
StringBuilder out = new StringBuilder();
try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
out.append(line).append('\n');
}
}
p.waitFor(5, TimeUnit.SECONDS);
String dump = out.toString();
List<String> missing = new ArrayList<>();
for (String line : dump.split("\n")) {
if (line.contains("not found")) {
missing.add(line.trim());
}
}
if (!missing.isEmpty()) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("binary", binary.toString());
data.put("missing", missing);
return new Finding("linux.libs", Status.ERROR,
"Chromium shared libraries missing — browser will fail to start", data,
"apt-get install -y " + String.join(" ", REQUIRED_LINUX_LIBS.stream().map(l -> l + "-dev").toList())
+ " (or your distro's equivalent)");
}
return new Finding("linux.libs", Status.OK, "All required shared libraries resolved",
Map.of("binary", binary.toString()), null);
} catch (Exception e) {
return new Finding("linux.libs", Status.INFO,
"ldd probe failed: " + e.getMessage(), Map.of(), null);
}
}
// ==================== Helpers ====================
private static Path playwrightCacheDir() {
String override = System.getenv("PLAYWRIGHT_BROWSERS_PATH");
if (override != null && !override.isBlank() && !"0".equals(override)) {
return Path.of(override);
}
String home = System.getProperty("user.home");
if (IS_WINDOWS) {
String local = System.getenv("LOCALAPPDATA");
if (local != null && !local.isBlank()) {
return Path.of(local, "ms-playwright");
}
return Path.of(home, "AppData", "Local", "ms-playwright");
}
if (System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("mac")) {
return Path.of(home, "Library", "Caches", "ms-playwright");
}
return Path.of(home, ".cache", "ms-playwright");
}
private static String stripTrailing(String url) {
String s = url.trim();
while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
return s;
}
private static String port(String url) {
int colon = url.lastIndexOf(':');
if (colon < 0) return "?";
String tail = url.substring(colon + 1);
int slash = tail.indexOf('/');
return slash > 0 ? tail.substring(0, slash) : tail;
}
private static String installBrowserAdvice() {
if (IS_WINDOWS) {
return "Install Chrome (https://www.google.com/chrome/) or Edge, or set mateclaw.browser.chrome-path.";
}
if (IS_LINUX) {
return "Install Chrome: `wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - && apt install google-chrome-stable` or `apt install chromium`.";
}
return "Install Chrome or Edge, or set mateclaw.browser.chrome-path to point at a browser binary.";
}
private static String deriveOverall(List<Finding> findings) {
boolean hasError = findings.stream().anyMatch(f -> f.status == Status.ERROR);
boolean hasWarn = findings.stream().anyMatch(f -> f.status == Status.WARN);
boolean canLaunch = findings.stream().anyMatch(
f -> f.status == Status.OK && (f.id.equals("system.browsers")
|| f.id.equals("config.cdp-url") || f.id.equals("config.chrome-path")
|| f.id.equals("playwright.cache")));
if (canLaunch && !hasError) return "healthy";
if (canLaunch) return "warning";
if (hasError || !canLaunch) return "error";
return hasWarn ? "warning" : "healthy";
}
private static List<String> deriveAdvice(List<Finding> findings) {
List<String> out = new ArrayList<>();
for (Finding f : findings) {
if (f.advice != null && (f.status == Status.ERROR || f.status == Status.WARN)) {
out.add("[" + f.id + "] " + f.advice);
}
}
if (out.isEmpty()) {
out.add("Browser stack looks healthy.");
}
return out;
}
// ==================== Records ====================
public enum Status { OK, WARN, ERROR, INFO }
public record Finding(String id, Status status, String message, Map<String, Object> data, String advice) {}
public record Report(String overall, List<Finding> findings, List<String> advice) {}
/** Summarise the report as a short string suitable for logs / tool responses. */
public static String summarise(Report r) {
StringBuilder sb = new StringBuilder();
sb.append("Browser diagnostics: ").append(r.overall).append('\n');
for (Finding f : r.findings) {
sb.append(" [").append(f.status).append("] ").append(f.id).append("").append(f.message).append('\n');
}
if (!r.advice.isEmpty()) {
sb.append("Advice:\n");
for (String a : r.advice) sb.append(" - ").append(a).append('\n');
}
return sb.toString();
}
}

View File

@ -0,0 +1,29 @@
package vip.mate.tool.browser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import vip.mate.common.result.R;
/**
* Browser self-check endpoint. Call this when the browser tool fails the response
* tells you exactly what's broken (missing binary, missing libs, broken CDP, etc.)
* and how to fix it, without needing to inspect server logs.
*/
@Tag(name = "System Health")
@RestController
@RequestMapping("/api/v1/system")
@RequiredArgsConstructor
public class BrowserHealthController {
private final BrowserDiagnosticsService diagnostics;
@Operation(summary = "Browser launch diagnostics")
@GetMapping("/browser-health")
public R<BrowserDiagnosticsService.Report> getBrowserHealth() {
return R.ok(diagnostics.run());
}
}

View File

@ -0,0 +1,516 @@
package vip.mate.tool.browser;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.BrowserType;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import com.microsoft.playwright.PlaywrightException;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
/**
* Multi-strategy browser launcher. Tries, in order: an existing CDP endpoint, a
* user-configured executable, a Playwright channel, auto-detected system Chrome /
* Edge / Brave, Playwright's bundled Chromium, and finally self-launching a system
* browser with {@code --remote-debugging-port=0} and attaching over CDP (the same
* pattern openfang uses).
*
* <p>Each attempt is recorded with its outcome so diagnostics can surface exactly
* what failed and how the user can fix it.
*/
@Slf4j
@Component
public class BrowserLauncher {
private static final boolean IS_WINDOWS = System.getProperty("os.name", "")
.toLowerCase(Locale.ROOT).contains("win");
private static final boolean IS_MAC = System.getProperty("os.name", "")
.toLowerCase(Locale.ROOT).contains("mac");
private final BrowserProperties props;
public BrowserLauncher(BrowserProperties props) {
this.props = props;
}
public BrowserProperties properties() {
return props;
}
/**
* Launch a browser session. Tries every available strategy until one succeeds.
* The returned result always contains an {@code attempts} trace, even on success,
* so callers can surface "what we ended up using".
*/
public Result launch(Playwright pw, boolean headed) {
List<Attempt> trace = new ArrayList<>();
// 1. Explicit CDP endpoint user manages the Chrome process
String cdpUrl = props.getCdpUrl();
if (cdpUrl != null && !cdpUrl.isBlank()) {
Result r = tryCdp(pw, cdpUrl, trace, Strategy.CONFIG_CDP);
if (r != null) return r;
}
// 2. Explicit executable path (property or env var)
String explicitPath = firstNonBlank(props.getChromePath(), System.getenv("CHROME_PATH"));
if (explicitPath != null) {
Result r = tryExecutablePath(pw, explicitPath, headed, trace, Strategy.CONFIG_PATH);
if (r != null) return r;
}
// 3. Explicit channel (chrome / msedge / etc.)
String channel = props.getChannel();
if (channel != null && !channel.isBlank()) {
Result r = tryChannel(pw, channel, headed, trace, Strategy.CONFIG_CHANNEL);
if (r != null) return r;
}
// 4. Prefer system browser via channel auto-detection (chrome, then msedge)
if (props.isPreferSystem()) {
for (String autoChannel : new String[]{"chrome", "msedge"}) {
Result r = tryChannel(pw, autoChannel, headed, trace, Strategy.AUTO_CHANNEL);
if (r != null) return r;
}
// 5. Scan well-known install paths and launch via executablePath
for (Path candidate : systemBrowserCandidates()) {
Result r = tryExecutablePath(pw, candidate.toString(), headed, trace, Strategy.AUTO_PATH);
if (r != null) return r;
}
}
// 6. Playwright's bundled Chromium (requires `playwright install`)
Result bundled = tryBundled(pw, headed, trace);
if (bundled != null) return bundled;
// 7. Last resort: spawn system chrome with --remote-debugging-port=0 and attach via CDP.
// This bypasses Playwright's Node launcher entirely useful when Playwright install is broken.
if (props.isAllowExternalCdpFallback()) {
Result external = tryExternalCdpLaunch(pw, headed, trace);
if (external != null) return external;
}
// All strategies failed
log.warn("[BrowserLauncher] All launch strategies failed. Trace:\n{}", formatTrace(trace));
return Result.failure(trace, summariseFailure(trace));
}
// ==================== Strategy implementations ====================
private Result tryCdp(Playwright pw, String url, List<Attempt> trace, Strategy strategy) {
String normalized = normalizeCdpUrl(url);
long t0 = System.currentTimeMillis();
try {
Browser browser = pw.chromium().connectOverCDP(normalized);
BrowserContext context;
Page page;
List<BrowserContext> contexts = browser.contexts();
if (!contexts.isEmpty()) {
context = contexts.get(0);
List<Page> pages = context.pages();
page = pages.isEmpty() ? context.newPage() : pages.get(0);
} else {
context = browser.newContext();
page = context.newPage();
}
long elapsed = System.currentTimeMillis() - t0;
trace.add(Attempt.ok(strategy, "connectOverCDP(" + normalized + ")", elapsed));
return Result.success(browser, context, page, true, normalized, strategy, trace);
} catch (Exception e) {
trace.add(Attempt.fail(strategy, "connectOverCDP(" + normalized + ")",
System.currentTimeMillis() - t0, e.getMessage()));
return null;
}
}
private Result tryExecutablePath(Playwright pw, String path, boolean headed,
List<Attempt> trace, Strategy strategy) {
if (!Files.exists(Path.of(path))) {
trace.add(Attempt.fail(strategy, "executablePath=" + path, 0, "file not found"));
return null;
}
long t0 = System.currentTimeMillis();
try {
BrowserType.LaunchOptions opts = baseLaunchOptions(headed)
.setExecutablePath(Path.of(path));
Browser browser = pw.chromium().launch(opts);
Result r = wrapLocalBrowser(browser, strategy, "executablePath=" + path,
System.currentTimeMillis() - t0, trace);
return r;
} catch (PlaywrightException e) {
trace.add(Attempt.fail(strategy, "executablePath=" + path,
System.currentTimeMillis() - t0, e.getMessage()));
return null;
}
}
private Result tryChannel(Playwright pw, String channel, boolean headed,
List<Attempt> trace, Strategy strategy) {
long t0 = System.currentTimeMillis();
try {
BrowserType.LaunchOptions opts = baseLaunchOptions(headed).setChannel(channel);
Browser browser = pw.chromium().launch(opts);
return wrapLocalBrowser(browser, strategy, "channel=" + channel,
System.currentTimeMillis() - t0, trace);
} catch (PlaywrightException e) {
trace.add(Attempt.fail(strategy, "channel=" + channel,
System.currentTimeMillis() - t0, e.getMessage()));
return null;
}
}
private Result tryBundled(Playwright pw, boolean headed, List<Attempt> trace) {
long t0 = System.currentTimeMillis();
try {
Browser browser = pw.chromium().launch(baseLaunchOptions(headed));
return wrapLocalBrowser(browser, Strategy.BUNDLED, "playwright-bundled-chromium",
System.currentTimeMillis() - t0, trace);
} catch (PlaywrightException e) {
trace.add(Attempt.fail(Strategy.BUNDLED, "playwright-bundled-chromium",
System.currentTimeMillis() - t0, e.getMessage()));
return null;
}
}
/**
* Spawn a system browser ourselves with {@code --remote-debugging-port=0}, parse stderr
* to recover the actual DevTools WebSocket URL, then attach via Playwright's CDP client.
* This is the openfang pattern it sidesteps Playwright's Node-based launcher entirely,
* so it still works when `playwright install` has not been run or Node is flaky.
*/
private Result tryExternalCdpLaunch(Playwright pw, boolean headed, List<Attempt> trace) {
long t0 = System.currentTimeMillis();
Path browserBin = null;
for (Path candidate : systemBrowserCandidates()) {
if (Files.exists(candidate)) {
browserBin = candidate;
break;
}
}
if (browserBin == null) {
trace.add(Attempt.fail(Strategy.EXTERNAL_CDP, "external-chrome-spawn",
System.currentTimeMillis() - t0, "no system browser executable found"));
return null;
}
List<String> command = new ArrayList<>();
command.add(browserBin.toString());
command.add("--remote-debugging-port=0");
command.add("--no-first-run");
command.add("--no-default-browser-check");
command.add("--disable-extensions");
command.add("--disable-background-networking");
if (props.isHeadless() && !headed) {
command.add("--headless=new");
}
if (isRunningAsRoot() || IS_WINDOWS) {
command.add("--no-sandbox");
}
if (isRunningInContainer()) {
command.add("--disable-dev-shm-usage");
}
command.add("about:blank");
ProcessBuilder pb = new ProcessBuilder(command).redirectErrorStream(false);
// SECURITY: don't leak the parent process's secrets (API keys, etc.) into chrome.
// Keep only the vars Chrome actually needs to run. openfang does the same via env_clear.
java.util.Map<String, String> env = pb.environment();
java.util.Map<String, String> keep = new java.util.LinkedHashMap<>();
for (String key : new String[]{"PATH", "HOME", "USERPROFILE", "SYSTEMROOT", "TEMP", "TMP", "TMPDIR",
"APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "DISPLAY", "WAYLAND_DISPLAY"}) {
String v = env.get(key);
if (v != null) keep.put(key, v);
}
env.clear();
env.putAll(keep);
Process proc;
try {
proc = pb.start();
} catch (Exception e) {
trace.add(Attempt.fail(Strategy.EXTERNAL_CDP, browserBin + " --remote-debugging-port",
System.currentTimeMillis() - t0, "spawn failed: " + e.getMessage()));
return null;
}
String wsUrl;
try {
wsUrl = readDevToolsUrl(proc, props.getCdpTimeoutSeconds());
} catch (Exception e) {
proc.destroyForcibly();
trace.add(Attempt.fail(Strategy.EXTERNAL_CDP, browserBin.toString(),
System.currentTimeMillis() - t0, e.getMessage()));
return null;
}
// Derive http base Playwright's connectOverCDP accepts ws:// directly, but http:// is safer.
String cdpBase = wsUrl.replaceFirst("^ws://", "http://").replaceFirst("/devtools/.*", "");
try {
Browser browser = pw.chromium().connectOverCDP(cdpBase);
BrowserContext context = browser.contexts().isEmpty()
? browser.newContext()
: browser.contexts().get(0);
Page page = context.pages().isEmpty() ? context.newPage() : context.pages().get(0);
long elapsed = System.currentTimeMillis() - t0;
trace.add(Attempt.ok(Strategy.EXTERNAL_CDP, browserBin + " + connectOverCDP(" + cdpBase + ")", elapsed));
return Result.success(browser, context, page, true, cdpBase, Strategy.EXTERNAL_CDP, trace);
} catch (Exception e) {
proc.destroyForcibly();
trace.add(Attempt.fail(Strategy.EXTERNAL_CDP, "connectOverCDP(" + cdpBase + ")",
System.currentTimeMillis() - t0, e.getMessage()));
return null;
}
}
// ==================== Helpers ====================
private BrowserType.LaunchOptions baseLaunchOptions(boolean headed) {
BrowserType.LaunchOptions opts = new BrowserType.LaunchOptions().setHeadless(!headed);
List<String> args = chromiumLaunchArgs();
if (!args.isEmpty()) {
opts.setArgs(args);
}
return opts;
}
private Result wrapLocalBrowser(Browser browser, Strategy strategy, String desc,
long elapsedMs, List<Attempt> trace) {
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
.setViewportSize(props.getViewportWidth(), props.getViewportHeight())
.setLocale("zh-CN"));
Page page = context.newPage();
trace.add(Attempt.ok(strategy, desc, elapsedMs));
return Result.success(browser, context, page, false, null, strategy, trace);
}
public static List<String> chromiumLaunchArgs() {
List<String> args = new ArrayList<>();
boolean inContainer = isRunningInContainer();
boolean asRoot = isRunningAsRoot();
if (IS_WINDOWS || inContainer || asRoot) {
args.add("--no-sandbox");
}
if (inContainer) {
args.add("--disable-dev-shm-usage");
}
if (IS_WINDOWS) {
args.add("--disable-gpu");
}
return args;
}
/** Platform-specific candidate paths — same list openfang uses. */
public static List<Path> systemBrowserCandidates() {
List<Path> paths = new ArrayList<>();
if (IS_WINDOWS) {
String pf = System.getenv("ProgramFiles");
String pf86 = System.getenv("ProgramFiles(x86)");
String local = System.getenv("LOCALAPPDATA");
for (String root : new String[]{pf, pf86}) {
if (root == null || root.isBlank()) continue;
paths.add(Path.of(root, "Google", "Chrome", "Application", "chrome.exe"));
paths.add(Path.of(root, "Microsoft", "Edge", "Application", "msedge.exe"));
paths.add(Path.of(root, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"));
}
if (local != null && !local.isBlank()) {
paths.add(Path.of(local, "Google", "Chrome", "Application", "chrome.exe"));
paths.add(Path.of(local, "Microsoft", "Edge", "Application", "msedge.exe"));
}
} else if (IS_MAC) {
paths.add(Path.of("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"));
paths.add(Path.of("/Applications/Chromium.app/Contents/MacOS/Chromium"));
paths.add(Path.of("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"));
paths.add(Path.of("/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"));
} else {
// Linux
paths.add(Path.of("/usr/bin/google-chrome"));
paths.add(Path.of("/usr/bin/google-chrome-stable"));
paths.add(Path.of("/usr/bin/chromium"));
paths.add(Path.of("/usr/bin/chromium-browser"));
paths.add(Path.of("/snap/bin/chromium"));
paths.add(Path.of("/usr/bin/microsoft-edge"));
paths.add(Path.of("/usr/bin/microsoft-edge-stable"));
paths.add(Path.of("/usr/bin/brave-browser"));
}
return paths;
}
public static boolean isRunningInContainer() {
try {
if (Files.exists(Path.of("/.dockerenv"))) return true;
Path cgroup = Path.of("/proc/1/cgroup");
if (Files.exists(cgroup)) {
String content = Files.readString(cgroup);
return content.contains("docker") || content.contains("kubepods") || content.contains("containerd");
}
} catch (Exception ignored) {}
return false;
}
public static boolean isRunningAsRoot() {
if (IS_WINDOWS) return false;
try {
Path self = Path.of("/proc/self/status");
if (Files.exists(self)) {
for (String line : Files.readAllLines(self)) {
if (line.startsWith("Uid:")) {
String[] parts = line.split("\\s+");
return parts.length > 1 && "0".equals(parts[1]);
}
}
}
String userName = System.getProperty("user.name", "");
return "root".equals(userName);
} catch (Exception ignored) {
return false;
}
}
private static String normalizeCdpUrl(String url) {
String s = url.trim();
if (!s.startsWith("http")) {
s = "http://" + s;
}
s = s.replace("://localhost:", "://127.0.0.1:");
s = s.replace("://localhost/", "://127.0.0.1/");
if (s.endsWith("://localhost")) {
s = s.replace("://localhost", "://127.0.0.1");
}
return s;
}
private static String firstNonBlank(String... values) {
if (values == null) return null;
for (String v : values) {
if (v != null && !v.isBlank()) return v;
}
return null;
}
private static String readDevToolsUrl(Process proc, int timeoutSeconds) throws Exception {
long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(timeoutSeconds);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(proc.getErrorStream(), StandardCharsets.UTF_8))) {
StringBuilder accumulated = new StringBuilder();
String line;
while (System.currentTimeMillis() < deadline) {
if (!reader.ready()) {
if (!proc.isAlive()) {
throw new IllegalStateException(
"Chromium exited before printing DevTools URL. stderr=" + accumulated);
}
Thread.sleep(50);
continue;
}
line = reader.readLine();
if (line == null) break;
accumulated.append(line).append('\n');
int idx = line.indexOf("DevTools listening on ");
if (idx >= 0) {
return line.substring(idx + "DevTools listening on ".length()).trim();
}
}
}
throw new IllegalStateException(
"Timed out (" + timeoutSeconds + "s) waiting for 'DevTools listening on' from chromium stderr");
}
public static String formatTrace(List<Attempt> trace) {
StringBuilder sb = new StringBuilder();
for (Attempt a : trace) {
sb.append(String.format(" [%s] %s %-7s %dms %s%n",
a.strategy(), a.ok() ? "\u2713" : "\u2717", a.strategy().name(),
a.elapsedMs(), a.ok() ? a.detail() : (a.detail() + " \u2014 " + a.error())));
}
return sb.toString();
}
private static String summariseFailure(List<Attempt> trace) {
StringBuilder sb = new StringBuilder("Browser launch failed. Tried: ");
for (int i = 0; i < trace.size(); i++) {
if (i > 0) sb.append("; ");
Attempt a = trace.get(i);
sb.append(a.strategy().name()).append(" ").append(a.ok() ? "ok" : "(" + brief(a.error()) + ")");
}
return sb.toString();
}
private static String brief(String err) {
if (err == null) return "unknown";
String first = err.lines().findFirst().orElse(err);
return first.length() > 120 ? first.substring(0, 120) + "..." : first;
}
// ==================== Types ====================
public enum Strategy {
/** User-configured CDP endpoint (mateclaw.browser.cdp-url). */
CONFIG_CDP,
/** User-configured executable path (mateclaw.browser.chrome-path or CHROME_PATH env). */
CONFIG_PATH,
/** User-configured channel (mateclaw.browser.channel). */
CONFIG_CHANNEL,
/** Auto-detected Playwright channel (chrome, msedge). */
AUTO_CHANNEL,
/** Auto-detected system browser on well-known install paths. */
AUTO_PATH,
/** Playwright's bundled Chromium (requires `playwright install`). */
BUNDLED,
/** Spawn system chrome with --remote-debugging-port=0 and attach via CDP. */
EXTERNAL_CDP
}
public record Attempt(Strategy strategy, String detail, long elapsedMs, boolean ok, String error) {
static Attempt ok(Strategy s, String d, long ms) { return new Attempt(s, d, ms, true, null); }
static Attempt fail(Strategy s, String d, long ms, String e) { return new Attempt(s, d, ms, false, e); }
}
@Getter
public static final class Result {
private final Browser browser;
private final BrowserContext context;
private final Page page;
private final boolean connectedViaCdp;
private final String cdpUrl;
private final Strategy strategy;
private final List<Attempt> attempts;
private final boolean success;
private final String failureSummary;
private Result(Browser browser, BrowserContext context, Page page,
boolean connectedViaCdp, String cdpUrl, Strategy strategy,
List<Attempt> attempts, boolean success, String failureSummary) {
this.browser = browser;
this.context = context;
this.page = page;
this.connectedViaCdp = connectedViaCdp;
this.cdpUrl = cdpUrl;
this.strategy = strategy;
this.attempts = attempts;
this.success = success;
this.failureSummary = failureSummary;
}
static Result success(Browser browser, BrowserContext context, Page page,
boolean cdp, String cdpUrl, Strategy strategy, List<Attempt> attempts) {
return new Result(browser, context, page, cdp, cdpUrl, strategy, attempts, true, null);
}
static Result failure(List<Attempt> attempts, String summary) {
return new Result(null, null, null, false, null, null, attempts, false, summary);
}
}
}

View File

@ -0,0 +1,60 @@
package vip.mate.tool.browser;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* Browser launch configuration. Supports multiple fallback strategies so we can
* launch a browser on machines where Playwright's bundled Chromium download is
* unavailable (offline CI, corporate firewalls, minimal containers).
*
* <p>Precedence when launching (highest first):
* <ol>
* <li>{@link #cdpUrl} connect to an already-running Chrome via DevTools Protocol</li>
* <li>{@link #chromePath} or {@code CHROME_PATH} env explicit executable</li>
* <li>{@link #channel} Playwright channel ("chrome", "msedge", ...)</li>
* <li>Auto-detect system Chrome/Edge/Brave on well-known paths</li>
* <li>Playwright's bundled Chromium (requires {@code playwright install})</li>
* <li>External-process CDP launch (run system chrome with --remote-debugging-port and attach)</li>
* </ol>
*/
@Data
@Component
@ConfigurationProperties(prefix = "mateclaw.browser")
public class BrowserProperties {
/** Pre-started Chrome CDP endpoint (e.g. http://127.0.0.1:9222). Highest priority when set. */
private String cdpUrl = "";
/** Absolute path to chrome.exe / google-chrome / msedge. Overrides channel/auto-detect. */
private String chromePath = "";
/** Playwright channel: chrome | msedge | chrome-beta | chrome-dev | msedge-beta | msedge-dev. */
private String channel = "";
/** Try system-installed browsers (channel + path scan) before Playwright's bundled Chromium. */
private boolean preferSystem = true;
/** Default headless for auto-started sessions. {@code action=start headed=true} overrides. */
private boolean headless = true;
/** Enable the last-resort strategy: spawn chrome --remote-debugging-port=0 and connect via CDP. */
private boolean allowExternalCdpFallback = true;
/** Connect timeout (seconds) for CDP / external-CDP attach. */
private int cdpTimeoutSeconds = 20;
/** Maximum concurrent browser sessions across all agents. Prevents runaway memory usage. */
private int maxSessions = 5;
/** Block navigations to loopback, private, link-local and cloud-metadata hosts. */
private boolean ssrfCheckEnabled = true;
/** Viewport width (px) for launched browsers. */
private int viewportWidth = 1280;
/** Viewport height (px) for launched browsers. */
private int viewportHeight = 800;
}

View File

@ -0,0 +1,82 @@
package vip.mate.tool.browser;
import java.net.InetAddress;
import java.net.URI;
import java.util.Set;
/**
* SSRF guard rejects URLs that resolve to loopback, link-local, private, or
* known cloud-metadata endpoints. Mirrors openfang's {@code check_ssrf} behaviour.
*
* <p>Call this before passing any user-controlled URL to the browser or to an
* outbound HTTP client.
*/
public final class UrlSafetyChecker {
/** Hostnames that must never be reachable via user-supplied URLs. */
private static final Set<String> BLOCKED_HOSTNAMES = Set.of(
"localhost",
"ip6-localhost",
"metadata.google.internal",
"metadata.aws.internal",
"instance-data",
"169.254.169.254", // AWS / Azure / GCP IMDS
"100.100.100.200", // Alibaba Cloud IMDS
"192.0.0.192", // Azure IMDS alternative
"0.0.0.0",
"::1"
);
private UrlSafetyChecker() {}
/**
* Throw {@link SecurityException} if the URL is unsafe. Accepts http:// and https:// only.
*/
public static void check(String url) {
if (url == null || url.isBlank()) {
throw new SecurityException("URL is required");
}
URI uri;
try {
uri = URI.create(url.trim());
} catch (IllegalArgumentException e) {
throw new SecurityException("Malformed URL: " + url);
}
String scheme = uri.getScheme();
if (scheme == null || !(scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))) {
throw new SecurityException("Only http:// and https:// URLs are allowed (got: " + scheme + ")");
}
String host = uri.getHost();
if (host == null || host.isBlank()) {
throw new SecurityException("URL must have a host");
}
String hostname = host.startsWith("[") && host.endsWith("]")
? host.substring(1, host.length() - 1)
: host;
if (BLOCKED_HOSTNAMES.contains(hostname.toLowerCase())) {
throw new SecurityException("SSRF blocked: " + hostname + " is a restricted hostname");
}
try {
for (InetAddress addr : InetAddress.getAllByName(hostname)) {
if (addr.isLoopbackAddress() || addr.isAnyLocalAddress()
|| addr.isLinkLocalAddress() || addr.isSiteLocalAddress()
|| addr.isMulticastAddress() || isMetadataIp(addr)) {
throw new SecurityException("SSRF blocked: " + hostname
+ " resolves to restricted address " + addr.getHostAddress());
}
}
} catch (SecurityException e) {
throw e;
} catch (Exception e) {
// DNS resolution failure let the caller deal with it (browser will show its own error).
}
}
private static boolean isMetadataIp(InetAddress addr) {
String ip = addr.getHostAddress();
return "169.254.169.254".equals(ip)
|| "100.100.100.200".equals(ip)
|| "192.0.0.192".equals(ip)
|| "fd00:ec2::254".equalsIgnoreCase(ip);
}
}

View File

@ -4,20 +4,25 @@ import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.microsoft.playwright.*;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import com.microsoft.playwright.PlaywrightException;
import com.microsoft.playwright.options.LoadState;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import vip.mate.tool.browser.BrowserDiagnosticsService;
import vip.mate.tool.browser.BrowserLauncher;
import vip.mate.tool.browser.UrlSafetyChecker;
import java.net.Socket;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.*;
/**
@ -34,14 +39,17 @@ public class BrowserUseTool {
private static final int CDP_SCAN_PORT_MIN = 9000;
private static final int CDP_SCAN_PORT_MAX = 10000;
private static final boolean IS_WINDOWS = System.getProperty("os.name", "")
.toLowerCase(Locale.ROOT).contains("win");
/** SSE 推送器(用于将浏览器操作实时推送到前端) */
/** SSE broadcaster for pushing browser actions to the frontend in real time. */
private final vip.mate.channel.web.ChatStreamTracker streamTracker;
private final BrowserLauncher launcher;
private final BrowserDiagnosticsService diagnostics;
public BrowserUseTool(vip.mate.channel.web.ChatStreamTracker streamTracker) {
public BrowserUseTool(vip.mate.channel.web.ChatStreamTracker streamTracker,
BrowserLauncher launcher,
BrowserDiagnosticsService diagnostics) {
this.streamTracker = streamTracker;
this.launcher = launcher;
this.diagnostics = diagnostics;
}
/**
@ -60,12 +68,13 @@ public class BrowserUseTool {
});
@Tool(description = """
Control a browser (Playwright). Default is headless. Use headed=true with action=start for a visible window.
Control a browser (Playwright with multi-strategy launch: system Chrome/Edge channel, explicit path, bundled, or external CDP).
Default is headless. Use headed=true with action=start for a visible window.
Typical flow: start open(url) snapshot click/type stop.
For CDP: connect_cdp(url="http://localhost:9222") to attach to an existing Chrome, or list_cdp_targets to scan.
If start fails, run action=diagnose for a full report of what's missing and how to fix it.
Supported actions:
- start: Launch a new browser. Optional headed=true for visible window.
- 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.
@ -76,9 +85,10 @@ public class BrowserUseTool {
- 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") String action,
@ToolParam(description = "Action: start|stop|open|snapshot|screenshot|click|type|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 for target element (for click/type)", required = false) String selector,
@ToolParam(description = "Text to type (for action=type)", required = false) String text,
@ -107,7 +117,8 @@ public class BrowserUseTool {
case "connect_cdp" -> doConnectCdp(sessionKey, url);
case "list_cdp_targets" -> doListCdpTargets(cdpPort);
case "navigate_back" -> doNavigateBack(sessionKey);
default -> error("Unknown action: " + action + ". Supported: start, stop, open, snapshot, screenshot, click, type, eval, connect_cdp, list_cdp_targets, navigate_back");
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");
};
} catch (PlaywrightException e) {
log.error("[BrowserUse] Playwright error: {}", e.getMessage());
@ -181,34 +192,40 @@ public class BrowserUseTool {
doStop(sessionKey);
}
log.info("[BrowserUse] Starting browser (headed={})", headed);
int max = launcher.properties().getMaxSessions();
if (max > 0 && sessions.size() >= max) {
return error("Maximum browser sessions reached (" + max
+ "). Stop an existing session first or raise mateclaw.browser.max-sessions.");
}
log.info("[BrowserUse] Starting browser via launcher (headed={})", headed);
long startTime = System.currentTimeMillis();
Playwright pw = getOrCreatePlaywright();
BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions()
.setHeadless(!headed);
BrowserLauncher.Result r = launcher.launch(pw, headed);
// 平台特定启动参数
List<String> extraArgs = chromiumLaunchArgs();
if (!extraArgs.isEmpty()) {
launchOptions.setArgs(extraArgs);
log.debug("[BrowserUse] Chromium extra args: {}", extraArgs);
if (!r.isSuccess()) {
log.warn("[BrowserUse] All launch strategies failed:\n{}",
BrowserLauncher.formatTrace(r.getAttempts()));
broadcastBrowserEvent("start", false, null, null, null,
System.currentTimeMillis() - startTime);
JSONObject result = new JSONObject();
result.set("ok", false);
result.set("error", r.getFailureSummary());
result.set("hint", "Run action=diagnose for a detailed report and fix suggestions.");
return JSONUtil.toJsonPrettyStr(result);
}
Browser browser = pw.chromium().launch(launchOptions);
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
.setViewportSize(1280, 800)
.setLocale("zh-CN"));
Page page = context.newPage();
BrowserSession session = new BrowserSession(browser, context, page, headed, false, null);
BrowserSession session = new BrowserSession(r.getBrowser(), r.getContext(), r.getPage(),
headed, r.isConnectedViaCdp(), r.getCdpUrl());
sessions.put(sessionKey, session);
scheduleIdleCheck(sessionKey);
long elapsed = System.currentTimeMillis() - startTime;
log.info("[BrowserUse] Browser started successfully (headed={}) in {}ms", headed, elapsed);
log.info("[BrowserUse] Browser started via {} in {}ms", r.getStrategy(), elapsed);
broadcastBrowserEvent("start", true, null, null, null, elapsed);
return ok("Browser started (headed=" + headed + ") in " + elapsed + "ms. Use action=open with url to navigate.");
return ok("Browser started via " + r.getStrategy() + " (headed=" + headed + ") in "
+ elapsed + "ms. Use action=open with url to navigate.");
}
private String doConnectCdp(String sessionKey, String cdpUrl) {
@ -216,60 +233,59 @@ public class BrowserUseTool {
return error("url is required for action=connect_cdp (e.g. http://127.0.0.1:9222)");
}
// Stop existing session if any
BrowserSession existing = sessions.get(sessionKey);
if (existing != null) {
doStop(sessionKey);
}
// Normalize CDP URL and force IPv4 to avoid ECONNREFUSED ::1 on macOS
String normalizedCdpUrl = cdpUrl.trim();
if (!normalizedCdpUrl.startsWith("http")) {
normalizedCdpUrl = "http://" + normalizedCdpUrl;
}
normalizedCdpUrl = normalizedCdpUrl.replace("://localhost:", "://127.0.0.1:");
normalizedCdpUrl = normalizedCdpUrl.replace("://localhost/", "://127.0.0.1/");
if (normalizedCdpUrl.endsWith("://localhost")) {
normalizedCdpUrl = normalizedCdpUrl.replace("://localhost", "://127.0.0.1");
}
log.info("[BrowserUse] Connecting to CDP at: {}", normalizedCdpUrl);
// Delegate to the launcher with the user-provided URL injected as a one-shot override.
// The launcher handles URL normalisation (localhost 127.0.0.1, protocol prefix).
long startTime = System.currentTimeMillis();
Playwright pw = getOrCreatePlaywright();
Browser browser = pw.chromium().connectOverCDP(normalizedCdpUrl);
// Get existing contexts and pages
List<BrowserContext> contexts = browser.contexts();
BrowserContext context;
Page page;
if (!contexts.isEmpty()) {
context = contexts.get(0);
List<Page> pages = context.pages();
page = pages.isEmpty() ? context.newPage() : pages.get(0);
} else {
context = browser.newContext();
page = context.newPage();
String priorCdp = launcher.properties().getCdpUrl();
launcher.properties().setCdpUrl(cdpUrl);
BrowserLauncher.Result r;
try {
r = launcher.launch(pw, true);
} finally {
launcher.properties().setCdpUrl(priorCdp);
}
BrowserSession session = new BrowserSession(browser, context, page, true, true, normalizedCdpUrl);
if (!r.isSuccess() || !r.isConnectedViaCdp()) {
log.warn("[BrowserUse] CDP connect failed. Trace:\n{}",
BrowserLauncher.formatTrace(r.getAttempts()));
return error("Failed to connect to CDP at " + cdpUrl + ": " + r.getFailureSummary());
}
BrowserSession session = new BrowserSession(r.getBrowser(), r.getContext(), r.getPage(),
true, true, r.getCdpUrl());
sessions.put(sessionKey, session);
scheduleIdleCheck(sessionKey);
String title = page.title();
String currentUrl = page.url();
long elapsed = System.currentTimeMillis() - startTime;
log.info("[BrowserUse] Connected to CDP at {} in {}ms (page: {} - {})", normalizedCdpUrl, elapsed, currentUrl, title);
String title = r.getPage().title();
String currentUrl = r.getPage().url();
log.info("[BrowserUse] Connected to CDP at {} in {}ms (page: {} - {})",
r.getCdpUrl(), elapsed, currentUrl, title);
JSONObject result = new JSONObject();
result.set("ok", true);
result.set("cdpUrl", normalizedCdpUrl);
result.set("cdpUrl", r.getCdpUrl());
result.set("currentUrl", currentUrl);
result.set("currentTitle", title);
result.set("pagesCount", context.pages().size());
result.set("message", "Connected to Chrome via CDP at " + normalizedCdpUrl + ". Current page: " + title);
result.set("pagesCount", r.getContext().pages().size());
result.set("message", "Connected to Chrome via CDP at " + r.getCdpUrl() + ". Current page: " + title);
return JSONUtil.toJsonPrettyStr(result);
}
private String doDiagnose() {
BrowserDiagnosticsService.Report report = diagnostics.run();
JSONObject result = new JSONObject();
result.set("ok", "healthy".equals(report.overall()) || "warning".equals(report.overall()));
result.set("overall", report.overall());
result.set("findings", JSONUtil.parseArray(JSONUtil.toJsonStr(report.findings())));
result.set("advice", report.advice());
result.set("summary", BrowserDiagnosticsService.summarise(report));
return JSONUtil.toJsonPrettyStr(result);
}
@ -342,20 +358,32 @@ public class BrowserUseTool {
return error("url is required for action=open");
}
BrowserSession session = getSession(sessionKey);
if (session == null) {
doStart(sessionKey, false);
session = getSession(sessionKey);
}
session.touch();
Page page = session.page;
String normalizedUrl = url.trim();
if (!normalizedUrl.matches("^https?://.*")) {
normalizedUrl = "https://" + normalizedUrl;
}
if (launcher.properties().isSsrfCheckEnabled()) {
try {
UrlSafetyChecker.check(normalizedUrl);
} catch (SecurityException se) {
log.warn("[BrowserUse] SSRF check rejected url={}: {}", normalizedUrl, se.getMessage());
return error(se.getMessage());
}
}
BrowserSession session = getSession(sessionKey);
if (session == null) {
String startResp = doStart(sessionKey, false);
session = getSession(sessionKey);
if (session == null) {
return startResp;
}
}
session.touch();
Page page = session.page;
page.navigate(normalizedUrl);
page.waitForLoadState(LoadState.DOMCONTENTLOADED);
@ -580,49 +608,6 @@ public class BrowserUseTool {
return JSONUtil.toJsonPrettyStr(result);
}
// ==================== Platform Helpers ====================
/**
* 返回 Chromium 在当前平台下需要的额外启动参数
* <p>
* Windows: --no-sandbox沙箱兼容性+ --disable-gpuGPU 硬件加速问题
* 容器环境: --no-sandbox + --disable-dev-shm-usage共享内存不足
*/
private static List<String> chromiumLaunchArgs() {
List<String> args = new ArrayList<>();
boolean inContainer = isRunningInContainer();
if (IS_WINDOWS || inContainer) {
args.add("--no-sandbox");
}
if (inContainer) {
args.add("--disable-dev-shm-usage");
}
if (IS_WINDOWS) {
args.add("--disable-gpu");
}
return args;
}
/**
* 检测是否运行在 Docker/容器环境中
*/
private static boolean isRunningInContainer() {
try {
// Docker 容器中通常存在 /.dockerenv 文件
if (java.nio.file.Files.exists(java.nio.file.Path.of("/.dockerenv"))) {
return true;
}
// 或者 /proc/1/cgroup 包含 docker/kubepods
java.nio.file.Path cgroup = java.nio.file.Path.of("/proc/1/cgroup");
if (java.nio.file.Files.exists(cgroup)) {
String content = java.nio.file.Files.readString(cgroup);
return content.contains("docker") || content.contains("kubepods") || content.contains("containerd");
}
} catch (Exception ignored) {}
return false;
}
// ==================== CDP Helpers ====================
private boolean isPortOpen(int port) {