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;