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