mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:10:44 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user