bluebubbles: always set method explicitly on text sends, force Private API on macOS 26 (#69070)

Merged via squash.

Prepared head SHA: e3af5c5d83
Co-authored-by: omarshahine <198016546+xqing3@users.noreply.github.com>
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Reviewed-by: @omarshahine
This commit is contained in:
Omar Shahine
2026-04-19 13:42:56 -07:00
committed by GitHub
parent f38a498985
commit 8fbf0972e7
5 changed files with 140 additions and 22 deletions

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -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<string, unknown> = {
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) {

View File

@@ -85,6 +85,7 @@ type BlueBubblesProbeMockModule = {
fetchBlueBubblesServerInfo: Mock<() => Promise<Record<string, unknown> | 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),
};
}

View File

@@ -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),