Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions extensions/imessage/src/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ const runtimeMock = vi.hoisted(() => ({
sendReaction: vi.fn(),
sendRichMessage: vi.fn(),
sendAttachment: vi.fn(),
renameGroup: vi.fn(),
setGroupIcon: vi.fn(),
addParticipant: vi.fn(),
removeParticipant: vi.fn(),
leaveGroup: vi.fn(),
}));

const rememberIMessageReplyCacheMock = vi.hoisted(() => vi.fn());
Expand Down Expand Up @@ -90,6 +95,11 @@ describe("imessage message actions", () => {
runtimeMock.sendReaction.mockReset();
runtimeMock.sendRichMessage.mockReset();
runtimeMock.sendAttachment.mockReset();
runtimeMock.renameGroup.mockReset();
runtimeMock.setGroupIcon.mockReset();
runtimeMock.addParticipant.mockReset();
runtimeMock.removeParticipant.mockReset();
runtimeMock.leaveGroup.mockReset();
rememberIMessageReplyCacheMock.mockReset();
probeMock.getCachedIMessagePrivateApiStatus.mockReset();
probeMock.probeIMessagePrivateApi.mockReset();
Expand Down Expand Up @@ -183,6 +193,100 @@ describe("imessage message actions", () => {
expect(described?.actions).toContain("edit");
});

it("requires a trusted requester for group management from iMessage turns", () => {
for (const action of [
"renameGroup",
"setGroupIcon",
"addParticipant",
"removeParticipant",
"leaveGroup",
] as const) {
expect(
imessageMessageActions.requiresTrustedRequesterSender?.({
action,
toolContext: { currentChannelProvider: "imessage" },
}),
).toBe(true);
}
expect(
imessageMessageActions.requiresTrustedRequesterSender?.({
action: "renameGroup",
toolContext: { currentChannelProvider: "discord" },
}),
).toBe(false);
expect(
imessageMessageActions.requiresTrustedRequesterSender?.({
action: "react",
toolContext: { currentChannelProvider: "imessage" },
}),
).toBe(false);
});

it.each([
["renameGroup", { name: "Unauthorized rename" }, runtimeMock.renameGroup],
[
"setGroupIcon",
{ buffer: Buffer.from("unauthorized icon").toString("base64"), filename: "icon.png" },
runtimeMock.setGroupIcon,
],
["addParticipant", { address: "+15551230001" }, runtimeMock.addParticipant],
["removeParticipant", { address: "+15551230002" }, runtimeMock.removeParticipant],
["leaveGroup", {}, runtimeMock.leaveGroup],
] as const)(
"rejects %s from non-owner non-admin callers before native mutation",
async (action, params, runtimeAction) => {
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
available: true,
v2Ready: true,
selectors: {},
});
await expect(
imessageMessageActions.handleAction?.({
action,
cfg: cfg(),
params: { chatGuid: "iMessage;+;chat0000", ...params },
senderIsOwner: false,
gatewayClientScopes: ["operator.write"],
} as never),
).rejects.toThrow("iMessage group management requires an owner or operator.admin requester.");
expect(runtimeAction).not.toHaveBeenCalled();
},
);

it("allows owner and operator.admin group management", async () => {
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
available: true,
v2Ready: true,
selectors: {},
});
runtimeMock.renameGroup.mockResolvedValue(undefined);
runtimeMock.leaveGroup.mockResolvedValue(undefined);

await imessageMessageActions.handleAction?.({
action: "renameGroup",
cfg: cfg(),
params: { chatGuid: "iMessage;+;chat0000", name: "Renamed group" },
senderIsOwner: true,
} as never);
await imessageMessageActions.handleAction?.({
action: "leaveGroup",
cfg: cfg(),
params: { chatGuid: "iMessage;+;chat0000" },
senderIsOwner: false,
gatewayClientScopes: ["operator.admin"],
} as never);

expect(runtimeMock.renameGroup).toHaveBeenCalledWith({
chatGuid: "iMessage;+;chat0000",
displayName: "Renamed group",
options: imsgOptions("iMessage;+;chat0000"),
});
expect(runtimeMock.leaveGroup).toHaveBeenCalledWith({
chatGuid: "iMessage;+;chat0000",
options: imsgOptions("iMessage;+;chat0000"),
});
});

it("emits a channels/imessage WARN when the private API bridge is unavailable", async () => {
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue(undefined);
probeMock.probeIMessagePrivateApi.mockResolvedValue({
Expand Down
30 changes: 29 additions & 1 deletion extensions/imessage/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
...IMESSAGE_ACTION_NAMES,
"upload-file",
]);
const GROUP_MANAGEMENT_ACTIONS = new Set<ChannelMessageActionName>([
"renameGroup",
"setGroupIcon",
"addParticipant",
"removeParticipant",
"leaveGroup",
]);

function readMessageText(params: Record<string, unknown>): string | undefined {
return readStringParam(params, "text") ?? readStringParam(params, "message");
}
Expand Down Expand Up @@ -388,6 +396,9 @@ function assertActionEnabled(
export const imessageMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: describeIMessageMessageTool,
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
requiresTrustedRequesterSender: ({ action, toolContext }) =>
normalizeOptionalLowercaseString(toolContext?.currentChannelProvider) === "imessage" &&
GROUP_MANAGEMENT_ACTIONS.has(action),
messageActionTargetAliases: {
react: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
edit: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] },
Expand Down Expand Up @@ -415,7 +426,24 @@ export const imessageMessageActions: ChannelMessageActionAdapter = {
leaveGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
},
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
handleAction: async ({
action,
params,
cfg,
accountId,
toolContext,
senderIsOwner,
gatewayClientScopes,
}) => {
// Group administration mutates the host's Messages identity, so model-driven
// actions need owner provenance or an admin-scoped Gateway caller.
if (
GROUP_MANAGEMENT_ACTIONS.has(action) &&
senderIsOwner !== true &&
!gatewayClientScopes?.includes("operator.admin")
) {
throw new Error("iMessage group management requires an owner or operator.admin requester.");
}
const runtime = await loadIMessageActionsRuntime();
const account = resolveIMessageAccount({
cfg,
Expand Down
Loading