Skip to content

Commit ab3af89

Browse files
committed
fix(imessage): native approval binding requires GUID, not numeric id
ClawSweeper third P1 review finding on #85952. approval-handler.runtime.ts deliverPending was using result.messageId as the approval-reaction binding key, but that field can be a numeric ROWID coerced to a string ('12345') when the imsg bridge returns only message_id. Inbound tapbacks carry reacted_to_guid which is always a GUID, so a numeric-id binding can never match. Fix mirrors the send.ts forwarding-path treatment: - IMessageSendResult now exposes a separate guid?: string field, populated from the same resolveOutboundMessageGuid helper send.ts already uses for the forwarding-path binding. The generic messageId field is unchanged so reply-cache, echo-cache, and receipt-building paths still see the broadest id form. - deliverPending now binds against result.guid; when it's undefined (numeric ROWID or 'ok'/'unknown' placeholders), the function returns null instead of binding against an id the inbound tapback can't possibly match. Tests: approval-handler.runtime.test.ts gets a deliverPending GUID-only binding describe block with three regression cases (numeric ROWID refused, GUID accepted, ok/unknown placeholders refused). vi.mock isolates sendMessageIMessage so the cases run synchronously without spawning imsg. 11 tests pass across handler.runtime + send specs.
1 parent c5ed982 commit ab3af89

3 files changed

Lines changed: 117 additions & 5 deletions

File tree

extensions/imessage/src/approval-handler.runtime.test.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1-
import { describe, expect, it } from "vitest";
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import { imessageApprovalNativeRuntime } from "./approval-handler.runtime.js";
33

