Skip to content

Commit d264775

Browse files
author
1052326311
committed
fix(imessage): honor actions.reply=false when resolving replyToId
When `channels.imessage.accounts.<id>.actions.reply` is explicitly `false` (e.g. because SIP prevents private-API injection), the iMessage send path still forwarded `replyToId` to the imsg bridge, causing outbound delivery to fail with: System Integrity Protection (SIP) is enabled. Refusing to inject into Messages.app. Disable SIP before using imsg launch. Gate `resolvedReplyToId` on `account.config.actions?.reply !== false`. The default (actions.reply undefined or true) preserves existing behavior. Tests: 46 → 51 (5 new regression tests) Closes #92142
1 parent b1caba5 commit d264775

2 files changed

Lines changed: 118 additions & 1 deletion

File tree

extensions/imessage/src/send.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,4 +1165,116 @@ describe("sendMessageIMessage receipts", () => {
11651165

11661166
expect(runCliJson).not.toHaveBeenCalled();
11671167
});
1168+
1169+
describe("actions.reply gating", () => {
1170+
const BASE_CFG = {
1171+
channels: {
1172+
imessage: {
1173+
accounts: { default: {} },
1174+
},
1175+
},
1176+
};
1177+
const REPLY_CFG = {
1178+
channels: {
1179+
imessage: {
1180+
accounts: {
1181+
default: { actions: { reply: true } },
1182+
},
1183+
},
1184+
},
1185+
};
1186+
const NO_REPLY_CFG = {
1187+
channels: {
1188+
imessage: {
1189+
accounts: {
1190+
default: { actions: { reply: false } },
1191+
},
1192+
},
1193+
},
1194+
};
1195+
1196+
it("includes reply_to when actions.reply is true", async () => {
1197+
const client = createClient({ guid: "p:0/GUID", status: "sent" });
1198+
1199+
await sendMessageIMessage("+15551234567", "hello", {
1200+
config: REPLY_CFG,
1201+
client,
1202+
replyToId: "reply-guid",
1203+
});
1204+
1205+
const params = (client.request as ReturnType<typeof vi.fn>).mock.calls[0][1];
1206+
expect(params.reply_to).toBe("reply-guid");
1207+
});
1208+
1209+
it("includes reply_to when actions.reply is not set (default-enabled)", async () => {
1210+
const client = createClient({ guid: "p:0/GUID", status: "sent" });
1211+
1212+
await sendMessageIMessage("+15551234567", "hello", {
1213+
config: BASE_CFG,
1214+
client,
1215+
replyToId: "reply-guid",
1216+
});
1217+
1218+
const params = (client.request as ReturnType<typeof vi.fn>).mock.calls[0][1];
1219+
expect(params.reply_to).toBe("reply-guid");
1220+
});
1221+
1222+
it("strips reply_to when actions.reply is false", async () => {
1223+
const client = createClient({ guid: "p:0/GUID", status: "sent" });
1224+
1225+
await sendMessageIMessage("+15551234567", "hello", {
1226+
config: NO_REPLY_CFG,
1227+
client,
1228+
replyToId: "reply-guid",
1229+
});
1230+
1231+
const params = (client.request as ReturnType<typeof vi.fn>).mock.calls[0][1];
1232+
expect(params.reply_to).toBeUndefined();
1233+
});
1234+
1235+
it("strips reply_to from media sends when actions.reply is false", async () => {
1236+
const client = createClient({ guid: "p:0/GUID", status: "sent" });
1237+
// send-attachment hits the imsg CLI which is unavailable in tests;
1238+
// provide a mock runCliJson to bypass the external binary.
1239+
const runCliJson = vi
1240+
.fn()
1241+
.mockRejectedValueOnce(new Error("unknown command send-attachment"));
1242+
1243+
await sendMessageIMessage("+15551234567", "", {
1244+
config: NO_REPLY_CFG,
1245+
client,
1246+
replyToId: "reply-guid",
1247+
mediaUrl: "https://example.com/test.png",
1248+
resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }),
1249+
runCliJson,
1250+
});
1251+
1252+
// send-attachment failed → falls back to rpc send; reply_to must be absent.
1253+
const sendCalls = (client.request as ReturnType<typeof vi.fn>).mock.calls.filter(
1254+
([method]: [string]) => method === "send",
1255+
);
1256+
for (const [, params] of sendCalls) {
1257+
expect(params.reply_to).toBeUndefined();
1258+
}
1259+
});
1260+
1261+
it("strips reply_to from attachment send-attachment when actions.reply is false and audioAsVoice", async () => {
1262+
// AudioAsVoice goes through send-attachment in spawn (needs mock runCliJson).
1263+
const runCliJson = vi.fn().mockResolvedValueOnce({ guid: "p:0/GUID", status: "sent" });
1264+
1265+
const result = await sendMessageIMessage("+15551234567", "", {
1266+
config: NO_REPLY_CFG,
1267+
replyToId: "reply-guid",
1268+
mediaUrl: "https://example.com/test.caf",
1269+
audioAsVoice: true,
1270+
resolveAttachmentImpl: async () => ({ path: "/tmp/voice.caf", contentType: "audio/x-caf" }),
1271+
runCliJson,
1272+
});
1273+
1274+
expect(result.messageId).toBe("p:0/GUID");
1275+
// runCliJson should have been called without --reply-to
1276+
const attachArgs = runCliJson.mock.calls[0][0];
1277+
expect(attachArgs).not.toContain("--reply-to");
1278+
});
1279+
});
11681280
});

extensions/imessage/src/send.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -948,7 +948,12 @@ export async function sendMessageIMessage(
948948
throw new Error("iMessage send requires text or media");
949949
}
950950
const echoText = resolveOutboundEchoText(message, filePath ? mediaContentType : undefined);
951-
const resolvedReplyToId = sanitizeReplyToId(opts.replyToId);
951+
// Honor actions.reply === false: when the user explicitly disables
952+
// threaded/native reply actions (e.g. because SIP prevents private-API
953+
// injection), strip replyToId so the bridge sends a normal top-level
954+
// message instead of triggering a SIP-gated reply pathway.
955+
const replyActionsEnabled = account.config.actions?.reply !== false;
956+
const resolvedReplyToId = replyActionsEnabled ? sanitizeReplyToId(opts.replyToId) : undefined;
952957
const runCliJson =
953958
opts.runCliJson ??
954959
((args: readonly string[]) => runIMessageCliJson(cliPath, dbPath, args, timeoutMs));

0 commit comments

Comments
 (0)