Skip to content

Commit 2c59ba2

Browse files
authored
fix(browser): detect local attachOnly loopback CDP sessions (openclaw#66080)
Merged via squash. Prepared head SHA: 90c1c10 Reviewed-by: @mbelinky
1 parent 117ae85 commit 2c59ba2

8 files changed

Lines changed: 192 additions & 8 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ Docs: https://docs.openclaw.ai
1717
- 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.
1818
- 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.
1919
- 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)
20+
- 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.
21+
2022
## 2026.4.12
2123

2224
### Changes

extensions/browser/src/browser/cdp-reachability-policy.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,10 @@ export function resolveCdpReachabilityPolicy(
77
ssrfPolicy?: SsrFPolicy,
88
): SsrFPolicy | undefined {
99
const capabilities = getBrowserProfileCapabilities(profile);
10-
if (
11-
capabilities.mode === "local-managed" &&
12-
profile.cdpIsLoopback &&
13-
!profile.attachOnly &&
14-
profile.driver === "openclaw"
15-
) {
10+
// The browser SSRF policy protects page/network navigation, not OpenClaw's
11+
// own local CDP control plane. Explicit local loopback CDP profiles should
12+
// not self-block health/control checks just because they target 127.0.0.1.
13+
if (!capabilities.isRemote && profile.cdpIsLoopback && profile.driver === "openclaw") {
1614
return undefined;
1715
}
1816
return ssrfPolicy;

extensions/browser/src/browser/cdp-timeouts.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ export const PROFILE_POST_RESTART_WS_TIMEOUT_MS = 600;
2020
export const CHROME_MCP_ATTACH_READY_WINDOW_MS = 8000;
2121
export const CHROME_MCP_ATTACH_READY_POLL_MS = 200;
2222

23+
export function usesFastLoopbackCdpProbeClass(params: {
24+
profileIsLoopback: boolean;
25+
attachOnly?: boolean;
26+
}): boolean {
27+
return params.profileIsLoopback && params.attachOnly !== true;
28+
}
29+
2330
function normalizeTimeoutMs(value: number | undefined): number | undefined {
2431
if (typeof value !== "number" || !Number.isFinite(value)) {
2532
return undefined;
@@ -29,12 +36,18 @@ function normalizeTimeoutMs(value: number | undefined): number | undefined {
2936

3037
export function resolveCdpReachabilityTimeouts(params: {
3138
profileIsLoopback: boolean;
39+
attachOnly?: boolean;
3240
timeoutMs?: number;
3341
remoteHttpTimeoutMs: number;
3442
remoteHandshakeTimeoutMs: number;
3543
}): { httpTimeoutMs: number; wsTimeoutMs: number } {
3644
const normalized = normalizeTimeoutMs(params.timeoutMs);
37-
if (params.profileIsLoopback) {
45+
if (
46+
usesFastLoopbackCdpProbeClass({
47+
profileIsLoopback: params.profileIsLoopback,
48+
attachOnly: params.attachOnly,
49+
})
50+
) {
3851
const httpTimeoutMs = normalized ?? PROFILE_HTTP_REACHABILITY_TIMEOUT_MS;
3952
const wsTimeoutMs = Math.max(
4053
PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import "../../../test-support.js";
3+
import "../server-context.chrome-test-harness.js";
4+
import * as chromeModule from "../chrome.js";
5+
import { createBrowserRouteContext } from "../server-context.js";
6+
import { makeBrowserServerState } from "../server-context.test-harness.js";
7+
import { registerBrowserTabRoutes } from "./tabs.js";
8+
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
9+
10+
afterEach(() => {
11+
vi.clearAllMocks();
12+
vi.restoreAllMocks();
13+
});
14+
15+
describe("browser tab routes attachOnly loopback profiles", () => {
16+
it("lists tabs for manual loopback CDP profiles under strict SSRF", async () => {
17+
const state = makeBrowserServerState({
18+
profile: {
19+
name: "manual-cdp",
20+
cdpUrl: "http://127.0.0.1:9222",
21+
cdpHost: "127.0.0.1",
22+
cdpIsLoopback: true,
23+
cdpPort: 9222,
24+
color: "#00AA00",
25+
driver: "openclaw",
26+
attachOnly: true,
27+
},
28+
resolvedOverrides: {
29+
defaultProfile: "manual-cdp",
30+
ssrfPolicy: {},
31+
},
32+
});
33+
34+
const isChromeCdpReady = vi.mocked(chromeModule.isChromeCdpReady);
35+
isChromeCdpReady.mockResolvedValue(true);
36+
37+
const fetchMock = vi.fn(async (url: unknown) => {
38+
expect(String(url)).toBe("http://127.0.0.1:9222/json/list");
39+
return {
40+
ok: true,
41+
json: async () => [
42+
{
43+
id: "PAGE-1",
44+
title: "WordPress",
45+
url: "https://example.test/wp-login.php",
46+
webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/PAGE-1",
47+
type: "page",
48+
},
49+
],
50+
} as unknown as Response;
51+
});
52+
vi.stubGlobal("fetch", fetchMock);
53+
54+
const ctx = createBrowserRouteContext({ getState: () => state });
55+
const { app, getHandlers } = createBrowserRouteApp();
56+
registerBrowserTabRoutes(app, ctx as never);
57+
const handler = getHandlers.get("/tabs");
58+
expect(handler).toBeTypeOf("function");
59+
60+
const response = createBrowserRouteResponse();
61+
await handler?.({ params: {}, query: { profile: "manual-cdp" }, body: {} }, response.res);
62+
63+
expect(isChromeCdpReady).toHaveBeenCalledWith(
64+
"http://127.0.0.1:9222",
65+
state.resolved.remoteCdpTimeoutMs,
66+
state.resolved.remoteCdpHandshakeTimeoutMs,
67+
undefined,
68+
);
69+
expect(response.statusCode).toBe(200);
70+
expect(response.body).toEqual({
71+
running: true,
72+
tabs: [
73+
{
74+
targetId: "PAGE-1",
75+
title: "WordPress",
76+
url: "https://example.test/wp-login.php",
77+
wsUrl: "ws://127.0.0.1:9222/devtools/page/PAGE-1",
78+
type: "page",
79+
},
80+
],
81+
});
82+
});
83+
});

extensions/browser/src/browser/server-context.availability.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export function createProfileAvailability({
6363
const resolveTimeouts = (timeoutMs: number | undefined) =>
6464
resolveCdpReachabilityTimeouts({
6565
profileIsLoopback: profile.cdpIsLoopback,
66+
attachOnly: profile.attachOnly,
6667
timeoutMs,
6768
remoteHttpTimeoutMs: state().resolved.remoteCdpTimeoutMs,
6869
remoteHandshakeTimeoutMs: state().resolved.remoteCdpHandshakeTimeoutMs,

extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,48 @@ describe("browser server-context ensureBrowserAvailable", () => {
131131
expect(launchOpenClawChrome).not.toHaveBeenCalled();
132132
expect(stopOpenClawChrome).not.toHaveBeenCalled();
133133
});
134+
135+
it("treats attachOnly loopback CDP as local control with remote-class probe timeouts", async () => {
136+
const { launchOpenClawChrome, stopOpenClawChrome } = setupEnsureBrowserAvailableHarness();
137+
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
138+
const isChromeCdpReady = vi.mocked(chromeModule.isChromeCdpReady);
139+
140+
const state = makeBrowserServerState({
141+
profile: {
142+
name: "manual-cdp",
143+
cdpUrl: "http://127.0.0.1:9222",
144+
cdpHost: "127.0.0.1",
145+
cdpIsLoopback: true,
146+
cdpPort: 9222,
147+
color: "#00AA00",
148+
driver: "openclaw",
149+
attachOnly: true,
150+
},
151+
resolvedOverrides: {
152+
defaultProfile: "manual-cdp",
153+
ssrfPolicy: {},
154+
},
155+
});
156+
const ctx = createBrowserRouteContext({ getState: () => state });
157+
const profile = ctx.forProfile("manual-cdp");
158+
159+
isChromeReachable.mockResolvedValueOnce(true);
160+
isChromeCdpReady.mockResolvedValueOnce(true);
161+
162+
await expect(profile.ensureBrowserAvailable()).resolves.toBeUndefined();
163+
164+
expect(isChromeReachable).toHaveBeenCalledWith(
165+
"http://127.0.0.1:9222",
166+
state.resolved.remoteCdpTimeoutMs,
167+
undefined,
168+
);
169+
expect(isChromeCdpReady).toHaveBeenCalledWith(
170+
"http://127.0.0.1:9222",
171+
state.resolved.remoteCdpTimeoutMs,
172+
state.resolved.remoteCdpHandshakeTimeoutMs,
173+
undefined,
174+
);
175+
expect(launchOpenClawChrome).not.toHaveBeenCalled();
176+
expect(stopOpenClawChrome).not.toHaveBeenCalled();
177+
});
134178
});

extensions/browser/src/browser/server-context.list-profiles.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,40 @@ describe("browser server-context listProfiles", () => {
3030
}),
3131
]);
3232
});
33+
34+
it("uses remote-class probes for attachOnly loopback CDP profiles", async () => {
35+
const state = makeBrowserServerState({
36+
profile: {
37+
name: "manual-cdp",
38+
cdpUrl: "http://127.0.0.1:9222",
39+
cdpHost: "127.0.0.1",
40+
cdpIsLoopback: true,
41+
cdpPort: 9222,
42+
color: "#00AA00",
43+
driver: "openclaw",
44+
attachOnly: true,
45+
},
46+
resolvedOverrides: {
47+
defaultProfile: "manual-cdp",
48+
ssrfPolicy: {},
49+
},
50+
});
51+
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
52+
isChromeReachable.mockResolvedValue(true);
53+
54+
const ctx = createBrowserRouteContext({ getState: () => state });
55+
const profiles = await ctx.listProfiles();
56+
57+
expect(isChromeReachable).toHaveBeenCalledWith(
58+
"http://127.0.0.1:9222",
59+
state.resolved.remoteCdpTimeoutMs,
60+
undefined,
61+
);
62+
expect(profiles).toEqual([
63+
expect.objectContaining({
64+
name: "manual-cdp",
65+
running: true,
66+
}),
67+
]);
68+
});
3369
});

extensions/browser/src/browser/server-context.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
resolveCdpControlPolicy,
33
resolveCdpReachabilityPolicy,
44
} from "./cdp-reachability-policy.js";
5+
import { usesFastLoopbackCdpProbeClass } from "./cdp-timeouts.js";
56
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
67
import type { ResolvedBrowserProfile } from "./config.js";
78
import { resolveProfile } from "./config.js";
@@ -190,9 +191,15 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
190191
} else {
191192
// Check if something is listening on the port
192193
try {
194+
const probeTimeoutMs = usesFastLoopbackCdpProbeClass({
195+
profileIsLoopback: profile.cdpIsLoopback,
196+
attachOnly: profile.attachOnly,
197+
})
198+
? 200
199+
: current.resolved.remoteCdpTimeoutMs;
193200
const reachable = await isChromeReachable(
194201
profile.cdpUrl,
195-
200,
202+
probeTimeoutMs,
196203
resolveCdpReachabilityPolicy(profile, current.resolved.ssrfPolicy),
197204
);
198205
if (reachable) {

0 commit comments

Comments
 (0)