Skip to content

Commit 8509d3d

Browse files
committed
Auto-merge upstream openclaw/openclaw
2 parents f7ac9d6 + 2c59ba2 commit 8509d3d

91 files changed

Lines changed: 3785 additions & 750 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ Docs: https://docs.openclaw.ai
1414
- Context engines: reject resolved plugin engines whose reported `info.id` does not match their registered slot id, so malformed engines fail fast before id-based runtime branches can misbehave. (#63222) Thanks @fuller-stack-dev.
1515
- 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.
1616
- 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.
17+
- 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.
18+
- 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.
19+
- 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+
1722
## 2026.4.12
1823

1924
### Changes
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { SsrFPolicy } from "../infra/net/ssrf.js";
2+
import type { ResolvedBrowserProfile } from "./config.js";
3+
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
4+
5+
export function resolveCdpReachabilityPolicy(
6+
profile: ResolvedBrowserProfile,
7+
ssrfPolicy?: SsrFPolicy,
8+
): SsrFPolicy | undefined {
9+
const capabilities = getBrowserProfileCapabilities(profile);
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") {
14+
return undefined;
15+
}
16+
return ssrfPolicy;
17+
}
18+
19+
export const resolveCdpControlPolicy = resolveCdpReachabilityPolicy;

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,

extensions/browser/src/browser/navigation-guard.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFP
4646
return !isPrivateNetworkAllowedByPolicy(ssrfPolicy);
4747
}
4848

49+
export function requiresInspectableBrowserNavigationRedirectsForUrl(
50+
url: string,
51+
ssrfPolicy?: SsrFPolicy,
52+
): boolean {
53+
if (!requiresInspectableBrowserNavigationRedirects(ssrfPolicy)) {
54+
return false;
55+
}
56+
try {
57+
const parsed = new URL(url);
58+
return NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol);
59+
} catch {
60+
return false;
61+
}
62+
}
63+
4964
function isIpLiteralHostname(hostname: string): boolean {
5065
return isIP(normalizeHostname(hostname)) !== 0;
5166
}
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: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from "node:fs";
2+
import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js";
23
import {
34
CHROME_MCP_ATTACH_READY_POLL_MS,
45
CHROME_MCP_ATTACH_READY_WINDOW_MS,
@@ -62,11 +63,15 @@ export function createProfileAvailability({
6263
const resolveTimeouts = (timeoutMs: number | undefined) =>
6364
resolveCdpReachabilityTimeouts({
6465
profileIsLoopback: profile.cdpIsLoopback,
66+
attachOnly: profile.attachOnly,
6567
timeoutMs,
6668
remoteHttpTimeoutMs: state().resolved.remoteCdpTimeoutMs,
6769
remoteHandshakeTimeoutMs: state().resolved.remoteCdpHandshakeTimeoutMs,
6870
});
6971

72+
const getCdpReachabilityPolicy = () =>
73+
resolveCdpReachabilityPolicy(profile, state().resolved.ssrfPolicy);
74+
7075
const isReachable = async (timeoutMs?: number) => {
7176
if (capabilities.usesChromeMcp) {
7277
// listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required
@@ -78,7 +83,7 @@ export function createProfileAvailability({
7883
profile.cdpUrl,
7984
httpTimeoutMs,
8085
wsTimeoutMs,
81-
state().resolved.ssrfPolicy,
86+
getCdpReachabilityPolicy(),
8287
);
8388
};
8489

@@ -87,7 +92,7 @@ export function createProfileAvailability({
8792
return await isReachable(timeoutMs);
8893
}
8994
const { httpTimeoutMs } = resolveTimeouts(timeoutMs);
90-
return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, state().resolved.ssrfPolicy);
95+
return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, getCdpReachabilityPolicy());
9196
};
9297

9398
const attachRunning = (running: NonNullable<ProfileRuntimeState["running"]>) => {

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

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ function setupEnsureBrowserAvailableHarness() {
2121
const ctx = createBrowserRouteContext({ getState: () => state });
2222
const profile = ctx.forProfile("openclaw");
2323

24-
return { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile };
24+
return { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile, state };
2525
}
2626

2727
afterEach(() => {
@@ -62,9 +62,10 @@ describe("browser server-context ensureBrowserAvailable", () => {
6262
});
6363

6464
it("reuses a pre-existing loopback browser after an initial short probe miss", async () => {
65-
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } =
65+
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile, state } =
6666
setupEnsureBrowserAvailableHarness();
6767
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
68+
state.resolved.ssrfPolicy = {};
6869

6970
isChromeReachable.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
7071
isChromeCdpReady.mockResolvedValueOnce(true);
@@ -75,17 +76,13 @@ describe("browser server-context ensureBrowserAvailable", () => {
7576
1,
7677
"http://127.0.0.1:18800",
7778
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
78-
{
79-
allowPrivateNetwork: true,
80-
},
79+
undefined,
8180
);
8281
expect(isChromeReachable).toHaveBeenNthCalledWith(
8382
2,
8483
"http://127.0.0.1:18800",
8584
PROFILE_ATTACH_RETRY_TIMEOUT_MS,
86-
{
87-
allowPrivateNetwork: true,
88-
},
85+
undefined,
8986
);
9087
expect(launchOpenClawChrome).not.toHaveBeenCalled();
9188
expect(stopOpenClawChrome).not.toHaveBeenCalled();
@@ -134,4 +131,48 @@ describe("browser server-context ensureBrowserAvailable", () => {
134131
expect(launchOpenClawChrome).not.toHaveBeenCalled();
135132
expect(stopOpenClawChrome).not.toHaveBeenCalled();
136133
});
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+
});
137178
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import "./server-context.chrome-test-harness.js";
3+
import * as chromeModule from "./chrome.js";
4+
import { createBrowserRouteContext } from "./server-context.js";
5+
import { makeBrowserServerState } from "./server-context.test-harness.js";
6+
7+
afterEach(() => {
8+
vi.clearAllMocks();
9+
vi.restoreAllMocks();
10+
});
11+
12+
describe("browser server-context listProfiles", () => {
13+
it("bypasses SSRF gating when probing managed loopback profiles", async () => {
14+
const state = makeBrowserServerState({
15+
resolvedOverrides: {
16+
ssrfPolicy: {},
17+
},
18+
});
19+
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
20+
isChromeReachable.mockResolvedValue(true);
21+
22+
const ctx = createBrowserRouteContext({ getState: () => state });
23+
const profiles = await ctx.listProfiles();
24+
25+
expect(isChromeReachable).toHaveBeenCalledWith("http://127.0.0.1:18800", 200, undefined);
26+
expect(profiles).toEqual([
27+
expect.objectContaining({
28+
name: "openclaw",
29+
running: true,
30+
}),
31+
]);
32+
});
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+
});
69+
});

0 commit comments

Comments
 (0)