Skip to content

Commit 6c35c0d

Browse files
authored
fix(imessage): self-explaining private-API failures and dedicated send timeout (#91041)
Append imsg's own status message (SIP / library validation / macOS 26 AMFI gate) to iMessage private-API blocked-action errors so operators see the real blocker instead of a generic "run imsg launch". Add a dedicated 150s default timeout for iMessage send RPCs (explicit opts and probeTimeoutMs still win) so macOS 26 bridge stalls are not aborted mid-send. Staged mitigation: the longer wait fully activates once the companion bridge timeout (openclaw/imsg#139) ships; on current imsg the bridge still returns at its own 10s, so there is no regression. Diagnostics half is live-proven; the delayed-send timeout is covered by source + unit proof + maintainer waiver.
1 parent af79cd6 commit 6c35c0d

7 files changed

Lines changed: 77 additions & 3 deletions

File tree

extensions/imessage/src/actions.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,11 +430,17 @@ export const imessageMessageActions: ChannelMessageActionAdapter = {
430430
// disappeared — they only see "channel: running" in `channels status`.
431431
// Common cause: gateway restart un-injects the imsg-bridge-helper.dylib
432432
// from Messages.app while imsg rpc keeps running.
433+
// imsg's status message names the actual blocker (SIP, library
434+
// validation, macOS 26 AMFI gate) — append it so the operator isn't
435+
// told to "run imsg launch" when the OS is rejecting the dylib.
436+
const reason = privateApiStatus?.statusMessage
437+
? ` imsg reports: ${privateApiStatus.statusMessage}`
438+
: "";
433439
log.warn(
434-
`iMessage ${action} blocked: private API bridge unavailable (accountId=${account.accountId}, cliPath=${cliPathForProbe}). Run \`imsg launch\` to re-inject the dylib, then \`openclaw channels status\` to refresh.`,
440+
`iMessage ${action} blocked: private API bridge unavailable (accountId=${account.accountId}, cliPath=${cliPathForProbe}). Run \`imsg launch\` to re-inject the dylib, then \`openclaw channels status\` to refresh.${reason}`,
435441
);
436442
throw new Error(
437-
`iMessage ${action} requires the imsg private API bridge. Run imsg launch, then openclaw channels status to refresh capability detection.`,
443+
`iMessage ${action} requires the imsg private API bridge. Run imsg launch, then openclaw channels status to refresh capability detection.${reason}`,
438444
);
439445
}
440446
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,11 @@
11
/** Default timeout for iMessage probe/RPC operations (10 seconds). */
22
export const DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS = 10_000;
3+
4+
// Sends get a much longer default than probes: on macOS 26 (Tahoe) the private
5+
// API bridge intermittently stalls up to ~124s before the send completes. The
6+
// 10s probe timeout aborts those mid-flight, and non-recoverable shapes
7+
// (attachment/reply) are then lost. This must clear the observed upper bound
8+
// plus headroom, otherwise the long tail of stalls still loses sends — 150s
9+
// covers 124s with margin. Decoupling keeps probes/health checks fast while
10+
// letting real sends ride out the stall. Akin to the BlueBubbles fix (#69193).
11+
export const DEFAULT_IMESSAGE_SEND_TIMEOUT_MS = 150_000;

extensions/imessage/src/private-api-status.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export type IMessagePrivateApiStatus = {
1212
cliCapabilities?: {
1313
sendRichSupportsAttachment?: boolean;
1414
};
15+
// imsg's own `status --json` `message` field. When advanced features are off
16+
// it explains why (SIP enabled, library validation, macOS 26 AMFI gate), so
17+
// callers can surface a real reason instead of a generic "run imsg launch".
18+
statusMessage?: string;
1519
error?: string;
1620
};
1721

extensions/imessage/src/probe.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ export async function probeIMessagePrivateApi(
237237
const rpcMethods = payload ? rpcMethodsFromPayload(payload) : [];
238238
const advancedFeatures = payload?.advanced_features === true;
239239
const v2Ready = payload?.v2_ready === true;
240+
// imsg explains an unavailable bridge here (SIP, library validation, macOS
241+
// 26 AMFI gate). Carry it forward so blocked actions can show the reason.
242+
const statusMessage = typeof payload?.message === "string" ? payload.message : undefined;
240243
// Probe `imsg send-rich --help` for the `--file` flag added by
241244
// openclaw/imsg#114. We do this even when the bridge is unavailable
242245
// because the help output ships with the CLI binary itself, and the
@@ -250,6 +253,7 @@ export async function probeIMessagePrivateApi(
250253
selectors,
251254
rpcMethods,
252255
cliCapabilities: { sendRichSupportsAttachment },
256+
...(statusMessage ? { statusMessage } : {}),
253257
...(result.code === 0
254258
? !payload && firstLineSnippet
255259
? {

extensions/imessage/src/send.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,19 @@ describe("sendMessageIMessage receipts", () => {
121121
expect(result.receipt.sentAt).toBeGreaterThan(0);
122122
});
123123

124+
it("uses the dedicated send timeout (covers macOS 26 stalls), not the 10s probe default", async () => {
125+
const client = createClient({ guid: "p:0/imsg-1" });
126+
127+
await sendMessageIMessage("chat_id:42", "hello", {
128+
config: IMESSAGE_TEST_CFG,
129+
client,
130+
});
131+
132+
expect(getClientMocks(client).request).toHaveBeenCalledWith("send", expect.any(Object), {
133+
timeoutMs: 150_000,
134+
});
135+
});
136+
124137
it("sends explicit chat media-only payloads through send-attachment auto transport", async () => {
125138
const client = createClient({ message_id: 12345 });
126139
const runCliJson = vi

extensions/imessage/src/send.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from "./approval-reactions.js";
2626
import { appendIMessageCliStderrTail, appendIMessageCliStdout } from "./cli-output.js";
2727
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
28+
import { DEFAULT_IMESSAGE_SEND_TIMEOUT_MS } from "./constants.js";
2829
import { extractMarkdownFormatRuns } from "./markdown-format.js";
2930
import { rememberIMessageReplyCache } from "./monitor-reply-cache.js";
3031
import { rememberPersistedIMessageEcho } from "./monitor/persisted-echo-cache.js";
@@ -857,7 +858,11 @@ export async function sendMessageIMessage(
857858
opts.service ??
858859
resolveTargetService(target) ??
859860
(account.config.service as IMessageService | undefined);
860-
const timeoutMs = opts.timeoutMs ?? account.config.probeTimeoutMs;
861+
// Sends use a dedicated longer default (not the 10s probe timeout) so macOS 26
862+
// bridge stalls aren't aborted mid-send. Explicit opts/probeTimeoutMs still win
863+
// for callers that tuned them. See DEFAULT_IMESSAGE_SEND_TIMEOUT_MS.
864+
const timeoutMs =
865+
opts.timeoutMs ?? account.config.probeTimeoutMs ?? DEFAULT_IMESSAGE_SEND_TIMEOUT_MS;
861866
const region = opts.region?.trim() || account.config.region?.trim() || "US";
862867
const maxBytes =
863868
typeof opts.maxBytes === "number"

extensions/imessage/src/status.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,39 @@ describe("probeIMessage", () => {
420420
expect(runCommand).toHaveBeenCalledTimes(4);
421421
});
422422

423+
it("propagates imsg's status message when advanced features are unavailable", async () => {
424+
const note =
425+
"System Integrity Protection (SIP) is enabled.\nAdvanced IMCore features are intentionally disabled.";
426+
vi.spyOn(processRuntime, "runCommandWithTimeout")
427+
.mockResolvedValueOnce({
428+
stdout: JSON.stringify({
429+
advanced_features: false,
430+
v2_ready: false,
431+
selectors: {},
432+
rpc_methods: ["chats.list"],
433+
message: note,
434+
}),
435+
stderr: "",
436+
code: 0,
437+
signal: null,
438+
killed: false,
439+
termination: "exit",
440+
})
441+
.mockResolvedValueOnce({
442+
stdout: "send-rich --help",
443+
stderr: "",
444+
code: 0,
445+
signal: null,
446+
killed: false,
447+
termination: "exit",
448+
});
449+
450+
await expect(probeIMessagePrivateApi("imsg-status-message-test", 1000)).resolves.toMatchObject({
451+
available: false,
452+
statusMessage: note,
453+
});
454+
});
455+
423456
it("fails fast for default local imsg probes on non-mac hosts", async () => {
424457
const createIMessageRpcClientMock = vi
425458
.spyOn(clientModule, "createIMessageRpcClient")

0 commit comments

Comments
 (0)