mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:40:44 +00:00
Browser: fix attachOnly loopback CDP detection
This commit is contained in:
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Heartbeat/Telegram topics: keep isolated heartbeat replies on the bound forum topic when `target=last`, instead of dropping them into the group root chat. (#66035) Thanks @mbelinky.
|
||||
- Browser/CDP: let managed local Chrome readiness, status probes, and managed loopback CDP control bypass browser SSRF policy for their own loopback control plane, so OpenClaw no longer misclassifies a healthy child browser as "not reachable after start". (#65695, #66043) Thanks @mbelinky.
|
||||
- Gateway/sessions: stop heartbeat, cron-event, and exec-event turns from overwriting shared-session routing and origin metadata, preventing synthetic `heartbeat` targets from poisoning later cron or user delivery. (#63733, #35300)
|
||||
- Browser/CDP: let local attach-only `manual-cdp` profiles reuse the local loopback CDP control plane under strict default policy and remote-class probe timeouts, so tabs/snapshot stop falsely reporting a live local browser session as not running. (#65611, #66080) Thanks @mbelinky.
|
||||
## 2026.4.12
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -7,12 +7,10 @@ export function resolveCdpReachabilityPolicy(
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): SsrFPolicy | undefined {
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
if (
|
||||
capabilities.mode === "local-managed" &&
|
||||
profile.cdpIsLoopback &&
|
||||
!profile.attachOnly &&
|
||||
profile.driver === "openclaw"
|
||||
) {
|
||||
// The browser SSRF policy protects page/network navigation, not OpenClaw's
|
||||
// own local CDP control plane. Explicit local loopback CDP profiles should
|
||||
// not self-block health/control checks just because they target 127.0.0.1.
|
||||
if (!capabilities.isRemote && profile.cdpIsLoopback && profile.driver === "openclaw") {
|
||||
return undefined;
|
||||
}
|
||||
return ssrfPolicy;
|
||||
|
||||
@@ -20,6 +20,13 @@ export const PROFILE_POST_RESTART_WS_TIMEOUT_MS = 600;
|
||||
export const CHROME_MCP_ATTACH_READY_WINDOW_MS = 8000;
|
||||
export const CHROME_MCP_ATTACH_READY_POLL_MS = 200;
|
||||
|
||||
export function usesFastLoopbackCdpProbeClass(params: {
|
||||
profileIsLoopback: boolean;
|
||||
attachOnly?: boolean;
|
||||
}): boolean {
|
||||
return params.profileIsLoopback && params.attachOnly !== true;
|
||||
}
|
||||
|
||||
function normalizeTimeoutMs(value: number | undefined): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
@@ -29,12 +36,18 @@ function normalizeTimeoutMs(value: number | undefined): number | undefined {
|
||||
|
||||
export function resolveCdpReachabilityTimeouts(params: {
|
||||
profileIsLoopback: boolean;
|
||||
attachOnly?: boolean;
|
||||
timeoutMs?: number;
|
||||
remoteHttpTimeoutMs: number;
|
||||
remoteHandshakeTimeoutMs: number;
|
||||
}): { httpTimeoutMs: number; wsTimeoutMs: number } {
|
||||
const normalized = normalizeTimeoutMs(params.timeoutMs);
|
||||
if (params.profileIsLoopback) {
|
||||
if (
|
||||
usesFastLoopbackCdpProbeClass({
|
||||
profileIsLoopback: params.profileIsLoopback,
|
||||
attachOnly: params.attachOnly,
|
||||
})
|
||||
) {
|
||||
const httpTimeoutMs = normalized ?? PROFILE_HTTP_REACHABILITY_TIMEOUT_MS;
|
||||
const wsTimeoutMs = Math.max(
|
||||
PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import "../../../test-support.js";
|
||||
import "../server-context.chrome-test-harness.js";
|
||||
import * as chromeModule from "../chrome.js";
|
||||
import { createBrowserRouteContext } from "../server-context.js";
|
||||
import { makeBrowserServerState } from "../server-context.test-harness.js";
|
||||
import { registerBrowserTabRoutes } from "./tabs.js";
|
||||
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("browser tab routes attachOnly loopback profiles", () => {
|
||||
it("lists tabs for manual loopback CDP profiles under strict SSRF", async () => {
|
||||
const state = makeBrowserServerState({
|
||||
profile: {
|
||||
name: "manual-cdp",
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
cdpHost: "127.0.0.1",
|
||||
cdpIsLoopback: true,
|
||||
cdpPort: 9222,
|
||||
color: "#00AA00",
|
||||
driver: "openclaw",
|
||||
attachOnly: true,
|
||||
},
|
||||
resolvedOverrides: {
|
||||
defaultProfile: "manual-cdp",
|
||||
ssrfPolicy: {},
|
||||
},
|
||||
});
|
||||
|
||||
const isChromeCdpReady = vi.mocked(chromeModule.isChromeCdpReady);
|
||||
isChromeCdpReady.mockResolvedValue(true);
|
||||
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
expect(String(url)).toBe("http://127.0.0.1:9222/json/list");
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
id: "PAGE-1",
|
||||
title: "WordPress",
|
||||
url: "https://example.test/wp-login.php",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/PAGE-1",
|
||||
type: "page",
|
||||
},
|
||||
],
|
||||
} as unknown as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const { app, getHandlers } = createBrowserRouteApp();
|
||||
registerBrowserTabRoutes(app, ctx as never);
|
||||
const handler = getHandlers.get("/tabs");
|
||||
expect(handler).toBeTypeOf("function");
|
||||
|
||||
const response = createBrowserRouteResponse();
|
||||
await handler?.({ params: {}, query: { profile: "manual-cdp" }, body: {} }, response.res);
|
||||
|
||||
expect(isChromeCdpReady).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:9222",
|
||||
state.resolved.remoteCdpTimeoutMs,
|
||||
state.resolved.remoteCdpHandshakeTimeoutMs,
|
||||
undefined,
|
||||
);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
running: true,
|
||||
tabs: [
|
||||
{
|
||||
targetId: "PAGE-1",
|
||||
title: "WordPress",
|
||||
url: "https://example.test/wp-login.php",
|
||||
wsUrl: "ws://127.0.0.1:9222/devtools/page/PAGE-1",
|
||||
type: "page",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,7 @@ export function createProfileAvailability({
|
||||
const resolveTimeouts = (timeoutMs: number | undefined) =>
|
||||
resolveCdpReachabilityTimeouts({
|
||||
profileIsLoopback: profile.cdpIsLoopback,
|
||||
attachOnly: profile.attachOnly,
|
||||
timeoutMs,
|
||||
remoteHttpTimeoutMs: state().resolved.remoteCdpTimeoutMs,
|
||||
remoteHandshakeTimeoutMs: state().resolved.remoteCdpHandshakeTimeoutMs,
|
||||
|
||||
@@ -131,4 +131,48 @@ describe("browser server-context ensureBrowserAvailable", () => {
|
||||
expect(launchOpenClawChrome).not.toHaveBeenCalled();
|
||||
expect(stopOpenClawChrome).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats attachOnly loopback CDP as local control with remote-class probe timeouts", async () => {
|
||||
const { launchOpenClawChrome, stopOpenClawChrome } = setupEnsureBrowserAvailableHarness();
|
||||
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
|
||||
const isChromeCdpReady = vi.mocked(chromeModule.isChromeCdpReady);
|
||||
|
||||
const state = makeBrowserServerState({
|
||||
profile: {
|
||||
name: "manual-cdp",
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
cdpHost: "127.0.0.1",
|
||||
cdpIsLoopback: true,
|
||||
cdpPort: 9222,
|
||||
color: "#00AA00",
|
||||
driver: "openclaw",
|
||||
attachOnly: true,
|
||||
},
|
||||
resolvedOverrides: {
|
||||
defaultProfile: "manual-cdp",
|
||||
ssrfPolicy: {},
|
||||
},
|
||||
});
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const profile = ctx.forProfile("manual-cdp");
|
||||
|
||||
isChromeReachable.mockResolvedValueOnce(true);
|
||||
isChromeCdpReady.mockResolvedValueOnce(true);
|
||||
|
||||
await expect(profile.ensureBrowserAvailable()).resolves.toBeUndefined();
|
||||
|
||||
expect(isChromeReachable).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:9222",
|
||||
state.resolved.remoteCdpTimeoutMs,
|
||||
undefined,
|
||||
);
|
||||
expect(isChromeCdpReady).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:9222",
|
||||
state.resolved.remoteCdpTimeoutMs,
|
||||
state.resolved.remoteCdpHandshakeTimeoutMs,
|
||||
undefined,
|
||||
);
|
||||
expect(launchOpenClawChrome).not.toHaveBeenCalled();
|
||||
expect(stopOpenClawChrome).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,4 +30,40 @@ describe("browser server-context listProfiles", () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses remote-class probes for attachOnly loopback CDP profiles", async () => {
|
||||
const state = makeBrowserServerState({
|
||||
profile: {
|
||||
name: "manual-cdp",
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
cdpHost: "127.0.0.1",
|
||||
cdpIsLoopback: true,
|
||||
cdpPort: 9222,
|
||||
color: "#00AA00",
|
||||
driver: "openclaw",
|
||||
attachOnly: true,
|
||||
},
|
||||
resolvedOverrides: {
|
||||
defaultProfile: "manual-cdp",
|
||||
ssrfPolicy: {},
|
||||
},
|
||||
});
|
||||
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
|
||||
isChromeReachable.mockResolvedValue(true);
|
||||
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const profiles = await ctx.listProfiles();
|
||||
|
||||
expect(isChromeReachable).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:9222",
|
||||
state.resolved.remoteCdpTimeoutMs,
|
||||
undefined,
|
||||
);
|
||||
expect(profiles).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "manual-cdp",
|
||||
running: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
resolveCdpControlPolicy,
|
||||
resolveCdpReachabilityPolicy,
|
||||
} from "./cdp-reachability-policy.js";
|
||||
import { usesFastLoopbackCdpProbeClass } from "./cdp-timeouts.js";
|
||||
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import { resolveProfile } from "./config.js";
|
||||
@@ -190,9 +191,15 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
||||
} else {
|
||||
// Check if something is listening on the port
|
||||
try {
|
||||
const probeTimeoutMs = usesFastLoopbackCdpProbeClass({
|
||||
profileIsLoopback: profile.cdpIsLoopback,
|
||||
attachOnly: profile.attachOnly,
|
||||
})
|
||||
? 200
|
||||
: current.resolved.remoteCdpTimeoutMs;
|
||||
const reachable = await isChromeReachable(
|
||||
profile.cdpUrl,
|
||||
200,
|
||||
probeTimeoutMs,
|
||||
resolveCdpReachabilityPolicy(profile, current.resolved.ssrfPolicy),
|
||||
);
|
||||
if (reachable) {
|
||||
|
||||
Reference in New Issue
Block a user