Skip to content

Commit 91a0ebb

Browse files
authored
Improve iMessage imsg setup and skill ownership (#101407)
Co-authored-by: Omar Shahine <[email protected]>
1 parent d563101 commit 91a0ebb

18 files changed

Lines changed: 835 additions & 46 deletions

docs/channels/imessage-from-bluebubbles.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,12 @@ The shortest safe path when you already know your old BlueBubbles config:
4343

4444
```bash
4545
brew install steipete/tap/imsg
46+
brew update && brew upgrade imsg
4647
imsg --version
4748
imsg chats --limit 3
4849
```
4950

50-
If `imsg chats` fails with `unable to open database file`, empty output, or `authorization denied`, grant Full Disk Access to the terminal, editor, Node process, Gateway service, or SSH parent process that launches `imsg`, then reopen that parent process.
51+
For the usual local setup, OpenClaw setup can offer a user-confirmed Homebrew install or update for `imsg` on the signed-in Messages Mac. Manual setup and SSH-wrapper topologies remain operator-managed: repeat the Homebrew update in the same local or remote user context that will run `imsg`. If `imsg chats` fails with `unable to open database file`, empty output, or `authorization denied`, grant Full Disk Access to the terminal, editor, Node process, Gateway service, or SSH parent process that launches `imsg`, then reopen that parent process.
5152

5253
2. Verify the read, watch, send, and RPC surfaces before changing OpenClaw config:
5354

@@ -61,14 +62,14 @@ The shortest safe path when you already know your old BlueBubbles config:
6162

6263
Replace `42` with a real chat id from `imsg chats`. Sending requires Automation permission for Messages.app. If OpenClaw will run through SSH, run these commands through the same SSH wrapper or user context that OpenClaw will use. If reads work but sends fail with AppleEvents `-1743`, check whether Automation landed on `/usr/libexec/sshd-keygen-wrapper`; see [SSH wrapper sends fail with AppleEvents -1743](/channels/imessage#requirements-and-permissions-macos).
6364

64-
3. Enable the private API bridge when you need advanced actions:
65+
3. Enable the private API bridge. It is strongly encouraged for OpenClaw iMessage because replies, tapbacks, effects, polls, attachment replies, and group actions depend on it:
6566

6667
```bash
6768
imsg launch
6869
imsg status --json
6970
```
7071

71-
`imsg launch` requires SIP to be disabled (and on modern macOS, library validation relaxed — see [Enabling the imsg private API](/channels/imessage#enabling-the-imsg-private-api)). Basic send, history, and watch work without `imsg launch`; advanced actions do not.
72+
`imsg launch` requires SIP to be disabled (and on modern macOS, library validation relaxed — see [Enabling the imsg private API](/channels/imessage#enabling-the-imsg-private-api)). Basic send, history, and watch work without `imsg launch`; the full OpenClaw iMessage action surface does not.
7273

7374
4. After you enable `channels.imessage` and start the Gateway, verify the bridge through OpenClaw:
7475

docs/channels/imessage.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ title: "iMessage"
77
---
88

99
<Note>
10-
For OpenClaw iMessage deployments, use `imsg` on a signed-in macOS Messages host. If your Gateway runs on Linux or Windows, point `channels.imessage.cliPath` at an SSH wrapper that runs `imsg` on the Mac.
10+
For the usual OpenClaw iMessage deployment, run the Gateway and `imsg` on the same signed-in macOS Messages host. If your Gateway runs elsewhere, point `channels.imessage.cliPath` at a transparent SSH wrapper that runs `imsg` on the Mac.
1111

1212
**Inbound recovery is automatic.** After a bridge or gateway restart, iMessage replays the messages missed while it was down and suppresses the stale "backlog bomb" Apple can flush after a Push recovery, deduping so nothing is dispatched twice. There is no config to enable — see [Inbound recovery after a bridge or gateway restart](#inbound-recovery-after-a-bridge-or-gateway-restart).
1313
</Note>
@@ -16,7 +16,9 @@ For OpenClaw iMessage deployments, use `imsg` on a signed-in macOS Messages host
1616
BlueBubbles support was removed. Migrate `channels.bluebubbles` configs to `channels.imessage`; OpenClaw supports iMessage through `imsg` only. Start with [BlueBubbles removal and the imsg iMessage path](/announcements/bluebubbles-imessage) for the short announcement, or [Coming from BlueBubbles](/channels/imessage-from-bluebubbles) for the full migration table.
1717
</Warning>
1818

19-
Status: native external CLI integration. The Gateway spawns `imsg rpc` and speaks JSON-RPC over stdio — no separate daemon or port. Advanced actions require `imsg launch` and a successful private API probe.
19+
Status: native external CLI integration. The Gateway spawns `imsg rpc` and speaks JSON-RPC over stdio — no separate daemon or port. Private API mode is strongly encouraged for a complete iMessage channel; replies, tapbacks, effects, polls, attachment replies, and group actions require `imsg launch` and a successful private API probe.
20+
21+
For the common local setup, OpenClaw setup can offer a user-confirmed Homebrew install or update for `imsg` on the signed-in Messages Mac. Manual setup and SSH-wrapper topologies remain operator-managed: install or update `imsg` in the same user context that will run the Gateway or wrapper.
2022

2123
<CardGroup cols={3}>
2224
<Card title="Private API actions" icon="wand-sparkles" href="#private-api-actions">
@@ -42,11 +44,14 @@ Status: native external CLI integration. The Gateway spawns `imsg rpc` and speak
4244

4345
```bash
4446
brew install steipete/tap/imsg
47+
brew update && brew upgrade imsg
4548
imsg rpc --help
4649
imsg launch
4750
openclaw channels status --probe
4851
```
4952

53+
When the local setup wizard detects a missing default `imsg` command, it can prompt to install `steipete/tap/imsg` through Homebrew. If it detects a Homebrew-managed `imsg`, it can prompt to reinstall or update it. Custom `cliPath` wrappers are not modified.
54+
5055
</Step>
5156

5257
<Step title="Configure OpenClaw">
@@ -87,11 +92,16 @@ openclaw pairing approve imessage <CODE>
8792
</Tab>
8893

8994
<Tab title="Remote Mac over SSH">
90-
OpenClaw only requires a stdio-compatible `cliPath`, so you can point `cliPath` at a wrapper script that SSHes to a remote Mac and runs `imsg`.
95+
Most setups do not need SSH. Use this topology only when the Gateway cannot run on the signed-in Messages Mac. OpenClaw only requires a stdio-compatible `cliPath`, so you can point `cliPath` at a wrapper script that SSHes to a remote Mac and runs `imsg`.
96+
Install and update `imsg` on that remote Mac, not on the Gateway host:
97+
98+
```bash
99+
ssh messages-mac 'brew install steipete/tap/imsg && brew update && brew upgrade imsg'
100+
```
91101

92102
```bash
93103
#!/usr/bin/env bash
94-
exec ssh -T gateway-host imsg "$@"
104+
exec ssh -T messages-mac imsg "$@"
95105
```
96106

97107
Recommended config when attachments are enabled:
@@ -176,12 +186,12 @@ Use one of the supported `imsg` process contexts instead:
176186

177187
## Enabling the imsg private API
178188

179-
`imsg` ships in two operational modes:
189+
`imsg` ships in two operational modes. For OpenClaw, Private API mode is the recommended setup because it gives the channel the native iMessage actions users expect. Basic mode remains useful for low-risk installs, initial verification, or hosts where SIP cannot be disabled.
180190

181191
- **Basic mode** (default, no SIP changes needed): outbound text and media via `send`, inbound watch/history, chat list. This is what you get out of the box from a fresh `brew install steipete/tap/imsg` plus the standard macOS permissions above.
182192
- **Private API mode**: `imsg` injects a helper dylib into `Messages.app` to call internal `IMCore` functions. This unlocks `react`, `edit`, `unsend`, `reply` (threaded), `sendWithEffect`, `poll` and `poll-vote` (native Messages polls), `renameGroup`, `setGroupIcon`, `addParticipant`, `removeParticipant`, `leaveGroup`, plus typing indicators and read receipts.
183193

184-
The advanced action surface on this page requires Private API mode. The `imsg` README is explicit about the requirement:
194+
The recommended action surface on this page requires Private API mode. The `imsg` README is explicit about the requirement:
185195

186196
> Advanced features such as `read`, `typing`, `launch`, bridge-backed rich send, message mutation, and chat management are opt-in. They require SIP to be disabled and a helper dylib to be injected into `Messages.app`. `imsg launch` refuses to inject when SIP is enabled.
187197
@@ -190,7 +200,7 @@ The helper-injection technique uses `imsg`'s own dylib to reach Messages private
190200
<Warning>
191201
**Disabling SIP is a real security tradeoff.** SIP is one of macOS's core protections against running modified system code; turning it off system-wide opens up additional attack surface and side effects. Notably, **disabling SIP on Apple Silicon Macs also disables the ability to install and run iOS apps on your Mac**.
192202

193-
Treat this as a deliberate operational choice, not a default. If your threat model cannot tolerate SIP being off, bundled iMessage is limited to basic mode — text and media send/receive only, no reactions / edit / unsend / effects / group ops.
203+
Treat this as a deliberate operational choice, especially on a primary personal Mac. For production-quality OpenClaw iMessage, prefer a dedicated Mac or bot macOS user where you are comfortable enabling the bridge. If your threat model cannot tolerate SIP being off anywhere, bundled iMessage is limited to basic mode — text and media send/receive only, no reactions / edit / unsend / effects / group ops.
194204
</Warning>
195205

196206
### Setup
@@ -199,6 +209,7 @@ Treat this as a deliberate operational choice, not a default. If your threat mod
199209

200210
```bash
201211
brew install steipete/tap/imsg
212+
brew update && brew upgrade imsg
202213
imsg --version
203214
imsg status --json
204215
```

extensions/imessage/openclaw.plugin.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"activation": {
55
"onStartup": false
66
},
7+
"skills": ["./skills"],
78
"channels": ["imessage"],
89
"configSchema": {
910
"type": "object",
Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Never infer a recipient from a casual name alone when several chats or handles c
4242
## Host Requirements
4343

4444
- macOS 14+ with Messages.app signed in for send/react/bridge actions.
45+
- Private API mode is strongly encouraged for OpenClaw iMessage. It unlocks replies, precise tapbacks, effects, polls, attachment replies, read/typing actions, and group management. Basic mode is a fallback for reads plus plain text/file send.
4546
- Full Disk Access for the process context that runs `imsg` or OpenClaw; reads fail without Messages DB access.
4647
- Automation permission for Messages.app when using public `send`.
4748
- Accessibility permission for the process context that runs public `imsg react`; it uses System Events UI automation. Bridge `tapback` uses private API instead.
@@ -73,13 +74,13 @@ Do not make `jq` a hard prerequisite for the skill; it is only a convenient form
7374

7475
## Capability Choice
7576

76-
Use public Messages automation when enough:
77+
Use standard commands for reads, target resolution, and plain sends:
7778

7879
- Read/list/search/watch: `chats`, `group`, `history`, `search`, `watch`
7980
- Basic text/file send: `send`
8081
- Standard tapback to most recent incoming message in a chat: `react`
8182

82-
Use the private API bridge only for features public automation cannot do:
83+
Use the private API bridge for the native iMessage actions OpenClaw users normally expect:
8384

8485
- Rich replies, text formatting, effects, subjects, multipart sends
8586
- Native Apple Messages polls and poll votes
@@ -89,7 +90,7 @@ Use the private API bridge only for features public automation cannot do:
8990
- Group create/name/photo/member/leave/delete/mark actions
9091
- Account, whois, nickname checks
9192

92-
Before bridge actions, check:
93+
For OpenClaw channel setup, check bridge availability early:
9394

9495
```bash
9596
imsg status --json
@@ -102,7 +103,7 @@ imsg launch
102103
imsg status --json
103104
```
104105

105-
If SIP, library validation, private entitlement checks, or missing selectors still block the capability, do not ask the user to disable SIP casually. Explain that the requested private-API action is unavailable on this host and offer the closest non-bridge action, if one exists.
106+
If SIP, library validation, private entitlement checks, or missing selectors still block the capability, explain that the requested private-API action is unavailable on this host and offer the closest non-bridge action, if one exists. Do not silently downgrade a threaded reply, effect, subject, poll, or GUID-targeted tapback into a plain send/react.
106107

107108
## DM Scenarios
108109

@@ -205,11 +206,11 @@ Use `send-rich --reply-to <message-guid>` for threaded replies. Confirm the refe
205206

206207
Native Apple Messages polls require the bridge. Creation needs at least two `--option` values. Voting requires one of `--option-id`, `--option-index`, or `--option`.
207208

208-
Messages renders only the options on a poll balloon; the `--question` title is not shown to recipients. Set `--question` (required) plus at least two `--option` values.
209+
Messages renders only the options on a poll balloon, so current `imsg poll send` echoes `--question` as a best-effort plain caption after the poll. Use `--comment` to override that caption. Do not retry automatically when only the caption fails: the poll may already be delivered.
209210

210211
```bash
211212
imsg poll send --chat 'iMessage;-;+15551234567' \
212-
--question "Dinner?" --option "Pizza" --option "Sushi"
213+
--question "Dinner?" --option "Pizza" --option "Sushi" --comment "Vote by 5pm"
213214
imsg poll send --chat 'iMessage;+;chat0000' --reply-to <message-guid> \
214215
--question "Approve?" --option "Yes" --option "No"
215216
imsg poll vote --chat 'iMessage;+;chat0000' \
@@ -222,7 +223,7 @@ Find poll IDs and options with:
222223
imsg history --chat-id 42 --limit 20 --json | jq -s '.[] | select(.poll != null) | {guid, poll}'
223224
```
224225

225-
Poll vote rows are `poll` events, not tapbacks; `watch --reactions` is not required to see them.
226+
`history` and `watch` backfill a title-less inbound native poll's `poll.question` from its clean caption row. Poll vote rows are `poll` events, not tapbacks; `watch --reactions` is not required to see them.
226227

227228
## Watch and Long-Running Agents
228229

@@ -246,4 +247,4 @@ For a daemon or multi-chat integration, use `imsg rpc`. It speaks JSON-RPC 2.0 o
246247
- Never send to unknown numbers or ambiguous contact-name matches without approval.
247248
- Confirm attachments exist and are the intended files.
248249
- Prefer E.164 phone numbers; use `--region US` or another region only when needed for local formats.
249-
- Do not use bridge actions just because they are available; use them only when the requested behavior needs them.
250+
- Use bridge actions for bridge-only semantics, but confirm visible state changes and destructive actions first.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// iMessage tests cover imsg CLI install behavior.
2+
import fs from "node:fs/promises";
3+
import path from "node:path";
4+
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
5+
import { withTempDir } from "openclaw/plugin-sdk/test-env";
6+
import { afterEach, describe, expect, it, vi } from "vitest";
7+
8+
const { resolveBrewExecutableMock, runPluginCommandWithTimeoutMock } = vi.hoisted(() => ({
9+
resolveBrewExecutableMock: vi.fn(),
10+
runPluginCommandWithTimeoutMock: vi.fn(),
11+
}));
12+
13+
vi.mock("openclaw/plugin-sdk/setup-tools", async (importOriginal) => {
14+
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/setup-tools")>();
15+
return {
16+
...actual,
17+
resolveBrewExecutable: resolveBrewExecutableMock,
18+
};
19+
});
20+
21+
vi.mock("openclaw/plugin-sdk/run-command", () => ({
22+
runPluginCommandWithTimeout: runPluginCommandWithTimeoutMock,
23+
}));
24+
25+
const { installIMessageCli } = await import("./install-imsg.js");
26+
27+
describe("installIMessageCli", () => {
28+
const originalPlatform = process.platform;
29+
30+
function setProcessPlatform(platform: NodeJS.Platform) {
31+
Object.defineProperty(process, "platform", { configurable: true, value: platform });
32+
}
33+
34+
afterEach(() => {
35+
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
36+
vi.clearAllMocks();
37+
});
38+
39+
it("installs imsg through Homebrew on macOS", async () => {
40+
setProcessPlatform("darwin");
41+
await withTempDir("openclaw-imsg-brew-", async (brewPrefix) => {
42+
await fs.mkdir(path.join(brewPrefix, "bin"), { recursive: true });
43+
await fs.writeFile(path.join(brewPrefix, "bin", "imsg"), "");
44+
resolveBrewExecutableMock.mockReturnValue("/opt/homebrew/bin/brew");
45+
runPluginCommandWithTimeoutMock
46+
.mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" })
47+
.mockResolvedValueOnce({ code: 0, stdout: `${brewPrefix}\n`, stderr: "" })
48+
.mockResolvedValueOnce({ code: 0, stdout: "0.13.0\n", stderr: "" });
49+
50+
const result = await installIMessageCli({ log: vi.fn() } as unknown as RuntimeEnv);
51+
52+
expect(result).toEqual({
53+
ok: true,
54+
cliPath: path.join(brewPrefix, "bin", "imsg"),
55+
version: "0.13.0",
56+
});
57+
expect(runPluginCommandWithTimeoutMock).toHaveBeenNthCalledWith(1, {
58+
argv: ["/opt/homebrew/bin/brew", "install", "steipete/tap/imsg"],
59+
timeoutMs: 15 * 60_000,
60+
});
61+
});
62+
});
63+
64+
it("updates imsg through Homebrew when requested", async () => {
65+
setProcessPlatform("darwin");
66+
await withTempDir("openclaw-imsg-brew-", async (brewPrefix) => {
67+
await fs.mkdir(path.join(brewPrefix, "bin"), { recursive: true });
68+
await fs.writeFile(path.join(brewPrefix, "bin", "imsg"), "");
69+
resolveBrewExecutableMock.mockReturnValue("/opt/homebrew/bin/brew");
70+
runPluginCommandWithTimeoutMock
71+
.mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" })
72+
.mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" })
73+
.mockResolvedValueOnce({ code: 0, stdout: `${brewPrefix}\n`, stderr: "" })
74+
.mockResolvedValueOnce({ code: 0, stdout: "0.13.1\n", stderr: "" });
75+
76+
const result = await installIMessageCli({ log: vi.fn() } as unknown as RuntimeEnv, {
77+
upgrade: true,
78+
});
79+
80+
expect(result).toEqual({
81+
ok: true,
82+
cliPath: path.join(brewPrefix, "bin", "imsg"),
83+
version: "0.13.1",
84+
});
85+
expect(runPluginCommandWithTimeoutMock).toHaveBeenNthCalledWith(1, {
86+
argv: ["/opt/homebrew/bin/brew", "update"],
87+
timeoutMs: 5 * 60_000,
88+
});
89+
expect(runPluginCommandWithTimeoutMock).toHaveBeenNthCalledWith(2, {
90+
argv: ["/opt/homebrew/bin/brew", "upgrade", "imsg"],
91+
timeoutMs: 15 * 60_000,
92+
});
93+
});
94+
});
95+
96+
it("explains that Homebrew is required when brew is missing", async () => {
97+
setProcessPlatform("darwin");
98+
resolveBrewExecutableMock.mockReturnValue(null);
99+
100+
const result = await installIMessageCli({ log: vi.fn() } as unknown as RuntimeEnv);
101+
102+
expect(result.ok).toBe(false);
103+
expect(result.error).toContain("Homebrew is required for imsg setup");
104+
expect(runPluginCommandWithTimeoutMock).not.toHaveBeenCalled();
105+
});
106+
107+
it("does not auto-install imsg on non-macOS hosts", async () => {
108+
setProcessPlatform("linux");
109+
110+
const result = await installIMessageCli({ log: vi.fn() } as unknown as RuntimeEnv);
111+
112+
expect(result).toEqual({
113+
ok: false,
114+
error: "imsg auto-install is supported only on macOS.",
115+
});
116+
expect(resolveBrewExecutableMock).not.toHaveBeenCalled();
117+
});
118+
});

0 commit comments

Comments
 (0)