diff --git a/CHANGELOG.md b/CHANGELOG.md index a54b896887c..e0f4ac366c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Cron/delivery: treat explicit `delivery.mode: "none"` runs as not requested even if the runner reports `delivered: false`, so no-delivery cron jobs no longer persist false delivery failures or errors. (#69285) Thanks @matsuri1987. - Plugins/install: repair active and default-enabled bundled plugin runtime dependencies before import in packaged installs, so bundled Discord, WhatsApp, Slack, Telegram, and provider plugins work without putting their dependency trees in core. +- BlueBubbles: raise the outbound `/api/v1/message/text` send timeout default from 10s to 30s, and add a configurable `channels.bluebubbles.sendTimeoutMs` (also per-account) so macOS 26 setups where Private API iMessage sends stall for 60+ seconds no longer silently lose messages at the 10s abort. Probes, chat lookups, and health checks keep the shorter 10s default. Fixes #67486. (#69193) Thanks @omarshahine. ## 2026.4.20 diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 85a14925092..fb3a8eddf13 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -b199851e694368264c24ba3d347a84764a19f632769e049fe94a82787c5e5d93 config-baseline.json +747a24d0acf12f95ec75feabb47dad8f03ff0e3a7173b4d277c648f75d956ce5 config-baseline.json cbb9a6ee1cb69068d5eb63f00f95512ba19778415ea5b2eabe056aaea38978b5 config-baseline.core.json -0982fc3d264047919333a57dfba1ba948e6639fb19659a400f947dfdd8b8d1de config-baseline.channel.json +e239cc20f20f8d0172812bc0ad3ee6df52da88e2e2702e3d03a47e01561132ae config-baseline.channel.json b695cb31b4c0cf1d31f842f2892e99cc3ff8d84263ae72b72977cae844b81d6e config-baseline.plugin.json diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index f471622badd..c717f273f34 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -384,6 +384,7 @@ Provider options: - `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`). - `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `false`; required for streaming replies). - `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000). +- `channels.bluebubbles.sendTimeoutMs`: Per-request timeout in ms for outbound text sends via `/api/v1/message/text` (default: 30000). Raise on macOS 26 setups where Private API iMessage sends can stall for 60+ seconds inside the iMessage framework; for example `45000` or `60000`. Probes, chat lookups, reactions, edits, and health checks currently keep the shorter 10s default; broadening coverage to reactions and edits is planned as a follow-up. Per-account override: `channels.bluebubbles.accounts..sendTimeoutMs`. - `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking. - `channels.bluebubbles.mediaMaxMb`: Inbound/outbound media cap in MB (default: 8). - `channels.bluebubbles.mediaLocalRoots`: Explicit allowlist of absolute local directories permitted for outbound local media paths. Local path sends are denied by default unless this is configured. Per-account override: `channels.bluebubbles.accounts..mediaLocalRoots`. diff --git a/extensions/bluebubbles/src/account-resolve.test.ts b/extensions/bluebubbles/src/account-resolve.test.ts index faf642df963..7e77a5c7d23 100644 --- a/extensions/bluebubbles/src/account-resolve.test.ts +++ b/extensions/bluebubbles/src/account-resolve.test.ts @@ -79,4 +79,71 @@ describe("resolveBlueBubblesServerAccount", () => { allowPrivateNetworkConfig: true, }); }); + + describe("sendTimeoutMs", () => { + it("returns channel-level sendTimeoutMs when configured", () => { + expect( + resolveBlueBubblesServerAccount({ + serverUrl: "http://localhost:1234", + password: "test-password", + cfg: { + channels: { + bluebubbles: { + sendTimeoutMs: 45_000, + }, + }, + }, + }), + ).toMatchObject({ sendTimeoutMs: 45_000 }); + }); + + it("returns per-account sendTimeoutMs when configured", () => { + expect( + resolveBlueBubblesServerAccount({ + accountId: "personal", + cfg: { + channels: { + bluebubbles: { + accounts: { + personal: { + serverUrl: "http://localhost:1234", + password: "test-password", + sendTimeoutMs: 60_000, + }, + }, + }, + }, + }, + }), + ).toMatchObject({ sendTimeoutMs: 60_000 }); + }); + + it("returns undefined sendTimeoutMs when unconfigured (use DEFAULT_SEND_TIMEOUT_MS downstream)", () => { + const resolved = resolveBlueBubblesServerAccount({ + serverUrl: "http://localhost:1234", + password: "test-password", + cfg: {}, + }); + expect(resolved.sendTimeoutMs).toBeUndefined(); + }); + + it("ignores non-positive / non-integer sendTimeoutMs values", () => { + for (const bad of [0, -1, 1.5, Number.NaN]) { + const resolved = resolveBlueBubblesServerAccount({ + serverUrl: "http://localhost:1234", + password: "test-password", + cfg: { + channels: { + bluebubbles: { + // runtime might receive a malformed value via raw config; the + // resolver must drop it so downstream falls back to the default. + sendTimeoutMs: bad as unknown as number, + }, + }, + }, + }); + expect(resolved.sendTimeoutMs).toBeUndefined(); + } + }); + }); }); diff --git a/extensions/bluebubbles/src/account-resolve.ts b/extensions/bluebubbles/src/account-resolve.ts index 49d14711c0d..f71d09a2210 100644 --- a/extensions/bluebubbles/src/account-resolve.ts +++ b/extensions/bluebubbles/src/account-resolve.ts @@ -19,6 +19,13 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv accountId: string; allowPrivateNetwork: boolean; allowPrivateNetworkConfig?: boolean; + /** + * Per-account send timeout from `channels.bluebubbles.sendTimeoutMs` (or + * `accounts..sendTimeoutMs`). Only returned when the caller configured + * a positive integer; `undefined` means "fall back to DEFAULT_SEND_TIMEOUT_MS". + * (#67486) + */ + sendTimeoutMs?: number; } { const account = resolveBlueBubblesAccount({ cfg: params.cfg ?? {}, @@ -49,6 +56,13 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv throw new Error("BlueBubbles password is required"); } + const rawSendTimeoutMs = account.config.sendTimeoutMs; + const sendTimeoutMs = + typeof rawSendTimeoutMs === "number" && + Number.isInteger(rawSendTimeoutMs) && + rawSendTimeoutMs > 0 + ? rawSendTimeoutMs + : undefined; return { baseUrl, password, @@ -58,5 +72,6 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv config: account.config, }), allowPrivateNetworkConfig: resolveBlueBubblesPrivateNetworkConfigValue(account.config), + sendTimeoutMs, }; } diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index a4b7fb5e7b3..3261cd190c6 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -79,6 +79,7 @@ const bluebubblesAccountSchema = z historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), textChunkLimit: z.number().int().positive().optional(), + sendTimeoutMs: z.number().int().positive().optional(), chunkMode: z.enum(["length", "newline"]).optional(), mediaMaxMb: z.number().int().positive().optional(), mediaLocalRoots: z.array(z.string()).optional(), diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 281a17de2bf..1d08452f254 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -1386,6 +1386,118 @@ describe("send", () => { } }); }); + + describe("send timeout (#67486)", () => { + // Capture the `timeoutMs` that the SSRF guard receives on each call. + // Index 0 is the `chat/query` preflight; index 1 is the actual + // `/api/v1/message/text` POST — that's the one we care about. + function installTimeoutCapture(): (number | undefined)[] { + const timeouts: (number | undefined)[] = []; + _setFetchGuardForTesting(async (guardParams) => { + timeouts.push(guardParams.timeoutMs); + const raw = await globalThis.fetch(guardParams.url, guardParams.init); + // Mirrors `createBlueBubblesFetchGuardPassthroughInstaller` so both + // `.json()`-only chat-query mocks and `.text()`-only send mocks work. + let body: ArrayBuffer; + if ( + typeof (raw as { arrayBuffer?: () => Promise }).arrayBuffer === "function" + ) { + body = await (raw as { arrayBuffer: () => Promise }).arrayBuffer(); + } else { + const text = + typeof (raw as { text?: () => Promise }).text === "function" + ? await (raw as { text: () => Promise }).text() + : typeof (raw as { json?: () => Promise }).json === "function" + ? JSON.stringify(await (raw as { json: () => Promise }).json()) + : ""; + body = new TextEncoder().encode(text).buffer; + } + return { + response: new Response(body, { + status: (raw as { status?: number }).status ?? 200, + headers: (raw as { headers?: HeadersInit }).headers, + }), + release: async () => {}, + finalUrl: guardParams.url, + }; + }); + return timeouts; + } + + it("defaults the /message/text send to DEFAULT_SEND_TIMEOUT_MS (30s), not 10s", async () => { + const timeouts = installTimeoutCapture(); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-default-timeout" } }); + + try { + const result = await sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }); + expect(result.messageId).toBe("msg-default-timeout"); + // chat/query preflight must stay at the short default; only the send POST rises. + expect(timeouts[0]).toBe(10_000); + expect(timeouts[1]).toBe(30_000); + } finally { + _setFetchGuardForTesting(null); + } + }); + + it("honors channels.bluebubbles.sendTimeoutMs from config for the send POST", async () => { + const timeouts = installTimeoutCapture(); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-config-timeout" } }); + + try { + const result = await sendMessageBlueBubbles("+15551234567", "Hello", { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test", + sendTimeoutMs: 45_000, + }, + }, + }, + }); + expect(result.messageId).toBe("msg-config-timeout"); + // chat/query preflight must stay at the short default; only the send POST rises. + expect(timeouts[0]).toBe(10_000); + expect(timeouts[1]).toBe(45_000); + } finally { + _setFetchGuardForTesting(null); + } + }); + + it("explicit opts.timeoutMs wins over both config and default", async () => { + const timeouts = installTimeoutCapture(); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-explicit-timeout" } }); + + try { + const result = await sendMessageBlueBubbles("+15551234567", "Hello", { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test", + sendTimeoutMs: 45_000, + }, + }, + }, + timeoutMs: 90_000, + }); + expect(result.messageId).toBe("msg-explicit-timeout"); + // Explicit opts.timeoutMs is forwarded to every call site, including + // the chat/query preflight — the only override that can push that + // preflight above the 10s default. + expect(timeouts[0]).toBe(90_000); + expect(timeouts[1]).toBe(90_000); + } finally { + _setFetchGuardForTesting(null); + } + }); + }); }); describe("createChatForHandle", () => { diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 24d2f664345..24eefe01a3a 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -17,7 +17,7 @@ import type { OpenClawConfig } from "./runtime-api.js"; import { warnBlueBubbles } from "./runtime.js"; import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js"; -import { type BlueBubblesSendTarget } from "./types.js"; +import { DEFAULT_SEND_TIMEOUT_MS, type BlueBubblesSendTarget } from "./types.js"; export type BlueBubblesSendOpts = { serverUrl?: string; @@ -497,12 +497,18 @@ export async function sendMessageBlueBubbles( throw new Error("BlueBubbles send requires text (message was empty after markdown removal)"); } - const { baseUrl, password, accountId, allowPrivateNetwork } = resolveBlueBubblesServerAccount({ - cfg: opts.cfg ?? {}, - accountId: opts.accountId, - serverUrl: opts.serverUrl, - password: opts.password, - }); + const { baseUrl, password, accountId, allowPrivateNetwork, sendTimeoutMs } = + resolveBlueBubblesServerAccount({ + cfg: opts.cfg ?? {}, + accountId: opts.accountId, + serverUrl: opts.serverUrl, + password: opts.password, + }); + // Send-path timeout: explicit caller override > per-account config > 30s default. + // Kept separate from the default 10s client timeout so chat lookups, probes, + // and health checks stay snappy while actual sends can ride out macOS 26 + // Private API stalls. (#67486) + const effectiveSendTimeoutMs = opts.timeoutMs ?? sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS; let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); const target = resolveBlueBubblesSendTarget(to); @@ -522,7 +528,7 @@ export async function sendMessageBlueBubbles( password, address: target.address, message: strippedText, - timeoutMs: opts.timeoutMs, + timeoutMs: effectiveSendTimeoutMs, allowPrivateNetwork, }); } @@ -602,7 +608,7 @@ export async function sendMessageBlueBubbles( method: "POST", path: "/api/v1/message/text", body: payload, - timeoutMs: opts.timeoutMs, + timeoutMs: effectiveSendTimeoutMs, }); if (!res.ok) { const errorText = await res.text(); diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index 307fe2b1809..07c75ad6f9b 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -62,6 +62,19 @@ export type BlueBubblesAccountConfig = { dms?: Record; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; + /** + * Per-request timeout (ms) for outbound text sends via + * `/api/v1/message/text` and the `createNewChatWithMessage` send path. + * Probes, chat lookups, catchup, and history keep the shorter default. + * Raise this on macOS 26 setups where Private API iMessage sends can stall + * for 60+s. Default: 30000. + * + * Reaction and edit paths (`sendBlueBubblesReaction`, + * `editBlueBubblesMessage`, `unsendBlueBubblesMessage`) still honor the + * shorter client default unless the caller passes `opts.timeoutMs` — covering + * those uniformly from config is tracked as a follow-up. (#67486) + */ + sendTimeoutMs?: number; /** Chunking mode: "newline" (default) splits on every newline; "length" splits by size. */ chunkMode?: "length" | "newline"; blockStreaming?: boolean; @@ -116,6 +129,16 @@ export type BlueBubblesAttachment = { const DEFAULT_TIMEOUT_MS = 10_000; +/** + * Default timeout for outbound message sends via `/api/v1/message/text` and + * the `createNewChatWithMessage` flow. Larger than `DEFAULT_TIMEOUT_MS` because + * Private API iMessage sends on macOS 26 (Tahoe) can stall for 60+ seconds + * inside the iMessage framework. Callers can override per-call via + * `opts.timeoutMs` or per-account via `channels.bluebubbles.sendTimeoutMs`. + * (#67486) + */ +export const DEFAULT_SEND_TIMEOUT_MS = 30_000; + export function normalizeBlueBubblesServerUrl(raw: string): string { const trimmed = raw.trim(); if (!trimmed) { diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index e07ae4fd833..987064af23e 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -214,6 +214,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ exclusiveMinimum: 0, maximum: 9007199254740991, }, + sendTimeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 9007199254740991, + }, chunkMode: { type: "string", enum: ["length", "newline"], @@ -520,6 +525,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ exclusiveMinimum: 0, maximum: 9007199254740991, }, + sendTimeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 9007199254740991, + }, chunkMode: { type: "string", enum: ["length", "newline"], diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 45212a65f4c..8dfa2e59054 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -1441,6 +1441,7 @@ export const BlueBubblesAccountSchemaBase = z dmHistoryLimit: z.number().int().min(0).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), + sendTimeoutMs: z.number().int().positive().optional(), chunkMode: z.enum(["length", "newline"]).optional(), mediaMaxMb: z.number().int().positive().optional(), mediaLocalRoots: z.array(z.string()).optional(),