From 7d4df2545161433fd5aa5d91c2f4490d44252385 Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Sat, 21 Feb 2026 16:08:19 -0500 Subject: [PATCH] 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 --- src/agents/tools/browser-tool.schema.ts | 8 +- src/browser/chrome.ts | 25 ++++- src/browser/server-context.availability.ts | 17 ++++ .../server-context.chrome-test-harness.ts | 1 + ...ontext.loopback-fallback-to-remote.test.ts | 91 +++++++++++++++++++ .../server.control-server.test-harness.ts | 1 + 6 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 src/browser/server-context.loopback-fallback-to-remote.test.ts diff --git a/src/agents/tools/browser-tool.schema.ts b/src/agents/tools/browser-tool.schema.ts index aef51f6359d..ba22c4b570e 100644 --- a/src/agents/tools/browser-tool.schema.ts +++ b/src/agents/tools/browser-tool.schema.ts @@ -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()), diff --git a/src/browser/chrome.ts b/src/browser/chrome.ts index 1cb94cf39fb..41a7c53efce 100644 --- a/src/browser/chrome.ts +++ b/src/browser/chrome.ts @@ -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}`, ); } diff --git a/src/browser/server-context.availability.ts b/src/browser/server-context.availability.ts index 6630c17a4c0..5097af05dbf 100644 --- a/src/browser/server-context.availability.ts +++ b/src/browser/server-context.availability.ts @@ -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 { diff --git a/src/browser/server-context.chrome-test-harness.ts b/src/browser/server-context.chrome-test-harness.ts index 95ebe8097e6..85b3db943e7 100644 --- a/src/browser/server-context.chrome-test-harness.ts +++ b/src/browser/server-context.chrome-test-harness.ts @@ -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 () => {}), })); diff --git a/src/browser/server-context.loopback-fallback-to-remote.test.ts b/src/browser/server-context.loopback-fallback-to-remote.test.ts new file mode 100644 index 00000000000..846669dedba --- /dev/null +++ b/src/browser/server-context.loopback-fallback-to-remote.test.ts @@ -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"); + }); +}); diff --git a/src/browser/server.control-server.test-harness.ts b/src/browser/server.control-server.test-harness.ts index 57b8d191655..2a9fb3082a4 100644 --- a/src/browser/server.control-server.test-harness.ts +++ b/src/browser/server.control-server.test-harness.ts @@ -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;