Skip to content

Commit 6ec67eb

Browse files
authored
Merge branch 'main' into fix/cron-model-id-whitespace
2 parents 1b65e7e + 323493f commit 6ec67eb

157 files changed

Lines changed: 5328 additions & 708 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: 35 additions & 7 deletions
Large diffs are not rendered by default.

apps/android/app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ android {
6666
minSdk = 31
6767
targetSdk = 36
6868
versionCode = 2026041401
69-
versionName = "2026.4.14-beta.1"
69+
versionName = "2026.4.14"
7070
ndk {
7171
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
7272
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

apps/macos/Sources/OpenClaw/Resources/Info.plist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
<key>CFBundlePackageType</key>
1616
<string>APPL</string>
1717
<key>CFBundleShortVersionString</key>
18-
<string>2026.4.14-beta.1</string>
18+
<string>2026.4.14</string>
1919
<key>CFBundleVersion</key>
2020
<string>2026041401</string>
2121
<key>CFBundleIconFile</key>
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
7003e0d0ba1cddb7eb388204825ac892206209a4a9c795e76c4e34b5fc7b50f0 plugin-sdk-api-baseline.json
2-
14e39520459abc7db7993a700a4f07adfa0855d9233d123c4725477b91f1cb13 plugin-sdk-api-baseline.jsonl
1+
7b121e2b694f80433fa91ce9037527ca58be546a7f18798470a4ade66593e5e1 plugin-sdk-api-baseline.json
2+
7b802cc04f0eac0b498b50711e39a7afe93bbb6b682a2013d2c303583fb73f40 plugin-sdk-api-baseline.jsonl

docs/cli/browser.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,20 @@ openclaw browser --browser-profile openclaw open https://example.com
3333
openclaw browser --browser-profile openclaw snapshot
3434
```
3535

36+
## Quick troubleshooting
37+
38+
If `start` fails with `not reachable after start`, troubleshoot CDP readiness first. If `start` and `tabs` succeed but `open` or `navigate` fails, the browser control plane is healthy and the failure is usually navigation SSRF policy.
39+
40+
Minimal sequence:
41+
42+
```bash
43+
openclaw browser --browser-profile openclaw start
44+
openclaw browser --browser-profile openclaw tabs
45+
openclaw browser --browser-profile openclaw open https://example.com
46+
```
47+
48+
Detailed guidance: [Browser troubleshooting](/tools/browser#cdp-startup-failure-vs-navigation-ssrf-block)
49+
3650
## Lifecycle
3751

3852
```bash

docs/gateway/local-models.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ Compatibility notes for stricter OpenAI-compatible backends:
174174

175175
- Gateway can reach the proxy? `curl http://127.0.0.1:1234/v1/models`.
176176
- LM Studio model unloaded? Reload; cold start is a common “hanging” cause.
177+
- OpenClaw warns when the detected context window is below **32k** and blocks below **16k**. If you hit that preflight, raise the server/model context limit or choose a larger model.
177178
- Context errors? Lower `contextWindow` or raise your server limit.
178179
- OpenAI-compatible server returns `messages[].content ... expected a string`?
179180
Add `compat.requiresStringContent: true` on that model entry.

docs/tools/browser.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,63 @@ For Linux-specific issues (especially snap Chromium), see
884884
For WSL2 Gateway + Windows Chrome split-host setups, see
885885
[WSL2 + Windows + remote Chrome CDP troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting).
886886

887+
### CDP startup failure vs navigation SSRF block
888+
889+
These are different failure classes and they point to different code paths.
890+
891+
- **CDP startup or readiness failure** means OpenClaw cannot confirm that the browser control plane is healthy.
892+
- **Navigation SSRF block** means the browser control plane is healthy, but a page navigation target is rejected by policy.
893+
894+
Common examples:
895+
896+
- CDP startup or readiness failure:
897+
- `Chrome CDP websocket for profile "openclaw" is not reachable after start`
898+
- `Remote CDP for profile "<name>" is not reachable at <cdpUrl>`
899+
- Navigation SSRF block:
900+
- `open`, `navigate`, snapshot, or tab-opening flows fail with a browser/network policy error while `start` and `tabs` still work
901+
902+
Use this minimal sequence to separate the two:
903+
904+
```bash
905+
openclaw browser --browser-profile openclaw start
906+
openclaw browser --browser-profile openclaw tabs
907+
openclaw browser --browser-profile openclaw open https://example.com
908+
```
909+
910+
How to read the results:
911+
912+
- If `start` fails with `not reachable after start`, troubleshoot CDP readiness first.
913+
- If `start` succeeds but `tabs` fails, the control plane is still unhealthy. Treat this as a CDP reachability problem, not a page-navigation problem.
914+
- If `start` and `tabs` succeed but `open` or `navigate` fails, the browser control plane is up and the failure is in navigation policy or the target page.
915+
- If `start`, `tabs`, and `open` all succeed, the basic managed-browser control path is healthy.
916+
917+
Important behavior details:
918+
919+
- Browser config defaults to a fail-closed SSRF policy object even when you do not configure `browser.ssrfPolicy`.
920+
- For the local loopback `openclaw` managed profile, CDP health checks intentionally skip browser SSRF reachability enforcement for OpenClaw's own local control plane.
921+
- Navigation protection is separate. A successful `start` or `tabs` result does not mean a later `open` or `navigate` target is allowed.
922+
923+
Security guidance:
924+
925+
- Do **not** relax browser SSRF policy by default.
926+
- Prefer narrow host exceptions such as `hostnameAllowlist` or `allowedHostnames` over broad private-network access.
927+
- Use `dangerouslyAllowPrivateNetwork: true` only in intentionally trusted environments where private-network browser access is required and reviewed.
928+
929+
Example: navigation blocked, control plane healthy
930+
931+
- `start` succeeds
932+
- `tabs` succeeds
933+
- `open http://internal.example` fails
934+
935+
That usually means browser startup is fine and the navigation target needs policy review.
936+
937+
Example: startup blocked before navigation matters
938+
939+
- `start` fails with `not reachable after start`
940+
- `tabs` also fails or cannot run
941+
942+
That points to browser launch or CDP reachability, not a page URL allowlist problem.
943+
887944
## Agent tools + how control works
888945

889946
The agent gets **one tool** for browser automation:

extensions/browser/src/browser/cdp.helpers.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
1010
};
1111
});
1212

