Skip to main content

mail4agent_messenger_shell/
lib.rs

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