Merge 7d4df2545161433fd5aa5d91c2f4490d44252385 into 8a05c05596ca9ba0735dafd8e359885de4c2c969

This commit is contained in:
Alberto Leal 2026-03-21 05:49:43 +00:00 committed by GitHub
commit 3bc407616b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 140 additions and 3 deletions

View File

@ -89,7 +89,13 @@ export const BrowserToolSchema = Type.Object({
action: stringEnum(BROWSER_TOOL_ACTIONS),
target: optionalStringEnum(BROWSER_TARGETS),
node: Type.Optional(Type.String()),
profile: Type.Optional(Type.String()),
profile: Type.Optional(
Type.String({
description:
"Browser profile name. Omit to use the default profile (recommended). " +
"Use action=profiles to list available profiles.",
}),
),
targetUrl: Type.Optional(Type.String()),
url: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),

View File

@ -2,6 +2,7 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { isLoopbackHost } from "../gateway/net.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { ensurePortAvailable } from "../infra/ports.js";
import { rawDataToString } from "../infra/ws.js";
@ -73,7 +74,9 @@ export type RunningChrome = {
proc: ChildProcessWithoutNullStreams;
};
function resolveBrowserExecutable(resolved: ResolvedBrowserConfig): BrowserExecutable | null {
export function resolveBrowserExecutable(
resolved: ResolvedBrowserConfig,
): BrowserExecutable | null {
return resolveBrowserExecutableForPlatform(resolved, process.platform);
}
@ -264,8 +267,26 @@ export async function launchOpenClawChrome(
const exe = resolveBrowserExecutable(resolved);
if (!exe) {
const remoteProfiles = Object.entries(resolved.profiles)
.filter(([, p]) => {
const url = p.cdpUrl?.trim();
if (!url) {
return false;
}
try {
return !isLoopbackHost(new URL(url).hostname);
} catch {
return false;
}
})
.map(([name]) => name);
const hint =
remoteProfiles.length > 0
? ` A remote browser profile is available: "${remoteProfiles[0]}". ` +
`Use profile="${remoteProfiles[0]}" or set browser.defaultProfile to "${remoteProfiles[0]}".`
: "";
throw new Error(
"No supported browser found (Chrome/Brave/Edge/Chromium on macOS, Linux, or Windows).",
`No supported browser found (Chrome/Brave/Edge/Chromium on macOS, Linux, or Windows).${hint}`,
);
}

View File

@ -13,8 +13,10 @@ import {
isChromeCdpReady,
isChromeReachable,
launchOpenClawChrome,
resolveBrowserExecutable,
stopOpenClawChrome,
} from "./chrome.js";
import { resolveProfile } from "./config.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserProfileUnavailableError } from "./errors.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
@ -182,6 +184,21 @@ export function createProfileAvailability({
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
);
}
// Before attempting local Chrome launch, check if a browser executable exists.
// If not and there's a different remote default profile, suggest using it instead
// of failing with "No supported browser found".
if (!resolveBrowserExecutable(current.resolved)) {
const defaultProfileName = current.resolved.defaultProfile;
if (defaultProfileName && defaultProfileName !== profile.name) {
const defaultResolved = resolveProfile(current.resolved, defaultProfileName);
if (defaultResolved && !defaultResolved.cdpIsLoopback) {
throw new Error(
`Profile "${profile.name}" requires a local browser but none is installed. ` +
`Use the default profile "${defaultProfileName}" instead (remote CDP at ${defaultResolved.cdpUrl}).`,
);
}
}
}
const launched = await launchOpenClawChrome(current.resolved, profile);
attachRunning(launched);
try {

View File

@ -10,6 +10,7 @@ vi.mock("./chrome.js", () => ({
launchOpenClawChrome: vi.fn(async () => {
throw new Error("unexpected launch");
}),
resolveBrowserExecutable: vi.fn(() => null),
resolveOpenClawUserDataDir: vi.fn(() => chromeUserDataDir.dir),
stopOpenClawChrome: vi.fn(async () => {}),
}));

View File

@ -0,0 +1,91 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BrowserServerState } from "./server-context.js";
import "./server-context.chrome-test-harness.js";
import { createBrowserRouteContext } from "./server-context.js";
afterEach(() => {
vi.restoreAllMocks();
});
/**
* Build a BrowserServerState with both a loopback "openclaw" profile and a remote profile.
* This simulates a containerized gateway that has a remote browser pod but no local Chrome.
*/
function makeStateWithRemoteDefault(): BrowserServerState {
return {
server: null,
port: 0,
resolved: {
enabled: true,
controlPort: 18791,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
cdpPortRangeStart: 18800,
cdpPortRangeEnd: 18899,
evaluateEnabled: false,
extraArgs: [],
color: "#FF4500",
headless: false,
noSandbox: false,
attachOnly: false,
ssrfPolicy: { allowPrivateNetwork: true },
defaultProfile: "remote",
profiles: {
remote: {
cdpUrl: "http://openclaw-browser.openclaw.svc.cluster.local:9222",
cdpPort: 9222,
color: "#0066CC",
},
openclaw: { cdpPort: 18800, color: "#FF4500" },
},
},
profiles: new Map(),
};
}
describe("ensureBrowserAvailable loopback-to-remote fallback", () => {
it("suggests the remote default profile when local Chrome is not installed", async () => {
const state = makeStateWithRemoteDefault();
// Mock: loopback CDP port is not reachable (nothing running locally)
const { isChromeReachable, resolveBrowserExecutable } = await import("./chrome.js");
vi.mocked(isChromeReachable).mockResolvedValue(false);
// No Chrome executable installed
vi.mocked(resolveBrowserExecutable).mockReturnValue(null);
const ctx = createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: false,
});
// Request the "openclaw" loopback profile explicitly (this is what the LLM does)
const profileCtx = ctx.forProfile("openclaw");
await expect(profileCtx.ensureBrowserAvailable()).rejects.toThrow(
/Use the default profile "remote" instead/,
);
});
it("throws original error when no remote default profile exists", async () => {
const state = makeStateWithRemoteDefault();
// Make the default profile also loopback
state.resolved.defaultProfile = "openclaw";
const { isChromeReachable, resolveBrowserExecutable } = await import("./chrome.js");
vi.mocked(isChromeReachable).mockResolvedValue(false);
vi.mocked(resolveBrowserExecutable).mockReturnValue(null);
const ctx = createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: false,
});
const profileCtx = ctx.forProfile("openclaw");
// Should fall through to launchOpenClawChrome which throws "unexpected launch" (from harness mock)
await expect(profileCtx.ensureBrowserAvailable()).rejects.toThrow("unexpected launch");
});
});

View File

@ -265,6 +265,7 @@ vi.mock("./chrome.js", () => ({
proc,
};
}),
resolveBrowserExecutable: vi.fn(() => ({ kind: "chrome", path: "/fake/chrome" })),
resolveOpenClawUserDataDir: vi.fn(() => chromeUserDataDir.dir),
stopOpenClawChrome: vi.fn(async () => {
state.reachable = false;