From 83567e95f01f03d16963f6ae8898b0be1cf63128 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 24 Apr 2026 21:37:46 +0800 Subject: [PATCH] fix(browser): multi-strategy launcher + self-diagnostics for win/linux --- .../system/service/SystemHealthService.java | 27 + .../browser/BrowserDiagnosticsService.java | 320 +++++++++++ .../tool/browser/BrowserHealthController.java | 29 + .../mate/tool/browser/BrowserLauncher.java | 516 ++++++++++++++++++ .../mate/tool/browser/BrowserProperties.java | 60 ++ .../mate/tool/browser/UrlSafetyChecker.java | 82 +++ .../vip/mate/tool/builtin/BrowserUseTool.java | 221 ++++---- 7 files changed, 1137 insertions(+), 118 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserDiagnosticsService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserHealthController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemHealthService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemHealthService.java index ef4e1a7a..1128ffb4 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemHealthService.java +++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemHealthService.java @@ -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 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 checks) {} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserDiagnosticsService.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserDiagnosticsService.java new file mode 100644 index 00000000..4d216bda --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserDiagnosticsService.java @@ -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. + * + *

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 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 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 advice = deriveAdvice(findings); + return new Report(overall, findings, advice); + } + + // ==================== Individual probes ==================== + + private Finding inspectEnvironment() { + Map 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 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> found = new ArrayList<>(); + for (Path candidate : BrowserLauncher.systemBrowserCandidates()) { + if (Files.exists(candidate)) { + Map 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 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 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 missing = new ArrayList<>(); + for (String line : dump.split("\n")) { + if (line.contains("not found")) { + missing.add(line.trim()); + } + } + if (!missing.isEmpty()) { + Map 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 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 deriveAdvice(List findings) { + List 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 data, String advice) {} + + public record Report(String overall, List findings, List 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(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserHealthController.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserHealthController.java new file mode 100644 index 00000000..a1c37837 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserHealthController.java @@ -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 getBrowserHealth() { + return R.ok(diagnostics.run()); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java new file mode 100644 index 00000000..de14bb4f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java @@ -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). + * + *

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 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 trace, Strategy strategy) { + String normalized = normalizeCdpUrl(url); + long t0 = System.currentTimeMillis(); + try { + Browser browser = pw.chromium().connectOverCDP(normalized); + BrowserContext context; + Page page; + List contexts = browser.contexts(); + if (!contexts.isEmpty()) { + context = contexts.get(0); + List 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 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 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 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 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 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 env = pb.environment(); + java.util.Map 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 args = chromiumLaunchArgs(); + if (!args.isEmpty()) { + opts.setArgs(args); + } + return opts; + } + + private Result wrapLocalBrowser(Browser browser, Strategy strategy, String desc, + long elapsedMs, List 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 chromiumLaunchArgs() { + List 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 systemBrowserCandidates() { + List 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 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 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 attempts; + private final boolean success; + private final String failureSummary; + + private Result(Browser browser, BrowserContext context, Page page, + boolean connectedViaCdp, String cdpUrl, Strategy strategy, + List 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 attempts) { + return new Result(browser, context, page, cdp, cdpUrl, strategy, attempts, true, null); + } + + static Result failure(List attempts, String summary) { + return new Result(null, null, null, false, null, null, attempts, false, summary); + } + } +} 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 new file mode 100644 index 00000000..c51c16bf --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserProperties.java @@ -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). + * + *

Precedence when launching (highest first): + *

    + *
  1. {@link #cdpUrl} — connect to an already-running Chrome via DevTools Protocol
  2. + *
  3. {@link #chromePath} or {@code CHROME_PATH} env — explicit executable
  4. + *
  5. {@link #channel} — Playwright channel ("chrome", "msedge", ...)
  6. + *
  7. Auto-detect system Chrome/Edge/Brave on well-known paths
  8. + *
  9. Playwright's bundled Chromium (requires {@code playwright install})
  10. + *
  11. External-process CDP launch (run system chrome with --remote-debugging-port and attach)
  12. + *
+ */ +@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; +} + diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java new file mode 100644 index 00000000..bed67935 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/UrlSafetyChecker.java @@ -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. + * + *

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 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); + } +} 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 d2880408..2b5b5ec7 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,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 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 contexts = browser.contexts(); - BrowserContext context; - Page page; - - if (!contexts.isEmpty()) { - context = contexts.get(0); - List 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 在当前平台下需要的额外启动参数。 - *

- * Windows: --no-sandbox(沙箱兼容性)+ --disable-gpu(GPU 硬件加速问题) - * 容器环境: --no-sandbox + --disable-dev-shm-usage(共享内存不足) - */ - private static List chromiumLaunchArgs() { - List 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) {