diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d4d35f7273..a69cf155626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Cron/main-session delivery: preserve `heartbeat.target="last"` through deferred wake queuing, gateway wake forwarding, and same-target wake coalescing so queued cron replies still return to the last active chat. (#69021) Thanks @obviyus. - Cron/gateway: ignore disabled channels when announce delivery ambiguity is checked, and validate main-session delivery patches against the live cron service default agent so hot-reloaded agent config does not falsely reject valid updates. (#69040) Thanks @obviyus. - Matrix/allowlists: hot-reload `dm.allowFrom` and `groupAllowFrom` entries on inbound messages while keeping config removals authoritative, so Matrix allowlist changes no longer require a channel restart to add or revoke a sender. (#68546) Thanks @johnlanni. +- BlueBubbles: always set `method` explicitly on outbound text sends (`"private-api"` when available, `"apple-script"` otherwise), and prefer Private API on macOS 26 even for plain text. Fixes silent delivery failure on macOS setups without Private API where an omitted `method` let BB Server fall back to version-dependent default behavior that silently drops the message (#64480), and the AppleScript `-1700` error on macOS 26 Tahoe plain text sends (#53159). (#69070) Thanks @xqing3. ## 2026.4.19-beta.2 diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 3253e841ef0..527774412b1 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import "./test-mocks.js"; -import { fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { + fetchBlueBubblesServerInfo, + getCachedBlueBubblesPrivateApiStatus, + isMacOS26OrHigher, +} from "./probe.js"; import type { PluginRuntime } from "./runtime-api.js"; import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js"; import { sendMessageBlueBubbles, resolveChatGuidForTarget, createChatForHandle } from "./send.js"; @@ -15,6 +19,7 @@ import { _setFetchGuardForTesting, type BlueBubblesSendTarget } from "./types.js const mockFetch = vi.fn(); const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus); const fetchServerInfoMock = vi.mocked(fetchBlueBubblesServerInfo); +const isMacOS26OrHigherMock = vi.mocked(isMacOS26OrHigher); const setFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); installBlueBubblesFetchTestHooks({ @@ -456,7 +461,7 @@ describe("send", () => { const body = JSON.parse(sendCall[1].body); expect(body.chatGuid).toBe("iMessage;-;+15551234567"); expect(body.message).toBe("Hello world!"); - expect(body.method).toBeUndefined(); + expect(body.method).toBe("apple-script"); }); it("auto-enables private-network fetches for loopback serverUrl when allowPrivateNetwork is not set", async () => { @@ -616,7 +621,7 @@ describe("send", () => { expect(result.messageId).toBe("msg-uuid-plain"); const sendCall = mockFetch.mock.calls[1]; const body = JSON.parse(sendCall[1].body); - expect(body.method).toBeUndefined(); + expect(body.method).toBe("apple-script"); expect(body.selectedMessageGuid).toBeUndefined(); expect(body.partIndex).toBeUndefined(); }); @@ -644,6 +649,62 @@ describe("send", () => { expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink"); }); + // macOS 26 Tahoe broke AppleScript Messages.app automation (-1700). When + // Private API is available on these hosts, plain text sends should prefer + // Private API even without reply/effect features. (#53159 Bug B, #64480) + it("forces Private API for plain text on macOS 26 when available", async () => { + mockBlueBubblesPrivateApiStatusOnce( + privateApiStatusMock, + BLUE_BUBBLES_PRIVATE_API_STATUS.enabled, + ); + isMacOS26OrHigherMock.mockReturnValue(true); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-macos26" } }); + + try { + const result = await sendMessageBlueBubbles("+15551234567", "Plain text", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("msg-macos26"); + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBe("private-api"); + } finally { + isMacOS26OrHigherMock.mockReturnValue(false); + } + }); + + // If macOS 26 host has Private API disabled, there is nothing we can do — + // the AppleScript path is broken on that OS. We still tag the send + // explicitly as apple-script rather than omitting `method`; BB Server's + // behavior on an omitted field is version-dependent and silently drops + // on some setups, which is the worse failure mode. (#64480) + it("falls back to apple-script on macOS 26 when Private API is disabled", async () => { + mockBlueBubblesPrivateApiStatusOnce( + privateApiStatusMock, + BLUE_BUBBLES_PRIVATE_API_STATUS.disabled, + ); + isMacOS26OrHigherMock.mockReturnValue(true); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-macos26-no-pa" } }); + + try { + const result = await sendMessageBlueBubbles("+15551234567", "Plain text", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("msg-macos26-no-pa"); + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBe("apple-script"); + } finally { + isMacOS26OrHigherMock.mockReturnValue(false); + } + }); + it("warns and downgrades private-api features when status is unknown", async () => { const runtimeLog = vi.fn(); setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime); @@ -666,7 +727,7 @@ describe("send", () => { const sendCall = mockFetch.mock.calls[1]; const body = JSON.parse(sendCall[1].body); - expect(body.method).toBeUndefined(); + expect(body.method).toBe("apple-script"); expect(body.selectedMessageGuid).toBeUndefined(); expect(body.partIndex).toBeUndefined(); expect(body.effectId).toBeUndefined(); @@ -925,7 +986,7 @@ describe("send", () => { expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown"); const sendCall = mockFetch.mock.calls[1]; const body = JSON.parse(sendCall[1].body); - expect(body.method).toBeUndefined(); + expect(body.method).toBe("apple-script"); expect(body.selectedMessageGuid).toBeUndefined(); } finally { clearBlueBubblesRuntime(); @@ -973,28 +1034,67 @@ describe("send", () => { expect(runtimeLog).not.toHaveBeenCalled(); const sendCall = mockFetch.mock.calls[1]; const body = JSON.parse(sendCall[1].body); - expect(body.method).toBeUndefined(); + expect(body.method).toBe("apple-script"); expect(body.selectedMessageGuid).toBeUndefined(); } finally { clearBlueBubblesRuntime(); } }); - it("does not refresh when no reply or effect is requested", async () => { - // Cache expired but no Private API features needed — skip refresh + // Plain-text sends also need the cache populated so `isMacOS26OrHigher` + // can read `os_version` from the same `serverInfoCache`. Without a + // refresh on cold/expired cache, macOS 26 detection would silently + // miss and force-route would fall back to broken AppleScript. + // (Greptile/Codex PR #69070) + it("refreshes cache for plain-text sends when status is unknown", async () => { + // First call returns null (cache cold/expired). The refresh path + // fetches server info; plain-text send still uses AppleScript when + // Private API is disabled on the server — but the refresh ran. + privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(false); + fetchServerInfoMock.mockResolvedValueOnce({ private_api: false }); mockResolvedHandleTarget(); - mockSendResponse({ data: { guid: "msg-plain" } }); + mockSendResponse({ data: { guid: "msg-plain-refreshed" } }); const result = await sendMessageBlueBubbles("+15551234567", "Plain message", { serverUrl: "http://localhost:1234", password: "test", }); - expect(result.messageId).toBe("msg-plain"); - expect(fetchServerInfoMock).not.toHaveBeenCalled(); + expect(result.messageId).toBe("msg-plain-refreshed"); + expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); const sendCall = mockFetch.mock.calls[1]; const body = JSON.parse(sendCall[1].body); - expect(body.method).toBeUndefined(); + expect(body.method).toBe("apple-script"); + }); + + // Cold cache + macOS 26 + Private API enabled on refresh — the + // refresh populates the cache, `isMacOS26OrHigher` returns true, and + // plain-text routes through Private API instead of broken AppleScript. + // (Greptile/Codex PR #69070) + it("force-routes macOS 26 plain-text through Private API after cold-cache refresh", async () => { + privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(true); + fetchServerInfoMock.mockResolvedValueOnce({ + private_api: true, + os_version: "26.0", + }); + isMacOS26OrHigherMock.mockReturnValue(true); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-macos26-refreshed" } }); + + try { + const result = await sendMessageBlueBubbles("+15551234567", "Plain message", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("msg-macos26-refreshed"); + expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBe("private-api"); + } finally { + isMacOS26OrHigherMock.mockReturnValue(false); + } }); it("degrades gracefully when refresh returns null (server unreachable)", async () => { diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 027714bc563..a2506a73ca5 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -11,6 +11,7 @@ import { fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus, isBlueBubblesPrivateApiStatusEnabled, + isMacOS26OrHigher, } from "./probe.js"; import type { OpenClawConfig } from "./runtime-api.js"; import { warnBlueBubbles } from "./runtime.js"; @@ -88,11 +89,18 @@ function resolvePrivateApiDecision(params: { privateApiStatus: boolean | null; wantsReplyThread: boolean; wantsEffect: boolean; + accountId?: string; }): PrivateApiDecision { - const { privateApiStatus, wantsReplyThread, wantsEffect } = params; + const { privateApiStatus, wantsReplyThread, wantsEffect, accountId } = params; const needsPrivateApi = wantsReplyThread || wantsEffect; + // On macOS 26 Tahoe, AppleScript Messages.app automation is broken + // (`-1700` error) for outbound sends. Prefer Private API even for plain + // text when it is available so sends still reach the recipient. + // (#53159 Bug B, #64480) + const forceOnMacOS26 = + isMacOS26OrHigher(accountId) && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus); const canUsePrivateApi = - needsPrivateApi && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus); + (needsPrivateApi || forceOnMacOS26) && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus); const throwEffectDisabledError = wantsEffect && privateApiStatus === false; if (!needsPrivateApi || privateApiStatus !== null) { return { canUsePrivateApi, throwEffectDisabledError }; @@ -474,10 +482,14 @@ export async function sendMessageBlueBubbles( const wantsReplyThread = normalizeOptionalString(opts.replyToMessageGuid) !== undefined; const wantsEffect = Boolean(effectId); - // Lazy refresh: when the cache has expired and Private API features are needed, - // fetch server info before making the decision. This prevents silent degradation - // of reply threading and effects after the 10-minute cache TTL expires. (#43764) - if (privateApiStatus === null && (wantsReplyThread || wantsEffect)) { + // Lazy refresh: when the cache has expired, fetch server info before + // making the decision. Originally scoped to reply/effect features (#43764) + // to avoid silent degradation after the 10-minute cache TTL expires. Now + // always fires on null status, because `isMacOS26OrHigher()` reads from + // the same cache and plain-text sends on macOS 26 need Private API too — + // without this, `forceOnMacOS26` silently falls back to broken AppleScript + // after TTL expiry or on a cold cache. (#64480, Greptile/Codex PR #69070) + if (privateApiStatus === null) { try { await fetchBlueBubblesServerInfo({ baseUrl, @@ -496,6 +508,7 @@ export async function sendMessageBlueBubbles( privateApiStatus, wantsReplyThread, wantsEffect, + accountId, }); if (privateApiDecision.throwEffectDisabledError) { throw new Error( @@ -505,14 +518,16 @@ export async function sendMessageBlueBubbles( if (privateApiDecision.warningMessage) { warnBlueBubbles(privateApiDecision.warningMessage); } + // Always set `method` explicitly. BB Server's behavior on an omitted + // `method` is version-dependent and silently drops on some setups (e.g. + // macOS without Private API — message lands in Messages.app locally but + // never reaches the phone). (#64480) const payload: Record = { chatGuid, tempGuid: crypto.randomUUID(), message: strippedText, + method: privateApiDecision.canUsePrivateApi ? "private-api" : "apple-script", }; - if (privateApiDecision.canUsePrivateApi) { - payload.method = "private-api"; - } // Add reply threading support if (wantsReplyThread && privateApiDecision.canUsePrivateApi) { diff --git a/extensions/bluebubbles/src/test-harness.ts b/extensions/bluebubbles/src/test-harness.ts index 000d080c8b7..1600f7904bb 100644 --- a/extensions/bluebubbles/src/test-harness.ts +++ b/extensions/bluebubbles/src/test-harness.ts @@ -85,6 +85,7 @@ type BlueBubblesProbeMockModule = { fetchBlueBubblesServerInfo: Mock<() => Promise | null>>; getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>; isBlueBubblesPrivateApiStatusEnabled: Mock<(status: boolean | null) => boolean>; + isMacOS26OrHigher: Mock<(accountId?: string) => boolean>; }; export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule { @@ -94,6 +95,7 @@ export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule { .fn() .mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown), isBlueBubblesPrivateApiStatusEnabled: vi.fn((status: boolean | null) => status === true), + isMacOS26OrHigher: vi.fn().mockReturnValue(false), }; } diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs index c5d35d3dddf..b5bfea51c62 100644 --- a/scripts/check-no-raw-channel-fetch.mjs +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -14,7 +14,7 @@ const sourceRoots = ["src/channels", "src/routing", "src/line", "extensions"]; // Temporary allowlist for legacy callsites. New raw fetch callsites in channel/plugin runtime // code should be rejected and migrated to fetchWithSsrFGuard/shared channel helpers. const allowedRawFetchCallsites = new Set([ - bundledPluginCallsite("bluebubbles", "src/test-harness.ts", 130), + bundledPluginCallsite("bluebubbles", "src/test-harness.ts", 132), bundledPluginCallsite("bluebubbles", "src/types.ts", 189), bundledPluginCallsite("browser", "src/browser/cdp.helpers.ts", 268), bundledPluginCallsite("browser", "src/browser/client-fetch.ts", 192),