13-
import { fetchJson, fetchOk } from "./cdp.helpers.js";
13+
import { assertCdpEndpointAllowed, fetchJson, fetchOk } from "./cdp.helpers.js";
1414

1515
describe("cdp helpers", () => {
1616
afterEach(() => {
@@ -43,6 +43,23 @@ describe("cdp helpers", () => {
4343
expect(release).toHaveBeenCalledTimes(1);
4444
});
4545

46+
it("allows loopback CDP endpoints in strict SSRF mode", async () => {
47+
await expect(
48+
assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", {
49+
dangerouslyAllowPrivateNetwork: false,
50+
}),
51+
).resolves.toBeUndefined();
52+
});
53+
54+
it("still enforces hostname allowlist for loopback CDP endpoints", async () => {
55+
await expect(
56+
assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", {
57+
dangerouslyAllowPrivateNetwork: false,
58+
hostnameAllowlist: ["*.corp.example"],
59+
}),
60+
).rejects.toThrow("browser endpoint blocked by policy");
61+
});
62+
4663
it("releases guarded CDP fetches for bodyless requests", async () => {
4764
const release = vi.fn(async () => {});
4865
fetchWithSsrFGuardMock.mockResolvedValueOnce({
@@ -62,4 +79,62 @@ describe("cdp helpers", () => {
6279

6380
expect(release).toHaveBeenCalledTimes(1);
6481
});
82+
83+
it("uses an exact loopback allowlist for guarded loopback CDP fetches", async () => {
84+
const release = vi.fn(async () => {});
85+
fetchWithSsrFGuardMock.mockResolvedValueOnce({
86+
response: {
87+
ok: true,
88+
status: 200,
89+
},
90+
release,
91+
});
92+
93+
await expect(
94+
fetchOk("http://127.0.0.1:9222/json/version", 250, undefined, {
95+
dangerouslyAllowPrivateNetwork: false,
96+
}),
97+
).resolves.toBeUndefined();
98+
99+
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
100+
expect.objectContaining({
101+
url: "http://127.0.0.1:9222/json/version",
102+
policy: {
103+
dangerouslyAllowPrivateNetwork: false,
104+
allowedHostnames: ["127.0.0.1"],
105+
},
106+
}),
107+
);
108+
expect(release).toHaveBeenCalledTimes(1);
109+
});
110+
111+
it("preserves hostname allowlist while allowing exact loopback CDP fetches", async () => {
112+
const release = vi.fn(async () => {});
113+
fetchWithSsrFGuardMock.mockResolvedValueOnce({
114+
response: {
115+
ok: true,
116+
status: 200,
117+
},
118+
release,
119+
});
120+
121+
await expect(
122+
fetchOk("http://127.0.0.1:9222/json/version", 250, undefined, {
123+
dangerouslyAllowPrivateNetwork: false,
124+
hostnameAllowlist: ["*.corp.example"],
125+
}),
126+
).resolves.toBeUndefined();
127+
128+
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
129+
expect.objectContaining({
130+
url: "http://127.0.0.1:9222/json/version",
131+
policy: {
132+
dangerouslyAllowPrivateNetwork: false,
133+
hostnameAllowlist: ["*.corp.example"],
134+
allowedHostnames: ["127.0.0.1"],
135+
},
136+
}),
137+
);
138+
expect(release).toHaveBeenCalledTimes(1);
139+
});
65140
});

