Merge 7d4df2545161433fd5aa5d91c2f4490d44252385 into 8a05c05596ca9ba0735dafd8e359885de4c2c969
This commit is contained in:
commit
3bc407616b
@ -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