Browser: fix attachOnly loopback CDP detection

This commit is contained in:
mbelinky
2026-04-13 19:03:58 +02:00
parent 117ae85bf5
commit a0725fa630
8 changed files with 191 additions and 8 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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,

View File

@@ -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",
},
],
});
});
});

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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,
}),
]);
});
});

View File

@@ -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) {