extensions/browser/src/browser/cdp.helpers.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,16 @@ export async function assertCdpEndpointAllowed(
6969
throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`);
7070
}
7171
try {
72+
const policy = isLoopbackHost(parsed.hostname)
73+
? {
74+
...ssrfPolicy,
75+
allowedHostnames: Array.from(
76+
new Set([...(ssrfPolicy?.allowedHostnames ?? []), parsed.hostname]),
77+
),
78+
}
79+
: ssrfPolicy;
7280
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
73-
policy: ssrfPolicy,
81+
policy,
7482
});
7583
} catch (error) {
7684
throw new BrowserCdpEndpointBlockedError({ cause: error });
@@ -263,11 +271,20 @@ export async function fetchCdpChecked(
263271
try {
264272
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
265273
const res = await withNoProxyForCdpUrl(url, async () => {
274+
const parsedUrl = new URL(url);
275+
const policy = isLoopbackHost(parsedUrl.hostname)
276+
? {
277+
...ssrfPolicy,
278+
allowedHostnames: Array.from(
279+
new Set([...(ssrfPolicy?.allowedHostnames ?? []), parsedUrl.hostname]),
280+
),
281+
}
282+
: (ssrfPolicy ?? { allowPrivateNetwork: true });
266283
const guarded = await fetchWithSsrFGuard({
267284
url,
268285
init: { ...init, headers },
269286
signal: ctrl.signal,
270-
policy: ssrfPolicy ?? { allowPrivateNetwork: true },
287+
policy,
271288
auditContext: "browser-cdp",
272289
});
273290
guardedRelease = guarded.release;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { createServer, type Server } from "node:http";
2+
import type { AddressInfo } from "node:net";
3+
import { afterEach, describe, expect, it } from "vitest";
4+
import { getChromeWebSocketUrl, isChromeReachable } from "./chrome.js";
5+
6+
type RunningServer = {
7+
server: Server;
8+
baseUrl: string;
9+
};
10+
11+
const runningServers: Server[] = [];
12+
13+
async function startLoopbackCdpServer(): Promise<RunningServer> {
14+
const server = createServer((req, res) => {
15+
if (req.url !== "/json/version") {
16+
res.statusCode = 404;
17+
res.end("not found");
18+
return;
19+
}
20+
const address = server.address() as AddressInfo;
21+
res.setHeader("content-type", "application/json");
22+
res.end(
23+
JSON.stringify({
24+
Browser: "Chrome/999.0.0.0",
25+
webSocketDebuggerUrl: `ws://127.0.0.1:${address.port}/devtools/browser/TEST`,
26+
}),
27+
);
28+
});
29+
30+
await new Promise<void>((resolve, reject) => {
31+
server.once("error", reject);
32+
server.listen(0, "127.0.0.1", () => resolve());
33+
});
34+
35+
runningServers.push(server);
36+
const address = server.address() as AddressInfo;
37+
return {
38+
server,
39+
baseUrl: `http://127.0.0.1:${address.port}`,
40+
};
41+
}
42+
43+
afterEach(async () => {
44+
await Promise.all(
45+
runningServers
46+
.splice(0)
47+
.map(
48+
(server) =>
49+
new Promise<void>((resolve, reject) =>
50+
server.close((err) => (err ? reject(err) : resolve())),
51+
),
52+
),
53+
);
54+
});
55+
56+
describe("chrome loopback SSRF integration", () => {
57+
it("keeps loopback CDP HTTP reachability working under strict default SSRF policy", async () => {
58+
const { baseUrl } = await startLoopbackCdpServer();
59+
60+
await expect(isChromeReachable(baseUrl, 500, {})).resolves.toBe(true);
61+
});
62+
63+
it("returns the loopback websocket URL under strict default SSRF policy", async () => {
64+
const { baseUrl } = await startLoopbackCdpServer();
65+
66+
await expect(getChromeWebSocketUrl(baseUrl, 500, {})).resolves.toMatch(
67+
/\/devtools\/browser\/TEST$/,
68+
);
69+
});
70+
});

0 commit comments

Comments
 (0)