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
6 changes: 5 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,11 @@ Files: [`src/imessage/`](src/imessage/)
- Uses local row IDs as the monotonic cursor.
- Sends through `osascript`.
- Keeps legacy unprefixed route and session aliases for migration.
- Supports text only.
- Joins ordered attachment metadata during polling without opening files.
- Opens images only after acceptance, confines canonical paths to the Messages
attachment directory, and converts HEIC or HEIF locally with `sips`.
- Applies provider-neutral image limits and temporary-file cleanup before
passing images to Claude Code, Codex, or Pi.

### Telegram

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ chrono = "0.4"
tracing = "0.1"
tracing-subscriber = "0.3"
pulldown-cmark = { version = "0.13", default-features = false }
libc = "0.2"

[profile.release]
strip = true
Expand Down
40 changes: 38 additions & 2 deletions docs/channels/imessage.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ iMessage API or expose a network service.

- macOS with Messages signed in
- Full Disk Access for the terminal or service process running Push
- access to `~/Library/Messages/chat.db`
- access to `~/Library/Messages/chat.db` and `~/Library/Messages/Attachments`
- `osascript` on `PATH`
- `/usr/bin/sips` for HEIC and HEIF image conversion

Run `push doctor` from the same user and environment as the long-running
service. A successful interactive check does not prove that a separate service
Expand Down Expand Up @@ -46,11 +47,42 @@ sensitivity.
Treat every allowed handle as an operator of the configured backend. A sender
can ask the agent to use any capability allowed by that agent's configuration.

## Image messages

Send up to four JPEG, PNG, WebP, HEIC, or HEIF images in one accepted
conversation, with or without message text. Their combined prepared size must
be at most 6 MiB. Each HEIC or HEIF source must be at most 32 MiB before local
conversion. Images work with Claude Code, Codex, and Pi.

Polling reads attachment paths, byte-size hints, and MIME type hints from
`chat.db`; it does not open the attachment files. After the direct-message,
sender, reply-marker, and message checks pass, an accepted attachment with no
filename gets a three-poll grace period. Push leaves that row unacknowledged so
the cursor cannot skip the image, while rejected and later ready messages can
continue. If the filename is still blank after the grace period, the worker
treats it as missing and sends the safe fallback. The worker canonicalizes each
ready path and requires it to remain under `~/Library/Messages/Attachments` (or
the `Attachments` directory beside a custom `imessage.db_path`). Missing files,
directories, escaping symlinks, and unsupported documents are rejected with a
safe retry reply before an agent starts.

JPEG, PNG, and WebP files go through the shared byte limit and signature
validation directly. Push converts HEIC and HEIF locally with macOS `sips` in
an owner-only temporary directory, validates the resulting JPEG against the
same shared limit, and removes the conversion file immediately. The prepared
agent handoff files are also owner-only and are removed after the turn.
Conversation history retains only the message text or an image placeholder,
not image bytes or local attachment paths. Review the configured backend's
image and data controls before using this feature.

Sending generated images back through iMessage is not supported.

## What Push ignores

- group chats
- tapbacks and Messages system rows
- blank messages
- blank messages without an image attachment
- stickers, videos, and Live Photo video components
- messages from handles outside the allowlist
- Push's own replies containing the built-in Push reply marker

