mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:20:43 +00:00
fix(browser): unblock managed loopback CDP startup and control (#66043)
Merged via squash.
Prepared head SHA: c3d0a99ffa
Reviewed-by: @mbelinky
This commit is contained in:
@@ -15,7 +15,7 @@ Docs: https://docs.openclaw.ai
|
||||
- WhatsApp: patch installed Baileys media encryption writes during OpenClaw postinstall so the default npm/install.sh delivery path waits for encrypted media files to finish flushing before readback, avoiding transient `ENOENT` crashes on image sends. (#65896) Thanks @frankekn.
|
||||
- Gateway/update: unify service entrypoint resolution around the canonical bundled gateway entrypoint so update, reinstall, and doctor repair stop drifting between stale `dist/entry.js` and current `dist/index.js` paths. (#65984) Thanks @mbelinky.
|
||||
- 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.
|
||||
## 2026.4.12
|
||||
|
||||
### Changes
|
||||
|
||||
21
extensions/browser/src/browser/cdp-reachability-policy.ts
Normal file
21
extensions/browser/src/browser/cdp-reachability-policy.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||
|
||||
export function resolveCdpReachabilityPolicy(
|
||||
profile: ResolvedBrowserProfile,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): SsrFPolicy | undefined {
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
if (
|
||||
capabilities.mode === "local-managed" &&
|
||||
profile.cdpIsLoopback &&
|
||||
!profile.attachOnly &&
|
||||
profile.driver === "openclaw"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return ssrfPolicy;
|
||||
}
|
||||
|
||||
export const resolveCdpControlPolicy = resolveCdpReachabilityPolicy;
|
||||
@@ -46,6 +46,21 @@ export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFP
|
||||
return !isPrivateNetworkAllowedByPolicy(ssrfPolicy);
|
||||
}
|
||||
|
||||
export function requiresInspectableBrowserNavigationRedirectsForUrl(
|
||||
url: string,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): boolean {
|
||||
if (!requiresInspectableBrowserNavigationRedirects(ssrfPolicy)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isIpLiteralHostname(hostname: string): boolean {
|
||||
return isIP(normalizeHostname(hostname)) !== 0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js";
|
||||
import {
|
||||
CHROME_MCP_ATTACH_READY_POLL_MS,
|
||||
CHROME_MCP_ATTACH_READY_WINDOW_MS,
|
||||
@@ -67,6 +68,9 @@ export function createProfileAvailability({
|
||||
remoteHandshakeTimeoutMs: state().resolved.remoteCdpHandshakeTimeoutMs,
|
||||
});
|
||||
|
||||
const getCdpReachabilityPolicy = () =>
|
||||
resolveCdpReachabilityPolicy(profile, state().resolved.ssrfPolicy);
|
||||
|
||||
const isReachable = async (timeoutMs?: number) => {
|
||||
if (capabilities.usesChromeMcp) {
|
||||
// listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required
|
||||
@@ -78,7 +82,7 @@ export function createProfileAvailability({
|
||||
profile.cdpUrl,
|
||||
httpTimeoutMs,
|
||||
wsTimeoutMs,
|
||||
state().resolved.ssrfPolicy,
|
||||
getCdpReachabilityPolicy(),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -87,7 +91,7 @@ export function createProfileAvailability({
|
||||
return await isReachable(timeoutMs);
|
||||
}
|
||||
const { httpTimeoutMs } = resolveTimeouts(timeoutMs);
|
||||
return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, state().resolved.ssrfPolicy);
|
||||
return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, getCdpReachabilityPolicy());
|
||||
};
|
||||
|
||||
const attachRunning = (running: NonNullable<ProfileRuntimeState["running"]>) => {
|
||||
|
||||
@@ -21,7 +21,7 @@ function setupEnsureBrowserAvailableHarness() {
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const profile = ctx.forProfile("openclaw");
|
||||
|
||||
return { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile };
|
||||
return { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile, state };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -62,9 +62,10 @@ describe("browser server-context ensureBrowserAvailable", () => {
|
||||
});
|
||||
|
||||
it("reuses a pre-existing loopback browser after an initial short probe miss", async () => {
|
||||
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } =
|
||||
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile, state } =
|
||||
setupEnsureBrowserAvailableHarness();
|
||||
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
|
||||
state.resolved.ssrfPolicy = {};
|
||||
|
||||
isChromeReachable.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
||||
isChromeCdpReady.mockResolvedValueOnce(true);
|
||||
@@ -75,17 +76,13 @@ describe("browser server-context ensureBrowserAvailable", () => {
|
||||
1,
|
||||
"http://127.0.0.1:18800",
|
||||
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
|
||||
{
|
||||
allowPrivateNetwork: true,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(isChromeReachable).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://127.0.0.1:18800",
|
||||
PROFILE_ATTACH_RETRY_TIMEOUT_MS,
|
||||
{
|
||||
allowPrivateNetwork: true,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(launchOpenClawChrome).not.toHaveBeenCalled();
|
||||
expect(stopOpenClawChrome).not.toHaveBeenCalled();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
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";
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("browser server-context listProfiles", () => {
|
||||
it("bypasses SSRF gating when probing managed loopback profiles", async () => {
|
||||
const state = makeBrowserServerState({
|
||||
resolvedOverrides: {
|
||||
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:18800", 200, undefined);
|
||||
expect(profiles).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "openclaw",
|
||||
running: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("browser server-context loopback direct WebSocket profiles", () => {
|
||||
it("uses an HTTP /json/list base when opening tabs", async () => {
|
||||
it("uses an HTTP /json/list base when opening about:blank under strict SSRF", async () => {
|
||||
const createTargetViaCdp = vi
|
||||
.spyOn(cdpModule, "createTargetViaCdp")
|
||||
.mockResolvedValue({ targetId: "CREATED" });
|
||||
@@ -25,7 +25,7 @@ describe("browser server-context loopback direct WebSocket profiles", () => {
|
||||
{
|
||||
id: "CREATED",
|
||||
title: "New Tab",
|
||||
url: "http://127.0.0.1:8080",
|
||||
url: "about:blank",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
|
||||
type: "page",
|
||||
},
|
||||
@@ -35,6 +35,7 @@ describe("browser server-context loopback direct WebSocket profiles", () => {
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.ssrfPolicy = {};
|
||||
state.resolved.profiles.openclaw = {
|
||||
cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc",
|
||||
color: "#FF4500",
|
||||
@@ -42,16 +43,16 @@ describe("browser server-context loopback direct WebSocket profiles", () => {
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const opened = await openclaw.openTab("http://127.0.0.1:8080");
|
||||
const opened = await openclaw.openTab("about:blank");
|
||||
expect(opened.targetId).toBe("CREATED");
|
||||
expect(createTargetViaCdp).toHaveBeenCalledWith({
|
||||
cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc",
|
||||
url: "http://127.0.0.1:8080",
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
url: "about:blank",
|
||||
ssrfPolicy: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an HTTP /json base for focus and close", async () => {
|
||||
it("uses an HTTP /json base for focus and close under strict SSRF", async () => {
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u === "http://127.0.0.1:18800/json/list?token=abc") {
|
||||
@@ -79,6 +80,7 @@ describe("browser server-context loopback direct WebSocket profiles", () => {
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.ssrfPolicy = {};
|
||||
state.resolved.profiles.openclaw = {
|
||||
cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc",
|
||||
color: "#FF4500",
|
||||
|
||||
@@ -14,7 +14,7 @@ import { resolveTargetIdFromTabs } from "./target-id.js";
|
||||
type SelectionDeps = {
|
||||
profile: ResolvedBrowserProfile;
|
||||
getProfileState: () => ProfileRuntimeState;
|
||||
getSsrFPolicy: () => SsrFPolicy | undefined;
|
||||
getCdpControlPolicy: () => SsrFPolicy | undefined;
|
||||
ensureBrowserAvailable: () => Promise<void>;
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
openTab: (url: string) => Promise<BrowserTab>;
|
||||
@@ -29,7 +29,7 @@ type SelectionOps = {
|
||||
export function createProfileSelectionOps({
|
||||
profile,
|
||||
getProfileState,
|
||||
getSsrFPolicy,
|
||||
getCdpControlPolicy,
|
||||
ensureBrowserAvailable,
|
||||
listTabs,
|
||||
openTab,
|
||||
@@ -112,7 +112,7 @@ export function createProfileSelectionOps({
|
||||
await focusPageByTargetIdViaPlaywright({
|
||||
cdpUrl: profile.cdpUrl,
|
||||
targetId: resolvedTargetId,
|
||||
ssrfPolicy: getSsrFPolicy(),
|
||||
ssrfPolicy: getCdpControlPolicy(),
|
||||
});
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = resolvedTargetId;
|
||||
@@ -124,7 +124,7 @@ export function createProfileSelectionOps({
|
||||
appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`),
|
||||
undefined,
|
||||
undefined,
|
||||
getSsrFPolicy(),
|
||||
getCdpControlPolicy(),
|
||||
);
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = resolvedTargetId;
|
||||
@@ -147,7 +147,7 @@ export function createProfileSelectionOps({
|
||||
await closePageByTargetIdViaPlaywright({
|
||||
cdpUrl: profile.cdpUrl,
|
||||
targetId: resolvedTargetId,
|
||||
ssrfPolicy: getSsrFPolicy(),
|
||||
ssrfPolicy: getCdpControlPolicy(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -157,7 +157,7 @@ export function createProfileSelectionOps({
|
||||
appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`),
|
||||
undefined,
|
||||
undefined,
|
||||
getSsrFPolicy(),
|
||||
getCdpControlPolicy(),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveCdpControlPolicy } from "./cdp-reachability-policy.js";
|
||||
import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js";
|
||||
import {
|
||||
assertCdpEndpointAllowed,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationResultAllowed,
|
||||
InvalidBrowserNavigationUrlError,
|
||||
requiresInspectableBrowserNavigationRedirects,
|
||||
requiresInspectableBrowserNavigationRedirectsForUrl,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "./navigation-guard.js";
|
||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||
@@ -69,7 +70,7 @@ export function createProfileTabOps({
|
||||
}: TabOpsDeps): ProfileTabOps {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
const getSsrFPolicy = () => state().resolved.ssrfPolicy;
|
||||
const getCdpControlPolicy = () => resolveCdpControlPolicy(profile, state().resolved.ssrfPolicy);
|
||||
|
||||
const listTabs = async (): Promise<BrowserTab[]> => {
|
||||
if (capabilities.usesChromeMcp) {
|
||||
@@ -80,7 +81,7 @@ export function createProfileTabOps({
|
||||
const mod = await getPwAiModule({ mode: "strict" });
|
||||
const listPagesViaPlaywright = (mod as Partial<PwAiModule> | null)?.listPagesViaPlaywright;
|
||||
if (typeof listPagesViaPlaywright === "function") {
|
||||
const ssrfPolicy = getSsrFPolicy();
|
||||
const ssrfPolicy = getCdpControlPolicy();
|
||||
await assertCdpEndpointAllowed(profile.cdpUrl, ssrfPolicy);
|
||||
const pages = await listPagesViaPlaywright({ cdpUrl: profile.cdpUrl, ssrfPolicy });
|
||||
return pages.map((p) => ({
|
||||
@@ -100,7 +101,7 @@ export function createProfileTabOps({
|
||||
webSocketDebuggerUrl?: string;
|
||||
type?: string;
|
||||
}>
|
||||
>(appendCdpPath(cdpHttpBase, "/json/list"), undefined, undefined, getSsrFPolicy());
|
||||
>(appendCdpPath(cdpHttpBase, "/json/list"), undefined, undefined, getCdpControlPolicy());
|
||||
return raw
|
||||
.map((t) => ({
|
||||
targetId: t.id ?? "",
|
||||
@@ -136,7 +137,7 @@ export function createProfileTabOps({
|
||||
appendCdpPath(cdpHttpBase, `/json/close/${tab.targetId}`),
|
||||
undefined,
|
||||
undefined,
|
||||
getSsrFPolicy(),
|
||||
getCdpControlPolicy(),
|
||||
).catch(() => {
|
||||
// best-effort cleanup only
|
||||
});
|
||||
@@ -182,7 +183,7 @@ export function createProfileTabOps({
|
||||
}
|
||||
}
|
||||
|
||||
if (requiresInspectableBrowserNavigationRedirects(state().resolved.ssrfPolicy)) {
|
||||
if (requiresInspectableBrowserNavigationRedirectsForUrl(url, state().resolved.ssrfPolicy)) {
|
||||
throw new InvalidBrowserNavigationUrlError(
|
||||
"Navigation blocked: strict browser SSRF policy requires Playwright-backed redirect-hop inspection",
|
||||
);
|
||||
@@ -191,7 +192,7 @@ export function createProfileTabOps({
|
||||
const createdViaCdp = await createTargetViaCdp({
|
||||
cdpUrl: profile.cdpUrl,
|
||||
url,
|
||||
...ssrfPolicyOpts,
|
||||
ssrfPolicy: getCdpControlPolicy(),
|
||||
})
|
||||
.then((r) => r.targetId)
|
||||
.catch(() => null);
|
||||
|
||||
@@ -125,7 +125,51 @@ describe("browser server-context tab selection state", () => {
|
||||
expect(createTargetViaCdp).toHaveBeenCalledWith({
|
||||
cdpUrl: "http://127.0.0.1:18800",
|
||||
url: "http://127.0.0.1:8080",
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
ssrfPolicy: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("can bootstrap a managed loopback tab under strict SSRF because CDP control stays local", async () => {
|
||||
const createTargetViaCdp = vi
|
||||
.spyOn(cdpModule, "createTargetViaCdp")
|
||||
.mockResolvedValue({ targetId: "CREATED" });
|
||||
|
||||
let listCount = 0;
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (!u.includes("/json/list")) {
|
||||
throw new Error(`unexpected fetch: ${u}`);
|
||||
}
|
||||
listCount += 1;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () =>
|
||||
listCount === 1
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "CREATED",
|
||||
title: "New Tab",
|
||||
url: "about:blank",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
|
||||
type: "page",
|
||||
},
|
||||
],
|
||||
} as unknown as Response;
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.ssrfPolicy = {};
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const selected = await openclaw.ensureTabAvailable();
|
||||
expect(selected.targetId).toBe("CREATED");
|
||||
expect(createTargetViaCdp).toHaveBeenCalledWith({
|
||||
cdpUrl: "http://127.0.0.1:18800",
|
||||
url: "about:blank",
|
||||
ssrfPolicy: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
resolveCdpControlPolicy,
|
||||
resolveCdpReachabilityPolicy,
|
||||
} from "./cdp-reachability-policy.js";
|
||||
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import { resolveProfile } from "./config.js";
|
||||
@@ -86,7 +90,7 @@ function createProfileContext(
|
||||
const { ensureTabAvailable, focusTab, closeTab } = createProfileSelectionOps({
|
||||
profile,
|
||||
getProfileState,
|
||||
getSsrFPolicy: () => state().resolved.ssrfPolicy,
|
||||
getCdpControlPolicy: () => resolveCdpControlPolicy(profile, state().resolved.ssrfPolicy),
|
||||
ensureBrowserAvailable,
|
||||
listTabs,
|
||||
openTab,
|
||||
@@ -189,7 +193,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
||||
const reachable = await isChromeReachable(
|
||||
profile.cdpUrl,
|
||||
200,
|
||||
current.resolved.ssrfPolicy,
|
||||
resolveCdpReachabilityPolicy(profile, current.resolved.ssrfPolicy),
|
||||
);
|
||||
if (reachable) {
|
||||
running = true;
|
||||
|
||||
Reference in New Issue
Block a user