car_messaging/messaging_orchestrator.rs
1//! In-process iMessage approval-transport orchestrator (Units 2 & 4).
2//!
3//! The ENTIRE transport loop is daemon-side Rust, owned by `car-server-core`,
4//! running Rust-to-Rust with **no new FFI/WS surface on the approval seam and
5//! no Swift in the transport path** (the only new WS surface in this feature is
6//! the host-gated `messaging.config.*` channel in Unit 3). This module is the
7//! two halves of that loop:
8//!
9//! - **Outbound (Unit 4).** [`MessagingOrchestrator::observe_and_notify`]
10//! polls [`HostState::approvals`] for new *fire-and-return* approvals
11//! (`action` NOT prefixed `ws.method:` — the only discriminator the
12//! persisted row carries, the blocking-gate convention from
13//! `handler.rs:2183-2184`). For each new eligible approval, when the
14//! feature is enabled and a paired handle exists, it sends ONE iMessage to
15//! the paired handle (action summary + a short per-approval code) **in
16//! process via the un-gated [`car_ffi_common::integrations::messages_send`]**
17//! so the send does not itself raise a `messages.send` approval (gate loop).
18//! An in-memory code↔approval_id map correlates the later reply.
19//!
20//! - **Inbound (Unit 4).** [`MessagingOrchestrator::handle_inbound`] takes one
21//! [`InboundMessage`] and FIRST drops it if the sender handle is not
22//! allowlisted (SC-7 — before ANY parse). It then parses the body to exactly
23//! one [`InboundIntent`] — `{Approve, Deny, PairingCode, Ignore}` — and maps
24//! it: a leading code resolves THAT approval; a bare `approve`/`deny`
25//! resolves the sole pending eligible approval; 2+ pending and
26//! bare/ambiguous resolves NOTHING and sends one disambiguation reply listing
27//! the pending codes; an unknown/already-resolved code resolves nothing; a
28//! pairing code routes to [`MessagingConfigStore::validate_and_consume_pairing_code`].
29//! **v1 single-handle invariant:** the config store rejects a second
30//! allowlisted handle (`MessagingConfigStore::add_handle`/`set_allowlist`),
31//! so there is exactly one paired user. "The sole pending approval" is
32//! therefore correct-by-invariant — all eligible pending approvals belong to
33//! the one paired handle; there is no per-sender scoping to do in v1.
34//! Resolution happens **in-process via [`HostState::resolve_approval`]**
35//! (system-level rows resolve directly, no WS session, no per-session ACL).
36//!
37//! - **Poller (Unit 2).** [`MessagingOrchestrator::poll_once`] reads new
38//! chat.db rows past the persisted watermark (via the Unit 1 reader),
39//! advances the watermark, and forwards each new row to `handle_inbound`.
40//! `MessagingOrchestrator::run_inbound_loop` drives `poll_once` on a bounded
41//! interval (`max_iterations` — the no-runaway-loop primitive, mirroring the
42//! `car-scheduler` `dream_loop` precedent), with a cancel token.
43//!
44//! ## The anti-injection wall (SC-6, structural half)
45//!
46//! The inbound path NEVER calls any config/allowlist mutator. Its only effects
47//! are: (1) resolve a known pending fire-and-return approval id, (2) send a
48//! disambiguation reply, or (3) validate-and-consume a pairing code. The
49//! `MessagingConfigStore` privileged mutators (the enabled-flag and allowlist
50//! setters) are reachable ONLY from the host-gated `messaging.config.*` WS
51//! surface — an inbound iMessage carries no such credential. A grep over THIS
52//! file for any config-mutator call name finds ZERO matches: the inbound path
53//! touches only `resolve_approval` and `validate_and_consume_pairing_code`.
54
55use crate::channel_supervisor::{
56 liveness_record_failure, liveness_record_success, ChannelSupervisor, SharedLiveness,
57};
58use crate::messaging_config::MessagingConfigStore;
59use car_ffi_common::integrations::InboundMessage;
60use car_proto::approval_summary::{approval_summary, sanitize_line};
61use car_proto::HostApprovalRequest;
62#[cfg(test)]
63use car_proto::HostApprovalStatus;
64use car_server_types::approval_core::{ApprovalCore, ResolveOutcome};
65use car_server_types::channel::{ChannelId, InboundChannel, InboundSink, SharedHost};
66// `CancelSignal` is only referenced by the macOS iMessage adapter below.
67#[cfg(target_os = "macos")]
68use car_server_types::channel::CancelSignal;
69use car_server_types::host::HostState;
70use std::collections::HashMap;
71use std::sync::Arc;
72use tokio::sync::Mutex;
73
74/// The system-raised principal the iMessage adapter supplies to
75/// [`ApprovalCore::resolve`]. The literal lives HERE (in the iMessage adapter),
76/// passed INTO the channel-agnostic core — it must NOT appear in
77/// `approval_core.rs` (MC-3 edge).
78const IMESSAGE_PRINCIPAL: &str = "imessage-transport";
79
80/// The resolution string the transport supplies on an approve.
81/// Matches the gate's `approve_label` (`handler.rs:2212` → `"approve"`), so
82/// `classify_resolution` reads an inbound approve exactly as a CarHost click.
83const APPROVE: &str = "approve";
84/// The resolution string on a deny. Any non-`approve` string classifies as
85/// denied; `"deny"` is the explicit, human-readable choice.
86const DENY: &str = "deny";
87
88/// The fixed, unmistakable body the `messaging.test_send` self-test sends (U4).
89/// It is plainly NOT an approval prompt (no code, no "reply approve/deny"), so
90/// it can never be mistaken for one — and on a solo Apple ID its echo back
91/// parses to `Ignore` (the closed-grammar parser drops any non-command body).
92pub const TEST_SEND_BODY: &str =
93 "CAR test: your iMessage approvals are connected. No action needed.";
94
95/// The structured result of one send attempt (U3). Distinguishes the THREE
96/// outcomes the orchestrator must record, instead of collapsing two of them to
97/// "success" as the pre-U3 `.map(|_| ())` did:
98///
99/// - `Err(reason)` from [`MessageSender::send`] = a HARD failure (osascript
100/// killed by a TCC/Automation denial, or a non-macOS no-op). The caller rolls
101/// back the code mapping + retries (existing reliability contract).
102/// - `Ok(SendOutcome { sent: false, reason })` = a SOFT failure — the JXA send
103/// returned `sent:false` (recipient-not-found / no Messages service). Pre-U3
104/// this was silently treated as success (`.map(|_| ())` discarded the bool);
105/// it is now recorded as a FAILURE with the reason (U3 regression guard).
106/// - `Ok(SendOutcome { sent: true, .. })` = a genuine success.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct SendOutcome {
109 /// Whether the message was actually delivered (`sent` from the JXA send).
110 pub sent: bool,
111 /// Failure reason when `sent == false`; `None` on success.
112 pub reason: Option<String>,
113}
114
115impl SendOutcome {
116 /// A delivered send.
117 pub fn ok() -> Self {
118 Self {
119 sent: true,
120 reason: None,
121 }
122 }
123
124 /// A soft failure (the send returned `sent:false`) with a reason.
125 pub fn soft_fail(reason: impl Into<String>) -> Self {
126 Self {
127 sent: false,
128 reason: Some(reason.into()),
129 }
130 }
131}
132
133/// Synchronous, injectable outbound-send seam. The production impl
134/// ([`RealMessageSender`]) calls the un-gated
135/// [`car_ffi_common::integrations::messages_send`]; tests substitute a
136/// capturing spy recording `(handle, body)` so SC-3/SC-5 assert the outbound
137/// behavior with no Messages.app.
138pub trait MessageSender: Send + Sync {
139 /// Send one iMessage `body` to `handle`. Returns:
140 /// - `Err(reason)` on a HARD failure (the orchestrator logs + rolls back +
141 /// retries; never panics).
142 /// - `Ok(SendOutcome { sent, reason })` otherwise — `sent:true` for a
143 /// delivered message, `sent:false` (with a reason) for a SOFT failure the
144 /// pre-U3 code swallowed as success.
145 fn send(&self, handle: &str, body: &str) -> Result<SendOutcome, String>;
146}
147
148/// Production send: routes through the un-gated plain Rust
149/// [`car_ffi_common::integrations::messages_send`] (`integrations.rs:105`) so
150/// the transport's own send does NOT raise a `messages.send` approval and loop
151/// the gate. On non-macOS the underlying backend returns an `Err` at runtime
152/// (the symbol still links), so this builds on every platform.
153#[derive(Debug, Clone, Default)]
154pub struct RealMessageSender;
155
156impl MessageSender for RealMessageSender {
157 fn send(&self, handle: &str, body: &str) -> Result<SendOutcome, String> {
158 // Build the `SendRequest` JSON the plain-Rust `messages_send` parses.
159 let req = serde_json::json!({ "recipient": handle, "body": body });
160 let req_json = req.to_string();
161 // U3: inspect the returned `SendResult` JSON instead of discarding it
162 // with `.map(|_| ())`. The JXA backend returns
163 // `{ available, reason, sent }`: a `sent:false` (recipient-not-found, no
164 // Messages service) is a SOFT failure that the pre-U3 code counted as
165 // success. Surface it as `Ok(SendOutcome::soft_fail(reason))` so the
166 // caller records it as a failure; a hard osascript error is already an
167 // `Err` from `messages_send`.
168 let value = car_ffi_common::integrations::messages_send(&req_json)?;
169 let sent = value.get("sent").and_then(|v| v.as_bool()).unwrap_or(false);
170 if sent {
171 Ok(SendOutcome::ok())
172 } else {
173 let reason = value
174 .get("reason")
175 .and_then(|v| v.as_str())
176 .map(|s| s.to_string())
177 .unwrap_or_else(|| "Messages reported the message was not sent".to_string());
178 Ok(SendOutcome::soft_fail(reason))
179 }
180 }
181}
182
183/// The parsed meaning of an inbound iMessage body. **Closed set** — the parser
184/// yields exactly one of these and nothing else, so there is no inbound→config
185/// edge by construction (SC-6).
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub enum InboundIntent {
188 /// `approve`, optionally naming a specific per-approval code.
189 Approve { code: Option<String> },
190 /// `deny`, optionally naming a specific per-approval code.
191 Deny { code: Option<String> },
192 /// A standalone token that looks like a pairing code (the high-entropy
193 /// base64url-no-pad code minted by `messaging.pairing.start`).
194 PairingCode(String),
195 /// Anything else — silently ignored. NOT a config mutation, ever.
196 Ignore,
197}
198
199/// Length of a minted pairing code (`mint_pairing_code` → 32 bytes
200/// base64url-no-pad = 43 chars). Used by the parser to recognize a standalone
201/// token as a pairing-code candidate (vs. a short per-approval code).
202const PAIRING_CODE_LEN: usize = 43;
203
204/// Short per-approval code length (e.g. `A7`). Kept tiny so it is easy to type
205/// back. The leading letter avoids collision with a bare number.
206const APPROVAL_CODE_LEN: usize = 2;
207
208/// In-process iMessage adapter (re-homed from #403's orchestrator). One per
209/// daemon; held behind an `Arc` and driven by its `InboundChannel::run()` poll
210/// loop. Cheap to clone the `Arc`. The channel-agnostic approval semantics live
211/// in [`ApprovalCore`]; this struct owns the iMessage-specific transport
212/// (chat.db poll, CodeMap text-code correlation, parse grammar).
213pub struct MessagingOrchestrator {
214 /// Channel-agnostic approval semantics (eligibility + resolve). The
215 /// `"imessage-transport"` principal is supplied by this adapter at each
216 /// `core.resolve(...)` call — it is not baked into the core.
217 core: ApprovalCore,
218 config: MessagingConfigStore,
219 sender: Arc<dyn MessageSender>,
220 /// Base dir for the inbound-read watermark (the `~/.car/` equivalent).
221 /// Injectable so tests never touch the developer's real `~/.car/`.
222 base_dir: std::path::PathBuf,
223 /// Bidirectional correlation between the short per-approval code we put in
224 /// the outbound prompt and the approval id. `code → approval_id` resolves
225 /// an inbound `<code> approve`; `approval_id → code` lets us (a) skip
226 /// re-sending a prompt for an approval we already notified, and (b) list
227 /// the pending codes in a disambiguation reply.
228 codes: Mutex<CodeMap>,
229 /// SC-7 instrumentation: count of inbound bodies that reached the parser.
230 /// A non-allowlisted sender's row must NOT increment this (it is dropped
231 /// before parse). Tests assert this stays 0 for a dropped row.
232 parse_calls: std::sync::atomic::AtomicU64,
233 /// Optional shared per-channel liveness sink (U2/U3). When `Some`, every
234 /// outbound send records its structured outcome (success / soft / hard
235 /// failure) here so `messaging.status` can report "last delivered" + the
236 /// last error. `None` for adapters built without a liveness sink (the #403
237 /// direct tests that don't assert liveness). The channel this adapter
238 /// serves is fixed at [`ChannelId::IMessage`] for outcome keying.
239 liveness: Option<SharedLiveness>,
240}
241
242/// The code↔approval_id correlation, kept together so both directions stay
243/// consistent under one lock.
244#[derive(Default)]
245struct CodeMap {
246 code_to_id: HashMap<String, String>,
247 id_to_code: HashMap<String, String>,
248 /// Monotonic counter feeding the short code suffix, so codes don't repeat
249 /// within a daemon uptime even after the map is cleaned.
250 next: u64,
251}
252
253impl CodeMap {
254 /// Mint the next short per-approval code (e.g. `A7`, `B12`). Letter cycles
255 /// A–Z, number increments — readable and easy to type back.
256 fn mint(&mut self) -> String {
257 let n = self.next;
258 self.next += 1;
259 let letter = (b'A' + (n % 26) as u8) as char;
260 let num = n / 26;
261 format!("{letter}{num}")
262 }
263}
264
265impl MessagingOrchestrator {
266 /// Build an orchestrator over the given host, config store, send seam, and
267 /// `~/.car/`-equivalent base dir. Production passes
268 /// `RealMessageSender`/`MessagingConfigStore::from_home()`/`~/.car`; tests
269 /// pass a spy sender and a temp base dir.
270 pub fn new(
271 host: Arc<HostState>,
272 config: MessagingConfigStore,
273 sender: Arc<dyn MessageSender>,
274 base_dir: impl Into<std::path::PathBuf>,
275 ) -> Self {
276 Self {
277 core: ApprovalCore::new(host),
278 config,
279 sender,
280 base_dir: base_dir.into(),
281 codes: Mutex::new(CodeMap::default()),
282 parse_calls: std::sync::atomic::AtomicU64::new(0),
283 liveness: None,
284 }
285 }
286
287 /// Build an orchestrator that ALSO records every outbound send's outcome
288 /// into the shared per-channel liveness map (U2/U3). The boot path and the
289 /// runtime-enable spawn closure use this so `messaging.status` sees real
290 /// "last delivered" + last-error state; the `messaging.test_send` self-test
291 /// (U4) relies on the same recording so a test failure surfaces identically
292 /// to a real one.
293 pub fn with_liveness(
294 host: Arc<HostState>,
295 config: MessagingConfigStore,
296 sender: Arc<dyn MessageSender>,
297 base_dir: impl Into<std::path::PathBuf>,
298 liveness: SharedLiveness,
299 ) -> Self {
300 Self {
301 core: ApprovalCore::new(host),
302 config,
303 sender,
304 base_dir: base_dir.into(),
305 codes: Mutex::new(CodeMap::default()),
306 parse_calls: std::sync::atomic::AtomicU64::new(0),
307 liveness: Some(liveness),
308 }
309 }
310
311 /// Record a send `outcome` into the shared liveness (if a sink is wired),
312 /// keyed to this adapter's channel (iMessage). A hard `Err` is recorded by
313 /// [`Self::record_hard_failure`]; this handles the `Ok(SendOutcome)` shape:
314 /// `sent:true` → success; `sent:false` → soft failure with its reason (the
315 /// case the pre-U3 code swallowed).
316 fn record_outcome(&self, outcome: &SendOutcome) {
317 let Some(liveness) = &self.liveness else {
318 return;
319 };
320 if outcome.sent {
321 liveness_record_success(liveness, ChannelId::IMessage);
322 } else {
323 let reason = outcome
324 .reason
325 .clone()
326 .unwrap_or_else(|| "send failed".to_string());
327 liveness_record_failure(liveness, ChannelId::IMessage, reason);
328 }
329 }
330
331 /// Record a HARD send failure (an `Err` from the sender — osascript killed
332 /// by an Automation/TCC denial) into the shared liveness.
333 fn record_hard_failure(&self, reason: &str) {
334 if let Some(liveness) = &self.liveness {
335 liveness_record_failure(liveness, ChannelId::IMessage, reason);
336 }
337 }
338
339 /// The shared host this adapter resolves against (the eviction sweep and
340 /// pending-code queries borrow it). Routed through the core so there is one
341 /// `Arc<HostState>`.
342 fn host(&self) -> &Arc<HostState> {
343 self.core.host()
344 }
345
346 /// Test/diagnostic accessor: how many inbound bodies reached the parser.
347 /// SC-7 asserts this is 0 after a non-allowlisted row is fed.
348 pub fn parse_call_count(&self) -> u64 {
349 self.parse_calls.load(std::sync::atomic::Ordering::Relaxed)
350 }
351
352 // -------------------------------------------------------------------
353 // OUTBOUND (Unit 4): observe new approvals → send one prompt each
354 // -------------------------------------------------------------------
355
356 /// Observe new eligible approvals on [`HostState`] and send ONE iMessage
357 /// prompt per newly-seen one. Idempotent per approval: an approval we have
358 /// already minted a code for is skipped, so calling this every tick sends
359 /// at most one prompt per approval.
360 ///
361 /// Gating (the enabled-flag wall): if the feature is disabled OR there is
362 /// no paired handle, this is a silent no-op — zero sends. Excluded rows:
363 /// any `ws.method:*` blocking-gate row (wrong producer) and any
364 /// already-resolved row.
365 ///
366 /// The single paired recipient is the FIRST allowlisted handle (v1 is one
367 /// paired/allowlisted user — ledger item 2). A send failure is logged and
368 /// does NOT poison the loop; the code stays mapped so the inbound reply
369 /// still correlates if the message did go through.
370 pub async fn observe_and_notify(&self) {
371 // Enabled-flag gate FIRST — feature off ⇒ no reads of approvals state
372 // matter, but cheap-exit anyway so an off feature does zero work.
373 if !self.config.is_enabled().unwrap_or(false) {
374 return;
375 }
376 let Some(recipient) = self.paired_handle() else {
377 return; // No paired handle ⇒ nobody to notify.
378 };
379
380 let approvals = self.host().approvals().await;
381
382 // Eviction sweep: drop code↔id entries whose approval is no longer
383 // present-and-Pending (resolved via a CarHost click, reaped, or pruned).
384 // Without this the map grows unbounded over a long daemon uptime, since
385 // the inbound resolve path only evicts codes IT consumes. Bound it here
386 // every tick against the live approval set.
387 {
388 let live_pending: std::collections::HashSet<&str> = approvals
389 .iter()
390 .filter(|a| ApprovalCore::is_eligible_pending(a))
391 .map(|a| a.id.as_str())
392 .collect();
393 let mut codes = self.codes.lock().await;
394 let stale: Vec<String> = codes
395 .id_to_code
396 .keys()
397 .filter(|id| !live_pending.contains(id.as_str()))
398 .cloned()
399 .collect();
400 for id in stale {
401 if let Some(code) = codes.id_to_code.remove(&id) {
402 codes.code_to_id.remove(&code);
403 }
404 }
405 }
406
407 for approval in approvals {
408 if !ApprovalCore::is_eligible_pending(&approval) {
409 continue;
410 }
411 // Skip approvals we've already prompted for (idempotency).
412 let code = {
413 let mut codes = self.codes.lock().await;
414 if codes.id_to_code.contains_key(&approval.id) {
415 continue;
416 }
417 let code = codes.mint();
418 codes.code_to_id.insert(code.clone(), approval.id.clone());
419 codes.id_to_code.insert(approval.id.clone(), code.clone());
420 code
421 };
422 let body = outbound_body(&approval, &code);
423 match self.sender.send(&recipient, &body) {
424 Ok(outcome) => {
425 // U3: record the structured outcome. A soft `sent:false`
426 // (recipient-not-found / no Messages service) is recorded as
427 // a FAILURE here — the case pre-U3 silently counted as
428 // success. The code mapping is KEPT on a soft failure (the
429 // approval is still pending; a later tick re-attempts via the
430 // idempotency guard) — only a HARD error rolls back to force
431 // a re-mint.
432 self.record_outcome(&outcome);
433 }
434 Err(e) => {
435 self.record_hard_failure(&e);
436 // Roll back the just-minted mapping so this approval is NOT
437 // permanently suppressed. The idempotency guard above skips
438 // any approval already in `id_to_code`, and the eviction
439 // sweep only fires once the approval leaves the pending set —
440 // so leaving a mapped-but-unsent code here would drop the
441 // prompt forever on a single transient Messages failure.
442 // Removing it lets the next tick re-mint (a fresh code via
443 // the monotonic counter — fine) and re-send.
444 {
445 let mut codes = self.codes.lock().await;
446 if let Some(c) = codes.id_to_code.remove(&approval.id) {
447 codes.code_to_id.remove(&c);
448 }
449 }
450 tracing::warn!(
451 approval_id = %approval.id,
452 error = %e,
453 "iMessage approval prompt send failed; rolled back code, will retry next tick"
454 );
455 }
456 }
457 }
458 }
459
460 /// Fan-out outbound (Unit 5): send the iMessage prompt for `approval` using
461 /// a SHARED `code` minted ONCE by the fan-out coordinator (not this
462 /// adapter's `CodeMap.mint`), so iMessage and Slack carry the SAME code
463 /// (MC-8). Records `code ↔ approval_id` into this adapter's `CodeMap` so the
464 /// inbound text-resolve path still correlates a reply. Idempotent per
465 /// approval: an approval already mapped here is skipped (no duplicate send).
466 /// A send failure rolls back the mapping (same reliability contract as
467 /// `observe_and_notify`).
468 ///
469 /// The rendered body is byte-for-byte the same `outbound_body` the iMessage
470 /// poll path uses — only the code SOURCE differs (shared vs self-minted), so
471 /// the iMessage grammar/behavior is unchanged (MC-1). Gating (enabled flag +
472 /// paired handle) is applied here too.
473 pub async fn send_shared_prompt(&self, approval: &HostApprovalRequest, code: &str) {
474 if !self.config.is_enabled().unwrap_or(false) {
475 return;
476 }
477 let Some(recipient) = self.paired_handle() else {
478 return;
479 };
480 // Idempotency + record the shared code in this adapter's map so an
481 // inbound `<code> approve` still resolves. Skip if already mapped.
482 {
483 let mut codes = self.codes.lock().await;
484 if codes.id_to_code.contains_key(&approval.id) {
485 return;
486 }
487 codes
488 .code_to_id
489 .insert(code.to_string(), approval.id.clone());
490 codes
491 .id_to_code
492 .insert(approval.id.clone(), code.to_string());
493 }
494 let body = outbound_body(approval, code);
495 match self.sender.send(&recipient, &body) {
496 Ok(outcome) => {
497 // U3: record success / soft-failure into liveness.
498 self.record_outcome(&outcome);
499 }
500 Err(e) => {
501 self.record_hard_failure(&e);
502 // Roll back so a transient failure does not permanently suppress.
503 let mut codes = self.codes.lock().await;
504 if let Some(c) = codes.id_to_code.remove(&approval.id) {
505 codes.code_to_id.remove(&c);
506 }
507 tracing::warn!(
508 approval_id = %approval.id,
509 error = %e,
510 "iMessage shared-code prompt send failed; rolled back, will retry next tick"
511 );
512 }
513 }
514 }
515
516 // -------------------------------------------------------------------
517 // INBOUND (Unit 4): allowlist-drop → parse → resolve / pair / disambiguate
518 // -------------------------------------------------------------------
519
520 /// Handle one inbound row. SC-7: a non-allowlisted sender is dropped
521 /// BEFORE any parse (the parse counter does not move). Then the body is
522 /// parsed to one [`InboundIntent`] and mapped:
523 ///
524 /// - `<code> approve|deny` → resolve THAT approval id (if it is a known,
525 /// still-pending, eligible approval).
526 /// - bare `approve|deny` → resolve the SOLE pending eligible approval; if
527 /// 2+ are pending, resolve nothing and send one disambiguation reply.
528 /// (v1 has one paired handle — see the module-level single-handle
529 /// invariant — so "the sole pending approval" needs no per-sender scope.)
530 /// - unknown / already-resolved code → resolve nothing.
531 /// - pairing code → validate-and-consume (the ONLY inbound-reachable
532 /// config mutation; binds the sender only on a constant-time code match).
533 /// - ignore → no-op.
534 ///
535 /// The enabled-flag gate applies: feature off ⇒ the row is dropped with no
536 /// parse/resolve/send.
537 pub async fn handle_inbound(&self, msg: &InboundMessage) {
538 // Enabled-flag wall: a disabled feature does zero inbound work.
539 if !self.config.is_enabled().unwrap_or(false) {
540 return;
541 }
542 // SC-7: drop non-allowlisted senders BEFORE any parse. is_allowlisted
543 // errs only on a malformed config file; treat an error as "not
544 // allowlisted" (fail closed).
545 if !self.config.is_allowlisted(&msg.handle_id).unwrap_or(false) {
546 return;
547 }
548
549 // Past the allowlist wall: now (and only now) we parse.
550 self.parse_calls
551 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
552 let intent = parse_inbound(&msg.body);
553
554 match intent {
555 InboundIntent::Approve { code } => {
556 self.resolve_intent(&msg.handle_id, code, APPROVE).await;
557 }
558 InboundIntent::Deny { code } => {
559 self.resolve_intent(&msg.handle_id, code, DENY).await;
560 }
561 InboundIntent::PairingCode(candidate) => {
562 // The ONLY inbound-reachable config mutation. Binds the sender
563 // ONLY on a constant-time match of the locally-minted code.
564 let _ = self
565 .config
566 .validate_and_consume_pairing_code(&msg.handle_id, &candidate);
567 }
568 InboundIntent::Ignore => {}
569 }
570 }
571
572 /// Map an approve/deny intent (with or without a code) onto a resolve.
573 /// Encapsulates the ledger rule: a code names exactly one approval; a bare
574 /// word resolves the sole pending eligible approval; 2+ pending and
575 /// bare/ambiguous resolves nothing and sends a disambiguation reply. (v1's
576 /// single-handle invariant means all eligible pending approvals belong to
577 /// the one paired user, so the bare-word "sole pending" rule is unambiguous
578 /// without per-sender scoping.)
579 async fn resolve_intent(&self, handle: &str, code: Option<String>, resolution: &str) {
580 match code {
581 Some(code) => {
582 // Named code: resolve THAT approval iff it maps to a known,
583 // still-pending, eligible approval. Unknown / already-resolved
584 // ⇒ resolve nothing (no disambiguation — the sender named a
585 // specific code, it just isn't actionable).
586 if let Some(approval_id) = self.lookup_pending_code(&code).await {
587 self.resolve(&approval_id, resolution).await;
588 }
589 }
590 None => {
591 // Bare word: resolve the SOLE pending eligible approval. 0 ⇒
592 // nothing to do; 1 ⇒ resolve it; 2+ ⇒ disambiguate.
593 let pending = self.pending_codes().await;
594 match pending.len() {
595 0 => {}
596 1 => {
597 let approval_id = pending[0].1.clone();
598 self.resolve(&approval_id, resolution).await;
599 }
600 _ => {
601 let reply = disambiguation_body(&pending);
602 // A disambiguation reply is an internal nudge, not an
603 // approval-delivery signal, so it is NOT recorded into
604 // liveness (which tracks approval-prompt delivery). A
605 // hard error is logged; a soft `sent:false` is ignored
606 // (the user will simply re-send their reply).
607 if let Err(e) = self.sender.send(handle, &reply) {
608 tracing::warn!(error = %e, "disambiguation reply send failed");
609 }
610 }
611 }
612 }
613 }
614 }
615
616 /// Resolve `approval_id` in-process via the channel-agnostic
617 /// [`ApprovalCore::resolve`] (which calls the untouched
618 /// `HostState::resolve_approval`). The iMessage adapter supplies its own
619 /// system-raised principal ([`IMESSAGE_PRINCIPAL`] = `"imessage-transport"`)
620 /// — the literal lives in this adapter, not in the core (MC-3 edge).
621 ///
622 /// On a real resolve ([`ResolveOutcome::Resolved`]) the consumed code is
623 /// dropped so a re-send of the same word doesn't re-resolve and it stops
624 /// showing in disambiguation lists. On a fan-out non-resolve
625 /// ([`ResolveOutcome::StillPending`]) or an error the code is NOT evicted —
626 /// the approval did not move, so the user's reply should be able to retry.
627 async fn resolve(&self, approval_id: &str, resolution: &str) {
628 if self
629 .core
630 .resolve(IMESSAGE_PRINCIPAL, approval_id, resolution)
631 .await
632 == ResolveOutcome::Resolved
633 {
634 let mut codes = self.codes.lock().await;
635 if let Some(code) = codes.id_to_code.remove(approval_id) {
636 codes.code_to_id.remove(&code);
637 }
638 }
639 }
640
641 /// Resolve a named code to its approval id IFF that approval is still
642 /// pending and eligible. Unknown code, or a code whose approval has been
643 /// resolved/excluded, yields `None` (resolve nothing).
644 async fn lookup_pending_code(&self, code: &str) -> Option<String> {
645 let approval_id = {
646 let codes = self.codes.lock().await;
647 codes.code_to_id.get(code).cloned()?
648 };
649 // Confirm the approval is still a pending, eligible row.
650 self.core
651 .is_id_eligible_pending(&approval_id)
652 .await
653 .then_some(approval_id)
654 }
655
656 /// The `(code, approval_id, action)` triples that are still pending +
657 /// eligible, sorted by code for a stable disambiguation listing. The action
658 /// rides along so the listing can name what each code refers to.
659 async fn pending_codes(&self) -> Vec<(String, String, String)> {
660 let pending_actions: std::collections::HashMap<String, String> = self
661 .core
662 .eligible_pending()
663 .await
664 .into_iter()
665 .map(|a| (a.id, a.action))
666 .collect();
667 let codes = self.codes.lock().await;
668 let mut out: Vec<(String, String, String)> = codes
669 .code_to_id
670 .iter()
671 .filter_map(|(c, id)| {
672 pending_actions
673 .get(id)
674 .map(|action| (c.clone(), id.clone(), action.clone()))
675 })
676 .collect();
677 out.sort_by(|a, b| a.0.cmp(&b.0));
678 out
679 }
680
681 /// The single paired recipient: the first allowlisted handle (v1 is one
682 /// paired user). `None` when the allowlist is empty.
683 fn paired_handle(&self) -> Option<String> {
684 self.config.allowlist().ok()?.into_iter().next()
685 }
686
687 // -------------------------------------------------------------------
688 // SELF-TEST (U4): on-demand "does my Mac actually text my phone" probe
689 // -------------------------------------------------------------------
690
691 /// Send the fixed, clearly-labeled self-test message to the paired handle
692 /// and return the outcome (U4). This is a PURE send probe: it composes
693 /// [`TEST_SEND_BODY`], sends through the SAME [`MessageSender`] the real
694 /// approval path uses, and records the outcome into liveness (so a passing
695 /// test genuinely proves Automation works and a failing one surfaces the
696 /// same way a real send failure does). It mints **no** per-approval/pairing
697 /// code mapping and resolves nothing — the `CodeMap` is untouched.
698 ///
699 /// Returns `Ok(())` when the message was delivered (`sent:true`), or
700 /// `Err(reason)` for: feature disabled, no paired handle, a hard send error
701 /// (Automation denied), or a soft `sent:false` (recipient-not-found). The
702 /// error strings are user-actionable (surfaced verbatim in the pane).
703 pub async fn send_test(&self) -> Result<(), String> {
704 if !self.config.is_enabled().unwrap_or(false) {
705 return Err("iMessage is off — turn it on first.".to_string());
706 }
707 let Some(recipient) = self.paired_handle() else {
708 return Err("No paired handle — text the pairing code first.".to_string());
709 };
710 match self.sender.send(&recipient, TEST_SEND_BODY) {
711 Ok(outcome) => {
712 // Record the outcome (success or soft failure) so "last
713 // delivered" + last-error reflect the probe, identical to a
714 // real send.
715 self.record_outcome(&outcome);
716 if outcome.sent {
717 Ok(())
718 } else {
719 Err(outcome
720 .reason
721 .unwrap_or_else(|| "the message was not sent".to_string()))
722 }
723 }
724 Err(e) => {
725 self.record_hard_failure(&e);
726 Err(e)
727 }
728 }
729 }
730
731 // -------------------------------------------------------------------
732 // POLLER (Unit 2): bounded interval read past the watermark
733 // -------------------------------------------------------------------
734
735 /// One poll tick: read new chat.db rows past the persisted watermark,
736 /// advance the watermark, and forward each new row to `handle_inbound`.
737 /// Does NO parse/resolve itself — that is `handle_inbound`'s job; the
738 /// poller is purely the change-detection tick.
739 ///
740 /// Watermark semantics: on a fresh/missing watermark, seed it to the
741 /// current `MAX(ROWID)` so no pre-existing text is ever replayed as new
742 /// (and return without forwarding anything that first tick). Otherwise read
743 /// strictly past `last_rowid`, forward, then persist the new high-water =
744 /// the max rowid seen this tick. The watermark advances monotonically.
745 ///
746 /// `read_max`/`read_new` are injected so the test can drive a temp chat.db
747 /// (or a synthetic source) with no macOS dependency; production passes the
748 /// macOS-gated default-DB readers.
749 pub async fn poll_once<FMax, FNew>(
750 &self,
751 read_max: FMax,
752 read_new: FNew,
753 ) -> Result<usize, String>
754 where
755 FMax: Fn() -> Result<i64, String>,
756 FNew: Fn(i64) -> Result<Vec<InboundMessage>, String>,
757 {
758 // Enabled-flag wall: a disabled feature performs NO chat.db read.
759 if !self.config.is_enabled().unwrap_or(false) {
760 return Ok(0);
761 }
762
763 use car_ffi_common::integrations::Watermark;
764 let existing = Watermark::load(&self.base_dir).map_err(|e| e.to_string())?;
765
766 let last = match existing {
767 Some(w) => w.last_rowid,
768 None => {
769 // Fresh boot: seed to MAX(ROWID) so old texts never replay.
770 let seed = read_max()?;
771 Watermark::new(seed)
772 .persist(&self.base_dir)
773 .map_err(|e| e.to_string())?;
774 return Ok(0);
775 }
776 };
777
778 let rows = read_new(last)?;
779 if rows.is_empty() {
780 return Ok(0);
781 }
782
783 // Forward each new row, then advance the watermark to the max rowid
784 // seen this tick. Persist AFTER forwarding so a crash mid-tick replays
785 // (at-least-once) rather than silently dropping a reply.
786 let mut max_seen = last;
787 for row in &rows {
788 if row.rowid > max_seen {
789 max_seen = row.rowid;
790 }
791 self.handle_inbound(row).await;
792 }
793 if max_seen > last {
794 Watermark::new(max_seen)
795 .persist(&self.base_dir)
796 .map_err(|e| e.to_string())?;
797 }
798 Ok(rows.len())
799 }
800
801 /// One poll tick against the user's REAL Messages library (macOS only).
802 /// Wraps `poll_once` with the production default-DB readers
803 /// (`messages_max_rowid` / `messages_read_inbound`). Returns 0 on a fresh
804 /// seed or an empty tick.
805 #[cfg(target_os = "macos")]
806 pub async fn poll_once_default_db(&self) -> Result<usize, String> {
807 self.poll_once(
808 || car_ffi_common::integrations::messages_max_rowid().map_err(|e| e.to_string()),
809 |min| {
810 car_ffi_common::integrations::messages_read_inbound(min).map_err(|e| e.to_string())
811 },
812 )
813 .await
814 }
815
816 /// Bounded interval poll loop — the no-runaway-loop primitive, and the body
817 /// of this adapter's [`InboundChannel::run`]. Each tick runs
818 /// `observe_and_notify` (outbound) then one `poll_once` against the real
819 /// Messages library (inbound), sleeps `interval`, and stops after
820 /// `max_iterations` ticks (when `Some`) or on cancel. Mirrors the
821 /// `car-scheduler` `dream_loop` bounded-interval precedent rather than a
822 /// hand-rolled `loop {}`.
823 ///
824 /// A failing tick is logged and the loop continues (one bad read must not
825 /// kill the transport). `cancel` is a `tokio::sync::watch` the daemon
826 /// shutdown path flips to `true`.
827 #[cfg(target_os = "macos")]
828 pub async fn run_inbound_loop(
829 &self,
830 interval: std::time::Duration,
831 max_iterations: Option<u32>,
832 mut cancel: tokio::sync::watch::Receiver<bool>,
833 ) {
834 let mut iterations: u32 = 0;
835 loop {
836 if let Some(max) = max_iterations {
837 if iterations >= max {
838 break;
839 }
840 }
841
842 // Outbound first (prompt newly-raised approvals), then inbound
843 // (drain replies). Both are best-effort; a panic in one is the
844 // caller's concern (the boot site wraps the spawn).
845 self.observe_and_notify().await;
846 if let Err(e) = self.poll_once_default_db().await {
847 tracing::debug!(error = %e, "messaging poll tick failed");
848 }
849 iterations += 1;
850
851 if let Some(max) = max_iterations {
852 if iterations >= max {
853 break;
854 }
855 }
856
857 tokio::select! {
858 _ = tokio::time::sleep(interval) => {}
859 _ = cancel.changed() => {
860 if *cancel.borrow() {
861 break;
862 }
863 }
864 }
865 }
866 }
867
868 /// Inbound-ONLY bounded poll loop (Unit 5 boot path). Each tick drains
869 /// chat.db replies via `poll_once_default_db` — but does NOT run
870 /// `observe_and_notify`, because under multi-channel fan-out the OUTBOUND
871 /// prompt is driven once by the [`crate::fanout::FanoutCoordinator`] (so
872 /// iMessage and Slack carry the SAME shared code, MC-8). Separating inbound
873 /// from outbound here is what lets the boot path notify both channels with
874 /// one shared code without double-prompting iMessage. `observe_and_notify`
875 /// and `run_inbound_loop` stay intact for the #403 direct-call tests (MC-1).
876 #[cfg(target_os = "macos")]
877 pub async fn run_inbound_only_loop(
878 &self,
879 interval: std::time::Duration,
880 mut cancel: tokio::sync::watch::Receiver<bool>,
881 ) {
882 loop {
883 if let Err(e) = self.poll_once_default_db().await {
884 tracing::debug!(error = %e, "messaging inbound poll tick failed");
885 }
886 tokio::select! {
887 _ = tokio::time::sleep(interval) => {}
888 _ = cancel.changed() => {
889 if *cancel.borrow() {
890 break;
891 }
892 }
893 }
894 }
895 }
896}
897
898// ===================================================================
899// The iMessage adapter as the FIRST `InboundChannel` (Unit 3)
900// ===================================================================
901
902/// The iMessage adapter implements the channel-agnostic [`InboundChannel`]
903/// seam: it names its [`ChannelId::IMessage`] and OWNS its poll loop, feeding
904/// observed inbound rows into the supplied sink. macOS-gated — the underlying
905/// `poll_once_default_db` reads the local Messages library.
906///
907/// The watermark/rowid stay PRIVATE to this adapter's poll loop (they never
908/// cross the sink — the sink sees only `handle_id` + `body`).
909#[cfg(target_os = "macos")]
910#[async_trait::async_trait]
911impl InboundChannel for MessagingOrchestrator {
912 fn channel(&self) -> ChannelId {
913 ChannelId::IMessage
914 }
915
916 async fn run(&self, _sink: &dyn InboundSink, cancel: CancelSignal) {
917 // The iMessage adapter is its own delivery path: each polled row goes to
918 // `self.handle_inbound`, which is the channel-agnostic approval sink for
919 // this channel. (The trait's `sink` param exists for adapters whose
920 // delivery is externally supplied, e.g. Slack in Unit 4.)
921 self.run_inbound_loop(std::time::Duration::from_secs(2), None, cancel)
922 .await;
923 }
924}
925
926/// Spawn every ENABLED approval-transport adapter at daemon boot (Unit 3 —
927/// cross-platform registry). Iterates [`ChannelId::ALL`]; for each channel that
928/// is enabled in `~/.car/messaging.json` it spawns that adapter's
929/// [`InboundChannel::run`] loop on the shared cancel signal. The iMessage
930/// adapter alone is `#[cfg(target_os = "macos")]`-gated (it reads the local
931/// Messages library); the registry/trait/`ChannelId` are unconditional — there
932/// are NO cargo feature flags.
933///
934/// The enabled-flag gate ALSO lives inside each adapter, so a channel that is
935/// off does zero work per tick even if spawned; the registry both (a) skips
936/// spawning disabled channels at boot, and (b) relies on the adapter's internal
937/// gate so a live toggle still flips behavior. Returns the cancel sender so the
938/// daemon shutdown path can stop every spawned loop.
939///
940/// **Unit 1 (runtime spawn-on-enable):** this also builds and returns a
941/// [`ChannelSupervisor`] — recorded with the channels spawned here at boot —
942/// that the host-gated `messaging.config.set` handler uses to spawn a channel's
943/// watcher the instant the user enables it, with NO daemon/app restart. The
944/// supervisor holds the SAME cancel signal and the SAME per-channel liveness map
945/// the boot adapters write into, so a runtime-spawned channel shuts down cleanly
946/// and reports status identically.
947pub fn spawn_channel_pollers(host: SharedHost) -> Arc<ChannelSupervisor> {
948 let (cancel_tx, _cancel_rx) = tokio::sync::watch::channel(false);
949 let store = MessagingConfigStore::from_home();
950
951 // The shared per-channel liveness map (U2/U3). The boot adapters and any
952 // runtime-spawned adapter write outbound-send outcomes here; `messaging.status`
953 // reads it. Build it ONCE and hand the same `Arc` to every adapter + the
954 // supervisor. Only the macOS iMessage adapter consumes it today, so it is
955 // macOS-gated to avoid an unused binding on other platforms.
956 #[cfg(target_os = "macos")]
957 let liveness: SharedLiveness =
958 Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
959
960 // The fan-out coordinator (Unit 5) drives OUTBOUND once across all enabled
961 // channels with ONE shared code (MC-8). Each enabled channel contributes its
962 // concrete adapter `Arc` to the coordinator (for outbound) AND spawns its
963 // INBOUND loop (for replies/clicks). iMessage's inbound is poll-only at boot
964 // (`run_inbound_only_loop`) so outbound is not double-driven.
965 let core = car_server_types::approval_core::ApprovalCore::new(host.clone());
966
967 // Build the iMessage adapter Arc (macOS only). Hold it both for the
968 // coordinator (outbound) and to spawn its inbound poll loop.
969 #[cfg(target_os = "macos")]
970 let imessage: Option<Arc<MessagingOrchestrator>> = {
971 if store.is_enabled_for(ChannelId::IMessage).unwrap_or(false) {
972 // Same state root the config store above resolved — `$CAR_HOME`
973 // when set, else `~/.car` — so the orchestrator's cursor state and
974 // its config never land under two different roots.
975 let base_dir = car_home::root_or_relative();
976 let orch = Arc::new(MessagingOrchestrator::with_liveness(
977 host.clone(),
978 MessagingConfigStore::from_home(),
979 Arc::new(RealMessageSender),
980 base_dir,
981 liveness.clone(),
982 ));
983 // Spawn the inbound-only poll loop.
984 let orch_in = orch.clone();
985 let cancel_rx = cancel_tx.subscribe();
986 tokio::spawn(async move {
987 orch_in
988 .run_inbound_only_loop(std::time::Duration::from_secs(2), cancel_rx)
989 .await;
990 });
991 Some(orch)
992 } else {
993 None
994 }
995 };
996 #[cfg(not(target_os = "macos"))]
997 let imessage: Option<Arc<MessagingOrchestrator>> = None;
998
999 // Build the Slack adapter Arc (UNCONDITIONAL — no cfg gate, MC-11). Its
1000 // tokens live in the OS keychain (MC-9); the on-disk config holds NO bearer
1001 // value, only a keychain REFERENCE (the key names) persisted by the
1002 // host-gated `messaging.config.set` provisioning path. The transport is
1003 // constructed FROM that persisted ref, so the live adapter fetches the real
1004 // creds the host provisioned. The post-channel id is read from CONFIG (the
1005 // `slack_channel_id` in `messaging.json`, set on the same host-gated call as
1006 // the tokens) — it is configuration, not a secret, so it lives in the config
1007 // file, not the keychain. If no token has been provisioned (no
1008 // `slack_token_ref` in the config), the adapter cannot run — log + SKIP
1009 // rather than hard-crashing the boot (mirrors how iMessage skips a missing
1010 // prerequisite / chat.db). A misconfigured/absent post-channel id ⇒ the
1011 // adapter is built but cannot post (no-op outbound), still safe.
1012 let slack: Option<Arc<crate::slack_adapter::SlackAdapter>> = {
1013 if store.is_enabled_for(ChannelId::Slack).unwrap_or(false) {
1014 match store.slack_token_ref_for(ChannelId::Slack).unwrap_or(None) {
1015 Some(token_ref) => {
1016 let post_channel = store
1017 .slack_channel_id_for(ChannelId::Slack)
1018 .unwrap_or(None)
1019 .unwrap_or_default();
1020 if post_channel.is_empty() {
1021 // No post-channel configured: outbound `post_prompt` is a
1022 // no-op (it early-returns on an empty channel), so we'd
1023 // post nothing — but inbound (pairing + button resolve)
1024 // still works, so build the adapter anyway. Warn ONCE
1025 // here at boot rather than every doomed outbound tick.
1026 tracing::warn!(
1027 "slack channel enabled but no slack_channel id configured \
1028 (no slack_channel in messaging.json) — outbound approval \
1029 prompts will be skipped; inbound pairing + button resolve \
1030 still active. Set via messaging.config.set \
1031 {{ channel: \"slack\", slack_channel: \"<id>\" }}"
1032 );
1033 }
1034 let transport = Arc::new(crate::slack_adapter::RealSlackTransport::new(
1035 token_ref.bot_token_key.clone(),
1036 token_ref.app_token_key.clone(),
1037 ));
1038 transport.clone().spawn_socket_loop(cancel_tx.subscribe());
1039 let adapter = Arc::new(crate::slack_adapter::SlackAdapter::new(
1040 host.clone(),
1041 MessagingConfigStore::from_home(),
1042 transport as Arc<dyn crate::slack_adapter::SlackTransport>,
1043 post_channel,
1044 ));
1045 // Spawn the Slack inbound `run()` loop (push-based; pulls
1046 // events off the transport seam and dispatches each).
1047 let adapter_in = adapter.clone();
1048 let cancel_rx = cancel_tx.subscribe();
1049 tokio::spawn(async move {
1050 let sink = NoopSink;
1051 adapter_in.run(&sink, cancel_rx).await;
1052 });
1053 Some(adapter)
1054 }
1055 None => {
1056 tracing::warn!(
1057 "slack channel enabled but no tokens provisioned \
1058 (no slack_token_ref in messaging.json) — skipping slack adapter; \
1059 provision via messaging.config.set {{ channel: \"slack\", bot_token, app_token }}"
1060 );
1061 None
1062 }
1063 }
1064 } else {
1065 None
1066 }
1067 };
1068
1069 // Track which channels we spawned at boot so the supervisor's live set is
1070 // accurate from the start (U1 idempotency: a runtime enable of a
1071 // boot-spawned channel is a no-op).
1072 let imessage_spawned = imessage.is_some();
1073 let slack_spawned = slack.is_some();
1074
1075 // Spawn the single fan-out OUTBOUND ticker over the enabled channels. One
1076 // shared code per approval reaches every enabled channel (MC-8). If no
1077 // channel is enabled this still runs but is a cheap no-op (eligible-pending
1078 // query over an empty/gated set).
1079 if imessage.is_some() || slack.is_some() {
1080 let coordinator = crate::fanout::FanoutCoordinator::new(core, imessage, slack);
1081 let mut cancel_rx = cancel_tx.subscribe();
1082 tokio::spawn(async move {
1083 loop {
1084 coordinator.observe_and_fanout().await;
1085 tokio::select! {
1086 _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {}
1087 _ = cancel_rx.changed() => {
1088 if *cancel_rx.borrow() {
1089 break;
1090 }
1091 }
1092 }
1093 }
1094 });
1095 }
1096
1097 // U1: build the runtime supervisor over the SAME cancel signal + liveness
1098 // map, with the per-channel spawn closure, and record the boot-spawned
1099 // channels as live. The host-gated `messaging.config.set` handler reaches it
1100 // via `ServerState` to call `ensure_spawned(channel)` on an off→on
1101 // transition — no restart.
1102 let supervisor = Arc::new(ChannelSupervisor::new(host, cancel_tx, make_spawn_fn()));
1103 if imessage_spawned {
1104 supervisor.mark_spawned(ChannelId::IMessage);
1105 }
1106 if slack_spawned {
1107 supervisor.mark_spawned(ChannelId::Slack);
1108 }
1109 supervisor
1110}
1111
1112/// Build the per-channel spawn closure the [`ChannelSupervisor`] calls to start
1113/// ONE channel's watcher at runtime (U1). It is handed the channel, the shared
1114/// host, the shared liveness map, and a cancel receiver subscribed to the
1115/// supervisor's signal.
1116///
1117/// For a runtime-enabled iMessage channel that was OFF at boot, this spawns the
1118/// SELF-CONTAINED `MessagingOrchestrator::run_inbound_loop` — which runs BOTH
1119/// `observe_and_notify` (outbound prompts) and the inbound poll per tick. This
1120/// is the #403 direct-call path: for a single channel it is byte-for-byte the
1121/// same `outbound_body` grammar as the boot path. (The boot path splits inbound
1122/// from outbound only so the multi-channel fan-out coordinator can drive
1123/// outbound ONCE with a shared code across SIMULTANEOUSLY-enabled channels —
1124/// which does not apply when the user flips ONE channel on at runtime.)
1125///
1126/// Slack runtime-enable always returns an `Err` (see [`spawn_slack_runtime`]):
1127/// its outbound prompt driver is the boot-time `FanoutCoordinator`, which a
1128/// runtime closure cannot rebuild, so the channel must NOT be marked live (which
1129/// would make `messaging.status` falsely report `watcher_running:true`). The
1130/// host UI surfaces the error; a restart activates Slack outbound.
1131fn make_spawn_fn() -> crate::channel_supervisor::SpawnFn {
1132 Box::new(
1133 |channel: ChannelId,
1134 host: &SharedHost,
1135 liveness: &SharedLiveness,
1136 cancel_rx: tokio::sync::watch::Receiver<bool>|
1137 -> Result<(), String> {
1138 match channel {
1139 ChannelId::IMessage => spawn_imessage_runtime(host, liveness, cancel_rx),
1140 ChannelId::Slack => spawn_slack_runtime(host, liveness, cancel_rx),
1141 }
1142 },
1143 )
1144}
1145
1146/// Spawn a runtime-enabled iMessage watcher (macOS). Self-contained
1147/// outbound+inbound loop (`run_inbound_loop`). On non-macOS this is a no-op that
1148/// returns `Ok(())` (the channel can be "enabled" in config but reads nothing —
1149/// matching the boot path, which builds no iMessage adapter off macOS).
1150#[allow(unused_variables)]
1151fn spawn_imessage_runtime(
1152 host: &SharedHost,
1153 liveness: &SharedLiveness,
1154 cancel_rx: tokio::sync::watch::Receiver<bool>,
1155) -> Result<(), String> {
1156 #[cfg(target_os = "macos")]
1157 {
1158 // The CAR state root (`$CAR_HOME`, else `~/.car`), matching the boot
1159 // path above so a runtime-enabled watcher reads the same cursor state.
1160 let base_dir = car_home::root_or_relative();
1161 let orch = Arc::new(MessagingOrchestrator::with_liveness(
1162 host.clone(),
1163 MessagingConfigStore::from_home(),
1164 Arc::new(RealMessageSender),
1165 base_dir,
1166 liveness.clone(),
1167 ));
1168 tokio::spawn(async move {
1169 orch.run_inbound_loop(std::time::Duration::from_secs(2), None, cancel_rx)
1170 .await;
1171 });
1172 Ok(())
1173 }
1174 #[cfg(not(target_os = "macos"))]
1175 {
1176 // Off macOS the iMessage adapter reads nothing; recording the channel as
1177 // "spawned" is harmless (the enabled-flag gate already makes every tick
1178 // a no-op) and keeps the live-set bookkeeping uniform.
1179 Ok(())
1180 }
1181}
1182
1183/// Runtime-enable for Slack is intentionally NOT supported in this build.
1184///
1185/// Slack's OUTBOUND approval prompts (`SlackAdapter::post_prompt`) have exactly
1186/// ONE driver — the [`FanoutCoordinator`], built ONCE at boot in
1187/// `spawn_channel_pollers` over whatever channels were enabled at boot. A
1188/// runtime-enable closure can only spawn Slack's INBOUND loop (`adapter.run`),
1189/// which never calls `post_prompt`. So a Slack channel enabled at runtime would
1190/// receive inbound replies but could NEVER send an outbound approval prompt —
1191/// yet marking it live would make `messaging.status` report
1192/// `watcher_running:true` and the host UI show a green "Ready" it cannot honor.
1193///
1194/// To keep `watcher_running` HONEST (the load-bearing invariant: never report a
1195/// channel live when its outbound can't fire), this returns an `Err`. The
1196/// supervisor's `ensure_spawned` rolls back the reserved live-set entry on this
1197/// `Err`, so the channel is NOT marked live. The config flag is still persisted
1198/// by `messaging.config.set` (config is config) — the channel is simply not
1199/// watcher_running until the daemon restarts, which rebuilds the boot-time
1200/// `FanoutCoordinator` over the now-enabled Slack channel and activates outbound.
1201///
1202/// Building a runtime Slack outbound path is deliberately out of v1 scope.
1203/// iMessage's runtime-enable is unaffected: `spawn_imessage_runtime` uses the
1204/// self-contained `run_inbound_loop`, which drives BOTH outbound and inbound.
1205#[allow(unused_variables)]
1206fn spawn_slack_runtime(
1207 host: &SharedHost,
1208 liveness: &SharedLiveness,
1209 cancel_rx: tokio::sync::watch::Receiver<bool>,
1210) -> Result<(), String> {
1211 Err(
1212 "Slack runtime-enable is not supported in this build; restart to activate \
1213 outbound delivery."
1214 .to_string(),
1215 )
1216}
1217
1218/// A no-op [`InboundSink`] for adapters that own their own delivery (the
1219/// iMessage adapter delivers via its internal `handle_inbound`; the Slack
1220/// adapter resolves by `approval_id` + pairs via the code primitive — neither
1221/// routes through the `handle_id`+`body` sink, so its `run` ignores the passed
1222/// sink). Present so the registry can drive any adapter through the seam
1223/// signature uniformly.
1224struct NoopSink;
1225
1226#[async_trait::async_trait]
1227impl InboundSink for NoopSink {
1228 async fn deliver(&self, _channel: ChannelId, _msg: &InboundMessage) {}
1229}
1230
1231/// Build the outbound prompt body: a short action summary plus the
1232/// per-approval code and how to reply. Kept compact for a text message.
1233///
1234/// `pub` so the MC-1 fan-out gate can assert the fan-out iMessage body
1235/// (rendered by [`MessagingOrchestrator::send_shared_prompt`]) is byte-for-byte
1236/// the poll-path grammar — pinning the MC-1 guarantee against a future drift in
1237/// `send_shared_prompt`.
1238pub fn outbound_body(approval: &HostApprovalRequest, code: &str) -> String {
1239 // `action` is agent-authored, unvalidated and unbounded. Sanitised for the
1240 // same reason the summary's fields are: raw, it can carry newlines that
1241 // forge lines looking like the labelled block below it, and length that
1242 // pushes that block off a phone screen.
1243 let mut body = format!("Approval needed: {}\n", sanitize_line(&approval.action));
1244 // What the action would actually do, when `details` says. Without it this
1245 // channel asked for a decision while withholding everything the decision
1246 // rests on — the dashboard has rendered the same payload all along
1247 // (`car_proto::approval_summary`).
1248 if let Some(summary) = approval_summary(approval) {
1249 body.push_str(&summary);
1250 body.push('\n');
1251 }
1252 body.push_str(&format!("Reply `{code} approve` or `{code} deny`."));
1253 body
1254}
1255
1256/// Build the disambiguation reply listing the pending codes (when a bare
1257/// approve/deny is ambiguous because 2+ approvals are pending).
1258fn disambiguation_body(pending: &[(String, String, String)]) -> String {
1259 let mut s =
1260 String::from("Multiple approvals are pending. Reply with a code, e.g. `<code> approve`:\n");
1261 // Name each action next to its code. A bare code list asks the approver to
1262 // choose between `A0` and `B0` on no information at all — the one moment
1263 // they are certain to be deciding about more than one thing.
1264 for (code, _id, action) in pending {
1265 // This listing is where the overseer maps an opaque code onto what it
1266 // means, and their reply resolves that code — so a raw multi-line
1267 // `action` here could forge a second row for a DIFFERENT pending
1268 // approval, or pad the real row out of view on a phone.
1269 s.push_str(&format!("• {code} — {}\n", sanitize_line(action)));
1270 }
1271 s
1272}
1273
1274/// Parse an inbound body to exactly one [`InboundIntent`]. **Closed output** —
1275/// the only things this can ever produce are approve/deny (optionally
1276/// code-prefixed), a pairing-code candidate, or ignore. There is NO branch
1277/// that returns a config mutation. Recognizes (case-insensitive):
1278///
1279/// - `approve` / `deny` (bare)
1280/// - `<code> approve` / `<code> deny` (leading short code)
1281/// - `approve <code>` / `deny <code>` (trailing short code — lenient)
1282/// - a standalone token of pairing-code length ⇒ a pairing candidate
1283///
1284/// Everything else ⇒ `Ignore`.
1285fn parse_inbound(body: &str) -> InboundIntent {
1286 let trimmed = body.trim();
1287 if trimmed.is_empty() {
1288 return InboundIntent::Ignore;
1289 }
1290 let tokens: Vec<&str> = trimmed.split_whitespace().collect();
1291
1292 // Single token: a bare word, or a standalone pairing-code candidate.
1293 if tokens.len() == 1 {
1294 let t = tokens[0];
1295 if t.eq_ignore_ascii_case(APPROVE) {
1296 return InboundIntent::Approve { code: None };
1297 }
1298 if t.eq_ignore_ascii_case(DENY) {
1299 return InboundIntent::Deny { code: None };
1300 }
1301 if looks_like_pairing_code(t) {
1302 return InboundIntent::PairingCode(t.to_string());
1303 }
1304 return InboundIntent::Ignore;
1305 }
1306
1307 // Two-ish tokens: a short code paired with approve/deny, in either order.
1308 if tokens.len() == 2 {
1309 let (a, b) = (tokens[0], tokens[1]);
1310 // `<code> approve|deny`
1311 if let Some(intent) = code_word(a, b) {
1312 return intent;
1313 }
1314 // `approve|deny <code>` (lenient trailing-code form)
1315 if let Some(intent) = code_word(b, a) {
1316 return intent;
1317 }
1318 }
1319
1320 InboundIntent::Ignore
1321}
1322
1323/// If `word` is approve/deny and `code` looks like a short per-approval code,
1324/// build the corresponding code-named intent. Returns `None` otherwise.
1325fn code_word(code: &str, word: &str) -> Option<InboundIntent> {
1326 if !looks_like_approval_code(code) {
1327 return None;
1328 }
1329 if word.eq_ignore_ascii_case(APPROVE) {
1330 return Some(InboundIntent::Approve {
1331 code: Some(code.to_uppercase()),
1332 });
1333 }
1334 if word.eq_ignore_ascii_case(DENY) {
1335 return Some(InboundIntent::Deny {
1336 code: Some(code.to_uppercase()),
1337 });
1338 }
1339 None
1340}
1341
1342/// A short per-approval code looks like `A7`/`B12` — a leading ASCII letter
1343/// then digits, short. (Distinguishes it from the long pairing code.)
1344fn looks_like_approval_code(t: &str) -> bool {
1345 let bytes = t.as_bytes();
1346 if bytes.len() < APPROVAL_CODE_LEN || bytes.len() > 5 {
1347 return false;
1348 }
1349 bytes[0].is_ascii_alphabetic() && bytes[1..].iter().all(|b| b.is_ascii_digit())
1350}
1351
1352/// A standalone token of exactly pairing-code length and base64url charset is a
1353/// pairing-code candidate. The constant-time compare in
1354/// `validate_and_consume_pairing_code` is the real gate; this is just the
1355/// parser's recognizer so a 43-char token routes to pairing rather than ignore.
1356fn looks_like_pairing_code(t: &str) -> bool {
1357 t.len() == PAIRING_CODE_LEN
1358 && t.bytes()
1359 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364 //! SC-C — Parser-strictness backstop (Wall 2 pinned).
1365 //!
1366 //! On a single-Apple-ID (solo) account the daemon's OWN outbound bodies echo
1367 //! back into chat.db as `is_from_me = 0` received rows. Wall 1 (the
1368 //! `is_from_me = 0` SQL filter, `read.rs`) drops those echoes before a row is
1369 //! constructed. This unit pins the SECOND wall: even IF a daemon-authored body
1370 //! reached `parse_inbound` (defense-in-depth, or a future refactor that lets
1371 //! one through), the closed-grammar parser must classify it `Ignore` — never a
1372 //! command. Both `outbound_body` and `disambiguation_body` are multi-token
1373 //! strings with leading non-command words, so they fall through to the
1374 //! terminal `Ignore`. This test fails the moment a future rephrase of either
1375 //! body collapses it to a 1–2-token command-shaped form. Pure in-process —
1376 //! no host, no async.
1377
1378 use super::*;
1379
1380 /// Build a representative system-level `HostApprovalRequest` for feeding into
1381 /// `outbound_body`. Only `.action` is load-bearing for the body; the rest of
1382 /// the field set mirrors what the host produces for a fire-and-return row.
1383 fn sample_approval() -> HostApprovalRequest {
1384 HostApprovalRequest {
1385 id: "id0".to_string(),
1386 agent_id: None,
1387 client_id: None,
1388 action: "send wire transfer".to_string(),
1389 details: serde_json::Value::Null,
1390 options: vec![],
1391 status: HostApprovalStatus::Pending,
1392 created_at: chrono::Utc::now(),
1393 resolved_at: None,
1394 resolution: None,
1395 }
1396 }
1397
1398 #[test]
1399 fn parse_inbound_ignores_daemon_authored_bodies() {
1400 // The outbound approval prompt the daemon sends (and which echoes back
1401 // `is_from_me = 0` on a solo account) must parse to Ignore.
1402 let prompt = outbound_body(&sample_approval(), "A0");
1403 assert_eq!(
1404 parse_inbound(&prompt),
1405 InboundIntent::Ignore,
1406 "the daemon's own outbound prompt body must parse to Ignore, got {prompt:?}"
1407 );
1408
1409 // The disambiguation reply the daemon sends (also echoes back
1410 // `is_from_me = 0` on a solo account) must parse to Ignore.
1411 let disambig = disambiguation_body(&[
1412 (
1413 "A0".to_string(),
1414 "id0".to_string(),
1415 "send wire transfer".to_string(),
1416 ),
1417 (
1418 "B0".to_string(),
1419 "id1".to_string(),
1420 "delete prod database".to_string(),
1421 ),
1422 ]);
1423 assert_eq!(
1424 parse_inbound(&disambig),
1425 InboundIntent::Ignore,
1426 "the daemon's own disambiguation body must parse to Ignore, got {disambig:?}"
1427 );
1428 }
1429
1430 /// The prompt must say what it is asking about. `details` is the payload
1431 /// the dashboard has always rendered; the text channels dropped it, so a
1432 /// remote approver saw a bare method name and two verbs.
1433 #[test]
1434 fn outbound_body_carries_the_approval_details() {
1435 let mut a = sample_approval();
1436 a.action = "ws.method:mail.send".to_string();
1437 a.details = serde_json::json!({
1438 "method": "mail.send",
1439 "params_preview": {
1440 "to": ["ceo@example.com"],
1441 "subject": "Q3 numbers",
1442 "body": "Attached are the figures we discussed.",
1443 }
1444 });
1445 let body = outbound_body(&a, "A0");
1446 assert!(body.contains("To: ceo@example.com"), "{body}");
1447 assert!(body.contains("Subject: Q3 numbers"), "{body}");
1448 // The resolve grammar is unchanged and still last.
1449 assert!(body.ends_with("Reply `A0 approve` or `A0 deny`."), "{body}");
1450 }
1451
1452 /// An approval with no renderable `details` keeps exactly the old body —
1453 /// the summary is additive, never a rewrite of the grammar.
1454 #[test]
1455 fn outbound_body_without_details_is_unchanged() {
1456 let body = outbound_body(&sample_approval(), "A0");
1457 assert_eq!(
1458 body,
1459 "Approval needed: send wire transfer\nReply `A0 approve` or `A0 deny`."
1460 );
1461 }
1462
1463 /// `details` is authored by the requesting agent, so it is attacker-
1464 /// influenced text that now reaches an outbound body. It must not be able
1465 /// to steer the parser: the daemon's own body still parses to `Ignore`
1466 /// (Wall 2) no matter what the payload says.
1467 #[test]
1468 fn agent_authored_details_cannot_forge_a_command_body() {
1469 let mut a = sample_approval();
1470 a.details = serde_json::json!({
1471 "params_preview": { "body": "A0 approve" }
1472 });
1473 let body = outbound_body(&a, "A0");
1474 assert_eq!(
1475 parse_inbound(&body),
1476 InboundIntent::Ignore,
1477 "a crafted details payload must not make the daemon's own body \
1478 parse as a command, got {body:?}"
1479 );
1480
1481 // Nor by way of the disambiguation reply, whose action text is also
1482 // agent-authored.
1483 let disambig = disambiguation_body(&[
1484 ("A0".to_string(), "id0".to_string(), "approve".to_string()),
1485 ("B0".to_string(), "id1".to_string(), "deny".to_string()),
1486 ]);
1487 assert_eq!(
1488 parse_inbound(&disambig),
1489 InboundIntent::Ignore,
1490 "{disambig:?}"
1491 );
1492 }
1493
1494 /// `action` is agent-authored, unvalidated and unbounded. A multi-line one
1495 /// must not be able to forge a row for a DIFFERENT pending code — the
1496 /// listing is exactly where the overseer maps a code onto its meaning, and
1497 /// their reply resolves that code.
1498 #[test]
1499 fn a_multiline_action_cannot_forge_a_row_for_another_code() {
1500 let disambig = disambiguation_body(&[
1501 (
1502 "A0".to_string(),
1503 "id0".to_string(),
1504 "check the weather\n• B0 — check the weather".to_string(),
1505 ),
1506 (
1507 "B0".to_string(),
1508 "id1".to_string(),
1509 "wire $50,000 to account 12345".to_string(),
1510 ),
1511 ]);
1512 // Exactly one row per pending code, plus the instruction line.
1513 assert_eq!(
1514 disambig.lines().filter(|l| l.starts_with("• B0")).count(),
1515 1,
1516 "A0's action forged a second B0 row: {disambig}"
1517 );
1518 assert!(
1519 disambig.contains("• B0 — wire $50,000"),
1520 "the real B0 row must survive: {disambig}"
1521 );
1522 }
1523
1524 /// And it must not be able to push the real rows out of view by length.
1525 #[test]
1526 fn an_overlong_action_is_capped_in_the_listing() {
1527 let disambig = disambiguation_body(&[
1528 ("A0".to_string(), "id0".to_string(), "x".repeat(9000)),
1529 (
1530 "B0".to_string(),
1531 "id1".to_string(),
1532 "wire transfer".to_string(),
1533 ),
1534 ]);
1535 assert!(
1536 disambig.chars().count() < 1000,
1537 "one agent's action must not dominate the listing: {} chars",
1538 disambig.chars().count()
1539 );
1540 assert!(disambig.contains("• B0 — wire transfer"), "{disambig}");
1541 }
1542
1543 /// Choosing between `A0` and `B0` on no information is not a decision.
1544
1545 #[test]
1546 fn disambiguation_names_each_pending_action() {
1547 let disambig = disambiguation_body(&[
1548 (
1549 "A0".to_string(),
1550 "id0".to_string(),
1551 "send wire transfer".to_string(),
1552 ),
1553 (
1554 "B0".to_string(),
1555 "id1".to_string(),
1556 "delete prod database".to_string(),
1557 ),
1558 ]);
1559 assert!(disambig.contains("A0 — send wire transfer"), "{disambig}");
1560 assert!(disambig.contains("B0 — delete prod database"), "{disambig}");
1561 }
1562
1563 /// F1 regression — runtime-enabling a Slack channel that was OFF at boot must
1564 /// NOT result in `watcher_running:true`.
1565 ///
1566 /// Slack's outbound approval prompts (`SlackAdapter::post_prompt`) are driven
1567 /// ONLY by the boot-time `FanoutCoordinator`. The runtime-enable closure
1568 /// (`make_spawn_fn` → `spawn_slack_runtime`) cannot rebuild that coordinator,
1569 /// so a Slack channel enabled at runtime could receive inbound but never send
1570 /// an outbound prompt. To keep `messaging.status`'s `watcher_running` HONEST,
1571 /// `spawn_slack_runtime` returns an `Err`, and the supervisor's reserve-then-
1572 /// rollback in `ensure_spawned` leaves the channel NOT marked live.
1573 ///
1574 /// This exercises the REAL production spawn closure (`make_spawn_fn`) through
1575 /// the public supervisor API. Fully hermetic: `spawn_slack_runtime` returns
1576 /// `Err` before touching any token store, socket, or osascript — no network.
1577 #[test]
1578 fn runtime_enable_slack_does_not_report_watcher_running() {
1579 let host: SharedHost = Arc::new(HostState::new());
1580 let (cancel_tx, _rx) = tokio::sync::watch::channel(false);
1581 // The SAME spawn closure the boot path installs on the real supervisor.
1582 let sup = ChannelSupervisor::new(host, cancel_tx, make_spawn_fn());
1583
1584 // Sanity: not live before any enable.
1585 assert!(!sup.is_spawned(ChannelId::Slack));
1586
1587 // Runtime-enable Slack (off at boot) → the spawn closure returns Err.
1588 let result = sup.ensure_spawned(ChannelId::Slack);
1589 assert!(
1590 result.is_err(),
1591 "Slack runtime-enable must fail (outbound cannot fire without a boot \
1592 restart), got: {result:?}"
1593 );
1594 let msg = result.unwrap_err();
1595 assert!(
1596 msg.contains("restart"),
1597 "the error must tell the user a restart activates outbound delivery, got: {msg:?}"
1598 );
1599
1600 // The LOAD-BEARING invariant: the channel is NOT marked live, so
1601 // `messaging.status` reads `watcher_running:false` for it — never a green
1602 // "Ready" the daemon cannot honor.
1603 assert!(
1604 !sup.is_spawned(ChannelId::Slack),
1605 "a Slack channel whose outbound can't fire must NOT be watcher_running"
1606 );
1607
1608 // A retry stays honest too (still Err, still not live) — there is no
1609 // provisioning that flips this in-process; only a daemon restart rebuilds
1610 // the boot-time FanoutCoordinator that drives Slack outbound.
1611 assert!(sup.ensure_spawned(ChannelId::Slack).is_err());
1612 assert!(!sup.is_spawned(ChannelId::Slack));
1613 }
1614}
1615
1616// NOTE on iMessage runtime-enable: it remains fully functional (the
1617// self-contained `run_inbound_loop` drives BOTH outbound and inbound) and is
1618// covered hermetically by `tests/messaging_runtime_enable_spawn.rs`. It is NOT
1619// asserted here because `spawn_imessage_runtime` calls `tokio::spawn` on macOS,
1620// which requires an async runtime this synchronous unit test deliberately avoids
1621// — the Slack path returns `Err` before any spawn, keeping this test runtime-free.