Merge pull request #535 from mdahmann/fix/imessage-groupish-threads

imessage: isolate group-ish threads by chat_id
This commit is contained in:
Peter Steinberger
2026-01-09 17:42:42 +00:00
committed by GitHub
3 changed files with 139 additions and 31 deletions

View File

@@ -13,6 +13,7 @@ Status: external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio
- iMessage provider backed by `imsg` on macOS. - iMessage provider backed by `imsg` on macOS.
- Deterministic routing: replies always go back to iMessage. - Deterministic routing: replies always go back to iMessage.
- DMs share the agent's main session; groups are isolated (`imessage:group:<chat_id>`). - DMs share the agent's main session; groups are isolated (`imessage:group:<chat_id>`).
- If a multi-participant thread arrives with `is_group=false`, you can still isolate it by `chat_id` using `imessage.groups` (see “Group-ish threads” below).
## Requirements ## Requirements
- macOS with Messages signed in. - macOS with Messages signed in.
@@ -24,35 +25,61 @@ Status: external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio
1) Ensure Messages is signed in on this Mac. 1) Ensure Messages is signed in on this Mac.
2) Configure iMessage and start the gateway. 2) Configure iMessage and start the gateway.
### Remote/SSH variant (optional) ### Dedicated bot macOS user (for isolated identity)
If you want iMessage on another Mac, set `imessage.cliPath` to a wrapper that If you want the bot to send from a **separate iMessage identity** (and keep your personal Messages clean), use a dedicated Apple ID + a dedicated macOS user.
execs `ssh` and runs `imsg rpc` on the remote host. Clawdbot only needs a
stdio stream; `imsg` still runs on the remote macOS host.
Example wrapper (save somewhere in your PATH and `chmod +x`): 1) Create a dedicated Apple ID (example: `my-cool-bot@icloud.com`).
- Apple may require a phone number for verification / 2FA.
2) Create a macOS user (example: `clawdshome`) and sign into it.
3) Open Messages in that macOS user and sign into iMessage using the bot Apple ID.
4) Enable Remote Login (System Settings → General → Sharing → Remote Login).
5) Install `imsg`:
- `brew install steipete/tap/imsg`
6) Set up SSH so `ssh <bot-macos-user>@localhost true` works without a password.
7) Point `imessage.accounts.bot.cliPath` at an SSH wrapper that runs `imsg` as the bot user.
First-run note: sending/receiving may require GUI approvals (Automation + Full Disk Access) in the *bot macOS user*. If `imsg rpc` looks stuck or exits, log into that user (Screen Sharing helps), run a one-time `imsg chats --limit 1` / `imsg send ...`, approve prompts, then retry.
Example wrapper (`chmod +x`). Replace `<bot-macos-user>` with your actual macOS username:
```bash
#!/usr/bin/env bash
set -euo pipefail
# Run an interactive SSH once first to accept host keys:
# ssh <bot-macos-user>@localhost true
exec /usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=5 -T <bot-macos-user>@localhost \
"/usr/local/bin/imsg" "$@"
```
Example config:
```json5
{
imessage: {
enabled: true,
accounts: {
bot: {
name: "Bot",
enabled: true,
cliPath: "/path/to/imsg-bot",
dbPath: "/Users/<bot-macos-user>/Library/Messages/chat.db"
}
}
}
}
```
For single-account setups, use flat options (`imessage.cliPath`, `imessage.dbPath`) instead of the `accounts` map.
### Remote/SSH variant (optional)
If you want iMessage on another Mac, set `imessage.cliPath` to a wrapper that runs `imsg` on the remote macOS host over SSH. Clawdbot only needs stdio.
Example wrapper:
```bash ```bash
#!/usr/bin/env bash #!/usr/bin/env bash
exec ssh -T mac-mini imsg "$@" exec ssh -T mac-mini imsg "$@"
``` ```
Notes: Multi-account support: use `imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Dont commit `~/.clawdbot/clawdbot.json` (it often contains tokens).
- Remote Mac must have Messages signed in and `imsg` installed.
- Full Disk Access + Automation prompts happen on the remote Mac.
- Use SSH keys (no password prompt) so the gateway can launch `imsg rpc` unattended.
Example:
```json5
{
imessage: {
enabled: true,
cliPath: "/usr/local/bin/imessage-remote",
dmPolicy: "pairing",
allowFrom: ["+15555550123"]
}
}
```
Multi-account support: use `imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
## Access control (DMs + groups) ## Access control (DMs + groups)
DMs: DMs:
@@ -73,6 +100,27 @@ Groups:
- `imsg` streams message events; the gateway normalizes them into the shared provider envelope. - `imsg` streams message events; the gateway normalizes them into the shared provider envelope.
- Replies always route back to the same chat id or handle. - Replies always route back to the same chat id or handle.
## Group-ish threads (`is_group=false`)
Some iMessage threads can have multiple participants but still arrive with `is_group=false` depending on how Messages stores the chat identifier.
If you explicitly configure a `chat_id` under `imessage.groups`, Clawdbot treats that thread as a “group” for:
- session isolation (separate `imessage:group:<chat_id>` session key)
- group allowlisting / mention gating behavior
Example:
```json5
{
imessage: {
groupPolicy: "allowlist",
groupAllowFrom: ["+15555550123"],
groups: {
"42": { "requireMention": false }
}
}
}
```
This is useful when you want an isolated personality/model for a specific thread (see [Multi-agent routing](/concepts/multi-agent)). For filesystem isolation, see [Sandboxing](/gateway/sandboxing).
## Media + limits ## Media + limits
- Optional attachment ingestion via `imessage.includeAttachments`. - Optional attachment ingestion via `imessage.includeAttachments`.
- Media cap via `imessage.mediaMaxMb`. - Media cap via `imessage.mediaMaxMb`.

