1pub mod cli;
65mod cmd;
66#[cfg(feature = "wake-grok")]
67mod grok_listen;
68mod ipc;
69mod machine;
70#[cfg(feature = "local-bus")]
71mod local_bus;
72#[cfg(not(feature = "local-bus"))]
73#[path = "local_bus_off.rs"]
74mod local_bus;
75mod nick;
76mod webhook;
77pub mod wake_policy;
78#[cfg(feature = "wake-grok")]
79mod node;
80pub mod provider;
81mod bus_backend;
82pub mod resolve;
83mod send;
84pub mod store_key;
85
86use std::collections::HashSet;
87use std::path::{Component, Path, PathBuf};
88use std::sync::Arc;
89use std::time::Duration;
90
91use mail4agent_messenger::store::sealed::SealedRecordCodec;
92use mail4agent_messenger::wire::Membership;
93use mail4agent_messenger::{
94 CoreConfig, CoreSecrets, ItemContent, Jitter, MessengerCore,
95 MessengerError, OutgoingRequest, OutgoingRequestKind, RecordKey, SealedRecord, SendState,
96 StoreError,
97};
98use sha2::{Digest, Sha256};
99use m4a_agent::engine::Driver;
100use m4a_agent::{AgentError, Backend};
101use zeroize::Zeroizing;
102
103pub use machine::{
104 ensure_agent_webhook_routines, ensure_agent_webhook_routines_from_env, load_agents_dir,
105 load_session_records, HostSession, MachineClient, RoutineReport, TickReport, WakeOptions,
106 WakeStatus, AGENTS_DIR_ENV, AGENT_RESCAN_SECS_ENV, DEFAULT_AGENTS_DIR,
107 PROFILE_NOTE_ENV, SESSIONS_DIR_ENV, SESSION_IDS_ENV, SKIP_NICKS_ENV, STORE_LOCK_FILE,
108 WAKE_KEYCHAIN_FILE,
109};
110pub use mail4agent_messenger::{
111 CreateRoomKind, DeviceId, MessageKind, MessengerCommand, OutgoingMessage, RoomId, RoomKind,
112 UserId,
113};
114pub use cmd::{CmdReply, CmdRequest};
115pub use mail4agent_messenger::EventId;
116#[cfg(feature = "wake-grok")]
117pub use grok_listen::{hear, GrokListener, Heard, ListenReport};
118pub use nick::{nick_from_display_name, routine_folder_id};
119#[cfg(feature = "wake-grok")]
120pub use node::{NodeClient, NodeTickReport, NODE_DEFAULT_SOCK_NAME};
121pub use provider::{
122 plan_chain, HostEnv, HookFlavor, ResumeSpawnAdapter, SessionRecord, WakeChain, WebVendor,
123 adapter_for, wake_prompt, AdapterConfig, ClaudeChannelAdapter, ClaudeRoutineFireAdapter,
124 CodexAppServerAdapter, CodexCloudAdapter, CodexEndpoint, CursorAgentAdapter, GrokLeaderAdapter,
125 KimiServerAdapter, NoInboundAdapter, ProviderKind, ProviderSession, RoutineWebhookAdapter,
126 SessionKind, Surface, WakeAdapter, WakeError, WakeLetter, WakeOutcome, INBOX_DIR_ENV,
127 PROVIDER_ENV,
128};
129pub use m4a_agent::engine::PushedRoomEvent;
130pub use send::{
131 load_env_file, load_env_file_named, send_cmd_via_socket, send_sock_path, send_sock_path_named,
132 send_via_socket,
133 SendReply, SendRequest, DEFAULT_SOCK_NAME, ENV_FILE_ENV, MAX_SEND_BYTES, SEND_SOCK_ENV,
134};
135
136pub fn store_seal_key(session_id: &str) -> [u8; 32] {
139 let digest = Sha256::digest(session_id.as_bytes());
140 let mut key = [0u8; 32];
141 key.copy_from_slice(&digest);
142 key
143}
144
145pub const STORE_ROOT_ENV: &str = "M4A_STORE_ROOT";
148
149pub const HOMESERVER_URL_ENV: &str = "M4A_HOMESERVER_URL";
153
154pub const CONFIG_ENV: &str = "M4A_CONFIG";
158
159pub const BOT_NAME_ENV: &str = "M4A_BOT_NAME";
162
163pub const SESSION_ID_ENV: &str = "M4A_SESSION_ID";
166
167pub const PRODUCT_URL_ENV: &str = "M4A_PRODUCT_URL";
170pub const PRODUCT_INVITE_ENV: &str = "M4A_PRODUCT_INVITE";
173pub const TIER_ENV: &str = "M4A_TIER";
175#[derive(Clone)]
179#[cfg_attr(not(any(feature = "tier-server", feature = "tier-matrix")), allow(dead_code))]
180struct IdentityAuth {
181 tier: m4a_agent::BackendKind,
182 invite: Option<Zeroizing<String>>,
183 nick_request: Option<String>,
185}
186
187pub fn session_store_dir(root: &Path, session_id: &str) -> PathBuf {
195 root.join(hex_encode(&store_seal_key(session_id)))
196}
197
198pub fn store_root() -> Result<PathBuf, ShellError> {
202 if let Some(root) = nonempty_var(STORE_ROOT_ENV) {
203 return Ok(PathBuf::from(root));
204 }
205 #[cfg(test)]
206 {
207 return Ok(std::env::temp_dir().join(format!(
208 "mail4agent-messenger-shell-root-{}",
209 std::process::id()
210 )));
211 }
212 #[cfg(not(test))]
213 {
214 Err(ShellError::StoreRoot)
215 }
216}
217
218fn hex_encode(bytes: &[u8]) -> String {
219 const HEX: &[u8; 16] = b"0123456789abcdef";
220 let mut out = String::with_capacity(bytes.len() * 2);
221 for byte in bytes {
222 out.push(HEX[(byte >> 4) as usize] as char);
223 out.push(HEX[(byte & 0x0f) as usize] as char);
224 }
225 out
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct WakeAttempt {
232 pub event_id: String,
234 pub status: Option<u16>,
236}
237
238pub struct RoutineWake {
241 pub url: String,
243}
244
245pub const ROUTINE_URL_ENV: &str = "M4A_ROUTINE_URL";
247
248pub const ROUTINE_BEARER_ENV: &str = "M4A_ROUTINE_BEARER";
253
254pub const LEADER_SOCK_ENV: &str = "M4A_LEADER_SOCK";
256
257pub const LEADER_CWD_ENV: &str = "M4A_LEADER_CWD";
260
261pub struct SessionWake {
266 pub routine_url: Option<String>,
268 pub routine_bearer: Option<String>,
271 pub leader_sock: Option<PathBuf>,
273 pub leader_cwd: Option<String>,
275}
276
277impl Default for SessionWake {
278 fn default() -> Self {
279 Self {
280 routine_url: None,
281 routine_bearer: None,
282 leader_sock: None,
283 leader_cwd: None,
284 }
285 }
286}
287
288impl SessionWake {
289 pub fn web_host() -> Self {
293 Self::web_from_lookup(|key| nonempty_var(key))
294 }
295
296 pub fn web_from_lookup(mut get: impl FnMut(&str) -> Option<String>) -> Self {
299 let routine_url = get(ROUTINE_URL_ENV).filter(|value| !value.is_empty());
300 let routine_bearer = if routine_url.is_some() {
301 get(ROUTINE_BEARER_ENV).filter(|value| !value.is_empty())
302 } else {
303 None
304 };
305 Self {
306 routine_url,
307 routine_bearer,
308 leader_sock: None,
309 leader_cwd: None,
310 }
311 }
312
313 pub fn node_cli() -> Result<Self, ShellError> {
317 Self::node_from_lookup(|key| nonempty_var(key))
318 }
319
320 pub fn node_from_lookup(
322 mut get: impl FnMut(&str) -> Option<String>,
323 ) -> Result<Self, ShellError> {
324 let routine_url = get(ROUTINE_URL_ENV).filter(|value| !value.is_empty());
325 let routine_bearer = get(ROUTINE_BEARER_ENV).filter(|value| !value.is_empty());
326 if routine_url.is_some() || routine_bearer.is_some() {
327 return Err(ShellError::NodeRoutine);
328 }
329 Ok(Self {
330 routine_url: None,
331 routine_bearer: None,
332 leader_sock: get(LEADER_SOCK_ENV)
333 .filter(|value| !value.is_empty())
334 .map(PathBuf::from),
335 leader_cwd: get(LEADER_CWD_ENV).filter(|value| !value.is_empty()),
336 })
337 }
338}
339
340pub(crate) fn nonempty_var(name: &str) -> Option<String> {
341 std::env::var(name).ok().filter(|value| !value.is_empty())
342}
343
344pub struct SessionConfig {
348 homeserver_url: String,
349 session_id: String,
350 store_root: PathBuf,
351 identity: IdentityAuth,
352}
353
354impl SessionConfig {
355 pub fn new_identity(
359 product_url: impl Into<String>,
360 tier: m4a_agent::BackendKind,
361 session_id: impl Into<String>,
362 store_root: impl Into<PathBuf>,
363 invite: Option<String>,
364 ) -> Result<Self, ShellError> {
365 let homeserver_url = product_url.into();
366 parse_base_url(&homeserver_url)?;
367 let session_id = session_id.into();
368 validate_session_id(&session_id)?;
369 let store_root = store_root.into();
370 let invite = invite.filter(|i| !i.is_empty()).or_else(|| read_invite_file(&store_root, &session_id));
371 Ok(Self {
372 homeserver_url,
373 session_id,
374 store_root,
375 identity: IdentityAuth { tier, invite: invite.map(Zeroizing::new), nick_request: None },
376 })
377 }
378
379 pub fn with_nick_request(mut self, name: &str) -> Self {
383 self.identity.nick_request = nick_from_display_name(name.trim()).ok();
384 self
385 }
386
387 pub fn for_session(url: &str, session_id: &str, store_root: &Path) -> Result<Self, ShellError> {
390 let tier = match nonempty_var(TIER_ENV).as_deref() {
391 None | Some("server") => m4a_agent::BackendKind::Server,
392 Some("matrix") => m4a_agent::BackendKind::Matrix,
393 Some(_) => return Err(ShellError::Register(format!("{TIER_ENV} is server or matrix"))),
394 };
395 Self::new_identity(url, tier, session_id, store_root, None)
396 }
397
398 pub fn from_env() -> Result<Self, ShellError> {
402 let toml_text = load_homeserver_toml()?;
403 Self::from_lookup(
404 |key| std::env::var(key).ok().filter(|value| !value.is_empty()),
405 toml_text.as_deref(),
406 )
407 }
408
409 pub(crate) fn from_lookup(
410 mut get: impl FnMut(&str) -> Option<String>,
411 toml_text: Option<&str>,
412 ) -> Result<Self, ShellError> {
413 let from_toml = match toml_text {
414 Some(text) => homeserver_url_from_toml(text)?,
415 None => None,
416 };
417 let url = get(PRODUCT_URL_ENV)
418 .or_else(|| get(HOMESERVER_URL_ENV))
419 .or(from_toml)
420 .ok_or(ShellError::HomeserverUrl)?;
421 let tier = match get(TIER_ENV).as_deref() {
422 None | Some("server") => m4a_agent::BackendKind::Server,
423 Some("matrix") => m4a_agent::BackendKind::Matrix,
424 Some(_) => return Err(ShellError::Register(format!("{TIER_ENV} is server or matrix"))),
425 };
426 let session_id = get(SESSION_ID_ENV).ok_or(ShellError::EmptySession)?;
427 let store_root = get(STORE_ROOT_ENV).ok_or(ShellError::StoreRoot)?;
428 let config = Self::new_identity(url, tier, session_id, store_root, get(PRODUCT_INVITE_ENV))?;
430 Ok(match get(BOT_NAME_ENV) {
431 Some(name) => config.with_nick_request(&name),
432 None => config,
433 })
434 }
435
436 pub fn homeserver_url(&self) -> &str {
438 &self.homeserver_url
439 }
440
441 pub fn store_dir(&self) -> PathBuf {
443 session_store_dir(&self.store_root, &self.session_id)
444 }
445
446 pub fn session_id(&self) -> &str {
448 &self.session_id
449 }
450}
451
452impl std::fmt::Debug for SessionConfig {
453 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454 f.debug_struct("SessionConfig")
455 .field("homeserver_url", &self.homeserver_url)
456 .field("session_id", &self.session_id)
457 .field("store_root", &self.store_root)
458 .field("tier", &self.identity.tier)
459 .finish()
460 }
461}
462
463fn invite_path(store_root: &Path, session_id: &str) -> PathBuf {
466 store_root.join("invites").join(hex_encode(&store_seal_key(session_id)))
467}
468
469fn read_invite_file(store_root: &Path, session_id: &str) -> Option<String> {
470 let text = std::fs::read_to_string(invite_path(store_root, session_id)).ok()?;
471 let code = text.trim().to_string();
472 (!code.is_empty()).then_some(code)
473}
474
475fn validate_session_id(session_id: &str) -> Result<(), ShellError> {
476 if session_id.is_empty()
477 || session_id.len() > 128
478 || session_id.starts_with("legacy-user-")
479 || session_id
480 .chars()
481 .any(|ch| ch.is_whitespace() || ch.is_control())
482 {
483 return Err(ShellError::EmptySession);
484 }
485 Ok(())
486}
487
488fn load_homeserver_toml() -> Result<Option<String>, ShellError> {
489 let path = if let Some(configured) = nonempty_var(CONFIG_ENV) {
490 PathBuf::from(configured)
491 } else {
492 let cwd = PathBuf::from("mail4agent.toml");
493 if !cwd.exists() {
494 return Ok(None);
495 }
496 cwd
497 };
498 if !path.is_file() {
499 return Err(ShellError::Config("config file is missing".to_string()));
500 }
501 std::fs::read_to_string(&path)
502 .map(Some)
503 .map_err(|err| ShellError::Config(clip_public(err.to_string())))
504}
505
506fn homeserver_url_from_toml(text: &str) -> Result<Option<String>, ShellError> {
507 #[derive(serde::Deserialize)]
508 struct File {
509 #[serde(default)]
510 homeserver_url: Option<String>,
511 }
512 let file: File =
513 toml::from_str(text).map_err(|err| ShellError::Config(clip_public(err.to_string())))?;
514 Ok(file
515 .homeserver_url
516 .map(|value| value.trim().to_string())
517 .filter(|value| !value.is_empty()))
518}
519
520pub struct FoundSession {
522 pub nick: String,
524 pub user_id: String,
526}
527
528#[derive(Default)]
535pub struct DecryptedWake<'a> {
536 pub body: &'a str,
538 pub from: &'a str,
540 pub nick: Option<&'a str>,
542 pub event_id: &'a str,
544 pub room: Option<&'a str>,
546 pub from_nick: Option<&'a str>,
548 pub to: Option<&'a str>,
550 pub reply: Option<&'a str>,
553}
554
555pub const SEND_COMMAND: &str = "m4a-send";
557
558pub fn reply_hint(to: &str, from_nick: &str) -> String {
560 format!("{SEND_COMMAND} --as {to} --to {from_nick} '<your reply>'")
561}
562
563pub fn mxid_localpart(mxid: &str) -> &str {
565 let rest = mxid.strip_prefix('@').unwrap_or(mxid);
566 rest.split_once(':').map(|(local, _)| local).unwrap_or(rest)
567}
568
569pub fn post_decrypted(url: &str, wake: &DecryptedWake<'_>) -> Result<(), ShellError> {
574 post_decrypted_with_bearer(url, wake, None)
575}
576
577pub fn post_decrypted_with_bearer(
584 url: &str,
585 wake: &DecryptedWake<'_>,
586 bearer: Option<&str>,
587) -> Result<(), ShellError> {
588 let bytes = routine_json(wake)?;
589 post_routine_bytes(url, bytes, bearer)
590}
591
592pub fn post_routine_json(
597 url: &str,
598 body: &serde_json::Value,
599 bearer: Option<&str>,
600) -> Result<(), ShellError> {
601 let bytes = serde_json::to_vec(body)
602 .map_err(|err| ShellError::RoutineTransport(clip_public(err.to_string())))?;
603 post_routine_bytes(url, bytes, bearer)
604}
605
606fn post_routine_bytes(url: &str, bytes: Vec<u8>, bearer: Option<&str>) -> Result<(), ShellError> {
607 let target = parse_routine_url(url)?;
608 let client = routine_client()?;
609 let token = bearer.map(str::trim).filter(|token| !token.is_empty());
610 let mut builder = client
611 .post(target)
612 .header(reqwest::header::CONTENT_TYPE, "application/json");
613 if let Some(token) = token {
614 let authorization = bearer_header(token)?;
615 let mut automation_key =
616 reqwest::header::HeaderValue::from_str(token).map_err(|_| ShellError::RoutineBearer)?;
617 automation_key.set_sensitive(true);
618 builder = builder
619 .header(reqwest::header::AUTHORIZATION, authorization)
620 .header("x-automation-key", automation_key);
621 }
622 let response = builder.body(bytes).send().map_err(|err| {
623 ShellError::RoutineTransport(redact_wake(public_reqwest(&err), url, token))
624 })?;
625 let status = response.status().as_u16();
626 if !(200..300).contains(&status) {
627 return Err(ShellError::RoutineStatus(status));
628 }
629 Ok(())
630}
631
632fn redact_wake(mut text: String, url: &str, bearer: Option<&str>) -> String {
633 if !url.is_empty() {
634 text = text.replace(url, "[redacted]");
635 }
636 if let Some(token) = bearer.filter(|token| token.len() >= 4) {
637 text = text.replace(token, "[redacted]");
638 }
639 text
640}
641
642fn routine_json(wake: &DecryptedWake<'_>) -> Result<Vec<u8>, ShellError> {
643 let mut object = serde_json::Map::new();
644 object.insert(
645 "body".to_string(),
646 serde_json::Value::String(wake.body.to_string()),
647 );
648 object.insert(
649 "from".to_string(),
650 serde_json::Value::String(wake.from.to_string()),
651 );
652 object.insert(
653 "event_id".to_string(),
654 serde_json::Value::String(wake.event_id.to_string()),
655 );
656 if let Some(nick) = wake.nick.map(str::trim).filter(|nick| !nick.is_empty()) {
657 object.insert(
658 "nick".to_string(),
659 serde_json::Value::String(nick.to_string()),
660 );
661 }
662 for (key, value) in [
663 ("room", wake.room),
664 ("from_nick", wake.from_nick),
665 ("to", wake.to),
666 ("reply", wake.reply),
667 ] {
668 if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
669 object.insert(
670 key.to_string(),
671 serde_json::Value::String(value.to_string()),
672 );
673 }
674 }
675 serde_json::to_vec(&object)
676 .map_err(|err| ShellError::RoutineTransport(clip_public(err.to_string())))
677}
678
679fn bearer_header(token: &str) -> Result<reqwest::header::HeaderValue, ShellError> {
680 let token = webhook::bearer_token(token).ok_or(ShellError::RoutineBearer)?;
682 let mut header = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
683 .map_err(|_| ShellError::RoutineBearer)?;
684 header.set_sensitive(true);
685 Ok(header)
686}
687
688fn routine_client() -> Result<reqwest::blocking::Client, ShellError> {
689 reqwest::blocking::Client::builder()
692 .timeout(Duration::from_secs(8))
693 .redirect(reqwest::redirect::Policy::none())
694 .http1_only()
695 .use_rustls_tls()
696 .build()
697 .map_err(|err| ShellError::RoutineTransport(public_reqwest(&err)))
698}
699
700fn public_reqwest(err: &reqwest::Error) -> String {
701 let mut text = err.to_string();
702 let mut source = std::error::Error::source(err);
703 while let Some(inner) = source {
704 text.push_str(": ");
705 text.push_str(&inner.to_string());
706 source = inner.source();
707 if text.len() > 400 {
708 break;
709 }
710 }
711 clip_public(text)
712}
713
714fn parse_routine_url(url: &str) -> Result<reqwest::Url, ShellError> {
715 if !webhook::url_ok(url) {
717 return Err(ShellError::RoutineUrl);
718 }
719 let parsed = reqwest::Url::parse(url).map_err(|_| ShellError::RoutineUrl)?;
720 match parsed.scheme() {
721 "http" | "https" => {}
722 _ => return Err(ShellError::RoutineUrl),
723 }
724 if parsed.host_str().is_none() || !parsed.username().is_empty() || parsed.password().is_some() {
725 return Err(ShellError::RoutineUrl);
726 }
727 Ok(parsed)
728}
729
730pub struct OpenedStore {
735 dir: PathBuf,
736 driver: Driver<SealedRecordCodec>,
737 device_token: Zeroizing<String>,
738 base_url: String,
739 session_id: String,
742 nick: Option<String>,
745 routine_url: Option<String>,
746 routine_bearer: Option<Zeroizing<String>>,
747 leader_sock: Option<PathBuf>,
748 leader_cwd: Option<String>,
749 routine_sent: HashSet<String>,
751 routine_woken_seed_pending: bool,
755 routine_restart_catchup: bool,
759 leader_sent: HashSet<String>,
761 wake_note: Option<String>,
763 wake_log: Vec<WakeAttempt>,
764 wake_chain: Option<(provider::ProviderSession, provider::WakeChain)>,
768 wake_route: Option<String>,
770 bus: Option<Arc<machine::LocalBus>>,
773 local_peers: Vec<(String, String)>,
776 pushed_room_events: Vec<PushedRoomEvent>,
778 history_pulled: HashSet<String>,
781 tip_pulled: HashSet<String>,
784}
785
786struct ZeroJitter;
787
788impl Jitter for ZeroJitter {
789 fn next_unit(&mut self) -> f64 {
790 0.0
791 }
792}
793
794pub struct RoomView {
796 pub room_id: String,
798 pub membership: String,
801 pub encrypted: bool,
803}
804
805pub struct TextView {
807 pub room_id: String,
809 pub body: String,
811 pub outcome: String,
813 pub event_id: Option<String>,
816}
817
818impl OpenedStore {
819 pub fn open(
828 dir: &Path,
829 session_id: &str,
830 device_id: DeviceId,
831 user_id: &str,
832 server_name: &str,
833 backend: Arc<dyn Backend>,
834 device_token: &str,
835 ) -> Result<Self, ShellError> {
836 if session_id.is_empty() {
837 return Err(ShellError::EmptySession);
838 }
839 if device_token.is_empty()
840 || device_token
841 .bytes()
842 .any(|byte| byte.is_ascii_whitespace() || !byte.is_ascii())
843 {
844 return Err(ShellError::DeviceToken);
845 }
846 let base_url = backend.server_ref().to_string();
847 fs_create_dir(dir)?;
848 let user_id = UserId::parse(user_id)?;
849 let secrets = CoreSecrets {
850 store_seal_key: Some(store_key::store_key(dir, session_id)?),
851 backup_key: None,
852 };
853 let config = CoreConfig {
854 user_id,
855 device_id,
856 server_name: server_name.to_string(),
857 };
858 let records = read_records(dir)?;
859 let mut core =
860 match MessengerCore::open_sealed(records, secrets, config, 0, Box::new(ZeroJitter)) {
861 Ok(core) => core,
862 Err(MessengerError::Store(err)) => return Err(ShellError::Store(err)),
863 Err(err) => return Err(ShellError::Messenger(err)),
864 };
865 persist(dir, &mut core)?;
866 let mut opened = Self {
867 dir: dir.to_path_buf(),
868 driver: Driver::new(core, backend, {
869 let dir = dir.to_path_buf();
870 Box::new(move |core| persist(&dir, core).map_err(|e| AgentError::Store(clip_public(e.to_string()))))
871 }),
872 base_url,
873 device_token: Zeroizing::new(device_token.to_string()),
874 session_id: session_id.to_string(),
875 nick: None,
876 routine_url: None,
877 routine_bearer: None,
878 leader_sock: None,
879 leader_cwd: None,
880 routine_sent: HashSet::new(),
881 routine_woken_seed_pending: false,
882 routine_restart_catchup: false,
883 leader_sent: HashSet::new(),
884 wake_chain: None,
885 wake_route: None,
886 wake_note: None,
887 wake_log: Vec::new(),
888 bus: None,
889 local_peers: Vec::new(),
890 pushed_room_events: Vec::new(),
891 history_pulled: HashSet::new(),
892 tip_pulled: HashSet::new(),
893 };
894 opened.note_already_present();
897 Ok(opened)
898 }
899
900 pub fn connect(config: &SessionConfig) -> Result<Self, ShellError> {
909 Self::connect_with_wake(config, SessionWake::default())
910 }
911
912 pub(crate) fn connect_with_wake(config: &SessionConfig, wake: SessionWake) -> Result<Self, ShellError> {
913 let registered = register_session(config)?;
914 let server_name = registered
915 .user_id
916 .split_once(':')
917 .map(|(_, server)| server)
918 .filter(|server| !server.is_empty())
919 .ok_or(ShellError::Register("user id has no server".to_string()))?;
920 let mut opened = Self::open(
921 &config.store_dir(),
922 &config.session_id,
923 registered.device_id,
924 ®istered.user_id,
925 server_name,
926 registered.backend,
927 ®istered.bearer,
928 )?;
929 opened.nick = Some(registered.nick.clone());
930 opened.set_wake(wake);
931 opened.drive(1_000, false)?;
933 Ok(opened)
934 }
935
936 pub fn connect_from_env() -> Result<Self, ShellError> {
941 Self::connect(&SessionConfig::from_env()?)
942 }
943
944 pub fn connect_node_from_env() -> Result<Self, ShellError> {
949 let wake = SessionWake::node_cli()?;
950 Self::connect_with_wake(&SessionConfig::from_env()?, wake)
951 }
952
953 #[cfg(test)]
954 pub(crate) fn connect_node_from_lookup(
955 mut get: impl FnMut(&str) -> Option<String>,
956 toml_text: Option<&str>,
957 ) -> Result<Self, ShellError> {
958 let wake = SessionWake::node_from_lookup(&mut get)?;
959 let config = SessionConfig::from_lookup(&mut get, toml_text)?;
960 Self::connect_with_wake(&config, wake)
961 }
962
963 pub(crate) fn keep_prefix(&self) -> bool {
965 self.driver.backend().keep_prefix()
966 }
967
968 pub fn device_bearer(&self) -> &str {
969 self.device_token.as_str()
970 }
971
972 pub fn nick(&self) -> Option<&str> {
975 self.nick.as_deref()
976 }
977
978 pub fn session_id(&self) -> &str {
980 &self.session_id
981 }
982
983 pub fn homeserver_url(&self) -> &str {
985 self.base_url.trim_end_matches('/')
986 }
987
988 pub fn has_leader(&self) -> bool {
990 self.leader_sock.is_some()
991 }
992
993 pub fn store_dir(&self) -> &Path {
995 &self.dir
996 }
997
998 pub fn homeserver_hits(&self) -> u64 {
1001 self.bus.as_ref().map(|bus| bus.hits()).unwrap_or(0)
1002 }
1003
1004 pub fn pushed_room_events(&self) -> &[PushedRoomEvent] {
1006 &self.pushed_room_events
1007 }
1008
1009 pub(crate) fn record_push(&mut self, event: PushedRoomEvent) {
1010 self.pushed_room_events.push(event);
1011 }
1012
1013 pub(crate) fn attach_bus(&mut self, bus: Arc<machine::LocalBus>) {
1014 let inner = Arc::clone(self.driver.backend());
1015 self.driver.set_backend(Arc::new(bus_backend::BusBackend { inner, bus: Arc::clone(&bus), user_id: self.driver.core.user_id().as_str().to_string(), force_local: None }));
1016 self.bus = Some(bus);
1017 }
1018
1019 pub(crate) fn set_registered_nick(&mut self, nick: String) {
1020 self.nick = Some(nick);
1021 }
1022
1023 pub(crate) fn set_local_peers(&mut self, peers: Vec<(String, String)>) {
1024 self.local_peers = peers;
1025 }
1026
1027 pub(crate) fn abandon_inflight_sync(&mut self, now_ms: i64) -> Result<(), ShellError> {
1031 if self.driver.sync_inflight() {
1032 self.driver.abandon_sync(now_ms);
1033 self.persist_core()?;
1034 }
1035 Ok(())
1036 }
1037
1038 pub fn user_id(&self) -> &str {
1040 self.driver.core.user_id().as_str()
1041 }
1042
1043 pub fn member_joined(&self, room_id: &str, user_id: &str) -> bool {
1045 let Ok(room_id) = RoomId::parse(room_id) else {
1046 return false;
1047 };
1048 let Ok(user_id) = UserId::parse(user_id) else {
1049 return false;
1050 };
1051 self.driver.core.room_state(&room_id).is_some_and(|state| {
1052 state
1053 .members
1054 .get(&user_id)
1055 .is_some_and(|member| member.membership == Membership::Join)
1056 })
1057 }
1058
1059 pub fn find_nick(
1063 &mut self,
1064 name_or_nick: &str,
1065 now_ms: i64,
1066 ) -> Result<FoundSession, ShellError> {
1067 let needle = nick::lookup_nick(name_or_nick)?;
1068 if let Some((nick, user_id)) = self
1069 .local_peers
1070 .iter()
1071 .find(|(nick, _)| nick.eq_ignore_ascii_case(&needle))
1072 {
1073 return Ok(FoundSession {
1074 nick: nick.clone(),
1075 user_id: user_id.clone(),
1076 });
1077 }
1078 self.dispatch(
1079 MessengerCommand::SearchUsers {
1080 term: needle.clone(),
1081 },
1082 now_ms,
1083 )?;
1084 self.drive(now_ms, false)?;
1085 let mut hits: Vec<FoundSession> = self
1086 .driver.core
1087 .user_search_result()
1088 .iter()
1089 .filter_map(|entry| {
1090 let nick = entry.display_name.as_deref()?.trim();
1091 if !nick.eq_ignore_ascii_case(&needle) {
1092 return None;
1093 }
1094 Some(FoundSession {
1095 nick: nick.to_string(),
1096 user_id: entry.user_id.as_str().to_string(),
1097 })
1098 })
1099 .collect();
1100 hits.sort_by(|left, right| left.user_id.cmp(&right.user_id));
1101 hits.dedup_by(|left, right| left.user_id == right.user_id);
1102 match hits.len() {
1103 1 => Ok(hits.remove(0)),
1104 0 => Err(ShellError::UnknownNick),
1105 _ => Err(ShellError::UnknownNick),
1106 }
1107 }
1108
1109 pub fn ensure_dm(&mut self, name_or_nick: &str, mut now_ms: i64) -> Result<String, ShellError> {
1113 let (_peer, room_id) = self.open_dm(name_or_nick, &mut now_ms)?;
1114 Ok(room_id)
1115 }
1116
1117 pub fn accept_direct_invites(&mut self, mut now_ms: i64) -> Result<Vec<String>, ShellError> {
1122 let me = self.driver.core.user_id().clone();
1123 let invited: Vec<RoomId> = self
1124 .driver.core
1125 .room_ids()
1126 .cloned()
1127 .filter(|room_id| {
1128 let Some(state) = self.driver.core.room_state(room_id) else {
1129 return false;
1130 };
1131 state.members.get(&me).is_some_and(|member| {
1132 member.membership == Membership::Invite && member.is_direct
1133 })
1134 })
1135 .collect();
1136 let mut ids = Vec::new();
1137 for room_id in invited {
1138 self.dispatch(
1139 MessengerCommand::JoinRoom {
1140 room_id: room_id.clone(),
1141 },
1142 now_ms,
1143 )?;
1144 ids.push(room_id.as_str().to_string());
1145 }
1146 if ids.is_empty() {
1147 return Ok(ids);
1148 }
1149 for _ in 0..8 {
1150 now_ms += 1_000;
1151 self.drive(now_ms, false)?;
1152 let still_invited = ids.iter().any(|room_id| {
1153 self.rooms()
1154 .iter()
1155 .any(|room| room.room_id == *room_id && room.membership == "invite")
1156 });
1157 if !still_invited {
1158 break;
1159 }
1160 }
1161 Ok(ids)
1162 }
1163
1164 pub fn write_to_nick(
1173 &mut self,
1174 name_or_nick: &str,
1175 text: &str,
1176 mut now_ms: i64,
1177 ) -> Result<String, ShellError> {
1178 let (peer, room_id) = self.open_dm(name_or_nick, &mut now_ms)?;
1179 if !self.member_joined(&room_id, peer.as_str()) {
1180 return Err(ShellError::Dm);
1181 }
1182 let encrypted = self
1183 .rooms()
1184 .into_iter()
1185 .any(|room| room.room_id == room_id && room.encrypted);
1186 if !encrypted {
1187 return Err(ShellError::Dm);
1188 }
1189 self.dispatch(
1190 MessengerCommand::SendMessage {
1191 room_id: RoomId::parse(&room_id)?,
1192 message: OutgoingMessage {
1193 kind: MessageKind::Text,
1194 body: text.to_string(),
1195 reply_to: None,
1196 edit_of: None,
1197 },
1198 txn_id: None,
1199 },
1200 now_ms,
1201 )?;
1202 for _ in 0..20 {
1203 now_ms += 1_000;
1204 self.drive(now_ms, false)?;
1205 if let Some(row) = self
1206 .texts()
1207 .into_iter()
1208 .find(|row| row.room_id == room_id && row.body == text)
1209 {
1210 if row.outcome == "sent" {
1211 return Ok(room_id);
1212 }
1213 if row.outcome.starts_with("failed") {
1214 return Err(ShellError::Dm);
1215 }
1216 }
1217 }
1218 Err(ShellError::Dm)
1219 }
1220
1221 fn open_dm(
1222 &mut self,
1223 name_or_nick: &str,
1224 now_ms: &mut i64,
1225 ) -> Result<(UserId, String), ShellError> {
1226 let found = self.find_nick(name_or_nick, *now_ms)?;
1227 let peer = UserId::parse(&found.user_id)?;
1228 if &peer == self.driver.core.user_id() {
1229 return Err(ShellError::UnknownNick);
1230 }
1231 let room_id = if let Some(room_id) = self.dm_room(&peer) {
1232 room_id
1233 } else {
1234 self.dispatch(
1235 MessengerCommand::CreateRoom {
1236 kind: CreateRoomKind::Dm { peer: peer.clone() },
1237 },
1238 *now_ms,
1239 )?;
1240 self.wait_for_dm(&peer, now_ms)?
1241 };
1242 Ok((peer, room_id))
1243 }
1244
1245 fn wait_for_dm(&mut self, peer: &UserId, now_ms: &mut i64) -> Result<String, ShellError> {
1246 for attempt in 0..6 {
1247 *now_ms += 1_000;
1248 self.drive(*now_ms, attempt == 5)?;
1249 if let Some(room_id) = self.dm_room(peer) {
1250 return Ok(room_id);
1251 }
1252 }
1253 Err(ShellError::Dm)
1254 }
1255
1256 fn dm_room(&self, peer: &UserId) -> Option<String> {
1257 let me = self.driver.core.user_id().clone();
1258 let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1259 for room_id in &ids {
1260 let (joined, has_peer) = {
1261 let Some(state) = self.driver.core.room_state(room_id) else {
1262 continue;
1263 };
1264 let joined = state
1265 .members
1266 .get(&me)
1267 .is_some_and(|member| member.membership == Membership::Join);
1268 let has_peer = state.members.contains_key(peer);
1269 (joined, has_peer)
1270 };
1271 if !joined || !has_peer {
1272 continue;
1273 }
1274 if self.driver.core.room_kind(room_id) == Some(RoomKind::Dm) {
1275 return Some(room_id.as_str().to_string());
1276 }
1277 }
1278 None
1279 }
1280
1281 pub fn set_wake(&mut self, wake: SessionWake) {
1286 self.routine_url = wake.routine_url.filter(|url| !url.is_empty());
1287 self.routine_bearer = wake
1288 .routine_bearer
1289 .filter(|token| !token.is_empty())
1290 .map(Zeroizing::new);
1291 self.leader_sock = wake.leader_sock.filter(|path| !path.as_os_str().is_empty());
1292 self.leader_cwd = wake.leader_cwd.filter(|cwd| !cwd.is_empty());
1293 }
1294
1295 pub fn set_wake_chain(
1299 &mut self,
1300 session: provider::ProviderSession,
1301 chain: provider::WakeChain,
1302 ) {
1303 self.wake_chain = Some((session, chain));
1304 }
1305
1306 pub fn wake_chain_ids(&self) -> Vec<&'static str> {
1308 self.wake_chain
1309 .as_ref()
1310 .map(|(_, chain)| chain.ids())
1311 .unwrap_or_default()
1312 }
1313
1314 pub fn wake_route(&self) -> Option<&str> {
1316 self.wake_route.as_deref()
1317 }
1318
1319 pub fn take_wake_route(&mut self) -> Option<String> {
1321 self.wake_route.take()
1322 }
1323
1324 pub fn has_routine(&self) -> bool {
1326 self.routine_url.is_some()
1327 }
1328
1329 pub(crate) fn routine_target(&self) -> Option<(String, Option<String>)> {
1332 let url = self.routine_url.clone()?;
1333 let bearer = self.routine_bearer.as_ref().map(|token| token.as_str().to_string());
1334 Some((url, bearer))
1335 }
1336
1337 pub fn wake_note(&self) -> Option<&str> {
1341 self.wake_note.as_deref()
1342 }
1343
1344 pub fn wake_log(&self) -> &[WakeAttempt] {
1347 &self.wake_log
1348 }
1349
1350 pub fn dispatch(&mut self, command: MessengerCommand, now_ms: i64) -> Result<(), ShellError> {
1353 self.driver.core.dispatch(command, now_ms)?;
1354 self.persist_core()?;
1355 Ok(())
1356 }
1357
1358 pub fn send_room_message(
1365 &mut self,
1366 room_id: &str,
1367 text: &str,
1368 now_ms: i64,
1369 ) -> Result<Vec<OutgoingRequest>, ShellError> {
1370 let room_id = RoomId::parse(room_id)?;
1371 self.driver.core.dispatch(
1372 MessengerCommand::SendMessage {
1373 room_id,
1374 message: OutgoingMessage {
1375 kind: MessageKind::Text,
1376 body: text.to_string(),
1377 reply_to: None,
1378 edit_of: None,
1379 },
1380 txn_id: None,
1381 },
1382 now_ms,
1383 )?;
1384 self.persist_core()?;
1385 let mut released = self.driver.core.releasable_requests(now_ms);
1386 self.persist_core()?;
1387 if !released
1388 .iter()
1389 .any(|request| request.kind == OutgoingRequestKind::RoomSend)
1390 {
1391 released.extend(self.driver.core.releasable_requests(now_ms));
1392 self.persist_core()?;
1393 }
1394 Ok(released)
1395 }
1396
1397 pub fn drive(&mut self, now_ms: i64, wait_for_sync: bool) -> Result<(), ShellError> {
1407 self.driver.core.decrypt_loaded_timeline();
1408 let trace_at = self.driver.http_trace.len();
1409 let history = self.request_missing_history(now_ms)?;
1410 self.pull_room_tips()?;
1411 let mut waited_long_poll = false;
1412 if wait_for_sync && self.driver.sync_inflight() {
1413 waited_long_poll = self.driver.harvest_sync(now_ms, true)?;
1416 } else {
1417 self.driver.harvest_sync(now_ms, false)?;
1418 }
1419 self.wake_inbound();
1420 for _ in 0..24 {
1421 if !self.driver.step(now_ms, wait_for_sync, &mut waited_long_poll)? {
1422 break;
1423 }
1424 self.wake_inbound();
1425 }
1426 self.note_history_pages(&history, trace_at);
1427 self.note_room_tips();
1428 self.routine_restart_catchup = false;
1429 Ok(())
1430 }
1431
1432 fn pull_room_tips(&mut self) -> Result<(), ShellError> {
1435 let me = self.driver.core.user_id().clone();
1436 let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1437 for room_id in ids {
1438 if self.tip_pulled.contains(room_id.as_str()) {
1439 continue;
1440 }
1441 if self
1442 .driver.core
1443 .timeline(&room_id)
1444 .is_some_and(|timeline| !timeline.items().is_empty())
1445 {
1446 self.tip_pulled.insert(room_id.as_str().to_string());
1447 continue;
1448 }
1449 let joined = self.driver.core.room_state(&room_id).is_some_and(|state| {
1450 state
1451 .members
1452 .get(&me)
1453 .is_some_and(|member| matches!(member.membership, Membership::Join))
1454 });
1455 if !joined {
1456 continue;
1457 }
1458 self.driver.core.pull_latest_page(room_id)?;
1459 }
1460 Ok(())
1461 }
1462
1463 fn note_room_tips(&mut self) {
1464 let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1465 for room_id in ids {
1466 if self
1467 .driver.core
1468 .timeline(&room_id)
1469 .is_some_and(|timeline| !timeline.items().is_empty())
1470 {
1471 self.tip_pulled.insert(room_id.as_str().to_string());
1472 }
1473 }
1474 }
1475
1476 fn request_missing_history(&mut self, now_ms: i64) -> Result<Vec<String>, ShellError> {
1480 let me = self.driver.core.user_id().clone();
1481 let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1482 let mut requested = Vec::new();
1483 for room_id in ids {
1484 if !self.room_needs_history(&room_id, &me) {
1485 continue;
1486 }
1487 let label = room_id.as_str().to_string();
1488 self.driver.core
1489 .dispatch(MessengerCommand::LoadOlder { room_id }, now_ms)?;
1490 requested.push(label);
1491 }
1492 Ok(requested)
1493 }
1494
1495 fn room_needs_history(&self, room_id: &RoomId, me: &UserId) -> bool {
1496 if self.history_pulled.contains(room_id.as_str()) {
1497 return false;
1498 }
1499 let joined = match self
1500 .driver.core
1501 .room_state(room_id)
1502 .and_then(|state| state.members.get(me))
1503 {
1504 Some(member) => matches!(member.membership, Membership::Join),
1505 None => false,
1506 };
1507 if !joined {
1508 return false;
1509 }
1510 match self.driver.core.timeline(room_id) {
1511 Some(timeline) => timeline.items().is_empty(),
1512 None => true,
1513 }
1514 }
1515
1516 fn note_history_pages(&mut self, requested: &[String], trace_at: usize) {
1517 if requested.is_empty() {
1518 return;
1519 }
1520 let ok = self.driver.http_trace[trace_at..]
1521 .iter()
1522 .filter(|(kind, status)| {
1523 *kind == OutgoingRequestKind::RoomMessages && (200..300).contains(status)
1524 })
1525 .count();
1526 if ok < requested.len() {
1527 return;
1528 }
1529 for room_id in requested {
1530 self.history_pulled.insert(room_id.clone());
1531 }
1532 }
1533
1534 pub fn rooms(&self) -> Vec<RoomView> {
1536 let me = self.driver.core.user_id().clone();
1537 let mut rooms: Vec<RoomView> = self
1538 .driver.core
1539 .room_ids()
1540 .map(|room_id| {
1541 let state = self.driver.core.room_state(room_id);
1542 let membership = state
1543 .and_then(|state| state.members.get(&me))
1544 .map(|member| membership_name(&member.membership).to_string())
1545 .unwrap_or_else(|| "absent".to_string());
1546 let encrypted = state.and_then(|state| state.encryption.as_ref()).is_some();
1547 RoomView {
1548 room_id: room_id.as_str().to_string(),
1549 membership,
1550 encrypted,
1551 }
1552 })
1553 .collect();
1554 rooms.sort_by(|left, right| left.room_id.cmp(&right.room_id));
1555 rooms
1556 }
1557
1558 pub fn texts(&self) -> Vec<TextView> {
1561 let mut out = Vec::new();
1562 for room_id in self.driver.core.room_ids() {
1563 let Some(timeline) = self.driver.core.timeline(room_id) else {
1564 continue;
1565 };
1566 for item in timeline.items() {
1567 match &item.content {
1568 ItemContent::Text(text)
1569 | ItemContent::Notice(text)
1570 | ItemContent::Emote(text) => {
1571 out.push(TextView {
1572 room_id: room_id.as_str().to_string(),
1573 body: text.body.clone(),
1574 outcome: outcome_name(&item.send_state),
1575 event_id: item.event_id.as_ref().map(|id| id.as_str().to_string()),
1576 });
1577 }
1578 ItemContent::Undecryptable { reason } => out.push(TextView {
1579 room_id: room_id.as_str().to_string(),
1580 body: String::new(),
1581 outcome: format!("undecryptable:{reason}"),
1582 event_id: item.event_id.as_ref().map(|id| id.as_str().to_string()),
1583 }),
1584 _ => {}
1585 }
1586 }
1587 }
1588 out
1589 }
1590
1591 pub fn sync_inflight(&self) -> bool {
1593 self.driver.sync_inflight()
1594 }
1595
1596 pub fn http_trace(&self) -> Vec<String> {
1597 self.driver.http_trace
1598 .iter()
1599 .map(|(kind, status)| format!("{kind:?} {status}"))
1600 .collect()
1601 }
1602
1603 pub fn take_ingest_error(&mut self) -> Option<String> {
1605 self.driver.core.take_ingest_error().map(clip_public)
1606 }
1607
1608 fn persist_core(&mut self) -> Result<(), ShellError> {
1609 self.driver.persist().map_err(ShellError::from)
1610 }
1611
1612 pub fn take_security_alerts(&mut self) -> Vec<String> {
1614 std::mem::take(&mut self.driver.security_alerts)
1615 }
1616
1617 fn inbound_plaintexts(&self) -> Vec<InboundPlaintext> {
1618 let me = self.driver.core.user_id();
1619 let mut out = Vec::new();
1620 for room_id in self.driver.core.room_ids() {
1621 let Some(timeline) = self.driver.core.timeline(room_id) else {
1622 continue;
1623 };
1624 for item in timeline.items() {
1625 if item.redacted || &item.sender == me || item.send_state != SendState::Sent {
1626 continue;
1627 }
1628 let body = match &item.content {
1629 ItemContent::Text(text)
1630 | ItemContent::Notice(text)
1631 | ItemContent::Emote(text) => text.body.clone(),
1632 _ => continue,
1633 };
1634 let Some(event_id) = item.event_id.as_ref() else {
1635 continue;
1636 };
1637 let key = format!("{}\n{}", room_id.as_str(), event_id.as_str());
1638 out.push(InboundPlaintext {
1639 room_id: room_id.as_str().to_string(),
1640 key,
1641 body,
1642 from: item.sender.as_str().to_string(),
1643 nick: self.sender_nick(room_id, &item.sender),
1644 event_id: event_id.as_str().to_string(),
1645 });
1646 }
1647 }
1648 out
1649 }
1650
1651 fn sender_nick(&self, room_id: &RoomId, sender: &UserId) -> Option<String> {
1655 let name = self
1656 .driver.core
1657 .room_state(room_id)?
1658 .members
1659 .get(sender)?
1660 .displayname
1661 .as_deref()?
1662 .trim();
1663 if name.is_empty() {
1664 None
1665 } else {
1666 Some(name.to_string())
1667 }
1668 }
1669
1670 fn note_already_present(&mut self) {
1671 self.load_routine_woken();
1672 self.load_leader_prompted();
1673 }
1674
1675 fn load_routine_woken(&mut self) {
1678 let path = self.dir.join(ROUTINE_WOKEN_FILE);
1679 match std::fs::read_to_string(&path) {
1680 Ok(text) if !text.trim().is_empty() => {
1681 for line in text.lines() {
1682 let Some((room, event)) = line.split_once('\t') else {
1683 continue;
1684 };
1685 if room.is_empty() || event.is_empty() {
1686 continue;
1687 }
1688 self.routine_sent.insert(format!("{room}\n{event}"));
1689 }
1690 self.routine_woken_seed_pending = false;
1691 self.routine_restart_catchup = true;
1694 }
1695 Ok(_) | Err(_) => {
1696 self.routine_woken_seed_pending = true;
1698 self.routine_restart_catchup = false;
1699 }
1700 }
1701 }
1702
1703 fn seed_routine_woken_from_timeline(&mut self) {
1708 let path = self.dir.join(ROUTINE_WOKEN_FILE);
1709 let items = self.inbound_plaintexts();
1710 let mut newest: std::collections::HashMap<String, String> =
1711 std::collections::HashMap::new();
1712 for item in &items {
1713 newest.insert(item.room_id.clone(), item.key.clone());
1714 }
1715 let mut lines = String::new();
1716 for item in &items {
1717 if newest.get(&item.room_id) == Some(&item.key) {
1718 continue;
1719 }
1720 self.routine_sent.insert(item.key.clone());
1721 if let Some((room, event)) = item.key.split_once('\n') {
1722 lines.push_str(room);
1723 lines.push('\t');
1724 lines.push_str(event);
1725 lines.push('\n');
1726 }
1727 }
1728 let _ = std::fs::write(path, lines);
1729 self.routine_woken_seed_pending = false;
1730 }
1731
1732 fn remember_routine_wake(&mut self, key: &str) {
1733 self.routine_sent.insert(key.to_string());
1734 let Some((room, event)) = key.split_once('\n') else {
1735 return;
1736 };
1737 if room.is_empty() || event.is_empty() {
1738 return;
1739 }
1740 let path = self.dir.join(ROUTINE_WOKEN_FILE);
1741 let mut file = match std::fs::OpenOptions::new()
1742 .create(true)
1743 .append(true)
1744 .open(&path)
1745 {
1746 Ok(file) => file,
1747 Err(_) => return,
1748 };
1749 use std::io::Write;
1750 let _ = write!(file, "{room}\t{event}\n");
1751 }
1752
1753 fn load_leader_prompted(&mut self) {
1757 let path = self.dir.join(LEADER_PROMPTED_FILE);
1758 match std::fs::read_to_string(&path) {
1759 Ok(text) => {
1760 for line in text.lines() {
1761 let Some((room, event)) = line.split_once('\t') else {
1762 continue;
1763 };
1764 if room.is_empty() || event.is_empty() {
1765 continue;
1766 }
1767 self.leader_sent.insert(format!("{room}\n{event}"));
1768 }
1769 }
1770 Err(_) => self.seed_leader_prompted_except_newest(&path),
1771 }
1772 }
1773
1774 fn seed_leader_prompted_except_newest(&mut self, path: &Path) {
1775 let items = self.inbound_plaintexts();
1776 let mut newest: std::collections::HashMap<String, String> =
1777 std::collections::HashMap::new();
1778 for item in &items {
1779 newest.insert(item.room_id.clone(), item.key.clone());
1780 }
1781 let mut lines = String::new();
1782 for item in &items {
1783 if newest.get(&item.room_id) == Some(&item.key) {
1784 continue;
1785 }
1786 self.leader_sent.insert(item.key.clone());
1787 if let Some((room, event)) = item.key.split_once('\n') {
1788 lines.push_str(room);
1789 lines.push('\t');
1790 lines.push_str(event);
1791 lines.push('\n');
1792 }
1793 }
1794 let _ = std::fs::write(path, lines);
1795 }
1796
1797 fn remember_leader_prompt(&mut self, key: &str) {
1798 self.leader_sent.insert(key.to_string());
1799 let Some((room, event)) = key.split_once('\n') else {
1800 return;
1801 };
1802 if room.is_empty() || event.is_empty() {
1803 return;
1804 }
1805 let path = self.dir.join(LEADER_PROMPTED_FILE);
1806 let mut file = match std::fs::OpenOptions::new()
1807 .create(true)
1808 .append(true)
1809 .open(&path)
1810 {
1811 Ok(file) => file,
1812 Err(_) => return,
1813 };
1814 use std::io::Write;
1815 let _ = write!(file, "{room}\t{event}\n");
1816 }
1817
1818 fn arm_unprompted_newest(&mut self) {
1826 if (self.leader_sock.is_none() && self.wake_chain.is_none()) || !self.leader_sent.is_empty() {
1827 return;
1828 }
1829 if self.inbound_plaintexts().is_empty() {
1830 return;
1831 }
1832 let path = self.dir.join(LEADER_PROMPTED_FILE);
1833 self.seed_leader_prompted_except_newest(&path);
1834 }
1835
1836 fn room_is_direct(&self, room_id: &str) -> bool {
1838 let Some(id) = self.driver.core.room_ids().find(|r| r.as_str() == room_id) else { return false };
1839 let Some(state) = self.driver.core.room_state(id) else { return false };
1840 let live = state.members.values().filter(|m| matches!(membership_name(&m.membership), "join" | "invite")).count();
1841 state.name.is_none() && live <= 2
1842 }
1843
1844 #[cfg_attr(not(feature = "wake-grok"), allow(unused_variables))]
1845 fn wake_inbound(&mut self) {
1846 if self.routine_url.is_none() && self.leader_sock.is_none() && self.wake_chain.is_none() {
1847 return;
1848 }
1849 if self.routine_restart_catchup && self.routine_url.is_some() {
1850 for item in self.inbound_plaintexts() {
1853 if !self.routine_sent.contains(&item.key) {
1854 self.remember_routine_wake(&item.key);
1855 }
1856 }
1857 } else if self.routine_woken_seed_pending && self.routine_url.is_some() {
1858 if !self.inbound_plaintexts().is_empty() {
1862 self.seed_routine_woken_from_timeline();
1863 }
1864 }
1865 self.arm_unprompted_newest();
1866 let url = self.routine_url.clone();
1867 let bearer = self.routine_bearer.clone();
1868 let sock = self.leader_sock.clone();
1869 let cwd = self.leader_cwd.clone();
1870 let session_id = self.session_id.clone();
1871 let policy = crate::wake_policy::WakePolicy::load(self.store_dir());
1872 let me_id = self.driver.core.user_id().as_str().to_string();
1873 let mut items = self.inbound_plaintexts();
1874 items.retain(|item| {
1877 let direct = self.room_is_direct(&item.room_id);
1878 let mentioned = crate::wake_policy::addresses(&item.body, self.nick.as_deref(), &me_id);
1879 if policy.allows(direct, mentioned) {
1880 return true;
1881 }
1882 self.remember_routine_wake(&item.key);
1883 self.remember_leader_prompt(&item.key);
1884 false
1885 });
1886 for item in items {
1887 if let Some(url) = url.as_deref() {
1888 if !self.routine_restart_catchup && !self.routine_sent.contains(&item.key) {
1890 let from_nick = mxid_localpart(&item.from).to_string();
1891 let to = self.nick.clone();
1892 let reply = to.as_deref().map(|to| reply_hint(to, &from_nick));
1893 let wake = DecryptedWake {
1894 body: &item.body,
1895 from: &item.from,
1896 nick: item.nick.as_deref(),
1897 event_id: &item.event_id,
1898 room: Some(&item.room_id),
1899 from_nick: Some(&from_nick),
1900 to: to.as_deref(),
1901 reply: reply.as_deref(),
1902 };
1903 match post_decrypted_with_bearer(
1904 url,
1905 &wake,
1906 bearer.as_ref().map(|token| token.as_str()),
1907 ) {
1908 Ok(()) => {
1909 self.remember_routine_wake(&item.key);
1910 crate::wake_policy::note_attempt(self.store_dir(), Some(200));
1911 self.wake_log.push(WakeAttempt {
1912 event_id: item.event_id.clone(),
1913 status: Some(200),
1914 });
1915 }
1916 Err(err) => {
1917 let status = match &err {
1918 ShellError::RoutineStatus(code) => Some(*code),
1919 _ => None,
1920 };
1921 crate::wake_policy::note_attempt(self.store_dir(), status);
1922 self.wake_log.push(WakeAttempt {
1923 event_id: item.event_id.clone(),
1924 status,
1925 });
1926 self.wake_note = Some(clip_public(err.to_string()));
1927 }
1928 }
1929 }
1930 }
1931 if let Some(sock) = sock.as_deref() {
1932 if !self.leader_sent.contains(&item.key) {
1933 let cwd = cwd.clone().or_else(|| {
1934 std::env::current_dir()
1935 .ok()
1936 .map(|path| path.display().to_string())
1937 });
1938 let Some(cwd) = cwd.filter(|cwd| !cwd.is_empty()) else {
1939 self.wake_note = Some("leader cwd is empty".to_string());
1940 continue;
1941 };
1942 #[cfg(feature = "wake-grok")]
1943 {
1944 match mail4agent_grok::wake_decrypted_room_blocking(
1945 sock,
1946 &session_id,
1947 &cwd,
1948 &item.body,
1949 ) {
1950 Ok(()) => {
1951 self.remember_leader_prompt(&item.key);
1952 }
1953 Err(err) => {
1954 self.wake_note = Some(clip_public(err.to_string()));
1955 }
1956 }
1957 #[cfg(not(feature = "wake-grok"))]
1958 {
1959 let _ = (sock, &session_id, &cwd);
1960 self.wake_note = Some("this build was made without feature wake-grok".to_string());
1961 }
1962 }
1963 }
1964 }
1965 if sock.is_none() && !self.leader_sent.contains(&item.key) {
1966 let Some((session, mut chain)) = self.wake_chain.take() else {
1967 continue;
1968 };
1969 let from_nick = mxid_localpart(&item.from).to_string();
1970 let letter = provider::WakeLetter {
1971 body: &item.body,
1972 from_nick: &from_nick,
1973 event_id: &item.event_id,
1974 room: Some(&item.room_id),
1975 };
1976 match chain.wake(&session, &letter) {
1977 Ok((id, outcome)) => {
1978 self.remember_leader_prompt(&item.key);
1979 let how = match outcome {
1980 provider::WakeOutcome::Delivered => "delivered",
1981 provider::WakeOutcome::Queued(_) => "queued",
1982 };
1983 self.wake_route = Some(format!("{id} {how}"));
1984 }
1985 Err(err) => {
1986 self.wake_note = Some(clip_public(err.to_string()));
1987 }
1988 }
1989 self.wake_chain = Some((session, chain));
1990 }
1991 }
1992 }
1993}
1994
1995struct InboundPlaintext {
1996 room_id: String,
1997 key: String,
1998 body: String,
1999 from: String,
2000 nick: Option<String>,
2001 event_id: String,
2002}
2003
2004fn membership_name(membership: &Membership) -> &'static str {
2005 match membership {
2006 Membership::Invite => "invite",
2007 Membership::Join => "join",
2008 Membership::Knock => "knock",
2009 Membership::Leave => "leave",
2010 Membership::Ban => "ban",
2011 Membership::Unknown => "unknown",
2012 }
2013}
2014
2015fn outcome_name(state: &SendState) -> String {
2016 match state {
2017 SendState::Sent => "sent".to_string(),
2018 SendState::Sending | SendState::LocalEcho => "sending".to_string(),
2019 SendState::Failed { reason } => format!("failed:{reason}"),
2020 }
2021}
2022
2023fn parse_base_url(raw: &str) -> Result<reqwest::Url, ShellError> {
2024 let url = reqwest::Url::parse(raw).map_err(|_| ShellError::BaseUrl)?;
2025 match url.scheme() {
2026 "http" | "https" => {}
2027 _ => return Err(ShellError::BaseUrl),
2028 }
2029 if url.host_str().is_none()
2030 || url.query().is_some()
2031 || url.fragment().is_some()
2032 || !url.username().is_empty()
2033 || url.password().is_some()
2034 {
2035 return Err(ShellError::BaseUrl);
2036 }
2037 Ok(url)
2038}
2039
2040struct RegisteredSession {
2041 user_id: String,
2042 device_id: DeviceId,
2043 bearer: Zeroizing<String>,
2044 nick: String,
2046 backend: Arc<dyn Backend>,
2048}
2049
2050#[cfg(any(feature = "tier-server", feature = "tier-matrix"))]
2052fn identity_session(config: &SessionConfig, auth: &IdentityAuth) -> Result<RegisteredSession, ShellError> {
2053 let fail = |e: AgentError| ShellError::Register(clip_public(e.to_string()));
2054 parse_base_url(&config.homeserver_url)?;
2055 let ids = m4a_agent::IdentityStore::new(store_key::vault(&config.store_root)?);
2056 let backend: Arc<dyn Backend> = match auth.tier {
2057 #[cfg(feature = "tier-server")]
2058 m4a_agent::BackendKind::Server => Arc::new(m4a_agent::backend::server::ServerBackend::new(&config.homeserver_url).map_err(fail)?),
2059 #[cfg(feature = "tier-matrix")]
2062 m4a_agent::BackendKind::Matrix => Arc::new(m4a_agent::backend::matrix::MatrixBackend::discover(&config.homeserver_url).map_err(fail)?),
2063 #[allow(unreachable_patterns)]
2064 _ => return Err(ShellError::Register("this build does not include that tier".into())),
2065 };
2066 let mut id = ids.resolve(&config.session_id, auth.tier, backend.server_ref()).map_err(fail)?;
2067 if !id.enrolled && id.requested_nick != auth.nick_request {
2068 id.requested_nick = auth.nick_request.clone();
2069 }
2070 let session = backend.ensure_session(&ids, &mut id, auth.invite.as_ref().map(|i| i.as_str())).map_err(fail)?;
2071 let _ = std::fs::remove_file(invite_path(&config.store_root, &config.session_id));
2073 ids.adopt_nick(&mut id, &session.nick).map_err(fail)?;
2074 let (user_id, device_raw) = match (&session.user_id, &session.device_id) {
2075 (Some(u), Some(d)) => (u.clone(), d.clone()),
2076 _ => {
2077 backend.whoami().map_err(fail)?
2078 }
2079 };
2080 if user_id.is_empty() || device_raw.is_empty() {
2081 return Err(ShellError::Register("whoami missed an id".into()));
2082 }
2083 Ok(RegisteredSession { user_id, device_id: DeviceId::parse(&device_raw)?, bearer: session.token.clone(), nick: session.nick, backend })
2084}
2085
2086#[cfg(not(any(feature = "tier-server", feature = "tier-matrix")))]
2087fn identity_session(_: &SessionConfig, _: &IdentityAuth) -> Result<RegisteredSession, ShellError> {
2088 Err(ShellError::Register("this build has no tier for identity login".into()))
2089}
2090
2091fn register_session(config: &SessionConfig) -> Result<RegisteredSession, ShellError> {
2092 identity_session(config, &config.identity)
2093}
2094
2095fn clip_public(text: String) -> String {
2096 let mut out = String::new();
2097 for token in text.split_whitespace() {
2098 if token.len() > 80 {
2099 out.push_str("[omitted]");
2100 } else {
2101 out.push_str(token);
2102 }
2103 out.push(' ');
2104 if out.len() > 240 {
2105 break;
2106 }
2107 }
2108 out
2109}
2110
2111fn fs_create_dir(dir: &Path) -> Result<(), ShellError> {
2112 std::fs::create_dir_all(dir)?;
2113 Ok(())
2114}
2115
2116#[derive(Debug, thiserror::Error)]
2119pub enum ShellError {
2120 #[error("session id is empty")]
2122 EmptySession,
2123 #[error("store root is unset")]
2125 StoreRoot,
2126 #[error("homeserver url is unset")]
2128 HomeserverUrl,
2129 #[error("bot display name is unset")]
2131 BotName,
2132 #[error("nick could not be derived from the bot display name")]
2134 Nick,
2135 #[error("session list: {0}")]
2137 SessionList(String),
2138 #[error("homeserver register failed: {0}")]
2140 Register(String),
2141 #[error("no session with that nick")]
2143 UnknownNick,
2144 #[error("direct room was not ready")]
2146 Dm,
2147 #[error("config: {0}")]
2149 Config(String),
2150 #[error("device token is empty or not a single header value")]
2152 DeviceToken,
2153 #[error("base url must be http or https")]
2155 BaseUrl,
2156 #[error("record key escapes the store directory")]
2158 BadRecordKey,
2159 #[error("routine url is not an http or https url")]
2161 RoutineUrl,
2162 #[error("node cli does not take a routine url")]
2164 NodeRoutine,
2165 #[error("gateway: {0}")]
2167 Gateway(String),
2168 #[error("routine bearer is empty or not a single header value")]
2170 RoutineBearer,
2171 #[error("routine status {0}")]
2173 RoutineStatus(u16),
2174 #[error("routine post failed: {0}")]
2176 RoutineTransport(String),
2177 #[error("homeserver http failed: {0}")]
2179 Http(String),
2180 #[error("homeserver response was not ingested: {0}")]
2182 Ingest(String),
2183 #[error("io: {0}")]
2185 Io(#[from] std::io::Error),
2186 #[error(transparent)]
2188 Store(#[from] StoreError),
2189 #[error(transparent)]
2191 Messenger(#[from] MessengerError),
2192}
2193
2194impl From<AgentError> for ShellError {
2195 fn from(err: AgentError) -> Self {
2196 match err {
2197 AgentError::Transport(text) => ShellError::Http(text),
2198 AgentError::Ingest(text) => ShellError::Ingest(text),
2199 other => ShellError::Register(clip_public(other.to_string())),
2200 }
2201 }
2202}
2203
2204fn persist(dir: &Path, core: &mut MessengerCore<SealedRecordCodec>) -> Result<(), ShellError> {
2205 while let Some(batch) = core.take_flush_batch() {
2206 for record in &batch.records {
2207 let path = record_path(dir, &record.key)?;
2208 if let Some(parent) = path.parent() {
2209 std::fs::create_dir_all(parent)?;
2210 }
2211 std::fs::write(&path, &record.bytes)?;
2212 #[cfg(unix)]
2213 {
2214 use std::os::unix::fs::PermissionsExt;
2215 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
2216 }
2217 }
2218 for key in &batch.deletes {
2219 let path = record_path(dir, key)?;
2220 if path.exists() {
2221 std::fs::remove_file(path)?;
2222 }
2223 }
2224 core.ack_flush(batch.id);
2225 }
2226 Ok(())
2227}
2228
2229const NOT_RECORDS: &[&str] = &[
2231 machine::WAKE_KEYCHAIN_FILE,
2232 machine::STORE_LOCK_FILE,
2233 LEADER_PROMPTED_FILE,
2234 ROUTINE_WOKEN_FILE,
2235];
2236
2237const LEADER_PROMPTED_FILE: &str = "leader-prompted";
2240const ROUTINE_WOKEN_FILE: &str = "routine-woken";
2243
2244fn read_records(dir: &Path) -> Result<Vec<SealedRecord>, ShellError> {
2245 let mut records = Vec::new();
2246 if dir.exists() {
2247 walk(dir, dir, &mut records)?;
2248 }
2249 Ok(records)
2250}
2251
2252fn walk(dir: &Path, root: &Path, records: &mut Vec<SealedRecord>) -> Result<(), ShellError> {
2253 for entry in std::fs::read_dir(dir)? {
2254 let entry = entry?;
2255 let path = entry.path();
2256 if path.is_dir() {
2257 walk(&path, root, records)?;
2258 continue;
2259 }
2260 if dir == root
2263 && path
2264 .file_name()
2265 .and_then(|name| name.to_str())
2266 .is_some_and(|name| NOT_RECORDS.contains(&name))
2267 {
2268 continue;
2269 }
2270 let rel = path
2271 .strip_prefix(root)
2272 .map_err(|_| ShellError::BadRecordKey)?;
2273 let key = rel
2274 .components()
2275 .map(|component| component.as_os_str().to_string_lossy())
2276 .collect::<Vec<_>>()
2277 .join("/");
2278 let bytes = std::fs::read(&path)?;
2279 records.push(SealedRecord {
2280 key: RecordKey::new(key),
2281 bytes,
2282 });
2283 }
2284 Ok(())
2285}
2286
2287fn record_path(dir: &Path, key: &RecordKey) -> Result<PathBuf, ShellError> {
2288 let rel = Path::new(key.as_str());
2289 if rel.is_absolute()
2290 || rel.components().any(|component| {
2291 matches!(
2292 component,
2293 Component::ParentDir | Component::RootDir | Component::Prefix(_)
2294 )
2295 })
2296 {
2297 return Err(ShellError::BadRecordKey);
2298 }
2299 Ok(dir.join(rel))
2300}
2301
2302#[cfg(test)]
2303mod tests {
2304 use super::*;
2305
2306 #[test]
2307 fn session_config_is_identity_only_and_reads_no_secret() {
2308 let get = |extra: &'static [(&'static str, &'static str)]| {
2309 move |key: &str| {
2310 extra.iter().find(|(k, _)| *k == key).map(|(_, v)| v.to_string()).or_else(|| match key {
2311 SESSION_ID_ENV => Some("web-session-1".to_string()),
2312 STORE_ROOT_ENV => Some("/tmp/m4a-root".to_string()),
2313 "M4A_PRODUCT_PASSWORD" | "M4A_PRODUCT_TOKEN" | "M4A_DEVICE_TOKEN" => Some("must-not-be-read".to_string()),
2315 _ => None,
2316 })
2317 }
2318 };
2319 let config = SessionConfig::from_lookup(get(&[(HOMESERVER_URL_ENV, "http://127.0.0.1:9")]), None).expect("config");
2320 assert_eq!(config.store_dir(), session_store_dir(Path::new("/tmp/m4a-root"), "web-session-1"));
2321 assert!(!format!("{config:?}").contains("must-not-be-read"));
2322 assert_eq!(config.identity.tier, m4a_agent::BackendKind::Server);
2323 let matrix = SessionConfig::from_lookup(get(&[(PRODUCT_URL_ENV, "http://127.0.0.1:8"), (TIER_ENV, "matrix")]), None).expect("matrix");
2324 assert_eq!(matrix.identity.tier, m4a_agent::BackendKind::Matrix);
2325 assert!(SessionConfig::from_lookup(get(&[(TIER_ENV, "other"), (PRODUCT_URL_ENV, "http://127.0.0.1:8")]), None).is_err());
2326 let from_toml = SessionConfig::from_lookup(get(&[]), Some("homeserver_url = \"http://127.0.0.1:9\"\nother = \"ignored\"\n")).expect("toml url");
2327 assert_eq!(from_toml.homeserver_url, "http://127.0.0.1:9");
2328 assert!(SessionConfig::from_lookup(get(&[]), None).is_err(), "no server given");
2329 }
2330 use std::io::{Read, Write};
2331 use std::net::TcpListener;
2332 use std::process::{Command, Stdio};
2333 use std::sync::atomic::{AtomicBool, Ordering};
2334 use std::sync::{Arc, Mutex};
2335 use std::thread;
2336 use std::time::Instant;
2337
2338 fn plain(base: &str, token: &str) -> Arc<dyn Backend> {
2339 Arc::new(m4a_agent::backend::attached::AttachedBackend::with_prefix(m4a_agent::BackendKind::Server, base, token, false).expect("backend"))
2340 }
2341
2342 struct TempDir(PathBuf);
2343
2344 impl Drop for TempDir {
2345 fn drop(&mut self) {
2346 let _ = std::fs::remove_dir_all(&self.0);
2347 }
2348 }
2349
2350 fn temp_dir(name: &str) -> TempDir {
2351 let dir = TempDir(std::env::temp_dir().join(format!(
2352 "mail4agent-messenger-shell-{}-{name}",
2353 std::process::id()
2354 )));
2355 let _ = std::fs::remove_dir_all(&dir.0);
2356 dir
2357 }
2358
2359 fn open_alice(dir: &Path, session: &str) -> OpenedStore {
2360 let device = DeviceId::parse("DEVICE1").expect("device id");
2361 OpenedStore::open(
2362 dir,
2363 session,
2364 device,
2365 "@alice:localhost",
2366 "localhost",
2367 plain("http://127.0.0.1:9", "fake-token"),
2368 "fake-token",
2369 )
2370 .expect("open")
2371 }
2372
2373 #[test]
2374 fn same_session_opens_and_a_different_session_fails_the_seal() {
2375 let dir = temp_dir("seal");
2376 open_alice(&dir.0, "session-a");
2377 open_alice(&dir.0, "session-a");
2378
2379 let device = DeviceId::parse("DEVICE1").expect("device id");
2380 match OpenedStore::open(
2381 &dir.0,
2382 "session-b",
2383 device,
2384 "@alice:localhost",
2385 "localhost",
2386 plain("http://127.0.0.1:9", "fake-token"),
2387 "fake-token",
2388 ) {
2389 Err(ShellError::Store(StoreError::CodecOpen { .. })) => {}
2390 Ok(_) => panic!("different session opened the sealed store"),
2391 Err(err) => panic!("expected seal auth failure, got {err}"),
2392 }
2393 }
2394
2395 #[test]
2396 fn two_sessions_under_one_root_seal_and_reopen_only_with_their_own_id() {
2397 let root = temp_dir("isolate");
2398 let dir_a = session_store_dir(&root.0, "session-a");
2399 let dir_b = session_store_dir(&root.0, "session-b");
2400 assert_ne!(dir_a, dir_b);
2401 assert_eq!(dir_a.parent(), Some(root.0.as_path()));
2402 assert_eq!(dir_b.parent(), Some(root.0.as_path()));
2403 assert_eq!(session_store_dir(&root.0, "session-a"), dir_a);
2404 let slipped = session_store_dir(&root.0, "../session-a");
2405 assert_eq!(slipped.parent(), Some(root.0.as_path()));
2406 let name = slipped.file_name().expect("name").to_string_lossy();
2407 assert_eq!(name.len(), 64);
2408 assert!(name.chars().all(|ch| ch.is_ascii_hexdigit()));
2409
2410 let bearer = "isolation-bearer-7c2e";
2411 let device = DeviceId::parse("DEVICE1").expect("device id");
2412 let open_with = |dir: &Path, session: &str| {
2413 OpenedStore::open(
2414 dir,
2415 session,
2416 device.clone(),
2417 "@alice:localhost",
2418 "localhost",
2419 plain("http://127.0.0.1:9", bearer),
2420 bearer,
2421 )
2422 };
2423 open_with(&dir_a, "session-a").expect("seal a");
2424 open_with(&dir_b, "session-b").expect("seal b");
2425 open_with(&dir_a, "session-a").expect("reopen a");
2426 open_with(&dir_b, "session-b").expect("reopen b");
2427
2428 for (dir, session) in [(&dir_a, "session-b"), (&dir_b, "session-a")] {
2429 match open_with(dir, session) {
2430 Err(ShellError::Store(StoreError::CodecOpen { .. })) => {}
2431 Ok(_) => panic!("{session} opened the other sealed store"),
2432 Err(err) => panic!("expected seal auth failure, got {err}"),
2433 }
2434 }
2435
2436 fn contains_bearer(dir: &Path, needle: &str) -> bool {
2437 if !dir.exists() {
2438 return false;
2439 }
2440 for entry in std::fs::read_dir(dir).expect("read") {
2441 let entry = entry.expect("entry");
2442 let path = entry.path();
2443 if path.to_string_lossy().contains(needle) {
2444 return true;
2445 }
2446 if path.is_dir() {
2447 if contains_bearer(&path, needle) {
2448 return true;
2449 }
2450 } else if std::fs::read(&path)
2451 .expect("file")
2452 .windows(needle.len())
2453 .any(|window| window == needle.as_bytes())
2454 {
2455 return true;
2456 }
2457 }
2458 false
2459 }
2460 assert!(
2461 !contains_bearer(&root.0, bearer),
2462 "raw bearer was written under the store root"
2463 );
2464 }
2465
2466 #[test]
2467 fn post_decrypted_reaches_the_loopback_routine() {
2468 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2469 let addr = listener.local_addr().expect("addr");
2470 let server = thread::spawn(move || {
2471 let (mut sock, _) = listener.accept().expect("accept");
2472 sock.set_read_timeout(Some(Duration::from_secs(2)))
2473 .expect("timeout");
2474 let mut buf = Vec::new();
2475 let mut tmp = [0u8; 1024];
2476 loop {
2477 let n = sock.read(&mut tmp).unwrap_or(0);
2478 if n == 0 {
2479 break;
2480 }
2481 buf.extend_from_slice(&tmp[..n]);
2482 if let Some(header_end) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
2483 let headers = String::from_utf8_lossy(&buf[..header_end]).to_string();
2484 let length = headers
2485 .lines()
2486 .find_map(|line| {
2487 let (name, value) = line.split_once(':')?;
2488 if name.eq_ignore_ascii_case("content-length") {
2489 value.trim().parse::<usize>().ok()
2490 } else {
2491 None
2492 }
2493 })
2494 .unwrap_or(0);
2495 if buf.len() >= header_end + 4 + length {
2496 let body = buf[header_end + 4..header_end + 4 + length].to_vec();
2497 let _ = sock.write_all(
2498 b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
2499 );
2500 return (headers, body);
2501 }
2502 }
2503 }
2504 (String::new(), buf)
2505 });
2506
2507 let wake = RoutineWake {
2508 url: format!("http://{addr}/routine"),
2509 };
2510 post_decrypted(
2511 &wake.url,
2512 &DecryptedWake {
2513 body: "hello-from-room",
2514 from: "@bob:localhost",
2515 nick: None,
2516 event_id: "$m1:localhost",
2517 ..Default::default()
2518 },
2519 )
2520 .expect("post");
2521 let (headers, body) = server.join().expect("listener stopped");
2522 assert!(headers.starts_with("POST /routine HTTP/1.1"), "{headers}");
2523 assert!(
2524 headers
2525 .to_ascii_lowercase()
2526 .contains("content-type: application/json"),
2527 "{headers}"
2528 );
2529 assert!(
2530 !headers.to_ascii_lowercase().contains("authorization"),
2531 "routine post must not add a bearer"
2532 );
2533 assert!(
2534 !headers.to_ascii_lowercase().contains("x-automation-key"),
2535 "routine post must not add a key without a bearer"
2536 );
2537 assert!(!headers.contains("/mail/send"), "{headers}");
2538 assert_wake_json(
2539 &body,
2540 "hello-from-room",
2541 "@bob:localhost",
2542 "$m1:localhost",
2543 None,
2544 );
2545 }
2546
2547 #[test]
2548 fn post_decrypted_attempts_https_against_a_local_self_signed_listener() {
2549 let dir = temp_dir("https");
2550 std::fs::create_dir_all(&dir.0).expect("dir");
2551 let cert = dir.0.join("cert.pem");
2552 let key = dir.0.join("key.pem");
2553 let generated = Command::new("openssl")
2554 .args([
2555 "req",
2556 "-x509",
2557 "-newkey",
2558 "rsa:2048",
2559 "-keyout",
2560 key.to_str().expect("utf-8"),
2561 "-out",
2562 cert.to_str().expect("utf-8"),
2563 "-days",
2564 "1",
2565 "-nodes",
2566 "-subj",
2567 "/CN=127.0.0.1",
2568 ])
2569 .stdout(Stdio::null())
2570 .stderr(Stdio::null())
2571 .status()
2572 .expect("openssl req");
2573 assert!(generated.success(), "openssl did not write a local cert");
2574
2575 let probe = TcpListener::bind("127.0.0.1:0").expect("bind");
2576 let port = probe.local_addr().expect("addr").port();
2577 drop(probe);
2578 let mut server = Command::new("openssl")
2579 .args([
2580 "s_server",
2581 "-accept",
2582 &format!("127.0.0.1:{port}"),
2583 "-cert",
2584 cert.to_str().expect("utf-8"),
2585 "-key",
2586 key.to_str().expect("utf-8"),
2587 "-www",
2588 ])
2589 .stdout(Stdio::null())
2590 .stderr(Stdio::null())
2591 .spawn()
2592 .expect("openssl s_server");
2593 let started = std::time::Instant::now();
2594 while started.elapsed() < Duration::from_secs(3) {
2595 if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() {
2596 break;
2597 }
2598 thread::sleep(Duration::from_millis(20));
2599 }
2600
2601 let err = post_decrypted(
2602 &format!("https://127.0.0.1:{port}/hook"),
2603 &DecryptedWake {
2604 body: "hello-https",
2605 from: "@bob:localhost",
2606 nick: None,
2607 event_id: "$m1:localhost",
2608 ..Default::default()
2609 },
2610 )
2611 .expect_err("a self-signed cert must not verify");
2612 let _ = server.kill();
2613 let _ = server.wait();
2614 assert!(
2615 !matches!(err, ShellError::RoutineUrl),
2616 "https was refused before the client tried it: {err}"
2617 );
2618 let text = err.to_string().to_ascii_lowercase();
2619 assert!(
2620 text.contains("cert")
2621 || text.contains("tls")
2622 || text.contains("handshake")
2623 || text.contains("ssl")
2624 || text.contains("unknownissuer")
2625 || text.contains("invalidpeer"),
2626 "https did not reach a tls failure: {err}"
2627 );
2628 }
2629
2630 #[test]
2631 fn a_base_url_carries_no_fragment_credentials_or_query() {
2632 assert!(parse_base_url("http://127.0.0.1:9").is_ok());
2633 assert!(parse_base_url("http://127.0.0.1:9/#other").is_err());
2634 assert!(parse_base_url("http://u:p@127.0.0.1:9").is_err());
2635 assert!(parse_base_url("ftp://127.0.0.1:9").is_err());
2636 }
2637
2638 #[test]
2639 fn send_message_releases_a_matrix_room_send() {
2640 let dir = temp_dir("send");
2641 let mut store = open_alice(&dir.0, "session-a");
2642 let released = store
2643 .send_room_message("!room:localhost", "hello room", 0)
2644 .expect("release");
2645 let send = released
2646 .iter()
2647 .find(|request| request.kind == OutgoingRequestKind::RoomSend)
2648 .unwrap_or_else(|| {
2649 panic!(
2650 "engine did not release a RoomSend: {:?}",
2651 released
2652 .iter()
2653 .map(|request| request.kind)
2654 .collect::<Vec<_>>()
2655 )
2656 });
2657 assert!(
2658 send.path.starts_with("/_matrix/client/v3/rooms/")
2659 && send.path.contains("/send/m.room.message/"),
2660 "not a matrix room send: {}",
2661 send.path
2662 );
2663 assert!(!send.path.contains("/mail/send"), "{}", send.path);
2664 assert!(!send.path.contains("/admin/listener"), "{}", send.path);
2665 let body = send.body.as_ref().expect("room send body");
2666 assert_eq!(body["msgtype"], "m.text");
2667 assert_eq!(body["body"], "hello room");
2668 }
2669
2670 #[test]
2671 fn drive_performs_the_released_request_with_bearer_and_no_matrix_prefix() {
2672 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2673 let addr = listener.local_addr().expect("addr");
2674 let seen = Arc::new(Mutex::new(Vec::<String>::new()));
2675 let seen_worker = Arc::clone(&seen);
2676 listener.set_nonblocking(true).expect("nonblocking");
2677 let server = thread::spawn(move || {
2678 let deadline = std::time::Instant::now() + Duration::from_secs(3);
2679 loop {
2680 if std::time::Instant::now() > deadline {
2681 break;
2682 }
2683 let sock = match listener.accept() {
2684 Ok((sock, _)) => sock,
2685 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2686 thread::sleep(Duration::from_millis(20));
2687 continue;
2688 }
2689 Err(_) => break,
2690 };
2691 let mut sock = sock;
2692 let _ = sock.set_nonblocking(false);
2693 let _ = sock.set_read_timeout(Some(Duration::from_secs(2)));
2694 let mut buf = Vec::new();
2695 let mut tmp = [0u8; 2048];
2696 loop {
2697 let n = sock.read(&mut tmp).unwrap_or(0);
2698 if n == 0 {
2699 break;
2700 }
2701 buf.extend_from_slice(&tmp[..n]);
2702 if let Some(header_end) =
2703 buf.windows(4).position(|window| window == b"\r\n\r\n")
2704 {
2705 let headers = String::from_utf8_lossy(&buf[..header_end]).to_string();
2706 let length = headers
2707 .lines()
2708 .find_map(|line| {
2709 let (name, value) = line.split_once(':')?;
2710 if name.eq_ignore_ascii_case("content-length") {
2711 value.trim().parse::<usize>().ok()
2712 } else {
2713 None
2714 }
2715 })
2716 .unwrap_or(0);
2717 if buf.len() >= header_end + 4 + length {
2718 let body = buf[header_end + 4..header_end + 4 + length].to_vec();
2719 let first = headers.lines().next().unwrap_or("").to_string();
2720 let bearer_ok = headers.lines().any(|line| {
2721 let (name, value) = line.split_once(':').unwrap_or(("", ""));
2722 name.eq_ignore_ascii_case("authorization")
2723 && value.trim() == "Bearer fake-token"
2724 });
2725 let note = format!(
2726 "{first} bearer_ok={bearer_ok} body={}",
2727 String::from_utf8_lossy(&body)
2728 );
2729 seen_worker.lock().expect("seen").push(note);
2730 let request = first;
2731 let response_body = if request.contains("/send/") {
2732 br#"{"event_id":"$e:localhost"}"#.to_vec()
2733 } else if request.contains("/keys/") {
2734 br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
2735 } else {
2736 br#"{"next_batch":"s1","device_one_time_keys_count":{"signed_curve25519":50},"device_unused_fallback_key_types":["signed_curve25519"]}"#.to_vec()
2737 };
2738 let head = format!(
2739 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2740 response_body.len()
2741 );
2742 let _ = sock.write_all(head.as_bytes());
2743 let _ = sock.write_all(&response_body);
2744 break;
2745 }
2746 }
2747 }
2748 }
2749 });
2750
2751 let dir = temp_dir("drive");
2752 let device = DeviceId::parse("DEVICE1").expect("device id");
2753 let mut store = OpenedStore::open(
2754 &dir.0,
2755 "session-a",
2756 device,
2757 "@alice:localhost",
2758 "localhost",
2759 plain(&format!("http://{addr}"), "fake-token"),
2760 "fake-token",
2761 )
2762 .expect("open");
2763 store
2764 .dispatch(
2765 MessengerCommand::SendMessage {
2766 room_id: RoomId::parse("!room:localhost").expect("room"),
2767 message: OutgoingMessage {
2768 kind: MessageKind::Text,
2769 body: "hello room".to_string(),
2770 reply_to: None,
2771 edit_of: None,
2772 },
2773 txn_id: None,
2774 },
2775 0,
2776 )
2777 .expect("dispatch");
2778 store.drive(0, false).expect("drive");
2779 thread::sleep(Duration::from_millis(200));
2780 drop(store);
2781 let _ = server.join();
2782 let seen = seen.lock().expect("seen");
2783 assert!(
2784 seen.iter().any(|line| {
2785 line.contains("PUT /client/v3/rooms/")
2786 && line.contains("/send/m.room.message/")
2787 && line.contains("bearer_ok=true")
2788 && line.contains("hello room")
2789 && !line.contains("/_matrix")
2790 }),
2791 "released room send was not performed: {seen:?}"
2792 );
2793 assert!(
2794 seen.iter().all(|line| !line.contains("/_matrix")),
2795 "prefix was not stripped: {seen:?}"
2796 );
2797 }
2798
2799 struct Hit {
2800 headers: String,
2801 body: Vec<u8>,
2802 }
2803
2804 fn read_http(sock: &mut std::net::TcpStream) -> Option<(String, Vec<u8>)> {
2805 let _ = sock.set_read_timeout(Some(Duration::from_secs(2)));
2806 let mut buf = Vec::new();
2807 let mut tmp = [0u8; 2048];
2808 loop {
2809 let n = sock.read(&mut tmp).unwrap_or(0);
2810 if n == 0 {
2811 break;
2812 }
2813 buf.extend_from_slice(&tmp[..n]);
2814 if let Some(header_end) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
2815 let headers = String::from_utf8_lossy(&buf[..header_end]).to_string();
2816 let length = headers
2817 .lines()
2818 .find_map(|line| {
2819 let (name, value) = line.split_once(':')?;
2820 if name.eq_ignore_ascii_case("content-length") {
2821 value.trim().parse::<usize>().ok()
2822 } else {
2823 None
2824 }
2825 })
2826 .unwrap_or(0);
2827 if buf.len() >= header_end + 4 + length {
2828 let body = buf[header_end + 4..header_end + 4 + length].to_vec();
2829 return Some((headers, body));
2830 }
2831 }
2832 }
2833 None
2834 }
2835
2836 fn write_http(sock: &mut std::net::TcpStream, status: &str, body: &[u8]) {
2837 let head = format!(
2838 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2839 body.len()
2840 );
2841 let _ = sock.write_all(head.as_bytes());
2842 let _ = sock.write_all(body);
2843 }
2844
2845 fn assert_wake_json(bytes: &[u8], body: &str, from: &str, event_id: &str, nick: Option<&str>) {
2846 let parsed: serde_json::Value = serde_json::from_slice(bytes).expect("json wake");
2847 assert_eq!(parsed["body"], body);
2848 assert_eq!(parsed["from"], from);
2849 assert_eq!(parsed["event_id"], event_id);
2850 match nick {
2851 Some(nick) => assert_eq!(parsed["nick"], nick),
2852 None => assert!(
2853 parsed.get("nick").is_none() || parsed["nick"].is_null(),
2854 "unknown nick must be omitted or null, not invented: {parsed}"
2855 ),
2856 }
2857 }
2858
2859 fn sync_with_text_nick(body: &str, nick: Option<&str>) -> Vec<u8> {
2860 let mut state = Vec::new();
2861 if let Some(nick) = nick {
2862 state.push(serde_json::json!({
2863 "event_id": "$mem:localhost",
2864 "type": "m.room.member",
2865 "state_key": "@bob:localhost",
2866 "sender": "@bob:localhost",
2867 "origin_server_ts": 1,
2868 "content": { "membership": "join", "displayname": nick }
2869 }));
2870 }
2871 serde_json::json!({
2872 "next_batch": "s1",
2873 "rooms": {
2874 "join": {
2875 "!r:localhost": {
2876 "state": { "events": state },
2877 "timeline": {
2878 "events": [{
2879 "event_id": "$m1:localhost",
2880 "type": "m.room.message",
2881 "sender": "@bob:localhost",
2882 "origin_server_ts": 10,
2883 "content": { "msgtype": "m.text", "body": body }
2884 }]
2885 }
2886 }
2887 }
2888 },
2889 "device_one_time_keys_count": { "signed_curve25519": 50 },
2890 "device_unused_fallback_key_types": ["signed_curve25519"]
2891 })
2892 .to_string()
2893 .into_bytes()
2894 }
2895
2896 fn sync_empty() -> Vec<u8> {
2897 serde_json::json!({
2898 "next_batch": "s2",
2899 "device_one_time_keys_count": { "signed_curve25519": 50 },
2900 "device_unused_fallback_key_types": ["signed_curve25519"]
2901 })
2902 .to_string()
2903 .into_bytes()
2904 }
2905
2906 fn spawn_homeserver(text: &'static str) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
2907 spawn_homeserver_nick(text, None)
2908 }
2909
2910 fn spawn_homeserver_nick(
2911 text: &'static str,
2912 nick: Option<&'static str>,
2913 ) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
2914 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2915 let addr = listener.local_addr().expect("addr");
2916 listener.set_nonblocking(true).expect("nonblocking");
2917 let done = Arc::new(AtomicBool::new(false));
2918 let flag = Arc::clone(&done);
2919 let handle = thread::spawn(move || {
2920 let deadline = Instant::now() + Duration::from_secs(8);
2921 while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
2922 match listener.accept() {
2923 Ok((mut sock, _)) => {
2924 let _ = sock.set_nonblocking(false);
2925 let Some((headers, _)) = read_http(&mut sock) else {
2926 continue;
2927 };
2928 let line = headers.lines().next().unwrap_or("");
2929 let resp = if line.contains("/sync") && line.contains("since=") {
2930 sync_empty()
2931 } else if line.contains("/sync") {
2932 sync_with_text_nick(text, nick)
2933 } else if line.contains("/keys/") {
2934 br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
2935 } else {
2936 b"{}".to_vec()
2937 };
2938 write_http(&mut sock, "200 OK", &resp);
2939 }
2940 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2941 thread::sleep(Duration::from_millis(15));
2942 }
2943 Err(_) => break,
2944 }
2945 }
2946 });
2947 (format!("http://{addr}"), done, handle)
2948 }
2949
2950 fn spawn_routine() -> (
2951 String,
2952 Arc<Mutex<Vec<Hit>>>,
2953 Arc<AtomicBool>,
2954 thread::JoinHandle<()>,
2955 ) {
2956 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2957 let addr = listener.local_addr().expect("addr");
2958 listener.set_nonblocking(true).expect("nonblocking");
2959 let hits = Arc::new(Mutex::new(Vec::new()));
2960 let recorded = Arc::clone(&hits);
2961 let done = Arc::new(AtomicBool::new(false));
2962 let flag = Arc::clone(&done);
2963 let handle = thread::spawn(move || {
2964 let deadline = Instant::now() + Duration::from_secs(8);
2965 while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
2966 match listener.accept() {
2967 Ok((mut sock, _)) => {
2968 let _ = sock.set_nonblocking(false);
2969 if let Some((headers, body)) = read_http(&mut sock) {
2970 recorded.lock().expect("hits").push(Hit { headers, body });
2971 let _ = sock.write_all(
2972 b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
2973 );
2974 }
2975 }
2976 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2977 thread::sleep(Duration::from_millis(15));
2978 }
2979 Err(_) => break,
2980 }
2981 }
2982 });
2983 (format!("http://{addr}/routine"), hits, done, handle)
2984 }
2985
2986 fn stop(flag: &AtomicBool, handle: thread::JoinHandle<()>) {
2987 flag.store(true, Ordering::Relaxed);
2988 let _ = handle.join();
2989 }
2990
2991 fn open_against(base: &str, dir: &Path, session: &str) -> OpenedStore {
2992 let device = DeviceId::parse("DEVICE1").expect("device id");
2993 OpenedStore::open(
2994 dir,
2995 session,
2996 device,
2997 "@alice:localhost",
2998 "localhost",
2999 plain(base, "fake-token"),
3000 "fake-token",
3001 )
3002 .expect("open")
3003 }
3004
3005 #[test]
3006 fn inbound_room_text_goes_through_the_provider_chain_once() {
3007 let (base, home_done, home) = spawn_homeserver("wake-chain");
3008 let dir = temp_dir("wake-chain");
3009 let inbox_dir = dir.0.join("inbox");
3010 let mut store = open_against(&base, &dir.0, "session-c");
3011 let session = provider::ProviderSession {
3012 kind: provider::SessionKind::local(provider::ProviderKind::Codex),
3013 session_id: "thread-c".into(),
3014 nick: "builder".into(),
3015 cwd: None,
3016 headless: false,
3017 };
3018 let host = provider::HostEnv {
3019 surface: provider::Surface::Local,
3020 vendor: None,
3021 os: "linux",
3022 };
3023 let config = provider::AdapterConfig {
3024 inbox_dir: Some(inbox_dir.clone()),
3025 spawn_program: Some("/bin/false".into()),
3026 ..provider::AdapterConfig::default()
3027 };
3028 let chain = provider::plan_chain(&session, &host, &config);
3029 store.set_wake_chain(session, chain);
3030 assert_eq!(store.wake_chain_ids()[0], "codex-app-server-turn");
3031 store.drive(1_000, false).expect("drive");
3032 store.drive(3_000, false).expect("drive again");
3033 let route = store.take_wake_route();
3034 let note = store.wake_note().unwrap_or("").to_string();
3035 drop(store);
3036 stop(&home_done, home);
3037 assert_eq!(route.as_deref(), Some("inbox-queue queued"), "note={note}");
3039 let letters = provider::inbox::drain(&inbox_dir);
3040 assert_eq!(letters.len(), 1);
3041 assert!(letters[0].letter.prompt.ends_with("wake-chain"));
3042 assert!(letters[0].letter.prompt.contains("m4a-send --as builder"));
3043 }
3044
3045 #[test]
3046 fn inbound_room_text_hits_the_routine_once() {
3047 let (base, home_done, home) = spawn_homeserver("wake-plain");
3048 let (routine, hits, routine_done, routine_thread) = spawn_routine();
3049 let dir = temp_dir("wake-once");
3050 let mut store = open_against(&base, &dir.0, "session-a");
3051 store.set_wake(SessionWake {
3052 routine_url: Some(routine),
3053 ..SessionWake::default()
3054 });
3055 store.drive(1_000, false).expect("drive");
3056 store.drive(3_000, false).expect("drive again");
3057 let note = store.wake_note().unwrap_or("").to_string();
3058 let saw_text = store.texts().iter().any(|text| text.body == "wake-plain");
3059 let trace = store.http_trace();
3060 drop(store);
3061 stop(&routine_done, routine_thread);
3062 stop(&home_done, home);
3063 assert!(
3064 saw_text,
3065 "engine did not surface the inbound text; http={trace:?} note={note}"
3066 );
3067 let hits = hits.lock().expect("hits");
3068 assert_eq!(
3069 hits.len(),
3070 1,
3071 "routine was not hit exactly once; note={note}"
3072 );
3073 let hit = &hits[0];
3074 assert!(
3075 hit.headers.starts_with("POST /routine HTTP/1.1"),
3076 "{}",
3077 hit.headers.lines().next().unwrap_or("")
3078 );
3079 assert!(
3080 !hit.headers.to_ascii_lowercase().contains("authorization"),
3081 "no bearer was passed, so the routine post must not send one"
3082 );
3083 assert!(
3084 !hit.headers
3085 .to_ascii_lowercase()
3086 .contains("x-automation-key"),
3087 "no bearer was passed, so the routine post must not send a key"
3088 );
3089 assert!(!hit.headers.contains("/mail/send"));
3090 assert!(!hit.headers.contains("/admin/listener"));
3091 assert!(
3092 hit.headers
3093 .to_ascii_lowercase()
3094 .contains("content-type: application/json"),
3095 "{}",
3096 hit.headers
3097 );
3098 assert_wake_json(
3099 &hit.body,
3100 "wake-plain",
3101 "@bob:localhost",
3102 "$m1:localhost",
3103 None,
3104 );
3105 }
3106
3107 #[test]
3108 fn routine_wake_stays_once_across_store_reopen() {
3109 let (base, home_done, home) = spawn_homeserver("wake-persist");
3110 let (routine, hits, routine_done, routine_thread) = spawn_routine();
3111 let dir = temp_dir("wake-persist");
3112 let mut store = open_against(&base, &dir.0, "session-a");
3113 store.set_wake(SessionWake {
3114 routine_url: Some(routine.clone()),
3115 ..SessionWake::default()
3116 });
3117 store.drive(1_000, false).expect("drive");
3118 let ledger = dir.0.join("routine-woken");
3119 assert!(
3120 ledger.is_file(),
3121 "successful wake must persist routine-woken"
3122 );
3123 drop(store);
3124 let mut store = open_against(&base, &dir.0, "session-a");
3125 store.set_wake(SessionWake {
3126 routine_url: Some(routine),
3127 ..SessionWake::default()
3128 });
3129 store.drive(1_000, false).expect("drive again");
3130 store.drive(3_000, false).expect("drive third");
3131 let note = store.wake_note().unwrap_or("").to_string();
3132 drop(store);
3133 stop(&routine_done, routine_thread);
3134 stop(&home_done, home);
3135 let hits = hits.lock().expect("hits");
3136 assert_eq!(
3137 hits.len(),
3138 1,
3139 "reopen must not re-POST the same event; note={note} hits={}",
3140 hits.len()
3141 );
3142 }
3143
3144 #[test]
3145 fn inbound_room_text_posts_the_sender_nick_the_shell_already_has() {
3146 let (base, home_done, home) = spawn_homeserver_nick("wake-named", Some("Alice"));
3147 let (routine, hits, routine_done, routine_thread) = spawn_routine();
3148 let dir = temp_dir("wake-nick");
3149 let mut store = open_against(&base, &dir.0, "session-a");
3150 store.set_wake(SessionWake {
3151 routine_url: Some(routine),
3152 ..SessionWake::default()
3153 });
3154 store.drive(1_000, false).expect("drive");
3155 let note = store.wake_note().unwrap_or("").to_string();
3156 drop(store);
3157 stop(&routine_done, routine_thread);
3158 stop(&home_done, home);
3159 let hits = hits.lock().expect("hits");
3160 assert_eq!(
3161 hits.len(),
3162 1,
3163 "routine was not hit exactly once; note={note}"
3164 );
3165 assert!(
3166 !hits[0]
3167 .headers
3168 .to_ascii_lowercase()
3169 .contains("authorization"),
3170 "no bearer was passed"
3171 );
3172 assert_wake_json(
3173 &hits[0].body,
3174 "wake-named",
3175 "@bob:localhost",
3176 "$m1:localhost",
3177 Some("Alice"),
3178 );
3179 }
3180
3181 #[test]
3182 fn the_owners_policy_silences_a_wake_and_the_silenced_message_is_never_replayed() {
3183 let (base, home_done, home) = spawn_homeserver("wake-plain");
3184 let (routine, hits, routine_done, routine_thread) = spawn_routine();
3185 let dir = temp_dir("wake-policy");
3186 let mut store = open_against(&base, &dir.0, "session-a");
3187 store.set_wake(SessionWake { routine_url: Some(routine), ..SessionWake::default() });
3188 let off = wake_policy::WakePolicy { enabled: false, mode: wake_policy::WakeMode::Mention };
3189 off.save(store.store_dir()).expect("policy");
3190 store.drive(1_000, false).expect("drive");
3191 assert!(store.texts().iter().any(|t| t.body == "wake-plain"), "the text still arrives and can be read");
3192 assert!(hits.lock().expect("hits").is_empty(), "a disabled wake posted");
3193 wake_policy::WakePolicy::default().save(store.store_dir()).expect("policy");
3195 store.drive(3_000, false).expect("drive again");
3196 let hits_now = hits.lock().expect("hits").len();
3197 drop(store);
3198 stop(&routine_done, routine_thread);
3199 stop(&home_done, home);
3200 assert_eq!(hits_now, 0, "the silenced message was replayed");
3201 }
3202
3203 #[test]
3204 fn no_routine_url_posts_nothing() {
3205 let (base, home_done, home) = spawn_homeserver("wake-plain");
3206 let (_routine, hits, routine_done, routine_thread) = spawn_routine();
3207 let dir = temp_dir("wake-none");
3208 let mut store = open_against(&base, &dir.0, "session-a");
3209 store.set_wake(SessionWake::default());
3210 store.drive(1_000, false).expect("drive");
3211 let saw_text = store.texts().iter().any(|text| text.body == "wake-plain");
3212 let trace = store.http_trace();
3213 drop(store);
3214 stop(&routine_done, routine_thread);
3215 stop(&home_done, home);
3216 assert!(
3217 saw_text,
3218 "missing routine url dropped the inbound text; http={trace:?}"
3219 );
3220 let hits = hits.lock().expect("hits");
3221 assert!(hits.is_empty(), "a post happened with no routine url");
3222 }
3223
3224 #[test]
3225 fn routine_bearer_header_is_sent_only_when_the_caller_passed_one() {
3226 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
3227 let addr = listener.local_addr().expect("addr");
3228 let server = thread::spawn(move || {
3229 let (mut sock, _) = listener.accept().expect("accept");
3230 let (headers, body) = read_http(&mut sock).expect("request");
3231 let _ = sock
3232 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
3233 (headers, body)
3234 });
3235 let bearer = "env-bearer";
3236 post_decrypted_with_bearer(
3237 &format!("http://{addr}/routine"),
3238 &DecryptedWake {
3239 body: "letter",
3240 from: "@bob:localhost",
3241 nick: Some("Bob"),
3242 event_id: "$m1:localhost",
3243 ..Default::default()
3244 },
3245 Some(bearer),
3246 )
3247 .expect("post");
3248 let (headers, body) = server.join().expect("server");
3249 let line = headers
3250 .lines()
3251 .find(|line| line.to_ascii_lowercase().starts_with("authorization:"))
3252 .expect("authorization header");
3253 let (name, value) = line.split_once(':').expect("header");
3254 assert!(name.eq_ignore_ascii_case("authorization"));
3255 assert_eq!(value.trim(), format!("Bearer {bearer}"));
3256 let automation = headers
3257 .lines()
3258 .find(|line| line.to_ascii_lowercase().starts_with("x-automation-key:"))
3259 .expect("automation key header");
3260 let (name, value) = automation.split_once(':').expect("header");
3261 assert!(name.eq_ignore_ascii_case("x-automation-key"));
3262 assert_eq!(value.trim(), bearer);
3263 assert_eq!(
3264 headers
3265 .lines()
3266 .filter(|line| line.to_ascii_lowercase().starts_with("authorization:"))
3267 .count(),
3268 1
3269 );
3270 assert_eq!(
3271 headers
3272 .lines()
3273 .filter(|line| line.to_ascii_lowercase().starts_with("x-automation-key:"))
3274 .count(),
3275 1
3276 );
3277 assert_wake_json(
3278 &body,
3279 "letter",
3280 "@bob:localhost",
3281 "$m1:localhost",
3282 Some("Bob"),
3283 );
3284 }
3285
3286 #[test]
3287 fn post_decrypted_omits_a_missing_or_blank_nick() {
3288 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
3289 let addr = listener.local_addr().expect("addr");
3290 let server = thread::spawn(move || {
3291 let mut bodies = Vec::new();
3292 for _ in 0..2 {
3293 let (mut sock, _) = listener.accept().expect("accept");
3294 let (headers, body) = read_http(&mut sock).expect("request");
3295 assert!(
3296 !headers.to_ascii_lowercase().contains("authorization"),
3297 "no bearer was passed"
3298 );
3299 assert!(
3300 !headers.to_ascii_lowercase().contains("x-automation-key"),
3301 "no bearer was passed"
3302 );
3303 let _ = sock.write_all(
3304 b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
3305 );
3306 bodies.push(body);
3307 }
3308 bodies
3309 });
3310 let url = format!("http://{addr}/routine");
3311 post_decrypted(
3312 &url,
3313 &DecryptedWake {
3314 body: "plain",
3315 from: "@bob:localhost",
3316 nick: None,
3317 event_id: "$m1:localhost",
3318 ..Default::default()
3319 },
3320 )
3321 .expect("missing nick");
3322 post_decrypted(
3323 &url,
3324 &DecryptedWake {
3325 body: "plain",
3326 from: "@bob:localhost",
3327 nick: Some(" "),
3328 event_id: "$m1:localhost",
3329 ..Default::default()
3330 },
3331 )
3332 .expect("blank nick");
3333 let bodies = server.join().expect("server");
3334 for body in &bodies {
3335 assert_wake_json(body, "plain", "@bob:localhost", "$m1:localhost", None);
3336 }
3337 }
3338
3339 #[cfg(all(unix, feature = "wake-grok"))]
3340 #[test]
3341 fn inbound_room_text_prompts_the_leader_socket_once() {
3342 use std::os::unix::net::{UnixListener, UnixStream};
3343
3344 fn frame_read(sock: &mut UnixStream) -> Option<Vec<u8>> {
3345 let _ = sock.set_read_timeout(Some(Duration::from_secs(3)));
3346 let mut len_buf = [0u8; 4];
3347 sock.read_exact(&mut len_buf).ok()?;
3348 let len = u32::from_be_bytes(len_buf) as usize;
3349 if len > 1_000_000 {
3350 return None;
3351 }
3352 let mut buf = vec![0u8; len];
3353 sock.read_exact(&mut buf).ok()?;
3354 Some(buf)
3355 }
3356
3357 fn frame_write(sock: &mut UnixStream, value: &serde_json::Value) {
3358 let bytes = serde_json::to_vec(value).expect("json");
3359 let mut out = (bytes.len() as u32).to_be_bytes().to_vec();
3360 out.extend_from_slice(&bytes);
3361 sock.write_all(&out).expect("write");
3362 sock.flush().expect("flush");
3363 }
3364
3365 fn serve_one(sock: &mut UnixStream, prompts: &Mutex<Vec<String>>) {
3366 let Some(bytes) = frame_read(sock) else {
3367 return;
3368 };
3369 let register: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
3370 assert_eq!(register["type"], "register");
3371 frame_write(
3372 sock,
3373 &serde_json::json!({"type": "registered", "ready": true}),
3374 );
3375 loop {
3376 let Some(bytes) = frame_read(sock) else {
3377 break;
3378 };
3379 let value: serde_json::Value = match serde_json::from_slice(&bytes) {
3380 Ok(value) => value,
3381 Err(_) => break,
3382 };
3383 if value.get("type").and_then(|item| item.as_str()) == Some("disconnect") {
3384 break;
3385 }
3386 if value.get("type").and_then(|item| item.as_str()) != Some("acp") {
3387 continue;
3388 }
3389 let payload = value
3390 .get("payload")
3391 .and_then(|item| item.as_str())
3392 .unwrap_or("");
3393 let inner: serde_json::Value = serde_json::from_str(payload).unwrap_or_default();
3394 if inner.get("method").and_then(|item| item.as_str()) == Some("session/prompt") {
3395 let text = inner["params"]["prompt"][0]["text"]
3396 .as_str()
3397 .unwrap_or("")
3398 .to_string();
3399 let session = inner["params"]["sessionId"]
3400 .as_str()
3401 .unwrap_or("")
3402 .to_string();
3403 prompts
3404 .lock()
3405 .expect("prompts")
3406 .push(format!("{session} {text}"));
3407 }
3408 let id = inner.get("id").cloned().unwrap_or(serde_json::json!(null));
3409 let body = serde_json::json!({"jsonrpc":"2.0","id": id, "result": {}}).to_string();
3410 frame_write(sock, &serde_json::json!({"type":"acp","payload": body}));
3411 }
3412 }
3413
3414 let dir = temp_dir("leader");
3415 let sock_dir = dir.0.join("sock");
3416 std::fs::create_dir_all(&sock_dir).expect("dir");
3417 let path = sock_dir.join("leader.sock");
3418 let listener = UnixListener::bind(&path).expect("bind");
3419 listener.set_nonblocking(true).expect("nonblocking");
3420 let prompts = Arc::new(Mutex::new(Vec::<String>::new()));
3421 let recorded = Arc::clone(&prompts);
3422 let done = Arc::new(AtomicBool::new(false));
3423 let flag = Arc::clone(&done);
3424 let leader = thread::spawn(move || {
3425 let deadline = Instant::now() + Duration::from_secs(8);
3426 while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
3427 match listener.accept() {
3428 Ok((mut sock, _)) => {
3429 let _ = sock.set_nonblocking(false);
3430 serve_one(&mut sock, &recorded);
3431 }
3432 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
3433 thread::sleep(Duration::from_millis(15));
3434 }
3435 Err(_) => break,
3436 }
3437 }
3438 });
3439
3440 let (base, home_done, home) = spawn_homeserver("wake-leader");
3441 let mut store = open_against(&base, &dir.0.join("store"), "session-a");
3442 store.set_wake(SessionWake {
3443 leader_sock: Some(path),
3444 leader_cwd: Some("/tmp".to_string()),
3445 ..SessionWake::default()
3446 });
3447 store.drive(1_000, false).expect("drive");
3448 store.drive(3_000, false).expect("drive again");
3449 let note = store.wake_note().unwrap_or("").to_string();
3450 let saw_text = store.texts().iter().any(|text| text.body == "wake-leader");
3451 drop(store);
3452 stop(&done, leader);
3453 stop(&home_done, home);
3454 assert!(
3455 saw_text,
3456 "engine did not surface the inbound text; note={note}"
3457 );
3458 let prompts = prompts.lock().expect("prompts");
3459 assert_eq!(
3460 prompts.as_slice(),
3461 ["session-a wake-leader"],
3462 "leader prompt was not the decrypted text once; note={note}"
3463 );
3464 }
3465}