fix(browser): suggest remote profile when local Chrome is not installed
When a loopback profile (e.g., 'openclaw') is requested but no Chrome executable is found on the system, the error message now suggests using the configured remote default profile instead of the generic 'No supported browser found' message. This fixes browser tool failures in containerized deployments where: 1. The gateway has no local Chrome (runs in a minimal container) 2. A remote browser pod provides CDP access via a 'remote' profile 3. The agent LLM picks profile='openclaw' (the auto-created loopback profile) because the tool schema had no guidance on profile selection Changes: - Export resolveBrowserExecutable() from chrome.ts for reuse - Add pre-launch executable check in ensureBrowserAvailable() that detects missing Chrome and suggests the remote default profile - Improve error message in launchOpenClawChrome() to list available remote profiles when local browser detection fails - Add description to the profile parameter in the browser tool schema to guide LLMs to omit the profile and use the default - Add test harness mock for resolveBrowserExecutable - Add test for the loopback-to-remote fallback behavior
This commit is contained in:
parent
8a05c05596
commit
7d4df25451
@ -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()),
|
||||
|
||||
@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 () => {}),
|
||||
}));
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user