View File

@@ -215,6 +215,46 @@ describe("monitorIMessageProvider", () => {
expect(sendMock).not.toHaveBeenCalled(); expect(sendMock).not.toHaveBeenCalled();
}); });
it("treats configured chat_id as a group session even when is_group is false", async () => {
config = {
...config,
imessage: {
dmPolicy: "open",
allowFrom: ["*"],
groups: { "2": { requireMention: false } },
},
};
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
method: "message",
params: {
message: {
id: 14,
chat_id: 2,
sender: "+15550001111",
is_from_me: false,
text: "hello",
is_group: false,
},
},
});
await flush();
closeResolve?.();
await run;
expect(replyMock).toHaveBeenCalled();
const ctx = replyMock.mock.calls[0]?.[0] as {
ChatType?: string;
SessionKey?: string;
};
expect(ctx.ChatType).toBe("group");
expect(ctx.SessionKey).toBe("agent:main:imessage:group:2");
});
it("prefixes tool and final replies with responsePrefix", async () => { it("prefixes tool and final replies with responsePrefix", async () => {
config = { config = {
...config, ...config,

View File

@@ -170,10 +170,36 @@ export async function monitorIMessageProvider(
const chatId = message.chat_id ?? undefined; const chatId = message.chat_id ?? undefined;
const chatGuid = message.chat_guid ?? undefined; const chatGuid = message.chat_guid ?? undefined;
const chatIdentifier = message.chat_identifier ?? undefined; const chatIdentifier = message.chat_identifier ?? undefined;
const isGroup = Boolean(message.is_group);
const groupIdCandidate = chatId !== undefined ? String(chatId) : undefined;
const groupListPolicy = groupIdCandidate
? resolveProviderGroupPolicy({
cfg,
provider: "imessage",
accountId: accountInfo.accountId,
groupId: groupIdCandidate,
})
: {
allowlistEnabled: false,
allowed: true,
groupConfig: undefined,
defaultConfig: undefined,
};
// Some iMessage threads can have multiple participants but still report
// is_group=false depending on how Messages stores the identifier.
// If the owner explicitly configures a chat_id under imessage.groups, treat
// that thread as a "group" for permission gating and session isolation.
const treatAsGroupByConfig = Boolean(
groupIdCandidate &&
groupListPolicy.allowlistEnabled &&
groupListPolicy.groupConfig,
);
const isGroup = Boolean(message.is_group) || treatAsGroupByConfig;
if (isGroup && !chatId) return; if (isGroup && !chatId) return;
const groupId = isGroup ? String(chatId) : undefined; const groupId = isGroup ? groupIdCandidate : undefined;
const storeAllowFrom = await readProviderAllowFromStore("imessage").catch( const storeAllowFrom = await readProviderAllowFromStore("imessage").catch(
() => [], () => [],
); );
@@ -214,12 +240,6 @@ export async function monitorIMessageProvider(
return; return;
} }
} }
const groupListPolicy = resolveProviderGroupPolicy({
cfg,
provider: "imessage",
accountId: accountInfo.accountId,
groupId,
});
if (groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) { if (groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) {
logVerbose( logVerbose(
`imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`, `imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`,