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