fix(imessage): harden outbound send transport (#91783)

Merged via squash.

Prepared head SHA: 39ea25767b

Proof:
- Focused tests, docs/config generation, lint/type/doc checks passed before merge.
- ClawSweeper re-review marked proof and patch quality platinum after lobster live send proof.
- Maintainer accepted the `channels.imessage.sendTransport` config surface and compatibility-risk tradeoff.

Lobster proof id: openclaw-lobster-live-proof-c74895c2-b629-4bb0-abcb-e6521069b3d8

Reviewed-by: @omarshahine
This commit is contained in:
Omar Shahine
2026-06-09 19:09:15 -07:00
committed by GitHub
parent 9a1f2022b1
commit bfccbc3fee
14 changed files with 253 additions and 48 deletions

View File

@@ -763,6 +763,31 @@ imessage: suppressed stale inbound backlog account=<id> sent=<iso> recovery=<boo
</Accordion>
<Accordion title="Messages send but inbound iMessages do not arrive">
First prove whether the message reached the local Mac. If `chat.db` does not change, OpenClaw cannot receive the message even when `imsg status --json` reports a healthy bridge.
```bash
imsg chats --limit 10 --json
imsg watch --chat-id <chat-id> --json
sqlite3 ~/Library/Messages/chat.db \
"select datetime(max(date)/1000000000 + 978307200, 'unixepoch', 'localtime'), max(ROWID) from message;"
```
If phone-sent messages create no new rows, repair the macOS Messages and Apple Push layer before changing OpenClaw config. A one-shot service refresh is often enough:
```bash
launchctl kickstart -k system/com.apple.apsd
launchctl kickstart -k gui/$(id -u)/com.apple.CommCenter
launchctl kickstart -k gui/$(id -u)/com.apple.identityservicesd
launchctl kickstart -k gui/$(id -u)/com.apple.imagent
imsg launch
openclaw gateway restart
```
Send a fresh iMessage from the phone and confirm a new `chat.db` row or `imsg watch` event before debugging OpenClaw sessions. Do not run this as a periodic bridge-relaunch loop; repeated `imsg launch` plus gateway restarts during active work can interrupt deliveries and strand in-flight channel runs.
</Accordion>
<Accordion title="Gateway is not running on macOS">
The default `cliPath: "imsg"` must run on the Mac signed into Messages. On Linux or Windows, set `channels.imessage.cliPath` to a wrapper script that SSHes to that Mac and runs `imsg "$@"`.

View File

@@ -615,6 +615,7 @@ Before relying on an SSH wrapper for production sends, verify an outbound `imsg
remoteAttachmentRoots: ["/Users/*/Library/Messages/Attachments"],
mediaMaxMb: 16,
service: "auto",
sendTransport: "auto",
region: "US",
actions: {
reactions: true,
@@ -637,6 +638,7 @@ Before relying on an SSH wrapper for production sends, verify an outbound `imsg
- `attachmentRoots` and `remoteAttachmentRoots` restrict inbound attachment paths (default: `/Users/*/Library/Messages/Attachments`).
- SCP uses strict host-key checking, so ensure the relay host key already exists in `~/.ssh/known_hosts`.
- `channels.imessage.configWrites`: allow or deny iMessage-initiated config writes.
- `channels.imessage.sendTransport`: preferred `imsg` RPC send transport for normal outbound replies. `auto` (default) uses the IMCore bridge for existing chats when it is running, then falls back to AppleScript; `bridge` requires private-API delivery; `applescript` forces the public Messages automation path.
- `channels.imessage.actions.*`: enable private API actions that are also gated by `imsg status` / `openclaw channels status --probe`.
- `channels.imessage.includeAttachments` is off by default; set it to `true` before expecting inbound media in agent turns.
- Inbound recovery after a bridge/gateway restart is automatic (GUID dedupe plus a stale-backlog age fence). Existing `channels.imessage.catchup.enabled: true` configs are still honored as a deprecated compatibility profile.

View File

@@ -51,6 +51,26 @@ describe("resolveIMessageAccount", () => {
expect(resolved.config.dmPolicy).toBe("open");
expect(resolved.configured).toBe(true);
});
it("treats sendTransport as an intentional account config", () => {
const resolved = resolveIMessageAccount({
cfg: {
channels: {
imessage: {
accounts: {
work: {
sendTransport: "bridge",
},
},
},
},
} as never,
accountId: "work",
});
expect(resolved.config.sendTransport).toBe("bridge");
expect(resolved.configured).toBe(true);
});
});
describe("iMessage duplicate-source watcher ownership", () => {

View File

@@ -131,6 +131,7 @@ export function resolveIMessageAccount(params: {
merged.cliPath?.trim() ||
merged.dbPath?.trim() ||
merged.service ||
merged.sendTransport ||
merged.region?.trim() ||
(merged.allowFrom && merged.allowFrom.length > 0) ||
(merged.groupAllowFrom && merged.groupAllowFrom.length > 0) ||

View File

@@ -110,6 +110,38 @@ describe("imessage config schema", () => {
expect(res.success).toBe(true);
});
it("accepts send transport overrides", () => {
const res = IMessageConfigSchema.safeParse({
sendTransport: "auto",
accounts: {
bridge: {
sendTransport: "bridge",
},
applescript: {
sendTransport: "applescript",
},
},
});
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.sendTransport).toBe("auto");
expect(res.data.accounts?.bridge?.sendTransport).toBe("bridge");
expect(res.data.accounts?.applescript?.sendTransport).toBe("applescript");
}
});
it("rejects invalid send transport overrides", () => {
const res = IMessageConfigSchema.safeParse({
sendTransport: "private-api",
});
expect(res.success).toBe(false);
if (!res.success) {
expect(res.error.issues[0]?.path.join(".")).toBe("sendTransport");
}
});
it("rejects invalid reaction notification modes", () => {
const res = IMessageConfigSchema.safeParse({
reactionNotifications: "allowlist",

View File

@@ -18,4 +18,8 @@ export const iMessageChannelConfigUiHints = {
label: "iMessage CLI Path",
help: "Filesystem path to the iMessage bridge CLI binary used for send/receive operations. Set explicitly when the binary is not on PATH in service runtime environments.",
},
sendTransport: {
label: "iMessage Send Transport",
help: 'Preferred imsg RPC send transport for normal outbound replies. "auto" uses the IMCore bridge when available, "bridge" requires it, and "applescript" forces Messages automation.',
},
} satisfies Record<string, ChannelConfigUiHint>;

View File

@@ -31,7 +31,6 @@ let createIMessageEchoCachingSend: typeof import("./deliver.js").createIMessageE
describe("deliverReplies", () => {
const IMESSAGE_TEST_CFG = { channels: { imessage: { accounts: { default: {} } } } };
const runtime = { log: vi.fn(), error: vi.fn() } as unknown as RuntimeEnv;
const client = {} as Awaited<ReturnType<typeof import("../client.js").createIMessageRpcClient>>;
beforeAll(async () => {
({ createIMessageEchoCachingSend, deliverReplies } = await import("./deliver.js"));
@@ -48,14 +47,13 @@ describe("deliverReplies", () => {
vi.resetModules();
});
it("propagates payload replyToId through all text chunks", async () => {
it("sends monitor text chunks without reusing the watch rpc client", async () => {
chunkTextWithModeMock.mockImplementation((text: string) => text.split("|"));
await deliverReplies({
cfg: IMESSAGE_TEST_CFG,
replies: [{ text: "first|second", replyToId: "reply-1" }],
target: "chat_id:10",
client,
accountId: "default",
runtime,
maxBytes: 4096,
@@ -70,7 +68,6 @@ describe("deliverReplies", () => {
expect.objectContaining({
config: IMESSAGE_TEST_CFG,
maxBytes: 4096,
client,
accountId: "default",
replyToId: "reply-1",
}),
@@ -81,7 +78,6 @@ describe("deliverReplies", () => {
expect.objectContaining({
config: IMESSAGE_TEST_CFG,
maxBytes: 4096,
client,
accountId: "default",
replyToId: "reply-1",
}),
@@ -100,7 +96,6 @@ describe("deliverReplies", () => {
},
],
target: "chat_id:20",
client,
accountId: "acct-2",
runtime,
maxBytes: 8192,
@@ -116,7 +111,6 @@ describe("deliverReplies", () => {
config: IMESSAGE_TEST_CFG,
mediaUrl: "https://example.com/a.jpg",
maxBytes: 8192,
client,
accountId: "acct-2",
replyToId: "reply-2",
}),
@@ -128,7 +122,6 @@ describe("deliverReplies", () => {
config: IMESSAGE_TEST_CFG,
mediaUrl: "https://example.com/b.jpg",
maxBytes: 8192,
client,
accountId: "acct-2",
replyToId: "reply-2",
}),
@@ -139,7 +132,6 @@ describe("deliverReplies", () => {
it("records durable outbound sends in the sent-message cache", async () => {
const remember = vi.fn();
const send = createIMessageEchoCachingSend({
client,
accountId: "acct-5",
sentMessageCache: { remember },
});
@@ -160,7 +152,6 @@ describe("deliverReplies", () => {
expect.objectContaining({
config: IMESSAGE_TEST_CFG,
accountId: "acct-ignored",
client,
}),
],
]);
@@ -173,7 +164,6 @@ describe("deliverReplies", () => {
it("sanitizes durable outbound text before sending", async () => {
const remember = vi.fn();
const send = createIMessageEchoCachingSend({
client,
accountId: "acct-6",
sentMessageCache: { remember },
});
@@ -194,7 +184,6 @@ describe("deliverReplies", () => {
expect.objectContaining({
config: IMESSAGE_TEST_CFG,
accountId: "acct-ignored",
client,
}),
],
]);
@@ -217,7 +206,6 @@ describe("deliverReplies", () => {
cfg: IMESSAGE_TEST_CFG,
replies: [{ text: "first|second" }],
target: "chat_id:30",
client,
accountId: "acct-3",
runtime,
maxBytes: 2048,
@@ -247,7 +235,6 @@ describe("deliverReplies", () => {
cfg: IMESSAGE_TEST_CFG,
replies: [{ mediaUrls: ["https://example.com/a.jpg"] }],
target: "chat_id:40",
client,
accountId: "acct-4",
runtime,
maxBytes: 2048,

View File

@@ -6,7 +6,6 @@ import {
} from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { IMessageRpcClient } from "../client.js";
import { sendMessageIMessage } from "../send.js";
import {
chunkTextWithMode,
@@ -21,15 +20,13 @@ export async function deliverReplies(params: {
cfg: OpenClawConfig;
replies: ReplyPayload[];
target: string;
client: IMessageRpcClient;
accountId?: string;
runtime: RuntimeEnv;
maxBytes: number;
textLimit: number;
sentMessageCache?: Pick<SentMessageCache, "remember">;
}) {
const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } =
params;
const { replies, target, runtime, maxBytes, textLimit, accountId, sentMessageCache } = params;
const scope = `${accountId ?? ""}:${target}`;
const { cfg } = params;
const tableMode = resolveMarkdownTableMode({
@@ -51,7 +48,6 @@ export async function deliverReplies(params: {
const sent = await sendMessageIMessage(target, chunk, {
config: params.cfg,
maxBytes,
client,
accountId,
replyToId: payload.replyToId,
});
@@ -65,7 +61,6 @@ export async function deliverReplies(params: {
config: params.cfg,
mediaUrl,
maxBytes,
client,
accountId,
replyToId: payload.replyToId,
});
@@ -82,16 +77,12 @@ export async function deliverReplies(params: {
}
export function createIMessageEchoCachingSend(params: {
client: IMessageRpcClient;
accountId?: string;
sentMessageCache?: Pick<SentMessageCache, "remember">;
}): typeof sendMessageIMessage {
return async (target, text, opts) => {
const sanitizedText = sanitizeOutboundText(text);
const sent = await sendMessageIMessage(target, sanitizedText, {
...opts,
client: params.client,
});
const sent = await sendMessageIMessage(target, sanitizedText, opts);
const scope = `${params.accountId ?? opts.accountId ?? ""}:${target}`;
params.sentMessageCache?.remember(scope, {
text: sent.echoText ?? (sent.sentText || undefined),

View File

@@ -1089,7 +1089,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
to: target,
deps: {
imessage: createIMessageEchoCachingSend({
client: getActiveClient(),
accountId: accountInfo.accountId,
sentMessageCache,
}),
@@ -1105,7 +1104,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
cfg,
replies: [payload],
target,
client: getActiveClient(),
accountId: accountInfo.accountId,
runtime,
maxBytes: mediaMaxBytes,

View File

@@ -121,6 +121,56 @@ describe("sendMessageIMessage receipts", () => {
expect(result.receipt.sentAt).toBeGreaterThan(0);
});
it("passes the default RPC send transport", async () => {
const client = createClient({ guid: "p:0/imsg-transport-default" });
await sendMessageIMessage("chat_id:42", "hello", {
config: IMESSAGE_TEST_CFG,
client,
});
expect(getClientMocks(client).request).toHaveBeenCalledWith(
"send",
expect.objectContaining({
chat_id: 42,
text: "hello",
transport: "auto",
}),
expect.any(Object),
);
});
it("passes the configured RPC send transport", async () => {
const client = createClient({ guid: "p:0/imsg-transport-bridge" });
await sendMessageIMessage("chat_id:42", "hello", {
config: {
channels: {
imessage: {
sendTransport: "applescript",
accounts: {
work: {
sendTransport: "bridge",
},
},
},
},
},
accountId: "work",
client,
});
expect(getClientMocks(client).request).toHaveBeenCalledWith(
"send",
expect.objectContaining({
chat_id: 42,
text: "hello",
transport: "bridge",
}),
expect.any(Object),
);
});
it("uses the dedicated send timeout (covers macOS 26 stalls), not the 10s probe default", async () => {
const client = createClient({ guid: "p:0/imsg-1" });
@@ -940,10 +990,10 @@ describe("sendMessageIMessage receipts", () => {
});
});
it("throws the rpc timeout without resending for generic text", async () => {
it("throws the rpc timeout without matching generic text to older sent rows", async () => {
const client = createRejectingClient(new Error("imsg rpc timeout (send)"));
const runCliJson = vi.fn();
const resolveSentMessageGuidImpl = vi.fn(async () => "p:0/stale-guid");
const resolveSentMessageGuidImpl = vi.fn(async () => "p:0/older-identical-text-guid");
await expect(
sendMessageIMessage("chat_id:42", "hello", {
@@ -959,6 +1009,73 @@ describe("sendMessageIMessage receipts", () => {
expect(resolveSentMessageGuidImpl).not.toHaveBeenCalled();
});
it("throws the rpc timeout without resending when sent-row recovery misses", async () => {
const client = createRejectingClient(new Error("imsg rpc timeout (send)"));
const runCliJson = vi.fn();
const resolveSentMessageGuidImpl = vi.fn(async () => null);
const nowSpy = vi.spyOn(Date, "now");
nowSpy
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(6_001);
await expect(
sendMessageIMessage("chat_id:42", "hello", {
config: IMESSAGE_TEST_CFG,
createClient: async () => client,
runCliJson,
dbPath: "/Users/me/Library/Messages/chat.db",
resolveSentMessageGuidImpl,
}),
).rejects.toThrow("imsg rpc timeout (send)");
expect(getClientMocks(client).stop).toHaveBeenCalledTimes(1);
expect(runCliJson).not.toHaveBeenCalled();
});
it("does not stop caller-owned rpc clients after sent-row recovery misses", async () => {
const client = createRejectingClient(new Error("imsg rpc timeout (send)"));
const runCliJson = vi.fn();
const resolveSentMessageGuidImpl = vi.fn(async () => null);
const nowSpy = vi.spyOn(Date, "now");
nowSpy
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(6_001);
await expect(
sendMessageIMessage("chat_id:42", "hello", {
config: IMESSAGE_TEST_CFG,
client,
runCliJson,
dbPath: "/Users/me/Library/Messages/chat.db",
resolveSentMessageGuidImpl,
}),
).rejects.toThrow("imsg rpc timeout (send)");
expect(runCliJson).not.toHaveBeenCalled();
expect(getClientMocks(client).stop).not.toHaveBeenCalled();
});
it("throws the rpc timeout without resending when sent-row checks are unavailable", async () => {
const client = createRejectingClient(new Error("imsg rpc timeout (send)"));
const runCliJson = vi.fn();
await expect(
sendMessageIMessage("chat_id:42", "hello", {
config: IMESSAGE_TEST_CFG,
client,
runCliJson,
dbPath: "/Users/me/Library/Messages/chat.db",
}),
).rejects.toThrow("imsg rpc timeout (send)");
expect(runCliJson).not.toHaveBeenCalled();
expect(getClientMocks(client).stop).not.toHaveBeenCalled();
});
it("throws the rpc timeout without resending when approval GUID recovery misses", async () => {
const client = createRejectingClient(new Error("imsg rpc timeout (send)"));
const runCliJson = vi.fn();

View File

@@ -43,6 +43,7 @@ const require = createRequire(import.meta.url);
type ParsedIMessageTarget = ReturnType<typeof parseIMessageTarget>;
const MIN_PENDING_PERSISTED_ECHO_TTL_MS = 60_000;
const PENDING_PERSISTED_ECHO_GRACE_MS = 5_000;
type IMessageSendTransport = "auto" | "bridge" | "applescript";
type IMessageSendOpts = {
cliPath?: string;
@@ -471,6 +472,16 @@ function shouldRecoverApprovalPromptGuid(params: {
);
}
function canCheckSentMessageAfterRpcTimeout(params: {
dbPath?: string;
resolveSentMessageGuidImpl?: IMessageSendOpts["resolveSentMessageGuidImpl"];
}): boolean {
return (
Boolean(params.resolveSentMessageGuidImpl) ||
canResolveLatestSentMessageGuidFromChatDb(params.dbPath)
);
}
function resolveOutboundEchoText(text: string, mediaContentType?: string): string | undefined {
if (text.trim()) {
return text;
@@ -882,6 +893,7 @@ export async function sendMessageIMessage(
opts.service ??
resolveTargetService(target) ??
(account.config.service as IMessageService | undefined);
const sendTransport = (account.config.sendTransport ?? "auto") as IMessageSendTransport;
// Sends use a dedicated longer default (not the 10s probe timeout) so macOS 26
// bridge stalls aren't aborted mid-send. Explicit opts/probeTimeoutMs still win
// for callers that tuned them. See DEFAULT_IMESSAGE_SEND_TIMEOUT_MS.
@@ -990,6 +1002,7 @@ export async function sendMessageIMessage(
text: message,
service: service || "auto",
region,
transport: sendTransport,
};
if (resolvedReplyToId) {
params.reply_to = resolvedReplyToId;
@@ -1019,6 +1032,14 @@ export async function sendMessageIMessage(
? await opts.createClient({ cliPath, dbPath })
: await createIMessageRpcClient({ cliPath, dbPath }));
const shouldClose = !opts.client;
let closedClient = false;
const stopOwnedClient = async () => {
if (!shouldClose || closedClient) {
return;
}
closedClient = true;
await client.stop();
};
let result: Record<string, unknown>;
const sendStartedAtMs = Date.now();
let pendingEchoKey: string | undefined;
@@ -1036,7 +1057,7 @@ export async function sendMessageIMessage(
timeoutMs,
});
} catch (error) {
if (filePath || resolvedReplyToId || !isIMessageRpcSendTimeout(error)) {
if (filePath || !isIMessageRpcSendTimeout(error)) {
throw error;
}
if (
@@ -1044,6 +1065,10 @@ export async function sendMessageIMessage(
message,
filePath,
replyToId: resolvedReplyToId,
}) ||
!canCheckSentMessageAfterRpcTimeout({
dbPath: chatDbLookupPath,
resolveSentMessageGuidImpl: opts.resolveSentMessageGuidImpl,
})
) {
throw error;
@@ -1055,10 +1080,11 @@ export async function sendMessageIMessage(
sentAfterMs: sendStartedAtMs,
resolveSentMessageGuidImpl: opts.resolveSentMessageGuidImpl,
});
if (!recoveredGuid) {
if (recoveredGuid) {
result = { guid: recoveredGuid, status: "sent" };
} else {
throw error;
}
result = { guid: recoveredGuid, status: "sent" };
}
const resolvedId = resolveMessageId(result);
const messageId =
@@ -1149,8 +1175,6 @@ export async function sendMessageIMessage(
forgetPersistedIMessageEchoKey(pendingEchoKey);
throw error;
} finally {
if (shouldClose) {
await client.stop();
}
await stopOwnedClient();
}
}

File diff suppressed because one or more lines are too long

View File

@@ -34,6 +34,7 @@ export type IMessageActionConfig = {
/** Inbound tapback notification policy. */
export type IMessageReactionNotificationMode = "off" | "own" | "all";
export type IMessageSendTransport = "auto" | "bridge" | "applescript";
/** Per-account iMessage runtime/config shape. */
export type IMessageAccountConfig = {
@@ -57,6 +58,8 @@ export type IMessageAccountConfig = {
actions?: IMessageActionConfig;
/** Optional default send service (imessage|sms|auto). */
service?: "imessage" | "sms" | "auto";
/** Preferred imsg RPC send transport. Default: auto. */
sendTransport?: IMessageSendTransport;
/** Optional default region (used when sending SMS). */
region?: string;
/** Direct message access policy (default: pairing). */

View File

@@ -1396,6 +1396,7 @@ export const IMessageAccountSchemaBase = z
.optional(),
actions: IMessageActionSchema,
service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(),
sendTransport: z.enum(["auto", "bridge", "applescript"]).optional(),
region: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),