Skip to content

Commit 654cec2

Browse files
committed
Auto-merge upstream openclaw/openclaw
2 parents 8509d3d + 95ee120 commit 654cec2

81 files changed

Lines changed: 3895 additions & 878 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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- Telegram/forum topics: surface human topic names in agent context, prompt metadata, and plugin hook metadata by learning names from Telegram forum service messages. (#65973) Thanks @ptahdunbar.
10+
911
### Fixes
1012

13+
- fix(heartbeat): force owner downgrade for untrusted hook:wake system events [AI-assisted]. (#66031) Thanks @pgondhi987.
14+
- fix(browser): enforce SSRF policy on snapshot, screenshot, and tab routes [AI]. (#66040) Thanks @pgondhi987.
15+
- fix(msteams): enforce sender allowlist checks on SSO signin invokes [AI]. (#66033) Thanks @pgondhi987.
16+
- fix(config): redact sourceConfig and runtimeConfig alias fields in redactConfigSnapshot [AI]. (#66030) Thanks @pgondhi987.
1117
- Agents/context engines: run opt-in turn maintenance as idle-aware background work so the next foreground turn no longer waits on proactive maintenance. (#65233) thanks @100yenadmin
1218

1319
- Plugins/status: report the registered context-engine IDs in `plugins inspect` instead of the owning plugin ID, so non-matching engine IDs and multi-engine plugins are classified correctly. (#58766) thanks @zhuisDEV
@@ -18,6 +24,8 @@ Docs: https://docs.openclaw.ai
1824
- 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.
1925
- 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)
2026
- 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.
27+
- Cron/scheduler: stop inventing short retries when cron next-run calculation returns no valid future slot, and keep a maintenance wake armed so enabled unscheduled jobs recover without entering a refire loop. (#66019, #66083) Thanks @mbelinky.
28+
- Cron/scheduler: preserve the active error-backoff floor when maintenance repair recomputes a missing cron next-run, so recurring errored jobs do not resume early after a transient next-run resolution failure. (#66019, #66083, #66113) Thanks @mbelinky.
2129

2230
## 2026.4.12
2331

@@ -94,6 +102,7 @@ Docs: https://docs.openclaw.ai
94102
- CLI/audio providers: report env-authenticated providers as configured in `openclaw infer audio providers --json`, while keeping trusted workspace provider env lookup defaults stable during auth setup. (#65491)
95103
- Plugins/install: reinstall bundled runtime packages when the matching platform native optional child is missing, so packaged Windows installs can recover dependencies that were packed on another host OS.
96104
- Memory/QMD: preserve explicit `memory.qmd.command` paths, create missing agent workspaces before QMD probes, and keep the current Node binary on QMD subprocess PATH so service and gateway environments do not fall back to builtin search unnecessarily.
105+
- Plugins/Lobster: load the published `@clawdbot/lobster/core` runtime in process so bundled Lobster runs stop depending on private package internals. (#64755) Thanks @mbelinky.
97106

98107
## 2026.4.11
99108

extensions/bluebubbles/src/attachments.test.ts

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import "./test-mocks.js";
33
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
4-
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
4+
import { fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
55
import type { PluginRuntime } from "./runtime-api.js";
66
import { setBlueBubblesRuntime } from "./runtime.js";
77
import {
@@ -13,6 +13,7 @@ import {
1313
import type { BlueBubblesAttachment } from "./types.js";
1414

1515
const mockFetch = vi.fn();
16+
const fetchServerInfoMock = vi.mocked(fetchBlueBubblesServerInfo);
1617
const fetchRemoteMediaMock = vi.fn(
1718
async (params: {
1819
url: string;
@@ -381,6 +382,8 @@ describe("sendBlueBubblesAttachment", () => {
381382
vi.stubGlobal("fetch", mockFetch);
382383
mockFetch.mockReset();
383384
fetchRemoteMediaMock.mockClear();
385+
fetchServerInfoMock.mockReset();
386+
fetchServerInfoMock.mockResolvedValue(null);
384387
setBlueBubblesRuntime(runtimeStub);
385388
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
386389
mockBlueBubblesPrivateApiStatus(
@@ -620,6 +623,136 @@ describe("sendBlueBubblesAttachment", () => {
620623
expect(attachText).toContain("iMessage;-;+15557654321");
621624
});
622625

626+
describe("lazy private API refresh (#43764)", () => {
627+
const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus);
628+
629+
it("refreshes cache when expired and reply threading is requested", async () => {
630+
privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(true);
631+
fetchServerInfoMock.mockResolvedValueOnce({ private_api: true });
632+
mockFetch.mockResolvedValueOnce({
633+
ok: true,
634+
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-refreshed" } })),
635+
});
636+
637+
const result = await sendBlueBubblesAttachment({
638+
to: "chat_guid:iMessage;-;+15551234567",
639+
buffer: new Uint8Array([1, 2, 3]),
640+
filename: "photo.jpg",
641+
contentType: "image/jpeg",
642+
replyToMessageGuid: "reply-guid-456",
643+
opts: { serverUrl: "http://localhost:1234", password: "test" },
644+
});
645+
646+
expect(result.messageId).toBe("msg-refreshed");
647+
expect(fetchServerInfoMock).toHaveBeenCalledTimes(1);
648+
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
649+
const bodyText = decodeBody(body);
650+
expect(bodyText).toContain('name="method"');
651+
expect(bodyText).toContain("private-api");
652+
expect(bodyText).toContain('name="selectedMessageGuid"');
653+
});
654+
655+
it("does not refresh when cache is populated (cache hit)", async () => {
656+
mockBlueBubblesPrivateApiStatusOnce(
657+
privateApiStatusMock,
658+
BLUE_BUBBLES_PRIVATE_API_STATUS.enabled,
659+
);
660+
mockFetch.mockResolvedValueOnce({
661+
ok: true,
662+
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-cached" } })),
663+
});
664+
665+
await sendBlueBubblesAttachment({
666+
to: "chat_guid:iMessage;-;+15551234567",
667+
buffer: new Uint8Array([1, 2, 3]),
668+
filename: "photo.jpg",
669+
contentType: "image/jpeg",
670+
replyToMessageGuid: "reply-guid-123",
671+
opts: { serverUrl: "http://localhost:1234", password: "test" },
672+
});
673+
674+
expect(fetchServerInfoMock).not.toHaveBeenCalled();
675+
});
676+
677+
it("degrades gracefully when refresh fails", async () => {
678+
fetchServerInfoMock.mockRejectedValueOnce(new Error("network error"));
679+
mockFetch.mockResolvedValueOnce({
680+
ok: true,
681+
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-degraded" } })),
682+
});
683+
684+
const runtimeLog = vi.fn();
685+
setBlueBubblesRuntime({
686+
...runtimeStub,
687+
log: runtimeLog,
688+
} as unknown as PluginRuntime);
689+
690+
const result = await sendBlueBubblesAttachment({
691+
to: "chat_guid:iMessage;-;+15551234567",
692+
buffer: new Uint8Array([1, 2, 3]),
693+
filename: "photo.jpg",
694+
contentType: "image/jpeg",
695+
replyToMessageGuid: "reply-guid-789",
696+
opts: { serverUrl: "http://localhost:1234", password: "test" },
697+
});
698+
699+
expect(result.messageId).toBe("msg-degraded");
700+
expect(fetchServerInfoMock).toHaveBeenCalledTimes(1);
701+
expect(runtimeLog).toHaveBeenCalledTimes(1);
702+
expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
703+
});
704+
705+
it("degrades reply threading when refresh succeeds with private_api: false", async () => {
706+
privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(false);
707+
fetchServerInfoMock.mockResolvedValueOnce({ private_api: false });
708+
mockFetch.mockResolvedValueOnce({
709+
ok: true,
710+
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-disabled" } })),
711+
});
712+
713+
const runtimeLog = vi.fn();
714+
setBlueBubblesRuntime({
715+
...runtimeStub,
716+
log: runtimeLog,
717+
} as unknown as PluginRuntime);
718+
719+
const result = await sendBlueBubblesAttachment({
720+
to: "chat_guid:iMessage;-;+15551234567",
721+
buffer: new Uint8Array([1, 2, 3]),
722+
filename: "photo.jpg",
723+
contentType: "image/jpeg",
724+
replyToMessageGuid: "reply-guid-disabled",
725+
opts: { serverUrl: "http://localhost:1234", password: "test" },
726+
});
727+
728+
expect(result.messageId).toBe("msg-disabled");
729+
expect(fetchServerInfoMock).toHaveBeenCalledTimes(1);
730+
// No warning — status is known (disabled), not unknown
731+
expect(runtimeLog).not.toHaveBeenCalled();
732+
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
733+
const bodyText = decodeBody(body);
734+
expect(bodyText).not.toContain('name="selectedMessageGuid"');
735+
expect(bodyText).not.toContain('name="method"');
736+
});
737+
738+
it("does not refresh when no reply threading is requested", async () => {
739+
mockFetch.mockResolvedValueOnce({
740+
ok: true,
741+
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-plain" } })),
742+
});
743+
744+
await sendBlueBubblesAttachment({
745+
to: "chat_guid:iMessage;-;+15551234567",
746+
buffer: new Uint8Array([1, 2, 3]),
747+
filename: "photo.jpg",
748+
contentType: "image/jpeg",
749+
opts: { serverUrl: "http://localhost:1234", password: "test" },
750+
});
751+
752+
expect(fetchServerInfoMock).not.toHaveBeenCalled();
753+
});
754+
});
755+
623756
it("still throws for non-handle targets when chatGuid is not found", async () => {
624757
mockFetch.mockResolvedValueOnce({
625758
ok: true,

extensions/bluebubbles/src/attachments.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
1111
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
1212
import {
13+
fetchBlueBubblesServerInfo,
1314
getCachedBlueBubblesPrivateApiStatus,
1415
isBlueBubblesPrivateApiStatusEnabled,
1516
} from "./probe.js";
@@ -171,7 +172,27 @@ export async function sendBlueBubblesAttachment(params: {
171172
filename = sanitizeFilename(filename, fallbackName);
172173
contentType = normalizeOptionalString(contentType);
173174
const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(opts);
174-
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
175+
let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
176+
177+
// Lazy refresh: when the cache has expired and Private API features are needed,
178+
// fetch server info before making the decision. This prevents silent degradation
179+
// of reply threading after the 10-minute cache TTL expires. (#43764)
180+
const wantsReplyThread = Boolean(replyToMessageGuid?.trim());
181+
if (privateApiStatus === null && wantsReplyThread) {
182+
try {
183+
await fetchBlueBubblesServerInfo({
184+
baseUrl,
185+
password,
186+
accountId,
187+
timeoutMs: opts.timeoutMs ?? 5000,
188+
allowPrivateNetwork,
189+
});
190+
privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
191+
} catch {
192+
// Refresh failed — proceed with null status (existing graceful degradation)
193+
}
194+
}
195+
175196
const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
176197

177198
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).

0 commit comments

Comments
 (0)