mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(browser): multi-strategy launcher + self-diagnostics for win/linux
This commit is contained in:
parent
a3289d2780
commit
83567e95f0
@ -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) {}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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-gpu(GPU 硬件加速问题)
|
||||
* 容器环境: --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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user