diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e78645c4bb..00cdfa2cbd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/extensions/browser/src/browser/cdp-reachability-policy.ts b/extensions/browser/src/browser/cdp-reachability-policy.ts index a84388ef46a..73ebc5d2640 100644 --- a/extensions/browser/src/browser/cdp-reachability-policy.ts +++ b/extensions/browser/src/browser/cdp-reachability-policy.ts @@ -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; diff --git a/extensions/browser/src/browser/cdp-timeouts.ts b/extensions/browser/src/browser/cdp-timeouts.ts index 1014972e42c..9cf647ee48c 100644 --- a/extensions/browser/src/browser/cdp-timeouts.ts +++ b/extensions/browser/src/browser/cdp-timeouts.ts @@ -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, diff --git a/extensions/browser/src/browser/routes/tabs.attach-only.test.ts b/extensions/browser/src/browser/routes/tabs.attach-only.test.ts new file mode 100644 index 00000000000..b8a1a6d5f90 --- /dev/null +++ b/extensions/browser/src/browser/routes/tabs.attach-only.test.ts @@ -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", + }, + ], + }); + }); +}); diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index 26db2187fde..5f56db7fab1 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -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, diff --git a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts index 15c1bab4286..39f3e5770ab 100644 --- a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts +++ b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts @@ -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(); + }); }); diff --git a/extensions/browser/src/browser/server-context.list-profiles.test.ts b/extensions/browser/src/browser/server-context.list-profiles.test.ts index 57dc12d1e73..9ed03a4b6de 100644 --- a/extensions/browser/src/browser/server-context.list-profiles.test.ts +++ b/extensions/browser/src/browser/server-context.list-profiles.test.ts @@ -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, + }), + ]); + }); }); diff --git a/extensions/browser/src/browser/server-context.ts b/extensions/browser/src/browser/server-context.ts index 686e26a5730..2dd46b3294a 100644 --- a/extensions/browser/src/browser/server-context.ts +++ b/extensions/browser/src/browser/server-context.ts @@ -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) {