From 6c5bf226b1dcebe967756bcc748d720123b96185 Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:53:15 -0700 Subject: [PATCH] bluebubbles: always set method explicitly on text sends, force Private API on macOS 26 (#64480, #53159) --- CHANGELOG.md | 1 + extensions/bluebubbles/src/send.test.ts | 75 ++++++++++++++++++++-- extensions/bluebubbles/src/send.ts | 21 ++++-- extensions/bluebubbles/src/test-harness.ts | 2 + 4 files changed, 87 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d4d35f7273..8f7445b169d 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). 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..412fdff03cd 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,7 +1034,7 @@ 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(); @@ -994,7 +1055,7 @@ describe("send", () => { expect(fetchServerInfoMock).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"); }); 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..4d6c725506e 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 }; @@ -496,6 +504,7 @@ export async function sendMessageBlueBubbles( privateApiStatus, wantsReplyThread, wantsEffect, + accountId, }); if (privateApiDecision.throwEffectDisabledError) { throw new Error( @@ -505,14 +514,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), }; }