Expand Down Expand Up @@ -98,6 +130,10 @@ process, and rerun:
push doctor
```

Image messages also require that the same process can read
`~/Library/Messages/Attachments`. Recheck Full Disk Access if text works but
images fail.

### Messages are ignored

Confirm the conversation is one-to-one and that its sender or chat identifier
Expand Down
5 changes: 3 additions & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,9 @@ practical structure for identity, context, shared skills, jobs, and evals.

=== "iMessage"

Give the terminal or service host Full Disk Access in macOS System
Settings, then edit `$PUSH_HOME/config.toml`:
Give the terminal or service host Full Disk Access to the Messages database
and attachment files in macOS System Settings, then edit
`$PUSH_HOME/config.toml`:

```toml
channel = "imessage"
Expand Down
16 changes: 11 additions & 5 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,17 @@ variable or move the config outside. When `voice.openai_api_key` is configured,
chmod 600 "${PUSH_HOME:-$HOME/.push}/config.toml"
```

Accepted Telegram and Slack images are briefly written under `$PUSH_HOME/cache`
with owner-only permissions, passed to the selected agent backend, and
removed when the turn ends. Conversation history retains only the caption or an
image placeholder. Protect the cache directory while Push is running and
review the configured model provider's image data controls.
Accepted iMessage, Telegram, and Slack images are briefly written under
`$PUSH_HOME/cache` with owner-only permissions, passed to the selected agent
backend, and removed when the turn ends. Conversation history retains only the
message text or an image placeholder. Protect the cache directory while Push
is running and review the configured model provider's image data controls.

iMessage polling stores only attachment paths and safe metadata in memory. It
opens files only after conversation and sender acceptance, and only when their
canonical paths remain under the Messages attachment directory. HEIC and HEIF
conversion uses macOS `sips` in a separate owner-only temporary directory that
is removed immediately after conversion.

Slack's recovery inbox persists file IDs and safe size and MIME type hints, but
not private download URLs or file bytes. Push resolves and downloads a Slack
Expand Down
9 changes: 5 additions & 4 deletions docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
This guide covers running `push` continuously under a process manager.

The iMessage channel is macOS-only because it reads
`~/Library/Messages/chat.db` and sends replies with `osascript`. Telegram uses
outbound HTTPS long polling. Slack uses outbound Socket Mode. Both can run
under `systemd` on Linux or a VM.
`~/Library/Messages/chat.db`, opens accepted files under
`~/Library/Messages/Attachments`, and sends replies with `osascript`. Telegram
uses outbound HTTPS long polling. Slack uses outbound Socket Mode. Both can
run under `systemd` on Linux or a VM.

## Before Installing a Service

Expand All @@ -31,7 +32,7 @@ Set one absolute `PUSH_HOME` in the service definition. It defaults to
- agent write access to `assistant_root/jobs/` when jobs should be created from chat
- access to the selected `claude`, `codex`, or `pi` executable on `PATH`
- backend login, tokens, settings, MCP config, and project credentials
- for iMessage on macOS, Full Disk Access and `osascript`
- for iMessage on macOS, Full Disk Access, `osascript`, and `sips`
- for Telegram, a token in the private config and network access to
`api.telegram.org`
- for Slack, app and bot tokens in the private config or service environment,
Expand Down
70 changes: 63 additions & 7 deletions src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ use crate::config::{ChannelKind, Config};
use crate::image::DownloadedImage;
use crate::imessage::{Poller as IMessagePoller, Sender as IMessageSender};
use crate::slack::{parse_message_target, Slack};
use crate::store::Store;
use crate::telegram::Telegram;
use crate::voice::AudioClip;

pub(crate) const REPLY_MARKER: &str = "\n\n-- sent by push";
const IMESSAGE_PENDING_FILENAME_POLL_LIMIT: u8 = 3;

#[derive(Debug, Clone)]
pub struct InboundVoice {
Expand Down Expand Up @@ -97,8 +99,14 @@ trait ChannelContract {
fn id(&self) -> &'static str;
fn primary_target(&self, configured: &str) -> Result<String>;
async fn poll(&self, since: i64) -> Result<Vec<RawMessage>>;
fn poll_is_complete_snapshot(&self) -> bool {
false
}
async fn latest_cursor(&self) -> Result<i64>;
fn accept(&self, message: &RawMessage) -> Option<(String, String)>;
fn should_defer(&self, _message: &RawMessage, _store: &mut Store) -> Result<bool> {
Ok(false)
}
fn reject_reason(&self, message: &RawMessage) -> &'static str;
fn approval_origin(&self, message: &RawMessage, thread: &str) -> AnswerOrigin;
fn route_thread_groups(&self, thread: &str) -> Vec<Vec<String>>;
Expand Down Expand Up @@ -222,6 +230,14 @@ impl Channel {
}
}

pub fn poll_is_complete_snapshot(&self) -> bool {
match self {
Self::IMessage(channel) => ChannelContract::poll_is_complete_snapshot(channel),
Self::Telegram(channel) => ChannelContract::poll_is_complete_snapshot(channel),
Self::Slack(channel) => ChannelContract::poll_is_complete_snapshot(channel),
}
}

/// Returns `(thread_key, reply_target)` for an accepted message.
pub fn accept(&self, message: &RawMessage) -> Option<(String, String)> {
match self {
Expand All @@ -231,6 +247,14 @@ impl Channel {
}
}

pub fn should_defer(&self, message: &RawMessage, store: &mut Store) -> Result<bool> {
match self {
Self::IMessage(channel) => ChannelContract::should_defer(channel, message, store),
Self::Telegram(channel) => ChannelContract::should_defer(channel, message, store),
Self::Slack(channel) => ChannelContract::should_defer(channel, message, store),
}
}

pub fn reject_reason(&self, message: &RawMessage) -> &'static str {
match self {
Self::IMessage(channel) => ChannelContract::reject_reason(channel, message),
Expand Down Expand Up @@ -379,7 +403,23 @@ impl ChannelContract for IMessageChannel {
is_group: message.is_group,
text: message.text,
voice: None,
images: Vec::new(),
images: message
.attachments
.into_iter()
.map(|attachment| InboundImage {
locator: attachment.locator.clone(),
file_size: if crate::imessage::needs_conversion(
&attachment.locator,
attachment.mime_type.as_deref(),
) {
None
} else {
attachment.file_size
},
mime_type: attachment.mime_type,
data: None,
})
.collect(),
is_from_me: message.is_from_me,
is_supported: true,
thread_id: None,
Expand All @@ -392,6 +432,10 @@ impl ChannelContract for IMessageChannel {
tokio::task::spawn_blocking(move || poller.max_row_id()).await?
}

fn poll_is_complete_snapshot(&self) -> bool {
true
}

fn accept(&self, message: &RawMessage) -> Option<(String, String)> {
if common_reject_reason(message).is_some()
|| (!self.reply_marker.is_empty() && message.text.contains(&self.reply_marker))
Expand All @@ -414,6 +458,23 @@ impl ChannelContract for IMessageChannel {
None
}

fn should_defer(&self, message: &RawMessage, store: &mut Store) -> Result<bool> {
let pending = message
.images
.iter()
.any(|image| image.locator.trim().is_empty());
if pending {
store.should_defer_pending_filename(
self.id(),
message.row_id,
IMESSAGE_PENDING_FILENAME_POLL_LIMIT,
)
} else {
store.clear_pending_filename(self.id(), message.row_id)?;
Ok(false)
}
}

fn reject_reason(&self, message: &RawMessage) -> &'static str {
common_reject_reason(message).unwrap_or_else(|| {
if !self.reply_marker.is_empty() && message.text.contains(&self.reply_marker) {
Expand Down Expand Up @@ -476,12 +537,7 @@ impl ChannelContract for IMessageChannel {
}

async fn download_image(&self, image: &InboundImage) -> Result<DownloadedImage> {
let Some(bytes) = &image.data else {
bail!("iMessage image attachments are not supported yet");
};
Ok(DownloadedImage {
bytes: bytes.clone(),
})
self.poller.download_image(image).await
}
}

Expand Down
11 changes: 9 additions & 2 deletions src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ fn check_bins_with(
.is_ok_and(|channels| channels.contains(&config::ChannelKind::IMessage))
{
bins.push("osascript");
bins.push("/usr/bin/sips");
}
bins.sort_unstable();
bins.dedup();
Expand Down Expand Up @@ -641,6 +642,9 @@ claude_tools = []
assert!(checks.iter().any(|check| {
check.name == "binary osascript" && matches!(check.status, CheckStatus::Fail)
}));
assert!(checks.iter().any(|check| {
check.name == "binary /usr/bin/sips" && matches!(check.status, CheckStatus::Fail)
}));
}

#[test]
Expand All @@ -651,7 +655,8 @@ claude_tools = []
let mut checks = Vec::new();

check_bins_with(&cfg, &mut checks, |bin| {
(bin == "/custom/pi" || bin == "osascript").then(|| PathBuf::from(bin))
(bin == "/custom/pi" || bin == "osascript" || bin == "/usr/bin/sips")
.then(|| PathBuf::from(bin))
});

assert!(checks.iter().any(|check| {
Expand Down Expand Up @@ -680,7 +685,8 @@ claude_tools = []
let mut checks = Vec::new();

check_bins_with(&cfg, &mut checks, |bin| {
(bin == "codex" || bin == "osascript").then(|| PathBuf::from(bin))
(bin == "codex" || bin == "osascript" || bin == "/usr/bin/sips")
.then(|| PathBuf::from(bin))
});

assert!(checks.iter().any(|check| {
Expand Down Expand Up @@ -713,6 +719,7 @@ claude_tools = []
.iter()
.any(|check| check.name == "binary /fake/claude"));
assert!(!checks.iter().any(|check| check.name.contains("osascript")));
assert!(!checks.iter().any(|check| check.name.contains("sips")));
}

#[test]
Expand Down
Loading
Loading