4+
const sendMock = vi.hoisted(() => ({
5+
sendMessageIMessage: vi.fn(),
6+
}));
7+
8+
vi.mock("./send.js", () => ({
9+
sendMessageIMessage: sendMock.sendMessageIMessage,
10+
}));
11+
412
describe("imessageApprovalNativeRuntime", () => {
513
it("renders allowed thumbs-only reactions in pending exec approvals", async () => {
614
const payload = await imessageApprovalNativeRuntime.presentation.buildPendingPayload({
@@ -142,6 +150,87 @@ describe("imessageApprovalNativeRuntime", () => {
142150
});
143151
});
144152

153+
describe("deliverPending GUID-only binding", () => {
154+
beforeEach(() => {
155+
sendMock.sendMessageIMessage.mockReset();
156+
});
157+
158+
const baseDeliverArgs = {
159+
cfg: {} as never,
160+
accountId: "default",
161+
context: { accountId: "default" },
162+
preparedTarget: { to: "+15551230000", accountId: "default" },
163+
plannedTarget: {
164+
surface: "origin" as const,
165+
reason: "preferred" as const,
166+
target: { to: "+15551230000" },
167+
},
168+
request: {
169+
id: "exec-1",
170+
request: { command: "echo hi" },
171+
createdAtMs: 0,
172+
expiresAtMs: 60_000,
173+
},
174+
approvalKind: "exec" as const,
175+
view: {
176+
approvalKind: "exec",
177+
approvalId: "exec-1",
178+
commandText: "echo hi",
179+
actions: [],
180+
} as never,
181+
pendingPayload: {
182+
text: "Reply with: /approve exec-1 allow-once",
183+
allowedDecisions: ["allow-once" as const],
184+
},
185+
};
186+
187+
it("refuses to bind when the bridge returns only a numeric ROWID", async () => {
188+
// Regression for ClawSweeper P1: native deliverPending must require a
189+
// GUID for the binding because inbound `reacted_to_guid` is always a
190+
// GUID — never the numeric ROWID. A bridge that returns just
191+
// { message_id: 12345 } has no usable approval-reaction id.
192+
sendMock.sendMessageIMessage.mockResolvedValue({
193+
messageId: "12345",
194+
sentText: "Reply with: /approve exec-1 allow-once",
195+
receipt: { kind: "text" } as never,
196+
});
197+
198+
await expect(
199+
imessageApprovalNativeRuntime.transport.deliverPending(baseDeliverArgs),
200+
).resolves.toBeNull();
201+
});
202+
203+
it("binds against the GUID when the bridge returns one", async () => {
204+
sendMock.sendMessageIMessage.mockResolvedValue({
205+
messageId: "p:0/abc-123",
206+
guid: "p:0/abc-123",
207+
sentText: "Reply with: /approve exec-1 allow-once",
208+
receipt: { kind: "text" } as never,
209+
});
210+
211+
await expect(
212+
imessageApprovalNativeRuntime.transport.deliverPending(baseDeliverArgs),
213+
).resolves.toEqual({
214+
accountId: "default",
215+
to: "+15551230000",
216+
conversation: { handle: "+15551230000" },
217+
messageId: "p:0/abc-123",
218+
});
219+
});
220+
221+
it("refuses to bind when the bridge returns 'unknown' or 'ok' placeholders", async () => {
222+
sendMock.sendMessageIMessage.mockResolvedValue({
223+
messageId: "ok",
224+
sentText: "Reply with: /approve exec-1 allow-once",
225+
receipt: { kind: "text" } as never,
226+
});
227+
228+
await expect(
229+
imessageApprovalNativeRuntime.transport.deliverPending(baseDeliverArgs),
230+
).resolves.toBeNull();
231+
});
232+
});
233+
145234
it("preserves group chat targets when preparing delivery", async () => {
146235
await expect(
147236
imessageApprovalNativeRuntime.transport.prepareTarget({

extensions/imessage/src/approval-handler.runtime.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,12 @@ export const imessageApprovalNativeRuntime = createChannelApprovalNativeRuntimeA
206206
config: cfg,
207207
...(preparedTarget.accountId ? { accountId: preparedTarget.accountId } : {}),
208208
});
209-
if (!result.messageId || result.messageId === "unknown" || result.messageId === "ok") {
209+
// Approval reaction bindings must use the GUID-only id (matches the
210+
// inbound tapback's `reacted_to_guid`). When the bridge only returned a
211+
// numeric ROWID / `ok` / `unknown`, `result.guid` is undefined — refuse
212+
// to bind so the reaction shortcut won't silently miss a real tap.
213+
const guid = result.guid;
214+
if (!guid) {
210215
return null;
211216
}
212217
const conversation = buildConversationKeyForTarget(preparedTarget.to);
@@ -217,7 +222,7 @@ export const imessageApprovalNativeRuntime = createChannelApprovalNativeRuntimeA
217222
...(preparedTarget.accountId ? { accountId: preparedTarget.accountId } : {}),
218223
to: preparedTarget.to,
219224
conversation,
220-
messageId: result.messageId,
225+
messageId: guid,
221226
};
222227
},
223228
updateEntry: async ({ cfg, entry, payload }) => {

extensions/imessage/src/send.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,22 @@ type IMessageSendOpts = {
5454
createClient?: (params: { cliPath: string; dbPath?: string }) => Promise<IMessageRpcClient>;
5555
};
5656

57-
type IMessageSendResult = {
57+
export type IMessageSendResult = {
58+
/**
59+
* Generic identifier returned by the bridge. May be a GUID string, a
60+
* numeric ROWID stringified, or the literal "ok"/"unknown" placeholders
61+
* when the bridge declines to return one. Most callers (reply cache, echo
62+
* cache, receipts) want this field — it is the broadest match for
63+
* downstream lookups.
64+
*/
5865
messageId: string;
66+
/**
67+
* GUID-only identifier suitable for matching inbound `reacted_to_guid`
68+
* fields. Undefined when the bridge returned only a numeric ROWID or
69+
* placeholder. Approval-reaction bindings MUST use this field so the
70+
* outbound key matches what the inbound tapback will surface.
71+
*/
72+
guid?: string;
5973
sentText: string;
6074
echoText?: string;
6175
receipt: MessageReceipt;
@@ -301,6 +315,10 @@ export async function sendMessageIMessage(
301315
});
302316
const resolvedId = resolveMessageId(result);
303317
const messageId = resolvedId ?? (result?.ok ? "ok" : "unknown");
318+
// GUID-only id for approval-reaction binding (inbound `reacted_to_guid`
319+
// never carries a numeric ROWID, so the bind key must match). Undefined
320+
// when the bridge only returned a numeric or placeholder id.
321+
const approvalBindingMessageId = resolveOutboundMessageGuid(result);
304322
const echoScope = resolveOutboundEchoScope({ accountId: account.accountId, target });
305323
if (echoScope) {
306324
rememberPersistedIMessageEcho({
@@ -329,7 +347,6 @@ export async function sendMessageIMessage(
329347
isFromMe: true,
330348
});
331349
if (message) {
332-
const approvalBindingMessageId = resolveOutboundMessageGuid(result);
333350
if (approvalBindingMessageId) {
334351
const handleForKey =
335352
target.kind === "handle" ? normalizeIMessageHandle(target.to) : undefined;
@@ -350,6 +367,7 @@ export async function sendMessageIMessage(
350367
}
351368
return {
352369
messageId,
370+
...(approvalBindingMessageId ? { guid: approvalBindingMessageId } : {}),
353371
sentText: message,
354372
...(echoText ? { echoText } : {}),
355373
receipt: createIMessageSendReceipt({

0 commit comments

Comments
 (0)