1mod cmd;
68mod grok_listen;
69mod ipc;
70mod machine;
71mod nick;
72mod node;
73pub mod provider;
74mod push;
75mod send;
76
77use std::collections::HashSet;
78use std::path::{Component, Path, PathBuf};
79use std::sync::mpsc::{self, Receiver};
80use std::sync::Arc;
81use std::thread::{self, JoinHandle};
82use std::time::Duration;
83
84use mail4agent_messenger::store::sealed::SealedRecordCodec;
85use mail4agent_messenger::wire::Membership;
86use mail4agent_messenger::{
87 CoreConfig, CoreSecrets, HttpResponseDescriptor, ItemContent, Jitter, MessengerCore,
88 MessengerError, OutgoingRequest, OutgoingRequestKind, RecordKey, SealedRecord, SendState,
89 StoreError,
90};
91use sha2::{Digest, Sha256};
92use zeroize::Zeroizing;
93
94pub use machine::{
95 ensure_agent_webhook_routines, ensure_agent_webhook_routines_from_env, load_agents_dir,
96 load_session_records, HostSession, MachineClient, RoutineReport, TickReport, WakeOptions,
97 WakeStatus, AGENTS_DIR_ENV, AGENT_RESCAN_SECS_ENV, DEFAULT_AGENTS_DIR, KEYCHAIN_DIR_ENV,
98 PROFILE_NOTE_ENV, SESSIONS_DIR_ENV, SESSION_IDS_ENV, SKIP_NICKS_ENV, STORE_LOCK_FILE,
99 WAKE_KEYCHAIN_FILE,
100};
101pub use mail4agent_messenger::{
102 CreateRoomKind, DeviceId, MessageKind, MessengerCommand, OutgoingMessage, RoomId, RoomKind,
103 UserId,
104};
105pub use cmd::{CmdReply, CmdRequest};
106pub use mail4agent_messenger::EventId;
107pub use grok_listen::{hear, GrokListener, Heard, ListenReport};
108pub use nick::{nick_from_display_name, routine_folder_id};
109pub use node::{NodeClient, NodeTickReport, NODE_DEFAULT_SOCK_NAME};
110pub use provider::{
111 plan_chain, HostEnv, HookFlavor, ResumeSpawnAdapter, SessionRecord, WakeChain, WebVendor,
112 adapter_for, wake_prompt, AdapterConfig, ClaudeChannelAdapter, ClaudeRoutineFireAdapter,
113 CodexAppServerAdapter, CodexCloudAdapter, CodexEndpoint, CursorAgentAdapter, GrokLeaderAdapter,
114 KimiServerAdapter, NoInboundAdapter, ProviderKind, ProviderSession, RoutineWebhookAdapter,
115 SessionKind, Surface, WakeAdapter, WakeError, WakeLetter, WakeOutcome, INBOX_DIR_ENV,
116 PROVIDER_ENV,
117};
118pub use push::PushedRoomEvent;
119pub use send::{
120 load_env_file, load_env_file_named, send_cmd_via_socket, send_sock_path, send_sock_path_named,
121 send_via_socket,
122 SendReply, SendRequest, DEFAULT_SOCK_NAME, ENV_FILE_ENV, MAX_SEND_BYTES, SEND_SOCK_ENV,
123};
124
125pub fn store_seal_key(session_id: &str) -> [u8; 32] {
128 let digest = Sha256::digest(session_id.as_bytes());
129 let mut key = [0u8; 32];
130 key.copy_from_slice(&digest);
131 key
132}
133
134pub const STORE_ROOT_ENV: &str = "M4A_STORE_ROOT";
137
138pub const HOMESERVER_URL_ENV: &str = "M4A_HOMESERVER_URL";
142
143pub const CONFIG_ENV: &str = "M4A_CONFIG";
147
148pub const BOT_NAME_ENV: &str = "M4A_BOT_NAME";
151
152pub const SESSION_ID_ENV: &str = "M4A_SESSION_ID";
155
156pub const DEVICE_TOKEN_ENV: &str = "M4A_DEVICE_TOKEN";
160
161pub const PRODUCT_URL_ENV: &str = "M4A_PRODUCT_URL";
164pub const PRODUCT_NICK_ENV: &str = "M4A_PRODUCT_NICK";
166pub const PRODUCT_PASSWORD_ENV: &str = "M4A_PRODUCT_PASSWORD";
168pub const PRODUCT_TOKEN_ENV: &str = "M4A_PRODUCT_TOKEN";
170
171#[derive(Clone)]
173pub enum ProductSecret {
174 Password(Zeroizing<String>),
175 Token(Zeroizing<String>),
176}
177
178#[derive(Clone)]
179struct ProductAuth {
180 nick: String,
181 secret: ProductSecret,
182}
183
184pub fn session_store_dir(root: &Path, session_id: &str) -> PathBuf {
192 root.join(hex_encode(&store_seal_key(session_id)))
193}
194
195pub fn store_root() -> Result<PathBuf, ShellError> {
199 if let Some(root) = nonempty_var(STORE_ROOT_ENV) {
200 return Ok(PathBuf::from(root));
201 }
202 #[cfg(test)]
203 {
204 return Ok(std::env::temp_dir().join(format!(
205 "mail4agent-messenger-shell-root-{}",
206 std::process::id()
207 )));
208 }
209 #[cfg(not(test))]
210 {
211 Err(ShellError::StoreRoot)
212 }
213}
214
215fn hex_encode(bytes: &[u8]) -> String {
216 const HEX: &[u8; 16] = b"0123456789abcdef";
217 let mut out = String::with_capacity(bytes.len() * 2);
218 for byte in bytes {
219 out.push(HEX[(byte >> 4) as usize] as char);
220 out.push(HEX[(byte & 0x0f) as usize] as char);
221 }
222 out
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct WakeAttempt {
229 pub event_id: String,
231 pub status: Option<u16>,
233}
234
235pub struct RoutineWake {
238 pub url: String,
240}
241
242pub const ROUTINE_URL_ENV: &str = "M4A_ROUTINE_URL";
244
245pub const ROUTINE_BEARER_ENV: &str = "M4A_ROUTINE_BEARER";
250
251pub const LEADER_SOCK_ENV: &str = "M4A_LEADER_SOCK";
253
254pub const LEADER_CWD_ENV: &str = "M4A_LEADER_CWD";
257
258pub struct SessionWake {
263 pub routine_url: Option<String>,
265 pub routine_bearer: Option<String>,
268 pub leader_sock: Option<PathBuf>,
270 pub leader_cwd: Option<String>,
272}
273
274impl Default for SessionWake {
275 fn default() -> Self {
276 Self {
277 routine_url: None,
278 routine_bearer: None,
279 leader_sock: None,
280 leader_cwd: None,
281 }
282 }
283}
284
285impl SessionWake {
286 pub fn web_host() -> Self {
290 Self::web_from_lookup(|key| nonempty_var(key))
291 }
292
293 pub fn web_from_lookup(mut get: impl FnMut(&str) -> Option<String>) -> Self {
296 let routine_url = get(ROUTINE_URL_ENV).filter(|value| !value.is_empty());
297 let routine_bearer = if routine_url.is_some() {
298 get(ROUTINE_BEARER_ENV).filter(|value| !value.is_empty())
299 } else {
300 None
301 };
302 Self {
303 routine_url,
304 routine_bearer,
305 leader_sock: None,
306 leader_cwd: None,
307 }
308 }
309
310 pub fn node_cli() -> Result<Self, ShellError> {
314 Self::node_from_lookup(|key| nonempty_var(key))
315 }
316
317 pub fn node_from_lookup(
319 mut get: impl FnMut(&str) -> Option<String>,
320 ) -> Result<Self, ShellError> {
321 let routine_url = get(ROUTINE_URL_ENV).filter(|value| !value.is_empty());
322 let routine_bearer = get(ROUTINE_BEARER_ENV).filter(|value| !value.is_empty());
323 if routine_url.is_some() || routine_bearer.is_some() {
324 return Err(ShellError::NodeRoutine);
325 }
326 Ok(Self {
327 routine_url: None,
328 routine_bearer: None,
329 leader_sock: get(LEADER_SOCK_ENV)
330 .filter(|value| !value.is_empty())
331 .map(PathBuf::from),
332 leader_cwd: get(LEADER_CWD_ENV).filter(|value| !value.is_empty()),
333 })
334 }
335}
336
337pub(crate) fn nonempty_var(name: &str) -> Option<String> {
338 std::env::var(name).ok().filter(|value| !value.is_empty())
339}
340
341pub struct SessionConfig {
346 homeserver_url: String,
347 nick: String,
348 public_id: String,
349 session_id: String,
350 store_root: PathBuf,
351 device_token: Option<Zeroizing<String>>,
352 product: Option<ProductAuth>,
353}
354
355impl SessionConfig {
356 pub fn new(
358 homeserver_url: impl Into<String>,
359 bot_name: &str,
360 session_id: impl Into<String>,
361 store_root: impl Into<PathBuf>,
362 device_token: Option<String>,
363 ) -> Result<Self, ShellError> {
364 let homeserver_url = homeserver_url.into();
365 parse_base_url(&homeserver_url)?;
366 let session_id = session_id.into();
367 validate_session_id(&session_id)?;
368 let nick = nick_from_display_name(bot_name)?;
369 let device_token = device_token.filter(|token| !token.is_empty());
370 if let Some(token) = &device_token {
371 validate_device_token(token)?;
372 }
373 Ok(Self {
374 homeserver_url,
375 public_id: nick.clone(),
376 nick,
377 session_id,
378 store_root: store_root.into(),
379 device_token: device_token.map(Zeroizing::new),
380 product: None,
381 })
382 }
383
384 pub fn new_product(
388 product_url: impl Into<String>,
389 nick: &str,
390 secret: ProductSecret,
391 session_id: impl Into<String>,
392 store_root: impl Into<PathBuf>,
393 ) -> Result<Self, ShellError> {
394 let homeserver_url = product_url.into();
395 parse_base_url(&homeserver_url)?;
396 let session_id = session_id.into();
397 validate_session_id(&session_id)?;
398 if nick.is_empty() || nick.len() > 64 {
399 return Err(ShellError::Register("product nick is empty or too long".into()));
400 }
401 Ok(Self {
402 homeserver_url,
403 public_id: nick.to_string(),
404 nick: nick.to_string(),
405 session_id,
406 store_root: store_root.into(),
407 device_token: None,
408 product: Some(ProductAuth { nick: nick.to_string(), secret }),
409 })
410 }
411
412 pub fn from_env() -> Result<Self, ShellError> {
419 let toml_text = load_homeserver_toml()?;
420 Self::from_lookup(
421 |key| std::env::var(key).ok().filter(|value| !value.is_empty()),
422 toml_text.as_deref(),
423 )
424 }
425
426 pub(crate) fn from_lookup(
427 mut get: impl FnMut(&str) -> Option<String>,
428 toml_text: Option<&str>,
429 ) -> Result<Self, ShellError> {
430 if let Some(product_url) = get(PRODUCT_URL_ENV) {
431 let nick = get(PRODUCT_NICK_ENV).ok_or_else(|| ShellError::Register(format!("{PRODUCT_NICK_ENV} is required with {PRODUCT_URL_ENV}")))?;
432 let secret = match (get(PRODUCT_TOKEN_ENV), get(PRODUCT_PASSWORD_ENV)) {
433 (Some(t), _) => ProductSecret::Token(Zeroizing::new(t)),
434 (None, Some(p)) => ProductSecret::Password(Zeroizing::new(p)),
435 _ => return Err(ShellError::Register(format!("{PRODUCT_TOKEN_ENV} or {PRODUCT_PASSWORD_ENV} is required"))),
436 };
437 let session_id = get(SESSION_ID_ENV).ok_or(ShellError::EmptySession)?;
438 let store_root = get(STORE_ROOT_ENV).ok_or(ShellError::StoreRoot)?;
439 return Self::new_product(product_url, &nick, secret, session_id, store_root);
440 }
441 let from_toml = match toml_text {
442 Some(text) => homeserver_url_from_toml(text)?,
443 None => None,
444 };
445 let homeserver_url = get(HOMESERVER_URL_ENV)
446 .or(from_toml)
447 .ok_or(ShellError::HomeserverUrl)?;
448 let bot_name = get(BOT_NAME_ENV).ok_or(ShellError::BotName)?;
449 let session_id = get(SESSION_ID_ENV).ok_or(ShellError::EmptySession)?;
450 let store_root = get(STORE_ROOT_ENV).ok_or(ShellError::StoreRoot)?;
451 let device_token = get(DEVICE_TOKEN_ENV);
452 let _ignored_nick_slug = get("M4A_NICK");
453 let _ = _ignored_nick_slug;
454 Self::new(
455 homeserver_url,
456 &bot_name,
457 session_id,
458 store_root,
459 device_token,
460 )
461 }
462
463 pub fn is_product(&self) -> bool {
465 self.product.is_some()
466 }
467
468 pub fn nick(&self) -> &str {
470 &self.nick
471 }
472
473 pub fn homeserver_url(&self) -> &str {
475 &self.homeserver_url
476 }
477
478 pub fn store_dir(&self) -> PathBuf {
480 session_store_dir(&self.store_root, &self.session_id)
481 }
482
483 pub fn session_id(&self) -> &str {
485 &self.session_id
486 }
487}
488
489impl std::fmt::Debug for SessionConfig {
490 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
491 f.debug_struct("SessionConfig")
492 .field("homeserver_url", &self.homeserver_url)
493 .field("nick", &self.nick)
494 .field("session_id", &self.session_id)
495 .field("store_root", &self.store_root)
496 .field(
497 "device_token",
498 &self.device_token.as_ref().map(|_| "[redacted]"),
499 )
500 .field("product_nick", &self.product.as_ref().map(|p| p.nick.as_str()))
501 .finish()
502 }
503}
504
505fn validate_session_id(session_id: &str) -> Result<(), ShellError> {
506 if session_id.is_empty()
507 || session_id.len() > 128
508 || session_id.starts_with("legacy-user-")
509 || session_id
510 .chars()
511 .any(|ch| ch.is_whitespace() || ch.is_control())
512 {
513 return Err(ShellError::EmptySession);
514 }
515 Ok(())
516}
517
518fn validate_device_token(token: &str) -> Result<(), ShellError> {
519 if token.is_empty()
520 || token
521 .bytes()
522 .any(|byte| byte.is_ascii_whitespace() || !byte.is_ascii())
523 {
524 return Err(ShellError::DeviceToken);
525 }
526 Ok(())
527}
528
529fn load_homeserver_toml() -> Result<Option<String>, ShellError> {
530 let path = if let Some(configured) = nonempty_var(CONFIG_ENV) {
531 PathBuf::from(configured)
532 } else {
533 let cwd = PathBuf::from("mail4agent.toml");
534 if !cwd.exists() {
535 return Ok(None);
536 }
537 cwd
538 };
539 if !path.is_file() {
540 return Err(ShellError::Config("config file is missing".to_string()));
541 }
542 std::fs::read_to_string(&path)
543 .map(Some)
544 .map_err(|err| ShellError::Config(clip_public(err.to_string())))
545}
546
547fn homeserver_url_from_toml(text: &str) -> Result<Option<String>, ShellError> {
548 #[derive(serde::Deserialize)]
549 struct File {
550 #[serde(default)]
551 homeserver_url: Option<String>,
552 }
553 let file: File =
554 toml::from_str(text).map_err(|err| ShellError::Config(clip_public(err.to_string())))?;
555 Ok(file
556 .homeserver_url
557 .map(|value| value.trim().to_string())
558 .filter(|value| !value.is_empty()))
559}
560
561pub struct FoundSession {
563 pub nick: String,
565 pub user_id: String,
567}
568
569#[derive(Default)]
576pub struct DecryptedWake<'a> {
577 pub body: &'a str,
579 pub from: &'a str,
581 pub nick: Option<&'a str>,
583 pub event_id: &'a str,
585 pub room: Option<&'a str>,
587 pub from_nick: Option<&'a str>,
589 pub to: Option<&'a str>,
591 pub reply: Option<&'a str>,
594}
595
596pub const SEND_COMMAND: &str = "m4a-send";
598
599pub fn reply_hint(to: &str, from_nick: &str) -> String {
601 format!("{SEND_COMMAND} --as {to} --to {from_nick} '<your reply>'")
602}
603
604pub fn mxid_localpart(mxid: &str) -> &str {
606 let rest = mxid.strip_prefix('@').unwrap_or(mxid);
607 rest.split_once(':').map(|(local, _)| local).unwrap_or(rest)
608}
609
610pub fn post_decrypted(url: &str, wake: &DecryptedWake<'_>) -> Result<(), ShellError> {
615 post_decrypted_with_bearer(url, wake, None)
616}
617
618pub fn post_decrypted_with_bearer(
625 url: &str,
626 wake: &DecryptedWake<'_>,
627 bearer: Option<&str>,
628) -> Result<(), ShellError> {
629 let bytes = routine_json(wake)?;
630 post_routine_bytes(url, bytes, bearer)
631}
632
633pub fn post_routine_json(
638 url: &str,
639 body: &serde_json::Value,
640 bearer: Option<&str>,
641) -> Result<(), ShellError> {
642 let bytes = serde_json::to_vec(body)
643 .map_err(|err| ShellError::RoutineTransport(clip_public(err.to_string())))?;
644 post_routine_bytes(url, bytes, bearer)
645}
646
647fn post_routine_bytes(url: &str, bytes: Vec<u8>, bearer: Option<&str>) -> Result<(), ShellError> {
648 let target = parse_routine_url(url)?;
649 let client = routine_client()?;
650 let token = bearer.map(str::trim).filter(|token| !token.is_empty());
651 let mut builder = client
652 .post(target)
653 .header(reqwest::header::CONTENT_TYPE, "application/json");
654 if let Some(token) = token {
655 let authorization = bearer_header(token)?;
656 let mut automation_key =
657 reqwest::header::HeaderValue::from_str(token).map_err(|_| ShellError::RoutineBearer)?;
658 automation_key.set_sensitive(true);
659 builder = builder
660 .header(reqwest::header::AUTHORIZATION, authorization)
661 .header("x-automation-key", automation_key);
662 }
663 let response = builder.body(bytes).send().map_err(|err| {
664 ShellError::RoutineTransport(redact_wake(public_reqwest(&err), url, token))
665 })?;
666 let status = response.status().as_u16();
667 if !(200..300).contains(&status) {
668 return Err(ShellError::RoutineStatus(status));
669 }
670 Ok(())
671}
672
673fn redact_wake(mut text: String, url: &str, bearer: Option<&str>) -> String {
674 if !url.is_empty() {
675 text = text.replace(url, "[redacted]");
676 }
677 if let Some(token) = bearer.filter(|token| token.len() >= 4) {
678 text = text.replace(token, "[redacted]");
679 }
680 text
681}
682
683fn routine_json(wake: &DecryptedWake<'_>) -> Result<Vec<u8>, ShellError> {
684 let mut object = serde_json::Map::new();
685 object.insert(
686 "body".to_string(),
687 serde_json::Value::String(wake.body.to_string()),
688 );
689 object.insert(
690 "from".to_string(),
691 serde_json::Value::String(wake.from.to_string()),
692 );
693 object.insert(
694 "event_id".to_string(),
695 serde_json::Value::String(wake.event_id.to_string()),
696 );
697 if let Some(nick) = wake.nick.map(str::trim).filter(|nick| !nick.is_empty()) {
698 object.insert(
699 "nick".to_string(),
700 serde_json::Value::String(nick.to_string()),
701 );
702 }
703 for (key, value) in [
704 ("room", wake.room),
705 ("from_nick", wake.from_nick),
706 ("to", wake.to),
707 ("reply", wake.reply),
708 ] {
709 if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
710 object.insert(
711 key.to_string(),
712 serde_json::Value::String(value.to_string()),
713 );
714 }
715 }
716 serde_json::to_vec(&object)
717 .map_err(|err| ShellError::RoutineTransport(clip_public(err.to_string())))
718}
719
720fn bearer_header(token: &str) -> Result<reqwest::header::HeaderValue, ShellError> {
721 if token
722 .bytes()
723 .any(|byte| byte.is_ascii_whitespace() || !byte.is_ascii())
724 {
725 return Err(ShellError::RoutineBearer);
726 }
727 let mut header = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
728 .map_err(|_| ShellError::RoutineBearer)?;
729 header.set_sensitive(true);
730 Ok(header)
731}
732
733fn routine_client() -> Result<reqwest::blocking::Client, ShellError> {
734 reqwest::blocking::Client::builder()
737 .timeout(Duration::from_secs(8))
738 .redirect(reqwest::redirect::Policy::none())
739 .http1_only()
740 .use_rustls_tls()
741 .build()
742 .map_err(|err| ShellError::RoutineTransport(public_reqwest(&err)))
743}
744
745fn public_reqwest(err: &reqwest::Error) -> String {
746 let mut text = err.to_string();
747 let mut source = std::error::Error::source(err);
748 while let Some(inner) = source {
749 text.push_str(": ");
750 text.push_str(&inner.to_string());
751 source = inner.source();
752 if text.len() > 400 {
753 break;
754 }
755 }
756 clip_public(text)
757}
758
759fn parse_routine_url(url: &str) -> Result<reqwest::Url, ShellError> {
760 let parsed = reqwest::Url::parse(url).map_err(|_| ShellError::RoutineUrl)?;
761 match parsed.scheme() {
762 "http" | "https" => {}
763 _ => return Err(ShellError::RoutineUrl),
764 }
765 if parsed.host_str().is_none() || !parsed.username().is_empty() || parsed.password().is_some() {
766 return Err(ShellError::RoutineUrl);
767 }
768 Ok(parsed)
769}
770
771pub struct OpenedStore {
776 dir: PathBuf,
777 core: MessengerCore<SealedRecordCodec>,
778 base_url: reqwest::Url,
779 device_token: Zeroizing<String>,
780 client: reqwest::blocking::Client,
781 sync_flight: Option<SyncFlight>,
786 http_trace: Vec<(OutgoingRequestKind, u16)>,
788 session_id: String,
791 nick: Option<String>,
794 routine_url: Option<String>,
795 routine_bearer: Option<Zeroizing<String>>,
796 leader_sock: Option<PathBuf>,
797 leader_cwd: Option<String>,
798 routine_sent: HashSet<String>,
800 routine_woken_seed_pending: bool,
804 routine_restart_catchup: bool,
808 leader_sent: HashSet<String>,
810 wake_note: Option<String>,
812 security_alerts: Vec<String>,
814 wake_log: Vec<WakeAttempt>,
815 wake_chain: Option<(provider::ProviderSession, provider::WakeChain)>,
819 wake_route: Option<String>,
821 bus: Option<Arc<machine::LocalBus>>,
824 local_peers: Vec<(String, String)>,
827 pushed_room_events: Vec<PushedRoomEvent>,
829 history_pulled: HashSet<String>,
832 tip_pulled: HashSet<String>,
835}
836
837struct SyncFlight {
838 id: mail4agent_messenger::RequestId,
839 rx: Receiver<Result<HttpResponseDescriptor, String>>,
840 _worker: JoinHandle<()>,
843}
844
845struct ZeroJitter;
846
847impl Jitter for ZeroJitter {
848 fn next_unit(&mut self) -> f64 {
849 0.0
850 }
851}
852
853pub struct RoomView {
855 pub room_id: String,
857 pub membership: String,
860 pub encrypted: bool,
862}
863
864pub struct TextView {
866 pub room_id: String,
868 pub body: String,
870 pub outcome: String,
872 pub event_id: Option<String>,
875}
876
877impl OpenedStore {
878 pub fn open(
887 dir: &Path,
888 session_id: &str,
889 device_id: DeviceId,
890 user_id: &str,
891 server_name: &str,
892 base_url: &str,
893 device_token: &str,
894 ) -> Result<Self, ShellError> {
895 if session_id.is_empty() {
896 return Err(ShellError::EmptySession);
897 }
898 if device_token.is_empty()
899 || device_token
900 .bytes()
901 .any(|byte| byte.is_ascii_whitespace() || !byte.is_ascii())
902 {
903 return Err(ShellError::DeviceToken);
904 }
905 let base_url = parse_base_url(base_url)?;
906 fs_create_dir(dir)?;
907 let user_id = UserId::parse(user_id)?;
908 let secrets = CoreSecrets {
909 store_seal_key: Some(Zeroizing::new(store_seal_key(session_id))),
910 backup_key: None,
911 };
912 let config = CoreConfig {
913 user_id,
914 device_id,
915 server_name: server_name.to_string(),
916 };
917 let records = read_records(dir)?;
918 let mut core =
919 match MessengerCore::open_sealed(records, secrets, config, 0, Box::new(ZeroJitter)) {
920 Ok(core) => core,
921 Err(MessengerError::Store(err)) => return Err(ShellError::Store(err)),
922 Err(err) => return Err(ShellError::Messenger(err)),
923 };
924 persist(dir, &mut core)?;
925 let client = reqwest::blocking::Client::builder()
926 .timeout(Duration::from_secs(45))
927 .http1_only()
928 .build()
929 .map_err(|err| ShellError::Http(clip_public(err.to_string())))?;
930 let mut opened = Self {
931 dir: dir.to_path_buf(),
932 core,
933 base_url,
934 device_token: Zeroizing::new(device_token.to_string()),
935 client,
936 sync_flight: None,
937 http_trace: Vec::new(),
938 session_id: session_id.to_string(),
939 nick: None,
940 routine_url: None,
941 routine_bearer: None,
942 leader_sock: None,
943 leader_cwd: None,
944 routine_sent: HashSet::new(),
945 routine_woken_seed_pending: false,
946 routine_restart_catchup: false,
947 leader_sent: HashSet::new(),
948 wake_chain: None,
949 wake_route: None,
950 wake_note: None,
951 security_alerts: Vec::new(),
952 wake_log: Vec::new(),
953 bus: None,
954 local_peers: Vec::new(),
955 pushed_room_events: Vec::new(),
956 history_pulled: HashSet::new(),
957 tip_pulled: HashSet::new(),
958 };
959 opened.note_already_present();
962 Ok(opened)
963 }
964
965 pub fn connect(config: &SessionConfig) -> Result<Self, ShellError> {
974 Self::connect_with_wake(config, SessionWake::default())
975 }
976
977 pub(crate) fn connect_with_wake(config: &SessionConfig, wake: SessionWake) -> Result<Self, ShellError> {
978 let registered = register_session(config)?;
979 let server_name = registered
980 .user_id
981 .split_once(':')
982 .map(|(_, server)| server)
983 .filter(|server| !server.is_empty())
984 .ok_or(ShellError::Register("user id has no server".to_string()))?;
985 let mut opened = Self::open(
986 &config.store_dir(),
987 &config.session_id,
988 registered.device_id,
989 ®istered.user_id,
990 server_name,
991 &config.homeserver_url,
992 ®istered.bearer,
993 )?;
994 opened.nick = Some(config.nick.clone());
995 opened.set_wake(wake);
996 opened.drive(1_000, false)?;
998 Ok(opened)
999 }
1000
1001 pub fn connect_from_env() -> Result<Self, ShellError> {
1006 Self::connect(&SessionConfig::from_env()?)
1007 }
1008
1009 pub fn connect_node_from_env() -> Result<Self, ShellError> {
1014 let wake = SessionWake::node_cli()?;
1015 Self::connect_with_wake(&SessionConfig::from_env()?, wake)
1016 }
1017
1018 #[cfg(test)]
1019 pub(crate) fn connect_node_from_lookup(
1020 mut get: impl FnMut(&str) -> Option<String>,
1021 toml_text: Option<&str>,
1022 ) -> Result<Self, ShellError> {
1023 let wake = SessionWake::node_from_lookup(&mut get)?;
1024 let config = SessionConfig::from_lookup(&mut get, toml_text)?;
1025 Self::connect_with_wake(&config, wake)
1026 }
1027
1028 pub fn device_bearer(&self) -> &str {
1030 self.device_token.as_str()
1031 }
1032
1033 pub fn nick(&self) -> Option<&str> {
1036 self.nick.as_deref()
1037 }
1038
1039 pub fn session_id(&self) -> &str {
1041 &self.session_id
1042 }
1043
1044 pub fn homeserver_url(&self) -> &str {
1046 self.base_url.as_str().trim_end_matches('/')
1047 }
1048
1049 pub fn has_leader(&self) -> bool {
1051 self.leader_sock.is_some()
1052 }
1053
1054 pub fn store_dir(&self) -> &Path {
1056 &self.dir
1057 }
1058
1059 pub fn homeserver_hits(&self) -> u64 {
1062 self.bus.as_ref().map(|bus| bus.hits()).unwrap_or(0)
1063 }
1064
1065 pub fn pushed_room_events(&self) -> &[PushedRoomEvent] {
1067 &self.pushed_room_events
1068 }
1069
1070 pub(crate) fn record_push(&mut self, event: PushedRoomEvent) {
1071 self.pushed_room_events.push(event);
1072 }
1073
1074 pub(crate) fn attach_bus(&mut self, bus: Arc<machine::LocalBus>) {
1075 self.bus = Some(bus);
1076 }
1077
1078 pub(crate) fn set_registered_nick(&mut self, nick: String) {
1079 self.nick = Some(nick);
1080 }
1081
1082 pub(crate) fn set_local_peers(&mut self, peers: Vec<(String, String)>) {
1083 self.local_peers = peers;
1084 }
1085
1086 pub(crate) fn abandon_inflight_sync(&mut self, now_ms: i64) -> Result<(), ShellError> {
1090 if let Some(flight) = self.sync_flight.take() {
1091 self.core.on_transport_error(&flight.id, now_ms);
1092 self.persist_core()?;
1093 }
1094 Ok(())
1095 }
1096
1097 pub fn user_id(&self) -> &str {
1099 self.core.user_id().as_str()
1100 }
1101
1102 pub fn member_joined(&self, room_id: &str, user_id: &str) -> bool {
1104 let Ok(room_id) = RoomId::parse(room_id) else {
1105 return false;
1106 };
1107 let Ok(user_id) = UserId::parse(user_id) else {
1108 return false;
1109 };
1110 self.core.room_state(&room_id).is_some_and(|state| {
1111 state
1112 .members
1113 .get(&user_id)
1114 .is_some_and(|member| member.membership == Membership::Join)
1115 })
1116 }
1117
1118 pub fn find_nick(
1122 &mut self,
1123 name_or_nick: &str,
1124 now_ms: i64,
1125 ) -> Result<FoundSession, ShellError> {
1126 let needle = nick::lookup_nick(name_or_nick)?;
1127 if let Some((nick, user_id)) = self
1128 .local_peers
1129 .iter()
1130 .find(|(nick, _)| nick.eq_ignore_ascii_case(&needle))
1131 {
1132 return Ok(FoundSession {
1133 nick: nick.clone(),
1134 user_id: user_id.clone(),
1135 });
1136 }
1137 self.dispatch(
1138 MessengerCommand::SearchUsers {
1139 term: needle.clone(),
1140 },
1141 now_ms,
1142 )?;
1143 self.drive(now_ms, false)?;
1144 let mut hits: Vec<FoundSession> = self
1145 .core
1146 .user_search_result()
1147 .iter()
1148 .filter_map(|entry| {
1149 let nick = entry.display_name.as_deref()?.trim();
1150 if !nick.eq_ignore_ascii_case(&needle) {
1151 return None;
1152 }
1153 Some(FoundSession {
1154 nick: nick.to_string(),
1155 user_id: entry.user_id.as_str().to_string(),
1156 })
1157 })
1158 .collect();
1159 hits.sort_by(|left, right| left.user_id.cmp(&right.user_id));
1160 hits.dedup_by(|left, right| left.user_id == right.user_id);
1161 match hits.len() {
1162 1 => Ok(hits.remove(0)),
1163 0 => Err(ShellError::UnknownNick),
1164 _ => Err(ShellError::UnknownNick),
1165 }
1166 }
1167
1168 pub fn ensure_dm(&mut self, name_or_nick: &str, mut now_ms: i64) -> Result<String, ShellError> {
1172 let (_peer, room_id) = self.open_dm(name_or_nick, &mut now_ms)?;
1173 Ok(room_id)
1174 }
1175
1176 pub fn accept_direct_invites(&mut self, mut now_ms: i64) -> Result<Vec<String>, ShellError> {
1181 let me = self.core.user_id().clone();
1182 let invited: Vec<RoomId> = self
1183 .core
1184 .room_ids()
1185 .cloned()
1186 .filter(|room_id| {
1187 let Some(state) = self.core.room_state(room_id) else {
1188 return false;
1189 };
1190 state.members.get(&me).is_some_and(|member| {
1191 member.membership == Membership::Invite && member.is_direct
1192 })
1193 })
1194 .collect();
1195 let mut ids = Vec::new();
1196 for room_id in invited {
1197 self.dispatch(
1198 MessengerCommand::JoinRoom {
1199 room_id: room_id.clone(),
1200 },
1201 now_ms,
1202 )?;
1203 ids.push(room_id.as_str().to_string());
1204 }
1205 if ids.is_empty() {
1206 return Ok(ids);
1207 }
1208 for _ in 0..8 {
1209 now_ms += 1_000;
1210 self.drive(now_ms, false)?;
1211 let still_invited = ids.iter().any(|room_id| {
1212 self.rooms()
1213 .iter()
1214 .any(|room| room.room_id == *room_id && room.membership == "invite")
1215 });
1216 if !still_invited {
1217 break;
1218 }
1219 }
1220 Ok(ids)
1221 }
1222
1223 pub fn write_to_nick(
1232 &mut self,
1233 name_or_nick: &str,
1234 text: &str,
1235 mut now_ms: i64,
1236 ) -> Result<String, ShellError> {
1237 let (peer, room_id) = self.open_dm(name_or_nick, &mut now_ms)?;
1238 if !self.member_joined(&room_id, peer.as_str()) {
1239 return Err(ShellError::Dm);
1240 }
1241 let encrypted = self
1242 .rooms()
1243 .into_iter()
1244 .any(|room| room.room_id == room_id && room.encrypted);
1245 if !encrypted {
1246 return Err(ShellError::Dm);
1247 }
1248 self.dispatch(
1249 MessengerCommand::SendMessage {
1250 room_id: RoomId::parse(&room_id)?,
1251 message: OutgoingMessage {
1252 kind: MessageKind::Text,
1253 body: text.to_string(),
1254 reply_to: None,
1255 edit_of: None,
1256 },
1257 txn_id: None,
1258 },
1259 now_ms,
1260 )?;
1261 for _ in 0..20 {
1262 now_ms += 1_000;
1263 self.drive(now_ms, false)?;
1264 if let Some(row) = self
1265 .texts()
1266 .into_iter()
1267 .find(|row| row.room_id == room_id && row.body == text)
1268 {
1269 if row.outcome == "sent" {
1270 return Ok(room_id);
1271 }
1272 if row.outcome.starts_with("failed") {
1273 return Err(ShellError::Dm);
1274 }
1275 }
1276 }
1277 Err(ShellError::Dm)
1278 }
1279
1280 fn open_dm(
1281 &mut self,
1282 name_or_nick: &str,
1283 now_ms: &mut i64,
1284 ) -> Result<(UserId, String), ShellError> {
1285 let found = self.find_nick(name_or_nick, *now_ms)?;
1286 let peer = UserId::parse(&found.user_id)?;
1287 if &peer == self.core.user_id() {
1288 return Err(ShellError::UnknownNick);
1289 }
1290 let room_id = if let Some(room_id) = self.dm_room(&peer) {
1291 room_id
1292 } else {
1293 self.dispatch(
1294 MessengerCommand::CreateRoom {
1295 kind: CreateRoomKind::Dm { peer: peer.clone() },
1296 },
1297 *now_ms,
1298 )?;
1299 self.wait_for_dm(&peer, now_ms)?
1300 };
1301 Ok((peer, room_id))
1302 }
1303
1304 fn wait_for_dm(&mut self, peer: &UserId, now_ms: &mut i64) -> Result<String, ShellError> {
1305 for attempt in 0..6 {
1306 *now_ms += 1_000;
1307 self.drive(*now_ms, attempt == 5)?;
1308 if let Some(room_id) = self.dm_room(peer) {
1309 return Ok(room_id);
1310 }
1311 }
1312 Err(ShellError::Dm)
1313 }
1314
1315 fn dm_room(&self, peer: &UserId) -> Option<String> {
1316 let me = self.core.user_id().clone();
1317 let ids: Vec<RoomId> = self.core.room_ids().cloned().collect();
1318 for room_id in &ids {
1319 let (joined, has_peer) = {
1320 let Some(state) = self.core.room_state(room_id) else {
1321 continue;
1322 };
1323 let joined = state
1324 .members
1325 .get(&me)
1326 .is_some_and(|member| member.membership == Membership::Join);
1327 let has_peer = state.members.contains_key(peer);
1328 (joined, has_peer)
1329 };
1330 if !joined || !has_peer {
1331 continue;
1332 }
1333 if self.core.room_kind(room_id) == Some(RoomKind::Dm) {
1334 return Some(room_id.as_str().to_string());
1335 }
1336 }
1337 None
1338 }
1339
1340 pub fn set_wake(&mut self, wake: SessionWake) {
1345 self.routine_url = wake.routine_url.filter(|url| !url.is_empty());
1346 self.routine_bearer = wake
1347 .routine_bearer
1348 .filter(|token| !token.is_empty())
1349 .map(Zeroizing::new);
1350 self.leader_sock = wake.leader_sock.filter(|path| !path.as_os_str().is_empty());
1351 self.leader_cwd = wake.leader_cwd.filter(|cwd| !cwd.is_empty());
1352 }
1353
1354 pub fn set_wake_chain(
1358 &mut self,
1359 session: provider::ProviderSession,
1360 chain: provider::WakeChain,
1361 ) {
1362 self.wake_chain = Some((session, chain));
1363 }
1364
1365 pub fn wake_chain_ids(&self) -> Vec<&'static str> {
1367 self.wake_chain
1368 .as_ref()
1369 .map(|(_, chain)| chain.ids())
1370 .unwrap_or_default()
1371 }
1372
1373 pub fn wake_route(&self) -> Option<&str> {
1375 self.wake_route.as_deref()
1376 }
1377
1378 pub fn take_wake_route(&mut self) -> Option<String> {
1380 self.wake_route.take()
1381 }
1382
1383 pub fn has_routine(&self) -> bool {
1385 self.routine_url.is_some()
1386 }
1387
1388 pub(crate) fn routine_target(&self) -> Option<(String, Option<String>)> {
1391 let url = self.routine_url.clone()?;
1392 let bearer = self.routine_bearer.as_ref().map(|token| token.as_str().to_string());
1393 Some((url, bearer))
1394 }
1395
1396 pub fn wake_note(&self) -> Option<&str> {
1400 self.wake_note.as_deref()
1401 }
1402
1403 pub fn wake_log(&self) -> &[WakeAttempt] {
1406 &self.wake_log
1407 }
1408
1409 pub fn dispatch(&mut self, command: MessengerCommand, now_ms: i64) -> Result<(), ShellError> {
1412 self.core.dispatch(command, now_ms)?;
1413 self.persist_core()?;
1414 Ok(())
1415 }
1416
1417 pub fn send_room_message(
1424 &mut self,
1425 room_id: &str,
1426 text: &str,
1427 now_ms: i64,
1428 ) -> Result<Vec<OutgoingRequest>, ShellError> {
1429 let room_id = RoomId::parse(room_id)?;
1430 self.core.dispatch(
1431 MessengerCommand::SendMessage {
1432 room_id,
1433 message: OutgoingMessage {
1434 kind: MessageKind::Text,
1435 body: text.to_string(),
1436 reply_to: None,
1437 edit_of: None,
1438 },
1439 txn_id: None,
1440 },
1441 now_ms,
1442 )?;
1443 self.persist_core()?;
1444 let mut released = self.core.releasable_requests(now_ms);
1445 self.persist_core()?;
1446 if !released
1447 .iter()
1448 .any(|request| request.kind == OutgoingRequestKind::RoomSend)
1449 {
1450 released.extend(self.core.releasable_requests(now_ms));
1451 self.persist_core()?;
1452 }
1453 Ok(released)
1454 }
1455
1456 pub fn drive(&mut self, now_ms: i64, wait_for_sync: bool) -> Result<(), ShellError> {
1466 self.core.decrypt_loaded_timeline();
1467 let trace_at = self.http_trace.len();
1468 let history = self.request_missing_history(now_ms)?;
1469 self.pull_room_tips()?;
1470 let mut waited_long_poll = false;
1471 if wait_for_sync && self.sync_flight.is_some() {
1472 waited_long_poll = self.harvest_sync(now_ms, true)?;
1476 } else {
1477 self.harvest_sync(now_ms, false)?;
1478 }
1479 self.wake_inbound();
1480 for _ in 0..24 {
1481 let released = self.release_after_flush(now_ms)?;
1482 if released.is_empty() {
1483 break;
1484 }
1485 let (syncs, others): (Vec<_>, Vec<_>) = released
1486 .into_iter()
1487 .partition(|request| request.kind == OutgoingRequestKind::Sync);
1488 for request in &syncs {
1489 self.spawn_sync(request.clone())?;
1490 }
1491 for request in others {
1492 self.roundtrip(&request, now_ms)?;
1493 }
1494 self.persist_core()?;
1495 for request in &syncs {
1496 let timeout = sync_timeout_ms(request);
1497 let block = timeout == 0 || (wait_for_sync && !waited_long_poll);
1498 if block {
1499 self.harvest_sync(now_ms, true)?;
1500 if timeout != 0 {
1501 waited_long_poll = true;
1502 }
1503 }
1504 }
1505 self.persist_core()?;
1506 if let Some(err) = self.core.take_ingest_error() {
1507 return Err(ShellError::Ingest(clip_public(err)));
1508 }
1509 self.wake_inbound();
1510 }
1511 self.note_history_pages(&history, trace_at);
1512 self.note_room_tips();
1513 self.routine_restart_catchup = false;
1514 Ok(())
1515 }
1516
1517 fn pull_room_tips(&mut self) -> Result<(), ShellError> {
1520 let me = self.core.user_id().clone();
1521 let ids: Vec<RoomId> = self.core.room_ids().cloned().collect();
1522 for room_id in ids {
1523 if self.tip_pulled.contains(room_id.as_str()) {
1524 continue;
1525 }
1526 if self
1527 .core
1528 .timeline(&room_id)
1529 .is_some_and(|timeline| !timeline.items().is_empty())
1530 {
1531 self.tip_pulled.insert(room_id.as_str().to_string());
1532 continue;
1533 }
1534 let joined = self.core.room_state(&room_id).is_some_and(|state| {
1535 state
1536 .members
1537 .get(&me)
1538 .is_some_and(|member| matches!(member.membership, Membership::Join))
1539 });
1540 if !joined {
1541 continue;
1542 }
1543 self.core.pull_latest_page(room_id)?;
1544 }
1545 Ok(())
1546 }
1547
1548 fn note_room_tips(&mut self) {
1549 let ids: Vec<RoomId> = self.core.room_ids().cloned().collect();
1550 for room_id in ids {
1551 if self
1552 .core
1553 .timeline(&room_id)
1554 .is_some_and(|timeline| !timeline.items().is_empty())
1555 {
1556 self.tip_pulled.insert(room_id.as_str().to_string());
1557 }
1558 }
1559 }
1560
1561 fn request_missing_history(&mut self, now_ms: i64) -> Result<Vec<String>, ShellError> {
1565 let me = self.core.user_id().clone();
1566 let ids: Vec<RoomId> = self.core.room_ids().cloned().collect();
1567 let mut requested = Vec::new();
1568 for room_id in ids {
1569 if !self.room_needs_history(&room_id, &me) {
1570 continue;
1571 }
1572 let label = room_id.as_str().to_string();
1573 self.core
1574 .dispatch(MessengerCommand::LoadOlder { room_id }, now_ms)?;
1575 requested.push(label);
1576 }
1577 Ok(requested)
1578 }
1579
1580 fn room_needs_history(&self, room_id: &RoomId, me: &UserId) -> bool {
1581 if self.history_pulled.contains(room_id.as_str()) {
1582 return false;
1583 }
1584 let joined = match self
1585 .core
1586 .room_state(room_id)
1587 .and_then(|state| state.members.get(me))
1588 {
1589 Some(member) => matches!(member.membership, Membership::Join),
1590 None => false,
1591 };
1592 if !joined {
1593 return false;
1594 }
1595 match self.core.timeline(room_id) {
1596 Some(timeline) => timeline.items().is_empty(),
1597 None => true,
1598 }
1599 }
1600
1601 fn note_history_pages(&mut self, requested: &[String], trace_at: usize) {
1602 if requested.is_empty() {
1603 return;
1604 }
1605 let ok = self.http_trace[trace_at..]
1606 .iter()
1607 .filter(|(kind, status)| {
1608 *kind == OutgoingRequestKind::RoomMessages && (200..300).contains(status)
1609 })
1610 .count();
1611 if ok < requested.len() {
1612 return;
1613 }
1614 for room_id in requested {
1615 self.history_pulled.insert(room_id.clone());
1616 }
1617 }
1618
1619 pub fn rooms(&self) -> Vec<RoomView> {
1621 let me = self.core.user_id().clone();
1622 let mut rooms: Vec<RoomView> = self
1623 .core
1624 .room_ids()
1625 .map(|room_id| {
1626 let state = self.core.room_state(room_id);
1627 let membership = state
1628 .and_then(|state| state.members.get(&me))
1629 .map(|member| membership_name(&member.membership).to_string())
1630 .unwrap_or_else(|| "absent".to_string());
1631 let encrypted = state.and_then(|state| state.encryption.as_ref()).is_some();
1632 RoomView {
1633 room_id: room_id.as_str().to_string(),
1634 membership,
1635 encrypted,
1636 }
1637 })
1638 .collect();
1639 rooms.sort_by(|left, right| left.room_id.cmp(&right.room_id));
1640 rooms
1641 }
1642
1643 pub fn texts(&self) -> Vec<TextView> {
1646 let mut out = Vec::new();
1647 for room_id in self.core.room_ids() {
1648 let Some(timeline) = self.core.timeline(room_id) else {
1649 continue;
1650 };
1651 for item in timeline.items() {
1652 match &item.content {
1653 ItemContent::Text(text)
1654 | ItemContent::Notice(text)
1655 | ItemContent::Emote(text) => {
1656 out.push(TextView {
1657 room_id: room_id.as_str().to_string(),
1658 body: text.body.clone(),
1659 outcome: outcome_name(&item.send_state),
1660 event_id: item.event_id.as_ref().map(|id| id.as_str().to_string()),
1661 });
1662 }
1663 ItemContent::Undecryptable { reason } => out.push(TextView {
1664 room_id: room_id.as_str().to_string(),
1665 body: String::new(),
1666 outcome: format!("undecryptable:{reason}"),
1667 event_id: item.event_id.as_ref().map(|id| id.as_str().to_string()),
1668 }),
1669 _ => {}
1670 }
1671 }
1672 }
1673 out
1674 }
1675
1676 pub fn sync_inflight(&self) -> bool {
1678 self.sync_flight.is_some()
1679 }
1680
1681 pub fn http_trace(&self) -> Vec<String> {
1682 self.http_trace
1683 .iter()
1684 .map(|(kind, status)| format!("{kind:?} {status}"))
1685 .collect()
1686 }
1687
1688 pub fn take_ingest_error(&mut self) -> Option<String> {
1690 self.core.take_ingest_error().map(clip_public)
1691 }
1692
1693 fn persist_core(&mut self) -> Result<(), ShellError> {
1694 persist(&self.dir, &mut self.core)
1695 }
1696
1697 fn release_after_flush(&mut self, now_ms: i64) -> Result<Vec<OutgoingRequest>, ShellError> {
1698 self.persist_core()?;
1699 let mut released = self.core.releasable_requests(now_ms);
1700 if released.is_empty() {
1701 self.persist_core()?;
1702 released = self.core.releasable_requests(now_ms);
1703 }
1704 Ok(released)
1705 }
1706
1707 fn note_security_events(&mut self, events: &[mail4agent_messenger::MessengerEvent]) {
1708 for event in events {
1709 if let mail4agent_messenger::MessengerEvent::DeviceKeyChanged { user_id, device_id } = event {
1710 self.security_alerts.push(format!(
1711 "{} reset its keys (device {}); trust cleared, room keys re-shared",
1712 user_id.as_str(),
1713 device_id.as_str()
1714 ));
1715 }
1716 }
1717 }
1718
1719 pub fn take_security_alerts(&mut self) -> Vec<String> {
1721 std::mem::take(&mut self.security_alerts)
1722 }
1723
1724 fn roundtrip(&mut self, request: &OutgoingRequest, now_ms: i64) -> Result<(), ShellError> {
1725 match self.fulfill(request) {
1726 Ok(response) => {
1727 self.http_trace.push((request.kind, response.status));
1728 let events = self.core.on_response(request.id.clone(), response, now_ms);
1729 self.note_security_events(&events);
1730 Ok(())
1731 }
1732 Err(err) => {
1733 self.core.on_transport_error(&request.id, now_ms);
1734 Err(err)
1735 }
1736 }
1737 }
1738
1739 fn fulfill(&self, request: &OutgoingRequest) -> Result<HttpResponseDescriptor, ShellError> {
1740 if let Some(bus) = &self.bus {
1741 bus.fulfill(
1742 &self.client,
1743 &self.base_url,
1744 self.device_token.as_str(),
1745 self.core.user_id().as_str(),
1746 request,
1747 None,
1748 )
1749 .map(|(response, _hit_remote)| response)
1750 } else {
1751 perform_http(
1752 &self.client,
1753 &self.base_url,
1754 self.device_token.as_str(),
1755 request,
1756 )
1757 }
1758 }
1759
1760 fn spawn_sync(&mut self, request: OutgoingRequest) -> Result<(), ShellError> {
1761 if self.sync_flight.is_some() {
1762 self.core.on_transport_error(&request.id, 0);
1763 return Err(ShellError::Http(
1764 "a sync was already on the wire".to_string(),
1765 ));
1766 }
1767 let client = self.client.clone();
1768 let base_url = self.base_url.clone();
1769 let token = self.device_token.clone();
1770 let bus = self.bus.clone();
1771 let force_local = self.bus.as_ref().map(|bus| bus.local_only());
1772 let user_id = self.core.user_id().as_str().to_string();
1773 let id = request.id.clone();
1774 let (tx, rx) = mpsc::channel();
1775 let worker = thread::spawn(move || {
1776 let result = if let Some(bus) = &bus {
1777 bus.fulfill(
1778 &client,
1779 &base_url,
1780 token.as_str(),
1781 &user_id,
1782 &request,
1783 force_local,
1784 )
1785 .map(|(response, _hit_remote)| response)
1786 } else {
1787 perform_http(&client, &base_url, token.as_str(), &request)
1788 };
1789 let result = result.map_err(|err| match err {
1790 ShellError::Http(text) => text,
1791 other => clip_public(other.to_string()),
1792 });
1793 let _ = tx.send(result);
1794 });
1795 self.sync_flight = Some(SyncFlight {
1796 id,
1797 rx,
1798 _worker: worker,
1799 });
1800 Ok(())
1801 }
1802
1803 fn harvest_sync(&mut self, now_ms: i64, wait: bool) -> Result<bool, ShellError> {
1806 let Some(flight) = self.sync_flight.as_ref() else {
1807 return Ok(false);
1808 };
1809 let (received, blocked) = if wait {
1810 match flight.rx.try_recv() {
1811 Ok(result) => (Some(result), false),
1812 Err(mpsc::TryRecvError::Empty) => {
1813 match flight.rx.recv_timeout(Duration::from_secs(45)) {
1814 Ok(result) => (Some(result), true),
1815 Err(mpsc::RecvTimeoutError::Timeout) => {
1816 let id = flight.id.clone();
1817 self.sync_flight = None;
1818 self.core.on_transport_error(&id, now_ms);
1819 return Err(ShellError::Http("sync timed out".to_string()));
1820 }
1821 Err(mpsc::RecvTimeoutError::Disconnected) => {
1822 let id = flight.id.clone();
1823 self.sync_flight = None;
1824 self.core.on_transport_error(&id, now_ms);
1825 return Err(ShellError::Http("sync worker dropped".to_string()));
1826 }
1827 }
1828 }
1829 Err(mpsc::TryRecvError::Disconnected) => {
1830 let id = flight.id.clone();
1831 self.sync_flight = None;
1832 self.core.on_transport_error(&id, now_ms);
1833 return Err(ShellError::Http("sync worker dropped".to_string()));
1834 }
1835 }
1836 } else {
1837 match flight.rx.try_recv() {
1838 Ok(result) => (Some(result), false),
1839 Err(mpsc::TryRecvError::Empty) => (None, false),
1840 Err(mpsc::TryRecvError::Disconnected) => {
1841 let id = flight.id.clone();
1842 self.sync_flight = None;
1843 self.core.on_transport_error(&id, now_ms);
1844 return Err(ShellError::Http("sync worker dropped".to_string()));
1845 }
1846 }
1847 };
1848 let Some(result) = received else {
1849 return Ok(false);
1850 };
1851 let flight = self.sync_flight.take().expect("flight present");
1852 match result {
1853 Ok(response) => {
1854 self.http_trace
1855 .push((OutgoingRequestKind::Sync, response.status));
1856 let events = self.core.on_response(flight.id, response, now_ms);
1857 self.note_security_events(&events);
1858 }
1859 Err(err) => {
1860 self.core.on_transport_error(&flight.id, now_ms);
1861 return Err(ShellError::Http(err));
1862 }
1863 }
1864 self.persist_core()?;
1865 if let Some(err) = self.core.take_ingest_error() {
1866 return Err(ShellError::Ingest(clip_public(err)));
1867 }
1868 Ok(blocked)
1869 }
1870
1871 fn inbound_plaintexts(&self) -> Vec<InboundPlaintext> {
1872 let me = self.core.user_id();
1873 let mut out = Vec::new();
1874 for room_id in self.core.room_ids() {
1875 let Some(timeline) = self.core.timeline(room_id) else {
1876 continue;
1877 };
1878 for item in timeline.items() {
1879 if item.redacted || &item.sender == me || item.send_state != SendState::Sent {
1880 continue;
1881 }
1882 let body = match &item.content {
1883 ItemContent::Text(text)
1884 | ItemContent::Notice(text)
1885 | ItemContent::Emote(text) => text.body.clone(),
1886 _ => continue,
1887 };
1888 let Some(event_id) = item.event_id.as_ref() else {
1889 continue;
1890 };
1891 let key = format!("{}\n{}", room_id.as_str(), event_id.as_str());
1892 out.push(InboundPlaintext {
1893 room_id: room_id.as_str().to_string(),
1894 key,
1895 body,
1896 from: item.sender.as_str().to_string(),
1897 nick: self.sender_nick(room_id, &item.sender),
1898 event_id: event_id.as_str().to_string(),
1899 });
1900 }
1901 }
1902 out
1903 }
1904
1905 fn sender_nick(&self, room_id: &RoomId, sender: &UserId) -> Option<String> {
1909 let name = self
1910 .core
1911 .room_state(room_id)?
1912 .members
1913 .get(sender)?
1914 .displayname
1915 .as_deref()?
1916 .trim();
1917 if name.is_empty() {
1918 None
1919 } else {
1920 Some(name.to_string())
1921 }
1922 }
1923
1924 fn note_already_present(&mut self) {
1925 self.load_routine_woken();
1926 self.load_leader_prompted();
1927 }
1928
1929 fn load_routine_woken(&mut self) {
1932 let path = self.dir.join(ROUTINE_WOKEN_FILE);
1933 match std::fs::read_to_string(&path) {
1934 Ok(text) if !text.trim().is_empty() => {
1935 for line in text.lines() {
1936 let Some((room, event)) = line.split_once('\t') else {
1937 continue;
1938 };
1939 if room.is_empty() || event.is_empty() {
1940 continue;
1941 }
1942 self.routine_sent.insert(format!("{room}\n{event}"));
1943 }
1944 self.routine_woken_seed_pending = false;
1945 self.routine_restart_catchup = true;
1948 }
1949 Ok(_) | Err(_) => {
1950 self.routine_woken_seed_pending = true;
1952 self.routine_restart_catchup = false;
1953 }
1954 }
1955 }
1956
1957 fn seed_routine_woken_from_timeline(&mut self) {
1962 let path = self.dir.join(ROUTINE_WOKEN_FILE);
1963 let items = self.inbound_plaintexts();
1964 let mut newest: std::collections::HashMap<String, String> =
1965 std::collections::HashMap::new();
1966 for item in &items {
1967 newest.insert(item.room_id.clone(), item.key.clone());
1968 }
1969 let mut lines = String::new();
1970 for item in &items {
1971 if newest.get(&item.room_id) == Some(&item.key) {
1972 continue;
1973 }
1974 self.routine_sent.insert(item.key.clone());
1975 if let Some((room, event)) = item.key.split_once('\n') {
1976 lines.push_str(room);
1977 lines.push('\t');
1978 lines.push_str(event);
1979 lines.push('\n');
1980 }
1981 }
1982 let _ = std::fs::write(path, lines);
1983 self.routine_woken_seed_pending = false;
1984 }
1985
1986 fn remember_routine_wake(&mut self, key: &str) {
1987 self.routine_sent.insert(key.to_string());
1988 let Some((room, event)) = key.split_once('\n') else {
1989 return;
1990 };
1991 if room.is_empty() || event.is_empty() {
1992 return;
1993 }
1994 let path = self.dir.join(ROUTINE_WOKEN_FILE);
1995 let mut file = match std::fs::OpenOptions::new()
1996 .create(true)
1997 .append(true)
1998 .open(&path)
1999 {
2000 Ok(file) => file,
2001 Err(_) => return,
2002 };
2003 use std::io::Write;
2004 let _ = write!(file, "{room}\t{event}\n");
2005 }
2006
2007 fn load_leader_prompted(&mut self) {
2011 let path = self.dir.join(LEADER_PROMPTED_FILE);
2012 match std::fs::read_to_string(&path) {
2013 Ok(text) => {
2014 for line in text.lines() {
2015 let Some((room, event)) = line.split_once('\t') else {
2016 continue;
2017 };
2018 if room.is_empty() || event.is_empty() {
2019 continue;
2020 }
2021 self.leader_sent.insert(format!("{room}\n{event}"));
2022 }
2023 }
2024 Err(_) => self.seed_leader_prompted_except_newest(&path),
2025 }
2026 }
2027
2028 fn seed_leader_prompted_except_newest(&mut self, path: &Path) {
2029 let items = self.inbound_plaintexts();
2030 let mut newest: std::collections::HashMap<String, String> =
2031 std::collections::HashMap::new();
2032 for item in &items {
2033 newest.insert(item.room_id.clone(), item.key.clone());
2034 }
2035 let mut lines = String::new();
2036 for item in &items {
2037 if newest.get(&item.room_id) == Some(&item.key) {
2038 continue;
2039 }
2040 self.leader_sent.insert(item.key.clone());
2041 if let Some((room, event)) = item.key.split_once('\n') {
2042 lines.push_str(room);
2043 lines.push('\t');
2044 lines.push_str(event);
2045 lines.push('\n');
2046 }
2047 }
2048 let _ = std::fs::write(path, lines);
2049 }
2050
2051 fn remember_leader_prompt(&mut self, key: &str) {
2052 self.leader_sent.insert(key.to_string());
2053 let Some((room, event)) = key.split_once('\n') else {
2054 return;
2055 };
2056 if room.is_empty() || event.is_empty() {
2057 return;
2058 }
2059 let path = self.dir.join(LEADER_PROMPTED_FILE);
2060 let mut file = match std::fs::OpenOptions::new()
2061 .create(true)
2062 .append(true)
2063 .open(&path)
2064 {
2065 Ok(file) => file,
2066 Err(_) => return,
2067 };
2068 use std::io::Write;
2069 let _ = write!(file, "{room}\t{event}\n");
2070 }
2071
2072 fn arm_unprompted_newest(&mut self) {
2080 if (self.leader_sock.is_none() && self.wake_chain.is_none()) || !self.leader_sent.is_empty() {
2081 return;
2082 }
2083 if self.inbound_plaintexts().is_empty() {
2084 return;
2085 }
2086 let path = self.dir.join(LEADER_PROMPTED_FILE);
2087 self.seed_leader_prompted_except_newest(&path);
2088 }
2089
2090 fn wake_inbound(&mut self) {
2091 if self.routine_url.is_none() && self.leader_sock.is_none() && self.wake_chain.is_none() {
2092 return;
2093 }
2094 if self.routine_restart_catchup && self.routine_url.is_some() {
2095 for item in self.inbound_plaintexts() {
2098 if !self.routine_sent.contains(&item.key) {
2099 self.remember_routine_wake(&item.key);
2100 }
2101 }
2102 } else if self.routine_woken_seed_pending && self.routine_url.is_some() {
2103 if !self.inbound_plaintexts().is_empty() {
2107 self.seed_routine_woken_from_timeline();
2108 }
2109 }
2110 self.arm_unprompted_newest();
2111 let url = self.routine_url.clone();
2112 let bearer = self.routine_bearer.clone();
2113 let sock = self.leader_sock.clone();
2114 let cwd = self.leader_cwd.clone();
2115 let session_id = self.session_id.clone();
2116 let items = self.inbound_plaintexts();
2117 for item in items {
2118 if let Some(url) = url.as_deref() {
2119 if !self.routine_restart_catchup && !self.routine_sent.contains(&item.key) {
2121 let from_nick = mxid_localpart(&item.from).to_string();
2122 let to = self.nick.clone();
2123 let reply = to.as_deref().map(|to| reply_hint(to, &from_nick));
2124 let wake = DecryptedWake {
2125 body: &item.body,
2126 from: &item.from,
2127 nick: item.nick.as_deref(),
2128 event_id: &item.event_id,
2129 room: Some(&item.room_id),
2130 from_nick: Some(&from_nick),
2131 to: to.as_deref(),
2132 reply: reply.as_deref(),
2133 };
2134 match post_decrypted_with_bearer(
2135 url,
2136 &wake,
2137 bearer.as_ref().map(|token| token.as_str()),
2138 ) {
2139 Ok(()) => {
2140 self.remember_routine_wake(&item.key);
2141 self.wake_log.push(WakeAttempt {
2142 event_id: item.event_id.clone(),
2143 status: Some(200),
2144 });
2145 }
2146 Err(err) => {
2147 let status = match &err {
2148 ShellError::RoutineStatus(code) => Some(*code),
2149 _ => None,
2150 };
2151 self.wake_log.push(WakeAttempt {
2152 event_id: item.event_id.clone(),
2153 status,
2154 });
2155 self.wake_note = Some(clip_public(err.to_string()));
2156 }
2157 }
2158 }
2159 }
2160 if let Some(sock) = sock.as_deref() {
2161 if !self.leader_sent.contains(&item.key) {
2162 let cwd = cwd.clone().or_else(|| {
2163 std::env::current_dir()
2164 .ok()
2165 .map(|path| path.display().to_string())
2166 });
2167 let Some(cwd) = cwd.filter(|cwd| !cwd.is_empty()) else {
2168 self.wake_note = Some("leader cwd is empty".to_string());
2169 continue;
2170 };
2171 match mail4agent_grok::wake_decrypted_room_blocking(
2172 sock,
2173 &session_id,
2174 &cwd,
2175 &item.body,
2176 ) {
2177 Ok(()) => {
2178 self.remember_leader_prompt(&item.key);
2179 }
2180 Err(err) => {
2181 self.wake_note = Some(clip_public(err.to_string()));
2182 }
2183 }
2184 }
2185 }
2186 if sock.is_none() && !self.leader_sent.contains(&item.key) {
2187 let Some((session, mut chain)) = self.wake_chain.take() else {
2188 continue;
2189 };
2190 let from_nick = mxid_localpart(&item.from).to_string();
2191 let letter = provider::WakeLetter {
2192 body: &item.body,
2193 from_nick: &from_nick,
2194 event_id: &item.event_id,
2195 room: Some(&item.room_id),
2196 };
2197 match chain.wake(&session, &letter) {
2198 Ok((id, outcome)) => {
2199 self.remember_leader_prompt(&item.key);
2200 let how = match outcome {
2201 provider::WakeOutcome::Delivered => "delivered",
2202 provider::WakeOutcome::Queued(_) => "queued",
2203 };
2204 self.wake_route = Some(format!("{id} {how}"));
2205 }
2206 Err(err) => {
2207 self.wake_note = Some(clip_public(err.to_string()));
2208 }
2209 }
2210 self.wake_chain = Some((session, chain));
2211 }
2212 }
2213 }
2214}
2215
2216struct InboundPlaintext {
2217 room_id: String,
2218 key: String,
2219 body: String,
2220 from: String,
2221 nick: Option<String>,
2222 event_id: String,
2223}
2224
2225fn membership_name(membership: &Membership) -> &'static str {
2226 match membership {
2227 Membership::Invite => "invite",
2228 Membership::Join => "join",
2229 Membership::Knock => "knock",
2230 Membership::Leave => "leave",
2231 Membership::Ban => "ban",
2232 Membership::Unknown => "unknown",
2233 }
2234}
2235
2236fn outcome_name(state: &SendState) -> String {
2237 match state {
2238 SendState::Sent => "sent".to_string(),
2239 SendState::Sending | SendState::LocalEcho => "sending".to_string(),
2240 SendState::Failed { reason } => format!("failed:{reason}"),
2241 }
2242}
2243
2244fn parse_base_url(raw: &str) -> Result<reqwest::Url, ShellError> {
2245 let url = reqwest::Url::parse(raw).map_err(|_| ShellError::BaseUrl)?;
2246 match url.scheme() {
2247 "http" | "https" => {}
2248 _ => return Err(ShellError::BaseUrl),
2249 }
2250 if url.host_str().is_none()
2251 || url.query().is_some()
2252 || url.fragment().is_some()
2253 || !url.username().is_empty()
2254 || url.password().is_some()
2255 {
2256 return Err(ShellError::BaseUrl);
2257 }
2258 Ok(url)
2259}
2260
2261struct RegisteredSession {
2262 user_id: String,
2263 device_id: DeviceId,
2264 bearer: Zeroizing<String>,
2265}
2266
2267fn product_session(config: &SessionConfig, auth: &ProductAuth) -> Result<RegisteredSession, ShellError> {
2270 let http = |e: reqwest::Error| ShellError::Http(clip_public(e.to_string()));
2271 let client = reqwest::blocking::Client::builder().timeout(Duration::from_secs(10)).http1_only().build().map_err(http)?;
2272 let base = parse_base_url(&config.homeserver_url)?;
2273 let token: Zeroizing<String> = match &auth.secret {
2274 ProductSecret::Token(t) => t.clone(),
2275 ProductSecret::Password(pw) => {
2276 let mut url = base.clone();
2277 url.set_path("/product/v1/login");
2278 let resp = client
2279 .post(url)
2280 .json(&serde_json::json!({ "nick": auth.nick, "password": pw.as_str() }))
2281 .send()
2282 .map_err(http)?;
2283 let status = resp.status().as_u16();
2284 let bytes = resp.bytes().map_err(http)?;
2285 if !(200..300).contains(&status) {
2286 return Err(ShellError::Register(register_failure(status, &bytes)));
2287 }
2288 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
2289 let t = v.get("token").and_then(|t| t.as_str()).unwrap_or("");
2290 if t.is_empty() {
2291 return Err(ShellError::Register("product login returned no token".into()));
2292 }
2293 Zeroizing::new(t.to_string())
2294 }
2295 };
2296 let mut url = base;
2297 url.set_path("/client/v3/account/whoami");
2298 let resp = client.get(url).header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token.as_str())).send().map_err(http)?;
2299 let status = resp.status().as_u16();
2300 let bytes = resp.bytes().map_err(http)?;
2301 if !(200..300).contains(&status) {
2302 return Err(ShellError::Register(register_failure(status, &bytes)));
2303 }
2304 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
2305 let user_id = v.get("user_id").and_then(|x| x.as_str()).unwrap_or("").to_string();
2306 let device_raw = v.get("device_id").and_then(|x| x.as_str()).unwrap_or("");
2307 if user_id.is_empty() || device_raw.is_empty() {
2308 return Err(ShellError::Register("whoami missed an id".into()));
2309 }
2310 Ok(RegisteredSession { user_id, device_id: DeviceId::parse(device_raw)?, bearer: token })
2311}
2312
2313fn register_session(config: &SessionConfig) -> Result<RegisteredSession, ShellError> {
2314 if let Some(auth) = &config.product {
2315 return product_session(config, auth);
2316 }
2317 let base = parse_base_url(&config.homeserver_url)?;
2318 let mut url = base;
2319 url.set_path("/client/v3/register");
2320 let client = reqwest::blocking::Client::builder()
2321 .timeout(Duration::from_secs(10))
2322 .http1_only()
2323 .build()
2324 .map_err(|err| ShellError::Http(clip_public(err.to_string())))?;
2325 let body = serde_json::json!({
2326 "public_id": config.public_id,
2327 "nick": config.nick,
2328 "session_id": config.session_id,
2329 });
2330 let response = client
2331 .post(url)
2332 .header(reqwest::header::CONTENT_TYPE, "application/json")
2333 .body(
2334 serde_json::to_vec(&body)
2335 .map_err(|err| ShellError::Http(clip_public(err.to_string())))?,
2336 )
2337 .send()
2338 .map_err(|err| ShellError::Http(clip_public(err.to_string())))?;
2339 let status = response.status().as_u16();
2340 let bytes = response
2341 .bytes()
2342 .map_err(|err| ShellError::Http(clip_public(err.to_string())))?;
2343 if !(200..300).contains(&status) {
2344 return Err(ShellError::Register(register_failure(status, &bytes)));
2345 }
2346 let parsed: serde_json::Value = serde_json::from_slice(&bytes)
2347 .map_err(|_| ShellError::Register("register response was not json".to_string()))?;
2348 let user_id = parsed
2349 .get("user_id")
2350 .and_then(|value| value.as_str())
2351 .unwrap_or("")
2352 .to_string();
2353 let device_raw = parsed
2354 .get("device_id")
2355 .and_then(|value| value.as_str())
2356 .unwrap_or("");
2357 if user_id.is_empty() || device_raw.is_empty() {
2358 return Err(ShellError::Register(
2359 "register response missed an id".to_string(),
2360 ));
2361 }
2362 let device_id = DeviceId::parse(device_raw)?;
2363 let minted = parsed
2364 .get("access_token")
2365 .and_then(|value| value.as_str())
2366 .filter(|token| !token.is_empty());
2367 let bearer = if let Some(token) = minted {
2368 validate_device_token(token)?;
2369 Zeroizing::new(token.to_string())
2370 } else if let Some(token) = &config.device_token {
2371 token.clone()
2372 } else {
2373 return Err(ShellError::DeviceToken);
2374 };
2375 Ok(RegisteredSession {
2376 user_id,
2377 device_id,
2378 bearer,
2379 })
2380}
2381
2382fn register_failure(status: u16, body: &[u8]) -> String {
2383 let parsed: serde_json::Value = serde_json::from_slice(body).unwrap_or_default();
2384 let errcode = parsed
2385 .get("errcode")
2386 .and_then(|value| value.as_str())
2387 .unwrap_or("");
2388 let error = parsed
2389 .get("error")
2390 .and_then(|value| value.as_str())
2391 .unwrap_or("");
2392 clip_public(format!("status {status} {errcode} {error}"))
2393}
2394
2395fn sync_timeout_ms(request: &OutgoingRequest) -> u64 {
2396 request
2397 .query
2398 .iter()
2399 .find(|(name, _)| name == "timeout")
2400 .and_then(|(_, value)| value.parse().ok())
2401 .unwrap_or(0)
2402}
2403
2404fn perform_http(
2405 client: &reqwest::blocking::Client,
2406 base_url: &reqwest::Url,
2407 device_token: &str,
2408 request: &OutgoingRequest,
2409) -> Result<HttpResponseDescriptor, ShellError> {
2410 let url = request_url(base_url, &request.path, &request.query)?;
2411 let method = reqwest::Method::from_bytes(request.method.as_str().as_bytes())
2412 .map_err(|_| ShellError::Http("unsupported method".to_string()))?;
2413 let mut header = reqwest::header::HeaderValue::from_str(&format!("Bearer {device_token}"))
2414 .map_err(|_| ShellError::DeviceToken)?;
2415 header.set_sensitive(true);
2416 let mut builder = client
2417 .request(method, url)
2418 .header(reqwest::header::AUTHORIZATION, header);
2419 if let Some(body) = &request.body {
2420 let bytes = serde_json::to_vec(body).map_err(|err| ShellError::Http(err.to_string()))?;
2421 builder = builder
2422 .header(reqwest::header::CONTENT_TYPE, "application/json")
2423 .body(bytes);
2424 }
2425 let response = builder
2426 .send()
2427 .map_err(|err| ShellError::Http(clip_public(err.to_string())))?;
2428 let status = response.status().as_u16();
2429 let body = response
2430 .bytes()
2431 .map_err(|err| ShellError::Http(clip_public(err.to_string())))?
2432 .to_vec();
2433 Ok(HttpResponseDescriptor { status, body })
2434}
2435
2436fn request_url(
2440 base_url: &reqwest::Url,
2441 path: &str,
2442 query: &[(String, String)],
2443) -> Result<reqwest::Url, ShellError> {
2444 let path = path.strip_prefix("/_matrix").unwrap_or(path);
2445 if !path.starts_with('/') {
2446 return Err(ShellError::BaseUrl);
2447 }
2448 let mut raw = base_url.as_str().trim_end_matches('/').to_string();
2449 raw.push_str(path);
2450 if !query.is_empty() {
2451 raw.push('?');
2452 for (index, (name, value)) in query.iter().enumerate() {
2453 if index > 0 {
2454 raw.push('&');
2455 }
2456 raw.push_str(&percent_encode(name));
2457 raw.push('=');
2458 raw.push_str(&percent_encode(value));
2459 }
2460 }
2461 reqwest::Url::parse(&raw).map_err(|_| ShellError::BaseUrl)
2462}
2463
2464fn percent_encode(value: &str) -> String {
2465 let mut out = String::new();
2466 for byte in value.bytes() {
2467 match byte {
2468 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2469 out.push(byte as char)
2470 }
2471 _ => out.push_str(&format!("%{byte:02X}")),
2472 }
2473 }
2474 out
2475}
2476
2477fn clip_public(text: String) -> String {
2478 let mut out = String::new();
2479 for token in text.split_whitespace() {
2480 if token.len() > 80 {
2481 out.push_str("[omitted]");
2482 } else {
2483 out.push_str(token);
2484 }
2485 out.push(' ');
2486 if out.len() > 240 {
2487 break;
2488 }
2489 }
2490 out
2491}
2492
2493fn fs_create_dir(dir: &Path) -> Result<(), ShellError> {
2494 std::fs::create_dir_all(dir)?;
2495 Ok(())
2496}
2497
2498#[derive(Debug, thiserror::Error)]
2501pub enum ShellError {
2502 #[error("session id is empty")]
2504 EmptySession,
2505 #[error("store root is unset")]
2507 StoreRoot,
2508 #[error("homeserver url is unset")]
2510 HomeserverUrl,
2511 #[error("bot display name is unset")]
2513 BotName,
2514 #[error("nick could not be derived from the bot display name")]
2516 Nick,
2517 #[error("session list: {0}")]
2519 SessionList(String),
2520 #[error("homeserver register failed: {0}")]
2522 Register(String),
2523 #[error("no session with that nick")]
2525 UnknownNick,
2526 #[error("direct room was not ready")]
2528 Dm,
2529 #[error("config: {0}")]
2531 Config(String),
2532 #[error("device token is empty or not a single header value")]
2534 DeviceToken,
2535 #[error("base url must be http or https")]
2537 BaseUrl,
2538 #[error("record key escapes the store directory")]
2540 BadRecordKey,
2541 #[error("routine url is not an http or https url")]
2543 RoutineUrl,
2544 #[error("node cli does not take a routine url")]
2546 NodeRoutine,
2547 #[error("gateway: {0}")]
2549 Gateway(String),
2550 #[error("routine bearer is empty or not a single header value")]
2552 RoutineBearer,
2553 #[error("routine status {0}")]
2555 RoutineStatus(u16),
2556 #[error("routine post failed: {0}")]
2558 RoutineTransport(String),
2559 #[error("homeserver http failed: {0}")]
2561 Http(String),
2562 #[error("homeserver response was not ingested: {0}")]
2564 Ingest(String),
2565 #[error("io: {0}")]
2567 Io(#[from] std::io::Error),
2568 #[error(transparent)]
2570 Store(#[from] StoreError),
2571 #[error(transparent)]
2573 Messenger(#[from] MessengerError),
2574}
2575
2576fn persist(dir: &Path, core: &mut MessengerCore<SealedRecordCodec>) -> Result<(), ShellError> {
2577 while let Some(batch) = core.take_flush_batch() {
2578 for record in &batch.records {
2579 let path = record_path(dir, &record.key)?;
2580 if let Some(parent) = path.parent() {
2581 std::fs::create_dir_all(parent)?;
2582 }
2583 std::fs::write(&path, &record.bytes)?;
2584 #[cfg(unix)]
2585 {
2586 use std::os::unix::fs::PermissionsExt;
2587 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
2588 }
2589 }
2590 for key in &batch.deletes {
2591 let path = record_path(dir, key)?;
2592 if path.exists() {
2593 std::fs::remove_file(path)?;
2594 }
2595 }
2596 core.ack_flush(batch.id);
2597 }
2598 Ok(())
2599}
2600
2601const NOT_RECORDS: &[&str] = &[
2603 machine::WAKE_KEYCHAIN_FILE,
2604 machine::STORE_LOCK_FILE,
2605 LEADER_PROMPTED_FILE,
2606 ROUTINE_WOKEN_FILE,
2607];
2608
2609const LEADER_PROMPTED_FILE: &str = "leader-prompted";
2612const ROUTINE_WOKEN_FILE: &str = "routine-woken";
2615
2616fn read_records(dir: &Path) -> Result<Vec<SealedRecord>, ShellError> {
2617 let mut records = Vec::new();
2618 if dir.exists() {
2619 walk(dir, dir, &mut records)?;
2620 }
2621 Ok(records)
2622}
2623
2624fn walk(dir: &Path, root: &Path, records: &mut Vec<SealedRecord>) -> Result<(), ShellError> {
2625 for entry in std::fs::read_dir(dir)? {
2626 let entry = entry?;
2627 let path = entry.path();
2628 if path.is_dir() {
2629 walk(&path, root, records)?;
2630 continue;
2631 }
2632 if dir == root
2635 && path
2636 .file_name()
2637 .and_then(|name| name.to_str())
2638 .is_some_and(|name| NOT_RECORDS.contains(&name))
2639 {
2640 continue;
2641 }
2642 let rel = path
2643 .strip_prefix(root)
2644 .map_err(|_| ShellError::BadRecordKey)?;
2645 let key = rel
2646 .components()
2647 .map(|component| component.as_os_str().to_string_lossy())
2648 .collect::<Vec<_>>()
2649 .join("/");
2650 let bytes = std::fs::read(&path)?;
2651 records.push(SealedRecord {
2652 key: RecordKey::new(key),
2653 bytes,
2654 });
2655 }
2656 Ok(())
2657}
2658
2659fn record_path(dir: &Path, key: &RecordKey) -> Result<PathBuf, ShellError> {
2660 let rel = Path::new(key.as_str());
2661 if rel.is_absolute()
2662 || rel.components().any(|component| {
2663 matches!(
2664 component,
2665 Component::ParentDir | Component::RootDir | Component::Prefix(_)
2666 )
2667 })
2668 {
2669 return Err(ShellError::BadRecordKey);
2670 }
2671 Ok(dir.join(rel))
2672}
2673
2674#[cfg(test)]
2675mod tests {
2676 use super::*;
2677
2678 #[test]
2679 fn web_session_config_derives_the_nick_and_ignores_a_slug() {
2680 let config = SessionConfig::from_lookup(
2681 |key| match key {
2682 HOMESERVER_URL_ENV => Some("http://127.0.0.1:9".to_string()),
2683 BOT_NAME_ENV => Some("Привет мир".to_string()),
2684 SESSION_ID_ENV => Some("web-session-1".to_string()),
2685 STORE_ROOT_ENV => Some("/tmp/m4a-root".to_string()),
2686 "M4A_NICK" => Some("nachshtab".to_string()),
2687 _ => None,
2688 },
2689 None,
2690 )
2691 .expect("config");
2692 assert_eq!(config.nick(), "privet-mir");
2693 assert_eq!(
2694 config.store_dir(),
2695 session_store_dir(Path::new("/tmp/m4a-root"), "web-session-1")
2696 );
2697 let alice = SessionConfig::from_lookup(
2698 |key| match key {
2699 HOMESERVER_URL_ENV => Some("http://127.0.0.1:9".to_string()),
2700 BOT_NAME_ENV => Some("Alice".to_string()),
2701 SESSION_ID_ENV => Some("web-alice".to_string()),
2702 STORE_ROOT_ENV => Some("/tmp/m4a-root".to_string()),
2703 _ => None,
2704 },
2705 Some("homeserver_url = \"http://127.0.0.1:1\"\n"),
2706 )
2707 .expect("alice");
2708 assert_eq!(alice.nick(), "alice");
2709 assert!(alice.homeserver_url.contains("127.0.0.1:9"));
2710 let from_toml = SessionConfig::from_lookup(
2711 |key| match key {
2712 BOT_NAME_ENV => Some("Alice".to_string()),
2713 SESSION_ID_ENV => Some("web-alice".to_string()),
2714 STORE_ROOT_ENV => Some("/tmp/m4a-root".to_string()),
2715 _ => None,
2716 },
2717 Some("homeserver_url = \"http://127.0.0.1:9\"\nother = \"ignored\"\n"),
2718 )
2719 .expect("toml url");
2720 assert_eq!(from_toml.homeserver_url, "http://127.0.0.1:9");
2721 }
2722 use std::io::{Read, Write};
2723 use std::net::TcpListener;
2724 use std::process::{Command, Stdio};
2725 use std::sync::atomic::{AtomicBool, Ordering};
2726 use std::sync::{Arc, Mutex};
2727 use std::time::Instant;
2728
2729 struct TempDir(PathBuf);
2730
2731 impl Drop for TempDir {
2732 fn drop(&mut self) {
2733 let _ = std::fs::remove_dir_all(&self.0);
2734 }
2735 }
2736
2737 fn temp_dir(name: &str) -> TempDir {
2738 let dir = TempDir(std::env::temp_dir().join(format!(
2739 "mail4agent-messenger-shell-{}-{name}",
2740 std::process::id()
2741 )));
2742 let _ = std::fs::remove_dir_all(&dir.0);
2743 dir
2744 }
2745
2746 fn open_alice(dir: &Path, session: &str) -> OpenedStore {
2747 let device = DeviceId::parse("DEVICE1").expect("device id");
2748 OpenedStore::open(
2749 dir,
2750 session,
2751 device,
2752 "@alice:localhost",
2753 "localhost",
2754 "http://127.0.0.1:9",
2755 "fake-token",
2756 )
2757 .expect("open")
2758 }
2759
2760 #[test]
2761 fn same_session_opens_and_a_different_session_fails_the_seal() {
2762 let dir = temp_dir("seal");
2763 open_alice(&dir.0, "session-a");
2764 open_alice(&dir.0, "session-a");
2765
2766 let device = DeviceId::parse("DEVICE1").expect("device id");
2767 match OpenedStore::open(
2768 &dir.0,
2769 "session-b",
2770 device,
2771 "@alice:localhost",
2772 "localhost",
2773 "http://127.0.0.1:9",
2774 "fake-token",
2775 ) {
2776 Err(ShellError::Store(StoreError::CodecOpen { .. })) => {}
2777 Ok(_) => panic!("different session opened the sealed store"),
2778 Err(err) => panic!("expected seal auth failure, got {err}"),
2779 }
2780 }
2781
2782 #[test]
2783 fn two_sessions_under_one_root_seal_and_reopen_only_with_their_own_id() {
2784 let root = temp_dir("isolate");
2785 let dir_a = session_store_dir(&root.0, "session-a");
2786 let dir_b = session_store_dir(&root.0, "session-b");
2787 assert_ne!(dir_a, dir_b);
2788 assert_eq!(dir_a.parent(), Some(root.0.as_path()));
2789 assert_eq!(dir_b.parent(), Some(root.0.as_path()));
2790 assert_eq!(session_store_dir(&root.0, "session-a"), dir_a);
2791 let slipped = session_store_dir(&root.0, "../session-a");
2792 assert_eq!(slipped.parent(), Some(root.0.as_path()));
2793 let name = slipped.file_name().expect("name").to_string_lossy();
2794 assert_eq!(name.len(), 64);
2795 assert!(name.chars().all(|ch| ch.is_ascii_hexdigit()));
2796
2797 let bearer = "isolation-bearer-7c2e";
2798 let device = DeviceId::parse("DEVICE1").expect("device id");
2799 let open_with = |dir: &Path, session: &str| {
2800 OpenedStore::open(
2801 dir,
2802 session,
2803 device.clone(),
2804 "@alice:localhost",
2805 "localhost",
2806 "http://127.0.0.1:9",
2807 bearer,
2808 )
2809 };
2810 open_with(&dir_a, "session-a").expect("seal a");
2811 open_with(&dir_b, "session-b").expect("seal b");
2812 open_with(&dir_a, "session-a").expect("reopen a");
2813 open_with(&dir_b, "session-b").expect("reopen b");
2814
2815 for (dir, session) in [(&dir_a, "session-b"), (&dir_b, "session-a")] {
2816 match open_with(dir, session) {
2817 Err(ShellError::Store(StoreError::CodecOpen { .. })) => {}
2818 Ok(_) => panic!("{session} opened the other sealed store"),
2819 Err(err) => panic!("expected seal auth failure, got {err}"),
2820 }
2821 }
2822
2823 fn contains_bearer(dir: &Path, needle: &str) -> bool {
2824 if !dir.exists() {
2825 return false;
2826 }
2827 for entry in std::fs::read_dir(dir).expect("read") {
2828 let entry = entry.expect("entry");
2829 let path = entry.path();
2830 if path.to_string_lossy().contains(needle) {
2831 return true;
2832 }
2833 if path.is_dir() {
2834 if contains_bearer(&path, needle) {
2835 return true;
2836 }
2837 } else if std::fs::read(&path)
2838 .expect("file")
2839 .windows(needle.len())
2840 .any(|window| window == needle.as_bytes())
2841 {
2842 return true;
2843 }
2844 }
2845 false
2846 }
2847 assert!(
2848 !contains_bearer(&root.0, bearer),
2849 "raw bearer was written under the store root"
2850 );
2851 }
2852
2853 #[test]
2854 fn post_decrypted_reaches_the_loopback_routine() {
2855 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2856 let addr = listener.local_addr().expect("addr");
2857 let server = thread::spawn(move || {
2858 let (mut sock, _) = listener.accept().expect("accept");
2859 sock.set_read_timeout(Some(Duration::from_secs(2)))
2860 .expect("timeout");
2861 let mut buf = Vec::new();
2862 let mut tmp = [0u8; 1024];
2863 loop {
2864 let n = sock.read(&mut tmp).unwrap_or(0);
2865 if n == 0 {
2866 break;
2867 }
2868 buf.extend_from_slice(&tmp[..n]);
2869 if let Some(header_end) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
2870 let headers = String::from_utf8_lossy(&buf[..header_end]).to_string();
2871 let length = headers
2872 .lines()
2873 .find_map(|line| {
2874 let (name, value) = line.split_once(':')?;
2875 if name.eq_ignore_ascii_case("content-length") {
2876 value.trim().parse::<usize>().ok()
2877 } else {
2878 None
2879 }
2880 })
2881 .unwrap_or(0);
2882 if buf.len() >= header_end + 4 + length {
2883 let body = buf[header_end + 4..header_end + 4 + length].to_vec();
2884 let _ = sock.write_all(
2885 b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
2886 );
2887 return (headers, body);
2888 }
2889 }
2890 }
2891 (String::new(), buf)
2892 });
2893
2894 let wake = RoutineWake {
2895 url: format!("http://{addr}/routine"),
2896 };
2897 post_decrypted(
2898 &wake.url,
2899 &DecryptedWake {
2900 body: "hello-from-room",
2901 from: "@bob:localhost",
2902 nick: None,
2903 event_id: "$m1:localhost",
2904 ..Default::default()
2905 },
2906 )
2907 .expect("post");
2908 let (headers, body) = server.join().expect("listener stopped");
2909 assert!(headers.starts_with("POST /routine HTTP/1.1"), "{headers}");
2910 assert!(
2911 headers
2912 .to_ascii_lowercase()
2913 .contains("content-type: application/json"),
2914 "{headers}"
2915 );
2916 assert!(
2917 !headers.to_ascii_lowercase().contains("authorization"),
2918 "routine post must not add a bearer"
2919 );
2920 assert!(
2921 !headers.to_ascii_lowercase().contains("x-automation-key"),
2922 "routine post must not add a key without a bearer"
2923 );
2924 assert!(!headers.contains("/mail/send"), "{headers}");
2925 assert_wake_json(
2926 &body,
2927 "hello-from-room",
2928 "@bob:localhost",
2929 "$m1:localhost",
2930 None,
2931 );
2932 }
2933
2934 #[test]
2935 fn post_decrypted_attempts_https_against_a_local_self_signed_listener() {
2936 let dir = temp_dir("https");
2937 std::fs::create_dir_all(&dir.0).expect("dir");
2938 let cert = dir.0.join("cert.pem");
2939 let key = dir.0.join("key.pem");
2940 let generated = Command::new("openssl")
2941 .args([
2942 "req",
2943 "-x509",
2944 "-newkey",
2945 "rsa:2048",
2946 "-keyout",
2947 key.to_str().expect("utf-8"),
2948 "-out",
2949 cert.to_str().expect("utf-8"),
2950 "-days",
2951 "1",
2952 "-nodes",
2953 "-subj",
2954 "/CN=127.0.0.1",
2955 ])
2956 .stdout(Stdio::null())
2957 .stderr(Stdio::null())
2958 .status()
2959 .expect("openssl req");
2960 assert!(generated.success(), "openssl did not write a local cert");
2961
2962 let probe = TcpListener::bind("127.0.0.1:0").expect("bind");
2963 let port = probe.local_addr().expect("addr").port();
2964 drop(probe);
2965 let mut server = Command::new("openssl")
2966 .args([
2967 "s_server",
2968 "-accept",
2969 &format!("127.0.0.1:{port}"),
2970 "-cert",
2971 cert.to_str().expect("utf-8"),
2972 "-key",
2973 key.to_str().expect("utf-8"),
2974 "-www",
2975 ])
2976 .stdout(Stdio::null())
2977 .stderr(Stdio::null())
2978 .spawn()
2979 .expect("openssl s_server");
2980 let started = std::time::Instant::now();
2981 while started.elapsed() < Duration::from_secs(3) {
2982 if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() {
2983 break;
2984 }
2985 thread::sleep(Duration::from_millis(20));
2986 }
2987
2988 let err = post_decrypted(
2989 &format!("https://127.0.0.1:{port}/hook"),
2990 &DecryptedWake {
2991 body: "hello-https",
2992 from: "@bob:localhost",
2993 nick: None,
2994 event_id: "$m1:localhost",
2995 ..Default::default()
2996 },
2997 )
2998 .expect_err("a self-signed cert must not verify");
2999 let _ = server.kill();
3000 let _ = server.wait();
3001 assert!(
3002 !matches!(err, ShellError::RoutineUrl),
3003 "https was refused before the client tried it: {err}"
3004 );
3005 let text = err.to_string().to_ascii_lowercase();
3006 assert!(
3007 text.contains("cert")
3008 || text.contains("tls")
3009 || text.contains("handshake")
3010 || text.contains("ssl")
3011 || text.contains("unknownissuer")
3012 || text.contains("invalidpeer"),
3013 "https did not reach a tls failure: {err}"
3014 );
3015 }
3016
3017 #[test]
3018 fn matrix_path_drops_the_underscore_matrix_prefix() {
3019 let base = reqwest::Url::parse("http://127.0.0.1:9").expect("base");
3020 let url = request_url(
3021 &base,
3022 "/_matrix/client/v3/sync",
3023 &[("timeout".to_string(), "0".to_string())],
3024 )
3025 .expect("url");
3026 assert_eq!(url.path(), "/client/v3/sync");
3027 assert!(!url.path().contains("_matrix"));
3028 assert_eq!(url.query(), Some("timeout=0"));
3029 let untouched = request_url(&base, "/client/v3/sync", &[]).expect("url");
3030 assert_eq!(untouched.path(), "/client/v3/sync");
3031 }
3032
3033 #[test]
3034 fn send_message_releases_a_matrix_room_send() {
3035 let dir = temp_dir("send");
3036 let mut store = open_alice(&dir.0, "session-a");
3037 let released = store
3038 .send_room_message("!room:localhost", "hello room", 0)
3039 .expect("release");
3040 let send = released
3041 .iter()
3042 .find(|request| request.kind == OutgoingRequestKind::RoomSend)
3043 .unwrap_or_else(|| {
3044 panic!(
3045 "engine did not release a RoomSend: {:?}",
3046 released
3047 .iter()
3048 .map(|request| request.kind)
3049 .collect::<Vec<_>>()
3050 )
3051 });
3052 assert!(
3053 send.path.starts_with("/_matrix/client/v3/rooms/")
3054 && send.path.contains("/send/m.room.message/"),
3055 "not a matrix room send: {}",
3056 send.path
3057 );
3058 assert!(!send.path.contains("/mail/send"), "{}", send.path);
3059 assert!(!send.path.contains("/admin/listener"), "{}", send.path);
3060 let body = send.body.as_ref().expect("room send body");
3061 assert_eq!(body["msgtype"], "m.text");
3062 assert_eq!(body["body"], "hello room");
3063 }
3064
3065 #[test]
3066 fn drive_performs_the_released_request_with_bearer_and_no_matrix_prefix() {
3067 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
3068 let addr = listener.local_addr().expect("addr");
3069 let seen = Arc::new(Mutex::new(Vec::<String>::new()));
3070 let seen_worker = Arc::clone(&seen);
3071 listener.set_nonblocking(true).expect("nonblocking");
3072 let server = thread::spawn(move || {
3073 let deadline = std::time::Instant::now() + Duration::from_secs(3);
3074 loop {
3075 if std::time::Instant::now() > deadline {
3076 break;
3077 }
3078 let sock = match listener.accept() {
3079 Ok((sock, _)) => sock,
3080 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
3081 thread::sleep(Duration::from_millis(20));
3082 continue;
3083 }
3084 Err(_) => break,
3085 };
3086 let mut sock = sock;
3087 let _ = sock.set_nonblocking(false);
3088 let _ = sock.set_read_timeout(Some(Duration::from_secs(2)));
3089 let mut buf = Vec::new();
3090 let mut tmp = [0u8; 2048];
3091 loop {
3092 let n = sock.read(&mut tmp).unwrap_or(0);
3093 if n == 0 {
3094 break;
3095 }
3096 buf.extend_from_slice(&tmp[..n]);
3097 if let Some(header_end) =
3098 buf.windows(4).position(|window| window == b"\r\n\r\n")
3099 {
3100 let headers = String::from_utf8_lossy(&buf[..header_end]).to_string();
3101 let length = headers
3102 .lines()
3103 .find_map(|line| {
3104 let (name, value) = line.split_once(':')?;
3105 if name.eq_ignore_ascii_case("content-length") {
3106 value.trim().parse::<usize>().ok()
3107 } else {
3108 None
3109 }
3110 })
3111 .unwrap_or(0);
3112 if buf.len() >= header_end + 4 + length {
3113 let body = buf[header_end + 4..header_end + 4 + length].to_vec();
3114 let first = headers.lines().next().unwrap_or("").to_string();
3115 let bearer_ok = headers.lines().any(|line| {
3116 let (name, value) = line.split_once(':').unwrap_or(("", ""));
3117 name.eq_ignore_ascii_case("authorization")
3118 && value.trim() == "Bearer fake-token"
3119 });
3120 let note = format!(
3121 "{first} bearer_ok={bearer_ok} body={}",
3122 String::from_utf8_lossy(&body)
3123 );
3124 seen_worker.lock().expect("seen").push(note);
3125 let request = first;
3126 let response_body = if request.contains("/send/") {
3127 br#"{"event_id":"$e:localhost"}"#.to_vec()
3128 } else if request.contains("/keys/") {
3129 br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
3130 } else {
3131 br#"{"next_batch":"s1","device_one_time_keys_count":{"signed_curve25519":50},"device_unused_fallback_key_types":["signed_curve25519"]}"#.to_vec()
3132 };
3133 let head = format!(
3134 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
3135 response_body.len()
3136 );
3137 let _ = sock.write_all(head.as_bytes());
3138 let _ = sock.write_all(&response_body);
3139 break;
3140 }
3141 }
3142 }
3143 }
3144 });
3145
3146 let dir = temp_dir("drive");
3147 let device = DeviceId::parse("DEVICE1").expect("device id");
3148 let mut store = OpenedStore::open(
3149 &dir.0,
3150 "session-a",
3151 device,
3152 "@alice:localhost",
3153 "localhost",
3154 &format!("http://{addr}"),
3155 "fake-token",
3156 )
3157 .expect("open");
3158 store
3159 .dispatch(
3160 MessengerCommand::SendMessage {
3161 room_id: RoomId::parse("!room:localhost").expect("room"),
3162 message: OutgoingMessage {
3163 kind: MessageKind::Text,
3164 body: "hello room".to_string(),
3165 reply_to: None,
3166 edit_of: None,
3167 },
3168 txn_id: None,
3169 },
3170 0,
3171 )
3172 .expect("dispatch");
3173 store.drive(0, false).expect("drive");
3174 thread::sleep(Duration::from_millis(200));
3175 drop(store);
3176 let _ = server.join();
3177 let seen = seen.lock().expect("seen");
3178 assert!(
3179 seen.iter().any(|line| {
3180 line.contains("PUT /client/v3/rooms/")
3181 && line.contains("/send/m.room.message/")
3182 && line.contains("bearer_ok=true")
3183 && line.contains("hello room")
3184 && !line.contains("/_matrix")
3185 }),
3186 "released room send was not performed: {seen:?}"
3187 );
3188 assert!(
3189 seen.iter().all(|line| !line.contains("/_matrix")),
3190 "prefix was not stripped: {seen:?}"
3191 );
3192 }
3193
3194 struct Hit {
3195 headers: String,
3196 body: Vec<u8>,
3197 }
3198
3199 fn read_http(sock: &mut std::net::TcpStream) -> Option<(String, Vec<u8>)> {
3200 let _ = sock.set_read_timeout(Some(Duration::from_secs(2)));
3201 let mut buf = Vec::new();
3202 let mut tmp = [0u8; 2048];
3203 loop {
3204 let n = sock.read(&mut tmp).unwrap_or(0);
3205 if n == 0 {
3206 break;
3207 }
3208 buf.extend_from_slice(&tmp[..n]);
3209 if let Some(header_end) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
3210 let headers = String::from_utf8_lossy(&buf[..header_end]).to_string();
3211 let length = headers
3212 .lines()
3213 .find_map(|line| {
3214 let (name, value) = line.split_once(':')?;
3215 if name.eq_ignore_ascii_case("content-length") {
3216 value.trim().parse::<usize>().ok()
3217 } else {
3218 None
3219 }
3220 })
3221 .unwrap_or(0);
3222 if buf.len() >= header_end + 4 + length {
3223 let body = buf[header_end + 4..header_end + 4 + length].to_vec();
3224 return Some((headers, body));
3225 }
3226 }
3227 }
3228 None
3229 }
3230
3231 fn write_http(sock: &mut std::net::TcpStream, status: &str, body: &[u8]) {
3232 let head = format!(
3233 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
3234 body.len()
3235 );
3236 let _ = sock.write_all(head.as_bytes());
3237 let _ = sock.write_all(body);
3238 }
3239
3240 fn assert_wake_json(bytes: &[u8], body: &str, from: &str, event_id: &str, nick: Option<&str>) {
3241 let parsed: serde_json::Value = serde_json::from_slice(bytes).expect("json wake");
3242 assert_eq!(parsed["body"], body);
3243 assert_eq!(parsed["from"], from);
3244 assert_eq!(parsed["event_id"], event_id);
3245 match nick {
3246 Some(nick) => assert_eq!(parsed["nick"], nick),
3247 None => assert!(
3248 parsed.get("nick").is_none() || parsed["nick"].is_null(),
3249 "unknown nick must be omitted or null, not invented: {parsed}"
3250 ),
3251 }
3252 }
3253
3254 fn sync_with_text_nick(body: &str, nick: Option<&str>) -> Vec<u8> {
3255 let mut state = Vec::new();
3256 if let Some(nick) = nick {
3257 state.push(serde_json::json!({
3258 "event_id": "$mem:localhost",
3259 "type": "m.room.member",
3260 "state_key": "@bob:localhost",
3261 "sender": "@bob:localhost",
3262 "origin_server_ts": 1,
3263 "content": { "membership": "join", "displayname": nick }
3264 }));
3265 }
3266 serde_json::json!({
3267 "next_batch": "s1",
3268 "rooms": {
3269 "join": {
3270 "!r:localhost": {
3271 "state": { "events": state },
3272 "timeline": {
3273 "events": [{
3274 "event_id": "$m1:localhost",
3275 "type": "m.room.message",
3276 "sender": "@bob:localhost",
3277 "origin_server_ts": 10,
3278 "content": { "msgtype": "m.text", "body": body }
3279 }]
3280 }
3281 }
3282 }
3283 },
3284 "device_one_time_keys_count": { "signed_curve25519": 50 },
3285 "device_unused_fallback_key_types": ["signed_curve25519"]
3286 })
3287 .to_string()
3288 .into_bytes()
3289 }
3290
3291 fn sync_empty() -> Vec<u8> {
3292 serde_json::json!({
3293 "next_batch": "s2",
3294 "device_one_time_keys_count": { "signed_curve25519": 50 },
3295 "device_unused_fallback_key_types": ["signed_curve25519"]
3296 })
3297 .to_string()
3298 .into_bytes()
3299 }
3300
3301 fn spawn_homeserver(text: &'static str) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
3302 spawn_homeserver_nick(text, None)
3303 }
3304
3305 fn spawn_homeserver_nick(
3306 text: &'static str,
3307 nick: Option<&'static str>,
3308 ) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
3309 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
3310 let addr = listener.local_addr().expect("addr");
3311 listener.set_nonblocking(true).expect("nonblocking");
3312 let done = Arc::new(AtomicBool::new(false));
3313 let flag = Arc::clone(&done);
3314 let handle = thread::spawn(move || {
3315 let deadline = Instant::now() + Duration::from_secs(8);
3316 while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
3317 match listener.accept() {
3318 Ok((mut sock, _)) => {
3319 let _ = sock.set_nonblocking(false);
3320 let Some((headers, _)) = read_http(&mut sock) else {
3321 continue;
3322 };
3323 let line = headers.lines().next().unwrap_or("");
3324 let resp = if line.contains("/sync") && line.contains("since=") {
3325 sync_empty()
3326 } else if line.contains("/sync") {
3327 sync_with_text_nick(text, nick)
3328 } else if line.contains("/keys/") {
3329 br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
3330 } else {
3331 b"{}".to_vec()
3332 };
3333 write_http(&mut sock, "200 OK", &resp);
3334 }
3335 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
3336 thread::sleep(Duration::from_millis(15));
3337 }
3338 Err(_) => break,
3339 }
3340 }
3341 });
3342 (format!("http://{addr}"), done, handle)
3343 }
3344
3345 fn spawn_routine() -> (
3346 String,
3347 Arc<Mutex<Vec<Hit>>>,
3348 Arc<AtomicBool>,
3349 thread::JoinHandle<()>,
3350 ) {
3351 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
3352 let addr = listener.local_addr().expect("addr");
3353 listener.set_nonblocking(true).expect("nonblocking");
3354 let hits = Arc::new(Mutex::new(Vec::new()));
3355 let recorded = Arc::clone(&hits);
3356 let done = Arc::new(AtomicBool::new(false));
3357 let flag = Arc::clone(&done);
3358 let handle = thread::spawn(move || {
3359 let deadline = Instant::now() + Duration::from_secs(8);
3360 while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
3361 match listener.accept() {
3362 Ok((mut sock, _)) => {
3363 let _ = sock.set_nonblocking(false);
3364 if let Some((headers, body)) = read_http(&mut sock) {
3365 recorded.lock().expect("hits").push(Hit { headers, body });
3366 let _ = sock.write_all(
3367 b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
3368 );
3369 }
3370 }
3371 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
3372 thread::sleep(Duration::from_millis(15));
3373 }
3374 Err(_) => break,
3375 }
3376 }
3377 });
3378 (format!("http://{addr}/routine"), hits, done, handle)
3379 }
3380
3381 fn stop(flag: &AtomicBool, handle: thread::JoinHandle<()>) {
3382 flag.store(true, Ordering::Relaxed);
3383 let _ = handle.join();
3384 }
3385
3386 fn open_against(base: &str, dir: &Path, session: &str) -> OpenedStore {
3387 let device = DeviceId::parse("DEVICE1").expect("device id");
3388 OpenedStore::open(
3389 dir,
3390 session,
3391 device,
3392 "@alice:localhost",
3393 "localhost",
3394 base,
3395 "fake-token",
3396 )
3397 .expect("open")
3398 }
3399
3400 #[test]
3401 fn inbound_room_text_goes_through_the_provider_chain_once() {
3402 let (base, home_done, home) = spawn_homeserver("wake-chain");
3403 let dir = temp_dir("wake-chain");
3404 let inbox_dir = dir.0.join("inbox");
3405 let mut store = open_against(&base, &dir.0, "session-c");
3406 let session = provider::ProviderSession {
3407 kind: provider::SessionKind::local(provider::ProviderKind::Codex),
3408 session_id: "thread-c".into(),
3409 nick: "builder".into(),
3410 cwd: None,
3411 headless: false,
3412 };
3413 let host = provider::HostEnv {
3414 surface: provider::Surface::Local,
3415 vendor: None,
3416 os: "linux",
3417 };
3418 let config = provider::AdapterConfig {
3419 inbox_dir: Some(inbox_dir.clone()),
3420 spawn_program: Some("/bin/false".into()),
3421 ..provider::AdapterConfig::default()
3422 };
3423 let chain = provider::plan_chain(&session, &host, &config);
3424 store.set_wake_chain(session, chain);
3425 assert_eq!(store.wake_chain_ids()[0], "codex-app-server-turn");
3426 store.drive(1_000, false).expect("drive");
3427 store.drive(3_000, false).expect("drive again");
3428 let route = store.take_wake_route();
3429 let note = store.wake_note().unwrap_or("").to_string();
3430 drop(store);
3431 stop(&home_done, home);
3432 assert_eq!(route.as_deref(), Some("inbox-queue queued"), "note={note}");
3434 let letters = provider::inbox::drain(&inbox_dir);
3435 assert_eq!(letters.len(), 1);
3436 assert!(letters[0].letter.prompt.ends_with("wake-chain"));
3437 assert!(letters[0].letter.prompt.contains("m4a-send --as builder"));
3438 }
3439
3440 #[test]
3441 fn inbound_room_text_hits_the_routine_once() {
3442 let (base, home_done, home) = spawn_homeserver("wake-plain");
3443 let (routine, hits, routine_done, routine_thread) = spawn_routine();
3444 let dir = temp_dir("wake-once");
3445 let mut store = open_against(&base, &dir.0, "session-a");
3446 store.set_wake(SessionWake {
3447 routine_url: Some(routine),
3448 ..SessionWake::default()
3449 });
3450 store.drive(1_000, false).expect("drive");
3451 store.drive(3_000, false).expect("drive again");
3452 let note = store.wake_note().unwrap_or("").to_string();
3453 let saw_text = store.texts().iter().any(|text| text.body == "wake-plain");
3454 let trace = store.http_trace();
3455 drop(store);
3456 stop(&routine_done, routine_thread);
3457 stop(&home_done, home);
3458 assert!(
3459 saw_text,
3460 "engine did not surface the inbound text; http={trace:?} note={note}"
3461 );
3462 let hits = hits.lock().expect("hits");
3463 assert_eq!(
3464 hits.len(),
3465 1,
3466 "routine was not hit exactly once; note={note}"
3467 );
3468 let hit = &hits[0];
3469 assert!(
3470 hit.headers.starts_with("POST /routine HTTP/1.1"),
3471 "{}",
3472 hit.headers.lines().next().unwrap_or("")
3473 );
3474 assert!(
3475 !hit.headers.to_ascii_lowercase().contains("authorization"),
3476 "no bearer was passed, so the routine post must not send one"
3477 );
3478 assert!(
3479 !hit.headers
3480 .to_ascii_lowercase()
3481 .contains("x-automation-key"),
3482 "no bearer was passed, so the routine post must not send a key"
3483 );
3484 assert!(!hit.headers.contains("/mail/send"));
3485 assert!(!hit.headers.contains("/admin/listener"));
3486 assert!(
3487 hit.headers
3488 .to_ascii_lowercase()
3489 .contains("content-type: application/json"),
3490 "{}",
3491 hit.headers
3492 );
3493 assert_wake_json(
3494 &hit.body,
3495 "wake-plain",
3496 "@bob:localhost",
3497 "$m1:localhost",
3498 None,
3499 );
3500 }
3501
3502 #[test]
3503 fn routine_wake_stays_once_across_store_reopen() {
3504 let (base, home_done, home) = spawn_homeserver("wake-persist");
3505 let (routine, hits, routine_done, routine_thread) = spawn_routine();
3506 let dir = temp_dir("wake-persist");
3507 let mut store = open_against(&base, &dir.0, "session-a");
3508 store.set_wake(SessionWake {
3509 routine_url: Some(routine.clone()),
3510 ..SessionWake::default()
3511 });
3512 store.drive(1_000, false).expect("drive");
3513 let ledger = dir.0.join("routine-woken");
3514 assert!(
3515 ledger.is_file(),
3516 "successful wake must persist routine-woken"
3517 );
3518 drop(store);
3519 let mut store = open_against(&base, &dir.0, "session-a");
3520 store.set_wake(SessionWake {
3521 routine_url: Some(routine),
3522 ..SessionWake::default()
3523 });
3524 store.drive(1_000, false).expect("drive again");
3525 store.drive(3_000, false).expect("drive third");
3526 let note = store.wake_note().unwrap_or("").to_string();
3527 drop(store);
3528 stop(&routine_done, routine_thread);
3529 stop(&home_done, home);
3530 let hits = hits.lock().expect("hits");
3531 assert_eq!(
3532 hits.len(),
3533 1,
3534 "reopen must not re-POST the same event; note={note} hits={}",
3535 hits.len()
3536 );
3537 }
3538
3539 #[test]
3540 fn inbound_room_text_posts_the_sender_nick_the_shell_already_has() {
3541 let (base, home_done, home) = spawn_homeserver_nick("wake-named", Some("Alice"));
3542 let (routine, hits, routine_done, routine_thread) = spawn_routine();
3543 let dir = temp_dir("wake-nick");
3544 let mut store = open_against(&base, &dir.0, "session-a");
3545 store.set_wake(SessionWake {
3546 routine_url: Some(routine),
3547 ..SessionWake::default()
3548 });
3549 store.drive(1_000, false).expect("drive");
3550 let note = store.wake_note().unwrap_or("").to_string();
3551 drop(store);
3552 stop(&routine_done, routine_thread);
3553 stop(&home_done, home);
3554 let hits = hits.lock().expect("hits");
3555 assert_eq!(
3556 hits.len(),
3557 1,
3558 "routine was not hit exactly once; note={note}"
3559 );
3560 assert!(
3561 !hits[0]
3562 .headers
3563 .to_ascii_lowercase()
3564 .contains("authorization"),
3565 "no bearer was passed"
3566 );
3567 assert_wake_json(
3568 &hits[0].body,
3569 "wake-named",
3570 "@bob:localhost",
3571 "$m1:localhost",
3572 Some("Alice"),
3573 );
3574 }
3575
3576 #[test]
3577 fn no_routine_url_posts_nothing() {
3578 let (base, home_done, home) = spawn_homeserver("wake-plain");
3579 let (_routine, hits, routine_done, routine_thread) = spawn_routine();
3580 let dir = temp_dir("wake-none");
3581 let mut store = open_against(&base, &dir.0, "session-a");
3582 store.set_wake(SessionWake::default());
3583 store.drive(1_000, false).expect("drive");
3584 let saw_text = store.texts().iter().any(|text| text.body == "wake-plain");
3585 let trace = store.http_trace();
3586 drop(store);
3587 stop(&routine_done, routine_thread);
3588 stop(&home_done, home);
3589 assert!(
3590 saw_text,
3591 "missing routine url dropped the inbound text; http={trace:?}"
3592 );
3593 let hits = hits.lock().expect("hits");
3594 assert!(hits.is_empty(), "a post happened with no routine url");
3595 }
3596
3597 #[test]
3598 fn routine_bearer_header_is_sent_only_when_the_caller_passed_one() {
3599 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
3600 let addr = listener.local_addr().expect("addr");
3601 let server = thread::spawn(move || {
3602 let (mut sock, _) = listener.accept().expect("accept");
3603 let (headers, body) = read_http(&mut sock).expect("request");
3604 let _ = sock
3605 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
3606 (headers, body)
3607 });
3608 let bearer = "env-bearer";
3609 post_decrypted_with_bearer(
3610 &format!("http://{addr}/routine"),
3611 &DecryptedWake {
3612 body: "letter",
3613 from: "@bob:localhost",
3614 nick: Some("Bob"),
3615 event_id: "$m1:localhost",
3616 ..Default::default()
3617 },
3618 Some(bearer),
3619 )
3620 .expect("post");
3621 let (headers, body) = server.join().expect("server");
3622 let line = headers
3623 .lines()
3624 .find(|line| line.to_ascii_lowercase().starts_with("authorization:"))
3625 .expect("authorization header");
3626 let (name, value) = line.split_once(':').expect("header");
3627 assert!(name.eq_ignore_ascii_case("authorization"));
3628 assert_eq!(value.trim(), format!("Bearer {bearer}"));
3629 let automation = headers
3630 .lines()
3631 .find(|line| line.to_ascii_lowercase().starts_with("x-automation-key:"))
3632 .expect("automation key header");
3633 let (name, value) = automation.split_once(':').expect("header");
3634 assert!(name.eq_ignore_ascii_case("x-automation-key"));
3635 assert_eq!(value.trim(), bearer);
3636 assert_eq!(
3637 headers
3638 .lines()
3639 .filter(|line| line.to_ascii_lowercase().starts_with("authorization:"))
3640 .count(),
3641 1
3642 );
3643 assert_eq!(
3644 headers
3645 .lines()
3646 .filter(|line| line.to_ascii_lowercase().starts_with("x-automation-key:"))
3647 .count(),
3648 1
3649 );
3650 assert_wake_json(
3651 &body,
3652 "letter",
3653 "@bob:localhost",
3654 "$m1:localhost",
3655 Some("Bob"),
3656 );
3657 }
3658
3659 #[test]
3660 fn post_decrypted_omits_a_missing_or_blank_nick() {
3661 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
3662 let addr = listener.local_addr().expect("addr");
3663 let server = thread::spawn(move || {
3664 let mut bodies = Vec::new();
3665 for _ in 0..2 {
3666 let (mut sock, _) = listener.accept().expect("accept");
3667 let (headers, body) = read_http(&mut sock).expect("request");
3668 assert!(
3669 !headers.to_ascii_lowercase().contains("authorization"),
3670 "no bearer was passed"
3671 );
3672 assert!(
3673 !headers.to_ascii_lowercase().contains("x-automation-key"),
3674 "no bearer was passed"
3675 );
3676 let _ = sock.write_all(
3677 b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
3678 );
3679 bodies.push(body);
3680 }
3681 bodies
3682 });
3683 let url = format!("http://{addr}/routine");
3684 post_decrypted(
3685 &url,
3686 &DecryptedWake {
3687 body: "plain",
3688 from: "@bob:localhost",
3689 nick: None,
3690 event_id: "$m1:localhost",
3691 ..Default::default()
3692 },
3693 )
3694 .expect("missing nick");
3695 post_decrypted(
3696 &url,
3697 &DecryptedWake {
3698 body: "plain",
3699 from: "@bob:localhost",
3700 nick: Some(" "),
3701 event_id: "$m1:localhost",
3702 ..Default::default()
3703 },
3704 )
3705 .expect("blank nick");
3706 let bodies = server.join().expect("server");
3707 for body in &bodies {
3708 assert_wake_json(body, "plain", "@bob:localhost", "$m1:localhost", None);
3709 }
3710 }
3711
3712 #[cfg(unix)]
3713 #[test]
3714 fn inbound_room_text_prompts_the_leader_socket_once() {
3715 use std::os::unix::net::{UnixListener, UnixStream};
3716
3717 fn frame_read(sock: &mut UnixStream) -> Option<Vec<u8>> {
3718 let _ = sock.set_read_timeout(Some(Duration::from_secs(3)));
3719 let mut len_buf = [0u8; 4];
3720 sock.read_exact(&mut len_buf).ok()?;
3721 let len = u32::from_be_bytes(len_buf) as usize;
3722 if len > 1_000_000 {
3723 return None;
3724 }
3725 let mut buf = vec![0u8; len];
3726 sock.read_exact(&mut buf).ok()?;
3727 Some(buf)
3728 }
3729
3730 fn frame_write(sock: &mut UnixStream, value: &serde_json::Value) {
3731 let bytes = serde_json::to_vec(value).expect("json");
3732 let mut out = (bytes.len() as u32).to_be_bytes().to_vec();
3733 out.extend_from_slice(&bytes);
3734 sock.write_all(&out).expect("write");
3735 sock.flush().expect("flush");
3736 }
3737
3738 fn serve_one(sock: &mut UnixStream, prompts: &Mutex<Vec<String>>) {
3739 let Some(bytes) = frame_read(sock) else {
3740 return;
3741 };
3742 let register: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
3743 assert_eq!(register["type"], "register");
3744 frame_write(
3745 sock,
3746 &serde_json::json!({"type": "registered", "ready": true}),
3747 );
3748 loop {
3749 let Some(bytes) = frame_read(sock) else {
3750 break;
3751 };
3752 let value: serde_json::Value = match serde_json::from_slice(&bytes) {
3753 Ok(value) => value,
3754 Err(_) => break,
3755 };
3756 if value.get("type").and_then(|item| item.as_str()) == Some("disconnect") {
3757 break;
3758 }
3759 if value.get("type").and_then(|item| item.as_str()) != Some("acp") {
3760 continue;
3761 }
3762 let payload = value
3763 .get("payload")
3764 .and_then(|item| item.as_str())
3765 .unwrap_or("");
3766 let inner: serde_json::Value = serde_json::from_str(payload).unwrap_or_default();
3767 if inner.get("method").and_then(|item| item.as_str()) == Some("session/prompt") {
3768 let text = inner["params"]["prompt"][0]["text"]
3769 .as_str()
3770 .unwrap_or("")
3771 .to_string();
3772 let session = inner["params"]["sessionId"]
3773 .as_str()
3774 .unwrap_or("")
3775 .to_string();
3776 prompts
3777 .lock()
3778 .expect("prompts")
3779 .push(format!("{session} {text}"));
3780 }
3781 let id = inner.get("id").cloned().unwrap_or(serde_json::json!(null));
3782 let body = serde_json::json!({"jsonrpc":"2.0","id": id, "result": {}}).to_string();
3783 frame_write(sock, &serde_json::json!({"type":"acp","payload": body}));
3784 }
3785 }
3786
3787 let dir = temp_dir("leader");
3788 let sock_dir = dir.0.join("sock");
3789 std::fs::create_dir_all(&sock_dir).expect("dir");
3790 let path = sock_dir.join("leader.sock");
3791 let listener = UnixListener::bind(&path).expect("bind");
3792 listener.set_nonblocking(true).expect("nonblocking");
3793 let prompts = Arc::new(Mutex::new(Vec::<String>::new()));
3794 let recorded = Arc::clone(&prompts);
3795 let done = Arc::new(AtomicBool::new(false));
3796 let flag = Arc::clone(&done);
3797 let leader = thread::spawn(move || {
3798 let deadline = Instant::now() + Duration::from_secs(8);
3799 while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
3800 match listener.accept() {
3801 Ok((mut sock, _)) => {
3802 let _ = sock.set_nonblocking(false);
3803 serve_one(&mut sock, &recorded);
3804 }
3805 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
3806 thread::sleep(Duration::from_millis(15));
3807 }
3808 Err(_) => break,
3809 }
3810 }
3811 });
3812
3813 let (base, home_done, home) = spawn_homeserver("wake-leader");
3814 let mut store = open_against(&base, &dir.0.join("store"), "session-a");
3815 store.set_wake(SessionWake {
3816 leader_sock: Some(path),
3817 leader_cwd: Some("/tmp".to_string()),
3818 ..SessionWake::default()
3819 });
3820 store.drive(1_000, false).expect("drive");
3821 store.drive(3_000, false).expect("drive again");
3822 let note = store.wake_note().unwrap_or("").to_string();
3823 let saw_text = store.texts().iter().any(|text| text.body == "wake-leader");
3824 drop(store);
3825 stop(&done, leader);
3826 stop(&home_done, home);
3827 assert!(
3828 saw_text,
3829 "engine did not surface the inbound text; note={note}"
3830 );
3831 let prompts = prompts.lock().expect("prompts");
3832 assert_eq!(
3833 prompts.as_slice(),
3834 ["session-a wake-leader"],
3835 "leader prompt was not the decrypted text once; note={note}"
3836 );
3837 }
3838}