Skip to main content

mail4agent_messenger_shell/
lib.rs

1//! Client shell of the messenger: the store key (random, in the client vault), the one web-bot wake,
2//! and the HTTP a released [`OutgoingRequest`] actually performs.
3//!
4//! (Historical: the store key was SHA-256 of the session id string; it is now random and lives in the vault, see `store_key`.) `store_seal_key` is that old derivation. 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 session bearer stays in memory on [`OpenedStore`]. It comes from the client's own
31//! key-signature login (the identity lives in the vault; see `m4a-agent`), is sent as
32//! `Authorization: Bearer` and is never written next to the sealed records. No password, token or
33//! device bearer is ever read from the environment or handed in by a host.
34//! Paths the engine builds under `/_matrix` are sent without that prefix:
35//! `mail4agent-server-bin` mounts the Client-Server router at `/client/v3`.
36//!
37//! Two open paths, one shell. This is not a second product.
38//!
39//! The web machine client is [`MachineClient::from_env`]. One process
40//! uses the agents directory only when [`AGENTS_DIR_ENV`] names one:
41//! each folder is an agent id (also the mail session id) and
42//! `profile.json` has the display name. The home Grok Bot directory is
43//! not opened by default. Otherwise it reads [`SESSIONS_DIR_ENV`]. The nick is
44//! [`nick_from_display_name`] of that display name. Mail between sessions
45//! this client holds is in process
46//! ([`MachineClient::set_local_delivery`]). A peer that is not in the list
47//! uses the homeserver. Wake is the bot's own webhook routine, named by
48//! its nick, which only the bot can create (`UpdateRoutine`). This process
49//! keeps a disabled local mirror in the same folder so the gateway hands
50//! out that routine's URL and key; a null key is retried, never replaced
51//! by a second routine. [`ensure_agent_webhook_routines`] does that without
52//! opening stores. [`MachineClient::poll_agent_directory`] retries and
53//! rescans for new bots. The URL and key live in memory and in the
54//! session's keychain file under the store root, and are not logged. This
55//! path does not read [`LEADER_SOCK_ENV`].
56//!
57//! A node is [`OpenedStore::connect_node_from_env`]. One CLI session, woken
58//! by ACP on [`LEADER_SOCK_ENV`]. A routine URL or bearer in the environment
59//! is refused before register. This path does not gain a webhook config.
60//! [`OpenedStore::connect`] still registers one session a caller already
61//! built; it is not either of those open paths and it does not read a wake
62//! from the environment.
63
64pub mod cli;
65pub mod door_listen;
66pub mod session_account;
67pub mod letter_hook;
68pub mod interrupt;
69pub mod letter_content;
70#[doc(hidden)]
71pub fn ipc_own_uid() -> Option<u32> {
72    ipc::own_uid()
73}
74mod cmd;
75mod grok_listen;
76mod ipc;
77mod machine;
78#[cfg(feature = "local-bus")]
79mod local_bus;
80#[cfg(not(feature = "local-bus"))]
81#[path = "local_bus_off.rs"]
82mod local_bus;
83mod nick;
84mod push_heal;
85mod webhook;
86pub mod wake_policy;
87pub mod provider;
88mod bus_backend;
89pub mod resolve;
90mod send;
91#[cfg(windows)]
92mod ipc_winpipe;
93pub mod store_key;
94
95use std::collections::{HashMap, HashSet};
96use std::path::{Component, Path, PathBuf};
97use std::sync::Arc;
98
99use mail4agent_messenger::store::sealed::SealedRecordCodec;
100use mail4agent_messenger::wire::Membership;
101use mail4agent_messenger::{
102    CoreConfig, CoreSecrets, ItemContent, Jitter, MessengerCore,
103    MessengerError, OutgoingRequest, OutgoingRequestKind, RecordKey, SealedRecord, SendState,
104    StoreError,
105};
106use sha2::{Digest, Sha256};
107use m4a_agent::engine::Driver;
108use m4a_agent::{AgentError, Backend};
109use zeroize::Zeroizing;
110
111pub use machine::{
112    ensure_agent_webhook_routines, ensure_agent_webhook_routines_from_env, load_agents_dir,
113    load_session_records, HostSession, MachineClient, RoutineReport, TickReport, WakeOptions,
114    WakeStatus, AGENTS_DIR_ENV, AGENT_RESCAN_SECS_ENV, DEFAULT_AGENTS_DIR,
115    LOCAL_BUS_ENV, PROFILE_NOTE_ENV, SESSIONS_DIR_ENV, SESSION_IDS_ENV, SKIP_NICKS_ENV, STORE_LOCK_FILE,
116    WAKE_KEYCHAIN_FILE, session_gate, account_sessions, REQUIRE_SESSION_CAP_ENV, ACCOUNTS_ROOT_ENV,
117};
118pub use mail4agent_messenger::{
119    CreateRoomKind, DeviceId, MessageKind, MessengerCommand, OutgoingMessage, RoomId, RoomKind,
120    UserId,
121};
122pub use cmd::{CmdReply, CmdRequest};
123// AutoJoinPolicy is defined in this file.
124pub use mail4agent_messenger::EventId;
125pub use grok_listen::{hear, GrokListener, Heard, ListenReport};
126pub use nick::{nick_from_display_name, routine_folder_id};
127pub use provider::{
128    letter_chain, HostEnv, HookFlavor, ProviderKind, ProviderSession, SessionKind, SessionRecord, Surface,
129    WakeAdapter, WakeChain, WakeError, WakeLetter, WakeOutcome, INBOX_DIR_ENV, PROVIDER_ENV,
130};
131pub use m4a_agent::engine::PushedRoomEvent;
132pub use send::{
133    load_env_file, load_env_file_named, send_cmd_via_socket, send_sock_path, send_sock_path_named,
134    send_via_socket,
135    SendReply, SendRequest, DEFAULT_SOCK_NAME, ENV_FILE_ENV, MAX_SEND_BYTES, SEND_SOCK_ENV,
136};
137
138/// SHA-256 of `session_id`'s UTF-8 bytes. That digest is the check that this
139/// session may open the store. The bytes are not written to disk.
140pub fn store_seal_key(session_id: &str) -> [u8; 32] {
141    let digest = Sha256::digest(session_id.as_bytes());
142    let mut key = [0u8; 32];
143    key.copy_from_slice(&digest);
144    key
145}
146
147/// Shared store root on this machine. The caller supplies it. Unset is not
148/// a shared fallback outside tests.
149pub const STORE_ROOT_ENV: &str = "M4A_STORE_ROOT";
150
151/// Homeserver origin for a Grok Bot web session, for example
152/// `http://127.0.0.1:8741`. Wins over [`CONFIG_ENV`] / `mail4agent.toml`.
153/// There is no built-in host.
154pub const HOMESERVER_URL_ENV: &str = "M4A_HOMESERVER_URL";
155
156/// Optional path of a toml file whose `homeserver_url` is used when
157/// [`HOMESERVER_URL_ENV`] is unset. Unset looks at `./mail4agent.toml` and
158/// ignores it when that file is absent.
159pub const CONFIG_ENV: &str = "M4A_CONFIG";
160
161/// Display name of this Grok Bot web session, the name the host already
162/// shows. The nick is derived from it. This is not a nick slug.
163pub const BOT_NAME_ENV: &str = "M4A_BOT_NAME";
164
165/// Session id the web host already assigned. The store directory is
166/// [`session_store_dir`] of this id. The same id reopens the same store.
167pub const SESSION_ID_ENV: &str = "M4A_SESSION_ID";
168
169/// Product-session mode: base URL of the product server. Every Client-Server
170/// call goes through its proxy with the product session token as bearer.
171pub const PRODUCT_URL_ENV: &str = "M4A_PRODUCT_URL";
172/// Identity mode: the operator's one-time invite code (read by the CLIENT process, once; the agent
173/// is never given it). Not needed after the identity is enrolled.
174pub const PRODUCT_INVITE_ENV: &str = "M4A_PRODUCT_INVITE";
175/// Identity mode: `server` (our product API, default) or `matrix` (the Matrix client API).
176pub const TIER_ENV: &str = "M4A_TIER";
177/// Identity mode: the client owns an ed25519 identity and logs in by signature. There is no
178/// password and no token the agent could be handed; `invite` is the operator's one-time code,
179/// needed only until the identity is enrolled.
180#[derive(Clone)]
181#[cfg_attr(not(any(feature = "tier-server", feature = "tier-matrix")), allow(dead_code))]
182struct IdentityAuth {
183    tier: m4a_agent::BackendKind,
184    invite: Option<Zeroizing<String>>,
185    /// The nick to ask for at the first enrollment: the session name as a nick.
186    nick_request: Option<String>,
187}
188
189/// Directory for `session_id` under `root`.
190///
191/// The final component is the lowercase hex of [`store_seal_key`]. Different
192/// session ids get different directories. The same id gets the same directory
193/// again. The component is only hex, so it cannot leave `root`. Callers stop
194/// pointing two processes at one path by hand. The device bearer is not an
195/// input and is not written into the directory.
196pub fn session_store_dir(root: &Path, session_id: &str) -> PathBuf {
197    root.join(hex_encode(&store_seal_key(session_id)))
198}
199
200/// [`STORE_ROOT_ENV`] when set and non-empty. Tests with it unset get one
201/// temp directory for this process. Other builds return [`ShellError::StoreRoot`]
202/// and do not invent a path two agents would share.
203pub fn store_root() -> Result<PathBuf, ShellError> {
204    if let Some(root) = nonempty_var(STORE_ROOT_ENV) {
205        return Ok(PathBuf::from(root));
206    }
207    #[cfg(test)]
208    {
209        return Ok(std::env::temp_dir().join(format!(
210            "mail4agent-messenger-shell-root-{}",
211            std::process::id()
212        )));
213    }
214    #[cfg(not(test))]
215    {
216        Err(ShellError::StoreRoot)
217    }
218}
219
220fn hex_encode(bytes: &[u8]) -> String {
221    const HEX: &[u8; 16] = b"0123456789abcdef";
222    let mut out = String::with_capacity(bytes.len() * 2);
223    for byte in bytes {
224        out.push(HEX[(byte >> 4) as usize] as char);
225        out.push(HEX[(byte & 0x0f) as usize] as char);
226    }
227    out
228}
229
230/// One routine POST: the Matrix event id it carried and the HTTP status
231/// (`None` when no response arrived). `200` means the routine woke.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct WakeAttempt {
234    /// Event id of the room text that was posted.
235    pub event_id: String,
236    /// HTTP status of the POST, if one came back.
237    pub status: Option<u16>,
238}
239
240/// One URL per bot, already created. Neighbors are addressed by nick or mxid
241/// on the server.
242pub struct RoutineWake {
243    /// The bot's own routine URL. Configured once. Not a per-letter token.
244    pub url: String,
245}
246
247/// Routine POST target. Unset or empty: no POST. There is no default URL.
248pub const ROUTINE_URL_ENV: &str = "M4A_ROUTINE_URL";
249
250/// Optional `Authorization: Bearer` for [`ROUTINE_URL_ENV`]. Sent only when
251/// that URL is also set. A host keychain injects this into the process
252/// environment from outside. It is never written to disk and never read
253/// from source.
254pub const ROUTINE_BEARER_ENV: &str = "M4A_ROUTINE_BEARER";
255
256/// Leader socket for a local session (`leader.sock`). Unset: no ACP push.
257pub const LEADER_SOCK_ENV: &str = "M4A_LEADER_SOCK";
258
259/// `session/load` working directory when [`LEADER_SOCK_ENV`] is set.
260/// Unset uses the process current directory.
261pub const LEADER_CWD_ENV: &str = "M4A_LEADER_CWD";
262
263/// Wake targets for one shell. Empty fields mean that trigger is off.
264/// `routine_bearer` is kept only in memory and only attached when
265/// `routine_url` is set. The caller fills it from the environment, never
266/// from a literal in source.
267pub struct SessionWake {
268    /// Routine URL. `None` or empty skips the POST.
269    pub routine_url: Option<String>,
270    /// Bearer for the routine POST. `None` or empty sends no
271    /// `Authorization` header.
272    pub routine_bearer: Option<String>,
273    /// Path of an already-running `leader.sock`. `None` skips ACP.
274    pub leader_sock: Option<PathBuf>,
275    /// Working directory for `session/load`. `None` uses the env or cwd.
276    pub leader_cwd: Option<String>,
277}
278
279impl Default for SessionWake {
280    fn default() -> Self {
281        Self {
282            routine_url: None,
283            routine_bearer: None,
284            leader_sock: None,
285            leader_cwd: None,
286        }
287    }
288}
289
290impl SessionWake {
291    /// Web machine client. The host injected [`ROUTINE_URL_ENV`] and, when
292    /// that URL is set, [`ROUTINE_BEARER_ENV`]. [`LEADER_SOCK_ENV`] is not
293    /// read. Nothing is taken from a file.
294    pub fn web_host() -> Self {
295        Self::web_from_lookup(|key| nonempty_var(key))
296    }
297
298    /// Same as [`Self::web_host`] with an explicit lookup. A leader socket
299    /// returned by `get` is ignored.
300    pub fn web_from_lookup(mut get: impl FnMut(&str) -> Option<String>) -> Self {
301        let routine_url = get(ROUTINE_URL_ENV).filter(|value| !value.is_empty());
302        let routine_bearer = if routine_url.is_some() {
303            get(ROUTINE_BEARER_ENV).filter(|value| !value.is_empty())
304        } else {
305            None
306        };
307        Self {
308            routine_url,
309            routine_bearer,
310            leader_sock: None,
311            leader_cwd: None,
312        }
313    }
314
315    /// Node CLI. [`LEADER_SOCK_ENV`] and [`LEADER_CWD_ENV`] only. A routine
316    /// URL or bearer is refused. The refused value is not copied into the
317    /// error.
318    pub fn node_cli() -> Result<Self, ShellError> {
319        Self::node_from_lookup(|key| nonempty_var(key))
320    }
321
322    /// Same as [`Self::node_cli`] with an explicit lookup.
323    pub fn node_from_lookup(
324        mut get: impl FnMut(&str) -> Option<String>,
325    ) -> Result<Self, ShellError> {
326        let routine_url = get(ROUTINE_URL_ENV).filter(|value| !value.is_empty());
327        let routine_bearer = get(ROUTINE_BEARER_ENV).filter(|value| !value.is_empty());
328        if routine_url.is_some() || routine_bearer.is_some() {
329            return Err(ShellError::NodeRoutine);
330        }
331        Ok(Self {
332            routine_url: None,
333            routine_bearer: None,
334            leader_sock: get(LEADER_SOCK_ENV)
335                .filter(|value| !value.is_empty())
336                .map(PathBuf::from),
337            leader_cwd: get(LEADER_CWD_ENV).filter(|value| !value.is_empty()),
338        })
339    }
340}
341
342pub(crate) fn nonempty_var(name: &str) -> Option<String> {
343    std::env::var(name).ok().filter(|value| !value.is_empty())
344}
345
346/// One session of the client, ready to log in. Built from the host environment
347/// ([`SessionConfig::from_env`]) or [`SessionConfig::new_identity`]. The nick is the one the
348/// operator assigned with the invite; it is known after the first login.
349pub struct SessionConfig {
350    homeserver_url: String,
351    session_id: String,
352    store_root: PathBuf,
353    identity: IdentityAuth,
354}
355
356impl SessionConfig {
357    /// The session's identity is generated and kept by the client's vault under `store_root`, it
358    /// enrolls with the operator's `invite` once, and logs in by signature afterwards. `tier`
359    /// picks the product API (tier 2) or the Matrix API (tier 3).
360    pub fn new_identity(
361        product_url: impl Into<String>,
362        tier: m4a_agent::BackendKind,
363        session_id: impl Into<String>,
364        store_root: impl Into<PathBuf>,
365        invite: Option<String>,
366    ) -> Result<Self, ShellError> {
367        let homeserver_url = product_url.into();
368        parse_base_url(&homeserver_url)?;
369        let session_id = session_id.into();
370        validate_session_id(&session_id)?;
371        let store_root = store_root.into();
372        let invite = invite.filter(|i| !i.is_empty()).or_else(|| read_invite_file(&store_root, &session_id));
373        Ok(Self {
374            homeserver_url,
375            session_id,
376            store_root,
377            identity: IdentityAuth { tier, invite: invite.map(Zeroizing::new), nick_request: None },
378        })
379    }
380
381    /// Asks the server, at the first enrollment, for the nick of this session's name
382    /// ([`nick_from_display_name`] of `name`; a name that makes no nick asks for nothing). The
383    /// operator's invite still decides, and a nick the invite reserved wins.
384    pub fn with_nick_request(mut self, name: &str) -> Self {
385        let name = name.trim();
386        // A node auto-nick (`claude-3.hatchery-box.owner`) is already a nick: keep its dots.
387        self.identity.nick_request = if name.contains('.') && crate::nick::is_node_nick(name) { Some(name.to_owned()) } else { nick_from_display_name(name).ok() };
388        self
389    }
390
391    /// A session on `url`: the tier from [`TIER_ENV`] and the operator's invite from the invite
392    /// file (`<store_root>/invites/<hash of the session id>`), if the operator left one.
393    pub fn for_session(url: &str, session_id: &str, store_root: &Path) -> Result<Self, ShellError> {
394        let tier = match nonempty_var(TIER_ENV).as_deref() {
395            None | Some("server") => m4a_agent::BackendKind::Server,
396            Some("matrix") => m4a_agent::BackendKind::Matrix,
397            Some(_) => return Err(ShellError::Register(format!("{TIER_ENV} is server or matrix"))),
398        };
399        Self::new_identity(url, tier, session_id, store_root, None)
400    }
401
402    /// Host environment. Required: [`PRODUCT_URL_ENV`] (or [`HOMESERVER_URL_ENV`] / `homeserver_url`
403    /// in toml), [`SESSION_ID_ENV`], [`STORE_ROOT_ENV`]. Optional: [`PRODUCT_INVITE_ENV`] (first
404    /// login only), [`TIER_ENV`]. [`ROUTINE_URL_ENV`] and [`LEADER_SOCK_ENV`] are not read here.
405    pub fn from_env() -> Result<Self, ShellError> {
406        let toml_text = load_homeserver_toml()?;
407        Self::from_lookup(
408            |key| std::env::var(key).ok().filter(|value| !value.is_empty()),
409            toml_text.as_deref(),
410        )
411    }
412
413    pub(crate) fn from_lookup(
414        mut get: impl FnMut(&str) -> Option<String>,
415        toml_text: Option<&str>,
416    ) -> Result<Self, ShellError> {
417        let from_toml = match toml_text {
418            Some(text) => homeserver_url_from_toml(text)?,
419            None => None,
420        };
421        let url = get(PRODUCT_URL_ENV)
422            .or_else(|| get(HOMESERVER_URL_ENV))
423            .or(from_toml)
424            .ok_or(ShellError::HomeserverUrl)?;
425        let tier = match get(TIER_ENV).as_deref() {
426            None | Some("server") => m4a_agent::BackendKind::Server,
427            Some("matrix") => m4a_agent::BackendKind::Matrix,
428            Some(_) => return Err(ShellError::Register(format!("{TIER_ENV} is server or matrix"))),
429        };
430        let session_id = get(SESSION_ID_ENV).ok_or(ShellError::EmptySession)?;
431        let store_root = get(STORE_ROOT_ENV).ok_or(ShellError::StoreRoot)?;
432        // A nick is asked for only when the host named the session; a bare session id is not a name.
433        let config = Self::new_identity(url, tier, session_id, store_root, get(PRODUCT_INVITE_ENV))?;
434        Ok(match get(BOT_NAME_ENV) {
435            Some(name) => config.with_nick_request(&name),
436            None => config,
437        })
438    }
439
440    /// Homeserver (or product) origin this session logs in at.
441    pub fn homeserver_url(&self) -> &str {
442        &self.homeserver_url
443    }
444
445    /// [`session_store_dir`] for this session under the configured root.
446    pub fn store_dir(&self) -> PathBuf {
447        session_store_dir(&self.store_root, &self.session_id)
448    }
449
450    /// Session id the host assigned. Not a secret.
451    pub fn session_id(&self) -> &str {
452        &self.session_id
453    }
454}
455
456impl std::fmt::Debug for SessionConfig {
457    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
458        f.debug_struct("SessionConfig")
459            .field("homeserver_url", &self.homeserver_url)
460            .field("session_id", &self.session_id)
461            .field("store_root", &self.store_root)
462            .field("tier", &self.identity.tier)
463            .finish()
464    }
465}
466
467/// Where the operator leaves the one-time invite for a session: a file the client reads and
468/// deletes after the identity is enrolled. The agent is never given the code.
469fn invite_path(store_root: &Path, session_id: &str) -> PathBuf {
470    store_root.join("invites").join(hex_encode(&store_seal_key(session_id)))
471}
472
473fn read_invite_file(store_root: &Path, session_id: &str) -> Option<String> {
474    let text = std::fs::read_to_string(invite_path(store_root, session_id)).ok()?;
475    let code = text.trim().to_string();
476    (!code.is_empty()).then_some(code)
477}
478
479fn validate_session_id(session_id: &str) -> Result<(), ShellError> {
480    if session_id.is_empty()
481        || session_id.len() > 128
482        || session_id.starts_with("legacy-user-")
483        || session_id
484            .chars()
485            .any(|ch| ch.is_whitespace() || ch.is_control())
486    {
487        return Err(ShellError::EmptySession);
488    }
489    Ok(())
490}
491
492fn load_homeserver_toml() -> Result<Option<String>, ShellError> {
493    let path = if let Some(configured) = nonempty_var(CONFIG_ENV) {
494        PathBuf::from(configured)
495    } else {
496        let cwd = PathBuf::from("mail4agent.toml");
497        if !cwd.exists() {
498            return Ok(None);
499        }
500        cwd
501    };
502    if !path.is_file() {
503        return Err(ShellError::Config("config file is missing".to_string()));
504    }
505    std::fs::read_to_string(&path)
506        .map(Some)
507        .map_err(|err| ShellError::Config(clip_public(err.to_string())))
508}
509
510fn homeserver_url_from_toml(text: &str) -> Result<Option<String>, ShellError> {
511    #[derive(serde::Deserialize)]
512    struct File {
513        #[serde(default)]
514        homeserver_url: Option<String>,
515    }
516    let file: File =
517        toml::from_str(text).map_err(|err| ShellError::Config(clip_public(err.to_string())))?;
518    Ok(file
519        .homeserver_url
520        .map(|value| value.trim().to_string())
521        .filter(|value| !value.is_empty()))
522}
523
524/// A session found by nick. No routine URL and no bearer.
525pub struct FoundSession {
526    /// The nick stored for that session.
527    pub nick: String,
528    /// That session's Matrix user id.
529    pub user_id: String,
530}
531
532/// One decrypted room text the routine should learn about.
533///
534/// Serialized as a JSON object. `body` is the plaintext a routine that
535/// only reads the message still finds. `from` is the sender mxid.
536/// `event_id` is the Matrix event id. `nick` is omitted when this shell
537/// does not already know a display name; it is never invented.
538#[derive(Default)]
539pub struct DecryptedWake<'a> {
540    /// Plaintext body.
541    pub body: &'a str,
542    /// Sender mxid.
543    pub from: &'a str,
544    /// Sender display name, if the room state already has one.
545    pub nick: Option<&'a str>,
546    /// Matrix event id.
547    pub event_id: &'a str,
548    /// Room id the text arrived in (`room`).
549    pub room: Option<&'a str>,
550    /// Sender nick: the localpart of `from` (`from_nick`).
551    pub from_nick: Option<&'a str>,
552    /// Recipient nick: the session this wake is for (`to`).
553    pub to: Option<&'a str>,
554    /// One-line reply command for the recipient (`reply`), e.g.
555    /// `m4a-send --as <to> --to <from_nick> '<text>'`.
556    pub reply: Option<&'a str>,
557}
558
559/// Command a woken bot runs to answer, as shown in the wake's `reply`.
560pub const SEND_COMMAND: &str = "m4a-send";
561
562/// The `reply` hint for a wake from `from_nick` to `to`.
563pub fn reply_hint(to: &str, from_nick: &str) -> String {
564    format!("{SEND_COMMAND} --as {to} --to {from_nick} '<your reply>'")
565}
566
567/// Localpart of a Matrix user id (`@alice:server` -> `alice`).
568pub fn mxid_localpart(mxid: &str) -> &str {
569    let rest = mxid.strip_prefix('@').unwrap_or(mxid);
570    rest.split_once(':').map(|(local, _)| local).unwrap_or(rest)
571}
572
573/// A sealed messenger core opened for one session, plus the homeserver it
574/// performs released requests against. The Olm account was loaded or created
575/// into it. Private key material stays here. The device bearer stays in
576/// memory and is zeroed when this value is dropped.
577pub struct OpenedStore {
578    dir: PathBuf,
579    driver: Driver<SealedRecordCodec>,
580    device_token: Zeroizing<String>,
581    base_url: String,
582    /// Session id this store was opened with. Also the ACP `sessionId`
583    /// when a leader socket is configured. Not a secret.
584    session_id: String,
585    /// Set by [`OpenedStore::connect`]. Empty when opened with a bearer
586    /// the caller already held.
587    nick: Option<String>,
588    routine_url: Option<String>,
589    routine_bearer: Option<Zeroizing<String>>,
590    leader_sock: Option<PathBuf>,
591    leader_cwd: Option<String>,
592    /// Inbound event keys whose routine POST already succeeded.
593    routine_sent: HashSet<String>,
594    /// When true, the next [`Self::wake_inbound`] with timeline rows seeds
595    /// older keys into [`ROUTINE_WOKEN_FILE`] (newest still POSTs once) —
596    /// first open after a missing/empty ledger, once tip/history has landed.
597    routine_woken_seed_pending: bool,
598    /// Restart with an existing [`ROUTINE_WOKEN_FILE`]: tip/history may show
599    /// more rows than the ledger. While true, remember every inbound key
600    /// without POSTing. Cleared at the end of the first [`Self::drive`].
601    routine_restart_catchup: bool,
602    /// Reactions at or before this `origin_server_ts` were already in the store when this
603    /// process started; they never wake (no replay on restart). Set on the first drive.
604    reaction_floor: Option<i64>,
605    /// Inbound event keys whose leader prompt already succeeded.
606    leader_sent: HashSet<String>,
607    /// Last wake failure, clipped. No bearer and no message body.
608    wake_note: Option<String>,
609    wake_log: Vec<WakeAttempt>,
610    /// Letters whose live deliver failed and fell back to the durable inbox: retried through
611    /// the deliver link with backoff until it answers, never dropped. key -> (attempts, next try).
612    wake_retry: HashMap<String, (u32, std::time::Instant)>,
613    /// Provider wake chain for a session that has no leader socket
614    /// (Codex, Kimi, Claude, Cursor; Claude web / Codex cloud). Uses the
615    /// same `leader-prompted` ledger as the leader path.
616    wake_chain: Option<(provider::ProviderSession, provider::WakeChain)>,
617    /// `<link id> delivered|queued` for the newest chain wake.
618    wake_route: Option<String>,
619    /// In-process bus for sessions this machine's client already holds.
620    /// `None` on a store opened by itself: every request goes to the homeserver.
621    bus: Option<Arc<machine::LocalBus>>,
622    /// Nick and mxid of the other sessions in that client. Lookup does not
623    /// ask the homeserver.
624    local_peers: Vec<(String, String)>,
625    /// Room texts pushed on the machine socket for this session. Not `/sync`.
626    pushed_room_events: Vec<PushedRoomEvent>,
627    /// Joined rooms already paged once. Timelines are not stored, so a
628    /// resumed process asks `/messages` from the sync token a single time.
629    history_pulled: HashSet<String>,
630    /// Joined rooms whose tip page has already landed in this process.
631    /// A `/sync` token that has moved past an event will not replay it.
632    tip_pulled: HashSet<String>,
633}
634
635struct ZeroJitter;
636
637impl Jitter for ZeroJitter {
638    fn next_unit(&mut self) -> f64 {
639        0.0
640    }
641}
642
643/// One room this store currently holds, from this account's own view.
644pub struct RoomView {
645    /// The room id.
646    pub room_id: String,
647    /// This account's membership: `join`, `invite`, `leave`, `ban`, `knock`,
648    /// `unknown`, or `absent`.
649    pub membership: String,
650    /// Whether `m.room.encryption` is set.
651    pub encrypted: bool,
652}
653
654/// One text-like timeline row. Ciphertext is not included.
655pub struct TextView {
656    /// The room the row is in.
657    pub room_id: String,
658    /// The decrypted or plaintext body.
659    pub body: String,
660    /// `sent`, `sending`, `failed`, or `undecryptable`.
661    pub outcome: String,
662    /// Matrix event id once the server has one. Empty for a local echo
663    /// that has not been accepted yet.
664    pub event_id: Option<String>,
665}
666
667impl OpenedStore {
668    /// Derives the seal key from `session_id`, reads `dir`, and opens the
669    /// core. A different session id cannot open records this session sealed.
670    ///
671    /// The host keychain injects `device_token`. [`STORE_ROOT_ENV`] plus
672    /// `session_id` ([`session_store_dir`]) picks `dir`. This does not read
673    /// a routine URL or a leader socket. The web machine client and the
674    /// node CLI set wake on their own open paths. The device bearer stays
675    /// in memory and is not written under `dir`.
676    pub fn open(
677        dir: &Path,
678        session_id: &str,
679        device_id: DeviceId,
680        user_id: &str,
681        server_name: &str,
682        backend: Arc<dyn Backend>,
683        device_token: &str,
684    ) -> Result<Self, ShellError> {
685        if session_id.is_empty() {
686            return Err(ShellError::EmptySession);
687        }
688        if device_token.is_empty()
689            || device_token
690                .bytes()
691                .any(|byte| byte.is_ascii_whitespace() || !byte.is_ascii())
692        {
693            return Err(ShellError::DeviceToken);
694        }
695        let base_url = backend.server_ref().to_string();
696        at("create store dir", dir, fs_create_dir(dir))?;
697        let user_id = UserId::parse(user_id)?;
698        let secrets = CoreSecrets {
699            store_seal_key: Some(at("store key", dir, store_key::store_key(dir, session_id))?),
700            backup_key: None,
701        };
702        let config = CoreConfig {
703            user_id,
704            device_id,
705            server_name: server_name.to_string(),
706        };
707        let records = at("read records", dir, read_records(dir))?;
708        let mut core =
709            match MessengerCore::open_sealed(records, secrets, config, 0, Box::new(ZeroJitter)) {
710                Ok(core) => core,
711                Err(MessengerError::Store(err)) => return Err(ShellError::Store(err)),
712                Err(err) => return Err(ShellError::Messenger(err)),
713            };
714        at("persist records", dir, persist(dir, &mut core))?;
715        let mut opened = Self {
716            dir: dir.to_path_buf(),
717            driver: Driver::new(core, backend, {
718                let dir = dir.to_path_buf();
719                Box::new(move |core| persist(&dir, core).map_err(|e| AgentError::Store(clip_public(e.to_string()))))
720            }),
721            base_url,
722            device_token: Zeroizing::new(device_token.to_string()),
723            session_id: session_id.to_string(),
724            nick: None,
725            routine_url: None,
726            routine_bearer: None,
727            leader_sock: None,
728            leader_cwd: None,
729            routine_sent: HashSet::new(),
730            routine_woken_seed_pending: false,
731            routine_restart_catchup: false,
732            reaction_floor: None,
733            leader_sent: HashSet::new(),
734            wake_chain: None,
735            wake_route: None,
736            wake_note: None,
737            wake_log: Vec::new(),
738            wake_retry: HashMap::new(),
739            bus: None,
740            local_peers: Vec::new(),
741            pushed_room_events: Vec::new(),
742            history_pulled: HashSet::new(),
743            tip_pulled: HashSet::new(),
744        };
745        // Routine replay is suppressed for whatever is already on disk.
746        // A leader prompt is not. Only a recorded successful prompt is.
747        opened.note_already_present();
748        Ok(opened)
749    }
750
751    /// Registers this Grok Bot web session, then opens its sealed store.
752    ///
753    /// `POST /client/v3/register` with the derived nick, the session id, and
754    /// a public id equal to that nick. The bearer from a creating response
755    /// stays in memory. A repeat of the same session id returns no bearer;
756    /// [`DEVICE_TOKEN_ENV`] (already copied into `config`) is used instead.
757    /// One [`Self::drive`] publishes this device's public keys. The private
758    /// Olm account stays in the store.
759    pub fn connect(config: &SessionConfig) -> Result<Self, ShellError> {
760        Self::connect_with_wake(config, SessionWake::default())
761    }
762
763    pub(crate) fn connect_with_wake(config: &SessionConfig, wake: SessionWake) -> Result<Self, ShellError> {
764        let registered = register_session(config)?;
765        let server_name = registered
766            .user_id
767            .split_once(':')
768            .map(|(_, server)| server)
769            .filter(|server| !server.is_empty())
770            .ok_or(ShellError::Register("user id has no server".to_string()))?;
771        let mut opened = Self::open(
772            &config.store_dir(),
773            &config.session_id,
774            registered.device_id,
775            &registered.user_id,
776            server_name,
777            registered.backend,
778            &registered.bearer,
779        )?;
780        opened.nick = Some(registered.nick.clone());
781        opened.set_wake(wake);
782        // The first sync is what publishes the public keys.
783        opened.drive(1_000, false)?;
784        Ok(opened)
785    }
786
787    /// [`SessionConfig::from_env`] then [`Self::connect`]. No routine URL
788    /// and no leader socket. The web machine client is
789    /// [`MachineClient::from_env`]. The node CLI is
790    /// [`Self::connect_node_from_env`].
791    pub fn connect_from_env() -> Result<Self, ShellError> {
792        Self::connect(&SessionConfig::from_env()?)
793    }
794
795    /// Node CLI open path. One session from the host environment, woken by
796    /// ACP on [`LEADER_SOCK_ENV`]. [`ROUTINE_URL_ENV`] or
797    /// [`ROUTINE_BEARER_ENV`] is refused before register, is not stored, and
798    /// is not logged. This does not read a session json.
799    pub fn connect_node_from_env() -> Result<Self, ShellError> {
800        let wake = SessionWake::node_cli()?;
801        Self::connect_with_wake(&SessionConfig::from_env()?, wake)
802    }
803
804    #[cfg(test)]
805    pub(crate) fn connect_node_from_lookup(
806        mut get: impl FnMut(&str) -> Option<String>,
807        toml_text: Option<&str>,
808    ) -> Result<Self, ShellError> {
809        let wake = SessionWake::node_from_lookup(&mut get)?;
810        let config = SessionConfig::from_lookup(&mut get, toml_text)?;
811        Self::connect_with_wake(&config, wake)
812    }
813
814    /// Bearer for the host keychain. Not written to disk and not logged.
815    pub(crate) fn keep_prefix(&self) -> bool {
816        self.driver.backend().keep_prefix()
817    }
818
819    pub fn device_bearer(&self) -> &str {
820        self.device_token.as_str()
821    }
822
823    /// This session's derived nick, when [`Self::connect`] stored one.
824    /// [`Self::open`] leaves this empty.
825    pub fn nick(&self) -> Option<&str> {
826        self.nick.as_deref()
827    }
828
829    /// Session / device id used for the seal and for ACP `sessionId`.
830    pub fn session_id(&self) -> &str {
831        &self.session_id
832    }
833
834    /// Homeserver origin this store performs Client-Server calls against.
835    pub fn homeserver_url(&self) -> &str {
836        self.base_url.trim_end_matches('/')
837    }
838
839    /// Whether an ACP leader socket is configured for wake.
840    pub fn has_leader(&self) -> bool {
841        self.leader_sock.is_some()
842    }
843
844    /// Directory this store seals into. Not shared with another session.
845    pub fn store_dir(&self) -> &Path {
846        &self.dir
847    }
848
849    /// Homeserver calls this store's client has counted. In-process bus
850    /// calls are not included. Zero when this store has no bus.
851    pub fn homeserver_hits(&self) -> u64 {
852        self.bus.as_ref().map(|bus| bus.hits()).unwrap_or(0)
853    }
854
855    /// Room texts this session received on the machine push socket.
856    pub fn pushed_room_events(&self) -> &[PushedRoomEvent] {
857        &self.pushed_room_events
858    }
859
860    pub(crate) fn record_push(&mut self, event: PushedRoomEvent) {
861        self.pushed_room_events.push(event);
862    }
863
864    pub(crate) fn attach_bus(&mut self, bus: Arc<machine::LocalBus>) {
865        let inner = Arc::clone(self.driver.backend());
866        self.driver.set_backend(Arc::new(bus_backend::BusBackend { inner, bus: Arc::clone(&bus), user_id: self.driver.core.user_id().as_str().to_string(), force_local: None }));
867        self.bus = Some(bus);
868    }
869
870    pub(crate) fn set_registered_nick(&mut self, nick: String) {
871        self.nick = Some(nick);
872    }
873
874    pub(crate) fn set_local_peers(&mut self, peers: Vec<(String, String)>) {
875        self.local_peers = peers;
876    }
877
878    /// Drops a `/sync` left on the wire by [`Self::drive`] so the next drive
879    /// is not blocked on that poll. The engine retries the sync later. This
880    /// does not start another poll.
881    pub(crate) fn abandon_inflight_sync(&mut self, now_ms: i64) -> Result<(), ShellError> {
882        if self.driver.sync_inflight() {
883            self.driver.abandon_sync(now_ms);
884            self.persist_core()?;
885        }
886        Ok(())
887    }
888
889    /// This account's Matrix user id.
890    pub fn user_id(&self) -> &str {
891        self.driver.core.user_id().as_str()
892    }
893
894    /// Whether `user_id` is a joined member of `room_id` in this store.
895    pub fn member_joined(&self, room_id: &str, user_id: &str) -> bool {
896        let Ok(room_id) = RoomId::parse(room_id) else {
897            return false;
898        };
899        let Ok(user_id) = UserId::parse(user_id) else {
900            return false;
901        };
902        self.driver.core.room_state(&room_id).is_some_and(|state| {
903            state
904                .members
905                .get(&user_id)
906                .is_some_and(|member| member.membership == Membership::Join)
907        })
908    }
909
910    /// Looks up `name_or_nick` in the user directory. A display name is
911    /// derived first when it is not already a nick. The hit is nick plus
912    /// user id. No routine URL is returned or sent.
913    pub fn find_nick(
914        &mut self,
915        name_or_nick: &str,
916        now_ms: i64,
917    ) -> Result<FoundSession, ShellError> {
918        let needle = nick::lookup_nick(name_or_nick)?;
919        if let Some((nick, user_id)) = self
920            .local_peers
921            .iter()
922            .find(|(nick, _)| nick.eq_ignore_ascii_case(&needle))
923        {
924            return Ok(FoundSession {
925                nick: nick.clone(),
926                user_id: user_id.clone(),
927            });
928        }
929        self.dispatch(
930            MessengerCommand::SearchUsers {
931                term: needle.clone(),
932            },
933            now_ms,
934        )?;
935        self.drive(now_ms, false)?;
936        let mut hits: Vec<FoundSession> = self
937            .driver.core
938            .user_search_result()
939            .iter()
940            .filter_map(|entry| {
941                let nick = entry.display_name.as_deref()?.trim();
942                if !nick.eq_ignore_ascii_case(&needle) {
943                    return None;
944                }
945                Some(FoundSession {
946                    nick: nick.to_string(),
947                    user_id: entry.user_id.as_str().to_string(),
948                })
949            })
950            .collect();
951        hits.sort_by(|left, right| left.user_id.cmp(&right.user_id));
952        hits.dedup_by(|left, right| left.user_id == right.user_id);
953        match hits.len() {
954            1 => Ok(hits.remove(0)),
955            0 => Err(ShellError::UnknownNick),
956            _ => Err(ShellError::UnknownNick),
957        }
958    }
959
960    /// Looks `name_or_nick` up and opens the encrypted DM, creating it when
961    /// this account does not already have one. Does not send. The peer may
962    /// still be invited: [`Self::write_to_nick`] sends only after they join.
963    pub fn ensure_dm(&mut self, name_or_nick: &str, mut now_ms: i64) -> Result<String, ShellError> {
964        let (_peer, room_id) = self.open_dm(name_or_nick, &mut now_ms)?;
965        Ok(room_id)
966    }
967
968    /// Joins invites already visible to this store, by the auto-join policy
969    /// ([`AutoJoinPolicy::from_env`]): a DM invite when every other member is on this account's
970    /// own server, a group-room invite when the inviter is on this account's own server (and,
971    /// when an allow-list is set, is on it). Anything else is left alone. Returns the room ids
972    /// this call asked to join. This does not register the inviter.
973    pub fn accept_direct_invites(&mut self, now_ms: i64) -> Result<Vec<String>, ShellError> {
974        self.accept_invites(now_ms, &AutoJoinPolicy::from_env())
975    }
976
977    /// [`Self::accept_direct_invites`] with an explicit policy.
978    pub fn accept_invites(&mut self, mut now_ms: i64, policy: &AutoJoinPolicy) -> Result<Vec<String>, ShellError> {
979        let me = self.driver.core.user_id().clone();
980        let home = server_of(me.as_str()).to_string();
981        let invited: Vec<RoomId> = self
982            .driver.core
983            .room_ids()
984            .cloned()
985            .filter(|room_id| {
986                let Some(state) = self.driver.core.room_state(room_id) else {
987                    return false;
988                };
989                let Some(mine) = state.members.get(&me).filter(|m| m.membership == Membership::Invite) else {
990                    return false;
991                };
992                policy.accepts(&home, me.as_str(), mine.is_direct, mine.invited_by.as_ref().map(|u| u.as_str()), state.members.keys().map(|u| u.as_str()))
993            })
994            .collect();
995        let mut ids = Vec::new();
996        for room_id in invited {
997            self.dispatch(
998                MessengerCommand::JoinRoom {
999                    room_id: room_id.clone(),
1000                },
1001                now_ms,
1002            )?;
1003            ids.push(room_id.as_str().to_string());
1004        }
1005        if ids.is_empty() {
1006            return Ok(ids);
1007        }
1008        for _ in 0..8 {
1009            now_ms += 1_000;
1010            self.drive(now_ms, false)?;
1011            let still_invited = ids.iter().any(|room_id| {
1012                self.rooms()
1013                    .iter()
1014                    .any(|room| room.room_id == *room_id && room.membership == "invite")
1015            });
1016            if !still_invited {
1017                break;
1018            }
1019        }
1020        Ok(ids)
1021    }
1022
1023    /// Writes `text` to the session named by `name_or_nick`.
1024    ///
1025    /// The caller is already registered. This looks the nick up, reuses the
1026    /// encrypted DM when one exists, otherwise creates one, and sends only
1027    /// after the peer's membership is `join`. A peer who is still invited
1028    /// gets no ciphertext: the caller joins them first
1029    /// ([`Self::accept_direct_invites`]) and calls this again. It does not
1030    /// register the other session and it does not put a routine URL in the room.
1031    pub fn write_to_nick(
1032        &mut self,
1033        name_or_nick: &str,
1034        text: &str,
1035        mut now_ms: i64,
1036    ) -> Result<String, ShellError> {
1037        let (peer, room_id) = self.open_dm(name_or_nick, &mut now_ms)?;
1038        if !self.member_joined(&room_id, peer.as_str()) {
1039            return Err(ShellError::Dm);
1040        }
1041        let encrypted = self
1042            .rooms()
1043            .into_iter()
1044            .any(|room| room.room_id == room_id && room.encrypted);
1045        if !encrypted {
1046            return Err(ShellError::Dm);
1047        }
1048        self.dispatch(
1049            MessengerCommand::SendMessage {
1050                room_id: RoomId::parse(&room_id)?,
1051                message: OutgoingMessage {
1052                    kind: MessageKind::Text,
1053                    body: text.to_string(),
1054                    reply_to: None,
1055                    edit_of: None,
1056                },
1057                txn_id: None,
1058            },
1059            now_ms,
1060        )?;
1061        for _ in 0..20 {
1062            now_ms += 1_000;
1063            self.drive(now_ms, false)?;
1064            if let Some(row) = self
1065                .texts()
1066                .into_iter()
1067                .find(|row| row.room_id == room_id && row.body == text)
1068            {
1069                if row.outcome == "sent" {
1070                    return Ok(room_id);
1071                }
1072                if row.outcome.starts_with("failed") {
1073                    return Err(ShellError::Dm);
1074                }
1075            }
1076        }
1077        Err(ShellError::Dm)
1078    }
1079
1080    fn open_dm(
1081        &mut self,
1082        name_or_nick: &str,
1083        now_ms: &mut i64,
1084    ) -> Result<(UserId, String), ShellError> {
1085        let found = self.find_nick(name_or_nick, *now_ms)?;
1086        let peer = UserId::parse(&found.user_id)?;
1087        if &peer == self.driver.core.user_id() {
1088            return Err(ShellError::UnknownNick);
1089        }
1090        let room_id = if let Some(room_id) = self.dm_room(&peer) {
1091            room_id
1092        } else {
1093            self.dispatch(
1094                MessengerCommand::CreateRoom {
1095                    kind: CreateRoomKind::Dm { peer: peer.clone() },
1096                },
1097                *now_ms,
1098            )?;
1099            self.wait_for_dm(&peer, now_ms)?
1100        };
1101        Ok((peer, room_id))
1102    }
1103
1104    fn wait_for_dm(&mut self, peer: &UserId, now_ms: &mut i64) -> Result<String, ShellError> {
1105        for attempt in 0..6 {
1106            *now_ms += 1_000;
1107            self.drive(*now_ms, attempt == 5)?;
1108            if let Some(room_id) = self.dm_room(peer) {
1109                return Ok(room_id);
1110            }
1111        }
1112        Err(ShellError::Dm)
1113    }
1114
1115    fn dm_room(&self, peer: &UserId) -> Option<String> {
1116        let me = self.driver.core.user_id().clone();
1117        let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1118        for room_id in &ids {
1119            let (joined, has_peer) = {
1120                let Some(state) = self.driver.core.room_state(room_id) else {
1121                    continue;
1122                };
1123                let joined = state
1124                    .members
1125                    .get(&me)
1126                    .is_some_and(|member| member.membership == Membership::Join);
1127                let has_peer = state.members.contains_key(peer);
1128                (joined, has_peer)
1129            };
1130            if !joined || !has_peer {
1131                continue;
1132            }
1133            if self.driver.core.room_kind(room_id) == Some(RoomKind::Dm) {
1134                return Some(room_id.as_str().to_string());
1135            }
1136        }
1137        None
1138    }
1139
1140    /// Replaces the wake targets. `None` or empty turns that trigger off.
1141    /// The bearer is stored in [`Zeroizing`] memory and is not written to
1142    /// disk. [`Self::open`] does not read a wake; the web machine client
1143    /// and the node CLI set one on their open paths.
1144    pub fn set_wake(&mut self, wake: SessionWake) {
1145        self.routine_url = wake.routine_url.filter(|url| !url.is_empty());
1146        self.routine_bearer = wake
1147            .routine_bearer
1148            .filter(|token| !token.is_empty())
1149            .map(Zeroizing::new);
1150        self.leader_sock = wake.leader_sock.filter(|path| !path.as_os_str().is_empty());
1151        self.leader_cwd = wake.leader_cwd.filter(|cwd| !cwd.is_empty());
1152    }
1153
1154    /// Installs the provider wake chain ([`provider::plan_chain`]). Used
1155    /// only while no leader socket is set; the Grok leader path and the
1156    /// routine POST are unchanged by it.
1157    pub fn set_wake_chain(
1158        &mut self,
1159        session: provider::ProviderSession,
1160        chain: provider::WakeChain,
1161    ) {
1162        self.wake_chain = Some((session, chain));
1163    }
1164
1165    /// Link ids of the installed chain, in order. Empty when none.
1166    pub fn wake_chain_ids(&self) -> Vec<&'static str> {
1167        self.wake_chain
1168            .as_ref()
1169            .map(|(_, chain)| chain.ids())
1170            .unwrap_or_default()
1171    }
1172
1173    /// `<link id> delivered|queued` for the newest chain wake.
1174    pub fn wake_route(&self) -> Option<&str> {
1175        self.wake_route.as_deref()
1176    }
1177
1178    /// [`Self::wake_route`], cleared, so a listener prints it once.
1179    pub fn take_wake_route(&mut self) -> Option<String> {
1180        self.wake_route.take()
1181    }
1182
1183    /// Whether this store has a routine to wake (URL set).
1184    pub fn has_routine(&self) -> bool {
1185        self.routine_url.is_some()
1186    }
1187
1188    /// Last wake failure that did not drop the room text. `None` if the
1189    /// last attempt worked or nothing has been attempted. The bearer and
1190    /// the plaintext are not included.
1191    pub fn wake_note(&self) -> Option<&str> {
1192        self.wake_note.as_deref()
1193    }
1194
1195    /// Routine POSTs this store attempted since open, oldest first. Holds
1196    /// the event id and the HTTP status only: no URL, key, or body.
1197    pub fn wake_log(&self) -> &[WakeAttempt] {
1198        &self.wake_log
1199    }
1200
1201    /// Queues `command` on the engine. It does not perform HTTP; [`Self::drive`]
1202    /// does that for whatever the engine then releases.
1203    pub fn dispatch(&mut self, command: MessengerCommand, now_ms: i64) -> Result<(), ShellError> {
1204        self.driver.core.dispatch(command, now_ms)?;
1205        self.persist_core()?;
1206        Ok(())
1207    }
1208
1209    /// [`Self::dispatch`] of a [`MessengerCommand::SendMessage`] whose event content also carries
1210    /// `extra_content` (`fyi.*` keys only: `MessengerCore::send_message_with_content`).
1211    pub(crate) fn dispatch_send_with_content(
1212        &mut self,
1213        room_id: RoomId,
1214        message: OutgoingMessage,
1215        extra_content: Option<serde_json::Map<String, serde_json::Value>>,
1216        now_ms: i64,
1217    ) -> Result<(), ShellError> {
1218        match extra_content {
1219            Some(extra) => self.driver.core.send_message_with_content(room_id, message, extra, None, now_ms)?,
1220            None => self.driver.core.dispatch(MessengerCommand::SendMessage { room_id, message, txn_id: None }, now_ms)?,
1221        }
1222        self.persist_core()?;
1223        Ok(())
1224    }
1225
1226    /// Queues [`MessengerCommand::SendMessage`] and releases whatever the
1227    /// engine will send next. That is a room send on an unencrypted room,
1228    /// or the key-setup requests the engine emits first when the room is
1229    /// encrypted. Nothing here calls the old mailbox. The returned requests
1230    /// are already in flight; a caller that wants them performed uses
1231    /// [`Self::drive`] instead of this method.
1232    pub fn send_room_message(
1233        &mut self,
1234        room_id: &str,
1235        text: &str,
1236        now_ms: i64,
1237    ) -> Result<Vec<OutgoingRequest>, ShellError> {
1238        let room_id = RoomId::parse(room_id)?;
1239        self.driver.core.dispatch(
1240            MessengerCommand::SendMessage {
1241                room_id,
1242                message: OutgoingMessage {
1243                    kind: MessageKind::Text,
1244                    body: text.to_string(),
1245                    reply_to: None,
1246                    edit_of: None,
1247                },
1248                txn_id: None,
1249            },
1250            now_ms,
1251        )?;
1252        self.persist_core()?;
1253        let mut released = self.driver.core.releasable_requests(now_ms);
1254        self.persist_core()?;
1255        if !released
1256            .iter()
1257            .any(|request| request.kind == OutgoingRequestKind::RoomSend)
1258        {
1259            released.extend(self.driver.core.releasable_requests(now_ms));
1260            self.persist_core()?;
1261        }
1262        Ok(released)
1263    }
1264
1265    /// Performs every released request against `base_url`.
1266    ///
1267    /// `/sync` with `timeout=0` is always waited on. A long-poll `/sync` is
1268    /// started immediately and joined only when `wait_for_sync` is set, and
1269    /// at most once per call, so an idle 30s poll does not run back to back
1270    /// while key-setup requests are still in flight. The long-poll that is
1271    /// left on the wire is the engine's own request, including its timeout.
1272    /// A homeserver that wakes that poll (this server does) delivers the
1273    /// event without the shell polling twice.
1274    pub fn drive(&mut self, now_ms: i64, wait_for_sync: bool) -> Result<(), ShellError> {
1275        self.driver.core.decrypt_loaded_timeline();
1276        if self.reaction_floor.is_none() {
1277            // Reactions older than the first time this store ran with reaction wakes never wake, so a
1278            // deploy does not replay history; later ones (also while offline) are deduped by the ledger.
1279            let path = self.store_dir().join("reaction-wake-floor");
1280            // Wall clock, not `now_ms`: callers pass synthetic times on open (`drive(1_000)`).
1281            let _ = now_ms;
1282            let floor = std::fs::read_to_string(&path).ok().and_then(|t| t.trim().parse::<i64>().ok()).unwrap_or_else(|| {
1283                let wall = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0);
1284                let _ = std::fs::write(&path, wall.to_string());
1285                wall
1286            });
1287            self.reaction_floor = Some(floor);
1288        }
1289        let trace_at = self.driver.http_trace.len();
1290        let history = self.request_missing_history(now_ms)?;
1291        self.pull_room_tips()?;
1292        let mut waited_long_poll = false;
1293        if wait_for_sync && self.driver.sync_inflight() {
1294            // A poll that already finished is a stale catch-up. Do not count it: the caller is
1295            // waiting for whatever is current, which is the next `/sync` this call starts.
1296            waited_long_poll = self.driver.harvest_sync(now_ms, true)?;
1297        } else {
1298            self.driver.harvest_sync(now_ms, false)?;
1299        }
1300        self.wake_inbound(now_ms);
1301        for _ in 0..24 {
1302            if !self.driver.step(now_ms, wait_for_sync, &mut waited_long_poll)? {
1303                break;
1304            }
1305            self.wake_inbound(now_ms);
1306        }
1307        self.note_history_pages(&history, trace_at);
1308        self.note_room_tips();
1309        self.routine_restart_catchup = false;
1310        Ok(())
1311    }
1312
1313    /// Newest page of each joined room, once the timeline is still empty.
1314    /// [`MessengerCore::pull_latest_page`] does not use the sync token.
1315    fn pull_room_tips(&mut self) -> Result<(), ShellError> {
1316        let me = self.driver.core.user_id().clone();
1317        let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1318        for room_id in ids {
1319            if self.tip_pulled.contains(room_id.as_str()) {
1320                continue;
1321            }
1322            if self
1323                .driver.core
1324                .timeline(&room_id)
1325                .is_some_and(|timeline| !timeline.items().is_empty())
1326            {
1327                self.tip_pulled.insert(room_id.as_str().to_string());
1328                continue;
1329            }
1330            let joined = self.driver.core.room_state(&room_id).is_some_and(|state| {
1331                state
1332                    .members
1333                    .get(&me)
1334                    .is_some_and(|member| matches!(member.membership, Membership::Join))
1335            });
1336            if !joined {
1337                continue;
1338            }
1339            self.driver.core.pull_latest_page(room_id)?;
1340        }
1341        Ok(())
1342    }
1343
1344    fn note_room_tips(&mut self) {
1345        let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1346        for room_id in ids {
1347            if self
1348                .driver.core
1349                .timeline(&room_id)
1350                .is_some_and(|timeline| !timeline.items().is_empty())
1351            {
1352                self.tip_pulled.insert(room_id.as_str().to_string());
1353            }
1354        }
1355    }
1356
1357    /// Joined rooms whose timeline is still empty. The engine does not
1358    /// store timeline rows, and `/sync?since=` does not replay them. One
1359    /// backward `/messages` from the stored sync token loads that gap.
1360    fn request_missing_history(&mut self, now_ms: i64) -> Result<Vec<String>, ShellError> {
1361        let me = self.driver.core.user_id().clone();
1362        let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1363        let mut requested = Vec::new();
1364        for room_id in ids {
1365            if !self.room_needs_history(&room_id, &me) {
1366                continue;
1367            }
1368            let label = room_id.as_str().to_string();
1369            self.driver.core
1370                .dispatch(MessengerCommand::LoadOlder { room_id }, now_ms)?;
1371            requested.push(label);
1372        }
1373        Ok(requested)
1374    }
1375
1376    fn room_needs_history(&self, room_id: &RoomId, me: &UserId) -> bool {
1377        if self.history_pulled.contains(room_id.as_str()) {
1378            return false;
1379        }
1380        let joined = match self
1381            .driver.core
1382            .room_state(room_id)
1383            .and_then(|state| state.members.get(me))
1384        {
1385            Some(member) => matches!(member.membership, Membership::Join),
1386            None => false,
1387        };
1388        if !joined {
1389            return false;
1390        }
1391        match self.driver.core.timeline(room_id) {
1392            Some(timeline) => timeline.items().is_empty(),
1393            None => true,
1394        }
1395    }
1396
1397    fn note_history_pages(&mut self, requested: &[String], trace_at: usize) {
1398        if requested.is_empty() {
1399            return;
1400        }
1401        let ok = self.driver.http_trace[trace_at..]
1402            .iter()
1403            .filter(|(kind, status)| {
1404                *kind == OutgoingRequestKind::RoomMessages && (200..300).contains(status)
1405            })
1406            .count();
1407        if ok < requested.len() {
1408            return;
1409        }
1410        for room_id in requested {
1411            self.history_pulled.insert(room_id.clone());
1412        }
1413    }
1414
1415    /// Rooms this account has state for.
1416    pub fn rooms(&self) -> Vec<RoomView> {
1417        let me = self.driver.core.user_id().clone();
1418        let mut rooms: Vec<RoomView> = self
1419            .driver.core
1420            .room_ids()
1421            .map(|room_id| {
1422                let state = self.driver.core.room_state(room_id);
1423                let membership = state
1424                    .and_then(|state| state.members.get(&me))
1425                    .map(|member| membership_name(&member.membership).to_string())
1426                    .unwrap_or_else(|| "absent".to_string());
1427                let encrypted = state.and_then(|state| state.encryption.as_ref()).is_some();
1428                RoomView {
1429                    room_id: room_id.as_str().to_string(),
1430                    membership,
1431                    encrypted,
1432                }
1433            })
1434            .collect();
1435        rooms.sort_by(|left, right| left.room_id.cmp(&right.room_id));
1436        rooms
1437    }
1438
1439    /// Text-like timeline rows. Encrypted payloads that have not decrypted
1440    /// are reported as `undecryptable` without their ciphertext.
1441    pub fn texts(&self) -> Vec<TextView> {
1442        let mut out = Vec::new();
1443        for room_id in self.driver.core.room_ids() {
1444            let Some(timeline) = self.driver.core.timeline(room_id) else {
1445                continue;
1446            };
1447            for item in timeline.items() {
1448                match &item.content {
1449                    ItemContent::Text(text)
1450                    | ItemContent::Notice(text)
1451                    | ItemContent::Emote(text) => {
1452                        out.push(TextView {
1453                            room_id: room_id.as_str().to_string(),
1454                            body: text.body.clone(),
1455                            outcome: outcome_name(&item.send_state),
1456                            event_id: item.event_id.as_ref().map(|id| id.as_str().to_string()),
1457                        });
1458                    }
1459                    ItemContent::Undecryptable { reason } => out.push(TextView {
1460                        room_id: room_id.as_str().to_string(),
1461                        body: String::new(),
1462                        outcome: format!("undecryptable:{reason}"),
1463                        event_id: item.event_id.as_ref().map(|id| id.as_str().to_string()),
1464                    }),
1465                    _ => {}
1466                }
1467            }
1468        }
1469        out
1470    }
1471
1472    /// HTTP status codes this store has seen, oldest first. Bodies are not kept.
1473    pub fn sync_inflight(&self) -> bool {
1474        self.driver.sync_inflight()
1475    }
1476
1477    pub fn http_trace(&self) -> Vec<String> {
1478        self.driver.http_trace
1479            .iter()
1480            .map(|(kind, status)| format!("{kind:?} {status}"))
1481            .collect()
1482    }
1483
1484    /// The last response this core failed to ingest, once.
1485    pub fn take_ingest_error(&mut self) -> Option<String> {
1486        self.driver.core.take_ingest_error().map(clip_public)
1487    }
1488
1489    fn persist_core(&mut self) -> Result<(), ShellError> {
1490        self.driver.persist().map_err(ShellError::from)
1491    }
1492
1493    /// Drains peer key-change alerts (M3).
1494    pub fn take_security_alerts(&mut self) -> Vec<String> {
1495        std::mem::take(&mut self.driver.security_alerts)
1496    }
1497
1498    fn inbound_plaintexts(&self) -> Vec<InboundPlaintext> {
1499        let me = self.driver.core.user_id();
1500        let mut out = Vec::new();
1501        for room_id in self.driver.core.room_ids() {
1502            let Some(timeline) = self.driver.core.timeline(room_id) else {
1503                continue;
1504            };
1505            for item in timeline.items() {
1506                if item.redacted || &item.sender == me || item.send_state != SendState::Sent {
1507                    continue;
1508                }
1509                let mut body = match &item.content {
1510                    ItemContent::Text(text)
1511                    | ItemContent::Notice(text)
1512                    | ItemContent::Emote(text) => text.body.clone(),
1513                    _ => continue,
1514                };
1515                // An edit that landed before the wake: deliver only the final version.
1516                if let Some(edit) = &item.relations.latest_edit {
1517                    if let mail4agent_messenger::wire::events::RoomMessageContent::Text(t)
1518                    | mail4agent_messenger::wire::events::RoomMessageContent::Notice(t)
1519                    | mail4agent_messenger::wire::events::RoomMessageContent::Emote(t) = &edit.content
1520                    {
1521                        body = t.body.clone();
1522                    }
1523                }
1524                let Some(event_id) = item.event_id.as_ref() else {
1525                    continue;
1526                };
1527                let key = format!("{}\n{}", room_id.as_str(), event_id.as_str());
1528                out.push(InboundPlaintext {
1529                    room_id: room_id.as_str().to_string(),
1530                    key,
1531                    body,
1532                    from: item.sender.as_str().to_string(),
1533                    nick: self.sender_nick(room_id, &item.sender),
1534                    event_id: event_id.as_str().to_string(),
1535                    reaction: None,
1536                    ts: item.origin_server_ts,
1537                    hatchery: letter_content::hatchery_fields(item.event_content()),
1538                });
1539            }
1540        }
1541        out
1542    }
1543
1544    /// Reactions by others on this account's own messages, or on anything in a DM, newer than
1545    /// `floor`. A reaction that was taken back (redacted) is no longer a relating event, so one
1546    /// added and removed before the wake produces nothing; one on a redacted message neither.
1547    fn reaction_items(&self, floor: i64) -> Vec<InboundPlaintext> {
1548        let me = self.driver.core.user_id();
1549        let mut out = Vec::new();
1550        for room_id in self.driver.core.room_ids() {
1551            let Some(timeline) = self.driver.core.timeline(room_id) else { continue };
1552            let direct = self.room_is_direct(room_id.as_str());
1553            for item in timeline.items() {
1554                let Some(target) = item.event_id.as_ref() else { continue };
1555                if item.redacted || !(direct || &item.sender == me) {
1556                    continue;
1557                }
1558                let snippet = match &item.content {
1559                    ItemContent::Text(t) | ItemContent::Notice(t) | ItemContent::Emote(t) => {
1560                        let one: String = t.body.split_whitespace().collect::<Vec<_>>().join(" ");
1561                        if one.chars().count() > 80 { format!("{}…", one.chars().take(80).collect::<String>()) } else { one }
1562                    }
1563                    _ => String::new(),
1564                };
1565                for r in timeline.relating_events_for(target) {
1566                    let mail4agent_messenger::wire::events::RelatesTo::Annotation { key, .. } = &r.relation else { continue };
1567                    if &r.sender == me || r.origin_server_ts <= floor {
1568                        continue;
1569                    }
1570                    let who = mxid_localpart(r.sender.as_str()).to_string();
1571                    let whose = if &item.sender == me { "your message".to_string() } else { format!("{}'s message", mxid_localpart(item.sender.as_str())) };
1572                    out.push(InboundPlaintext {
1573                        room_id: room_id.as_str().to_string(),
1574                        key: format!("{}\n{}", room_id.as_str(), r.event_id.as_str()),
1575                        body: format!("{who} reacted {key} to {whose}: \"{snippet}\""),
1576                        from: r.sender.as_str().to_string(),
1577                        nick: None,
1578                        event_id: r.event_id.as_str().to_string(),
1579                        reaction: Some((key.clone(), target.as_str().to_string())),
1580                        ts: r.origin_server_ts,
1581                        hatchery: None,
1582                    });
1583                }
1584            }
1585        }
1586        out
1587    }
1588
1589    /// Display name already stored for `sender` in this room. Empty and
1590    /// missing names stay `None`. This does not query the network and
1591    /// does not invent a nick from the mxid.
1592    fn sender_nick(&self, room_id: &RoomId, sender: &UserId) -> Option<String> {
1593        let name = self
1594            .driver.core
1595            .room_state(room_id)?
1596            .members
1597            .get(sender)?
1598            .displayname
1599            .as_deref()?
1600            .trim();
1601        if name.is_empty() {
1602            None
1603        } else {
1604            Some(name.to_string())
1605        }
1606    }
1607
1608    fn note_already_present(&mut self) {
1609        self.load_routine_woken();
1610        self.load_leader_prompted();
1611    }
1612
1613    /// Load [`ROUTINE_WOKEN_FILE`]. A missing file means seed once tip/history
1614    /// has filled the timeline (see [`Self::wake_inbound`]).
1615    fn load_routine_woken(&mut self) {
1616        let path = self.dir.join(ROUTINE_WOKEN_FILE);
1617        match std::fs::read_to_string(&path) {
1618            Ok(text) if !text.trim().is_empty() => {
1619                for line in text.lines() {
1620                    let Some((room, event)) = line.split_once('\t') else {
1621                        continue;
1622                    };
1623                    if room.is_empty() || event.is_empty() {
1624                        continue;
1625                    }
1626                    self.routine_sent.insert(format!("{room}\n{event}"));
1627                }
1628                self.routine_woken_seed_pending = false;
1629                // Tip/history after restart can surface rows the ledger never
1630                // saw; silence them for the first drive instead of re-POSTing.
1631                self.routine_restart_catchup = true;
1632            }
1633            Ok(_) | Err(_) => {
1634                // Missing or empty ledger: seed once tip/history fills the timeline.
1635                self.routine_woken_seed_pending = true;
1636                self.routine_restart_catchup = false;
1637            }
1638        }
1639    }
1640
1641    /// Mark every inbound plaintext except the newest in each room as already
1642    /// woken, and persist. The newest still fires once (then
1643    /// [`Self::remember_routine_wake`] lasting across restarts). Mirrors
1644    /// [`Self::seed_leader_prompted_except_newest`].
1645    fn seed_routine_woken_from_timeline(&mut self) {
1646        let path = self.dir.join(ROUTINE_WOKEN_FILE);
1647        let items = self.inbound_plaintexts();
1648        let mut newest: std::collections::HashMap<String, String> =
1649            std::collections::HashMap::new();
1650        for item in &items {
1651            newest.insert(item.room_id.clone(), item.key.clone());
1652        }
1653        let mut lines = String::new();
1654        for item in &items {
1655            if newest.get(&item.room_id) == Some(&item.key) {
1656                continue;
1657            }
1658            self.routine_sent.insert(item.key.clone());
1659            if let Some((room, event)) = item.key.split_once('\n') {
1660                lines.push_str(room);
1661                lines.push('\t');
1662                lines.push_str(event);
1663                lines.push('\n');
1664            }
1665        }
1666        let _ = std::fs::write(path, lines);
1667        self.routine_woken_seed_pending = false;
1668    }
1669
1670    fn remember_routine_wake(&mut self, key: &str) {
1671        self.routine_sent.insert(key.to_string());
1672        let Some((room, event)) = key.split_once('\n') else {
1673            return;
1674        };
1675        if room.is_empty() || event.is_empty() {
1676            return;
1677        }
1678        let path = self.dir.join(ROUTINE_WOKEN_FILE);
1679        let mut file = match std::fs::OpenOptions::new()
1680            .create(true)
1681            .append(true)
1682            .open(&path)
1683        {
1684            Ok(file) => file,
1685            Err(_) => return,
1686        };
1687        use std::io::Write;
1688        let _ = write!(file, "{room}\t{event}\n");
1689    }
1690
1691    /// Keys already given to `session/prompt`. A missing file is the first
1692    /// run of this trigger: older rows stay quiet, and the newest inbound
1693    /// text in each room is left unmarked so the trigger fires for it.
1694    fn load_leader_prompted(&mut self) {
1695        let path = self.dir.join(LEADER_PROMPTED_FILE);
1696        match std::fs::read_to_string(&path) {
1697            Ok(text) => {
1698                for line in text.lines() {
1699                    let Some((room, event)) = line.split_once('\t') else {
1700                        continue;
1701                    };
1702                    if room.is_empty() || event.is_empty() {
1703                        continue;
1704                    }
1705                    self.leader_sent.insert(format!("{room}\n{event}"));
1706                }
1707            }
1708            Err(_) => self.seed_leader_prompted_except_newest(&path),
1709        }
1710    }
1711
1712    fn seed_leader_prompted_except_newest(&mut self, path: &Path) {
1713        let items = self.inbound_plaintexts();
1714        let mut newest: std::collections::HashMap<String, String> =
1715            std::collections::HashMap::new();
1716        for item in &items {
1717            newest.insert(item.room_id.clone(), item.key.clone());
1718        }
1719        let mut lines = String::new();
1720        for item in &items {
1721            if newest.get(&item.room_id) == Some(&item.key) {
1722                continue;
1723            }
1724            self.leader_sent.insert(item.key.clone());
1725            if let Some((room, event)) = item.key.split_once('\n') {
1726                lines.push_str(room);
1727                lines.push('\t');
1728                lines.push_str(event);
1729                lines.push('\n');
1730            }
1731        }
1732        let _ = std::fs::write(path, lines);
1733    }
1734
1735    fn remember_leader_prompt(&mut self, key: &str) {
1736        self.leader_sent.insert(key.to_string());
1737        let Some((room, event)) = key.split_once('\n') else {
1738            return;
1739        };
1740        if room.is_empty() || event.is_empty() {
1741            return;
1742        }
1743        let path = self.dir.join(LEADER_PROMPTED_FILE);
1744        let mut file = match std::fs::OpenOptions::new()
1745            .create(true)
1746            .append(true)
1747            .open(&path)
1748        {
1749            Ok(file) => file,
1750            Err(_) => return,
1751        };
1752        use std::io::Write;
1753        let _ = write!(file, "{room}\t{event}\n");
1754    }
1755
1756    /// Posts and/or prompts each new inbound plaintext. A missing URL or
1757    /// socket skips that trigger. A failed trigger is remembered on
1758    /// [`Self::wake_note`] and retried on a later [`Self::drive`]. The
1759    /// timeline row stays either way.
1760    /// An empty `leader-prompted` was often written before any timeline
1761    /// row existed. Once history is in memory, keep every inbound except
1762    /// the newest in each room off the leader.
1763    fn arm_unprompted_newest(&mut self) {
1764        if (self.leader_sock.is_none() && self.wake_chain.is_none()) || !self.leader_sent.is_empty() {
1765            return;
1766        }
1767        if self.inbound_plaintexts().is_empty() {
1768            return;
1769        }
1770        let path = self.dir.join(LEADER_PROMPTED_FILE);
1771        self.seed_leader_prompted_except_newest(&path);
1772    }
1773
1774    /// The letter's `fyi.hatchery.*` fields with its interrupt decided ([`crate::interrupt`]), and
1775    /// whether it asked to interrupt this session at all. An allowed interrupt must also address
1776    /// this session (a DM, or a tag): a room letter for someone else never cancels this one's turn.
1777    fn interrupt_fields(
1778        &self,
1779        item: &InboundPlaintext,
1780        me_id: &str,
1781        policy: Option<&crate::interrupt::InterruptPolicy>,
1782    ) -> (Option<serde_json::Value>, bool) {
1783        let (mut fields, asked, allowed) = crate::interrupt::authorize(item.hatchery.clone(), &item.from, me_id, policy);
1784        if !asked {
1785            return (fields, false);
1786        }
1787        let addressed = self.room_is_direct(&item.room_id)
1788            || crate::wake_policy::addresses(&item.body, self.nick.as_deref(), me_id);
1789        if allowed && !addressed {
1790            if let Some(map) = fields.as_mut().and_then(serde_json::Value::as_object_mut) {
1791                map.remove(crate::interrupt::INTERRUPT_KEY);
1792            }
1793            return (fields, false);
1794        }
1795        (fields, true)
1796    }
1797
1798    /// The receipt of an interrupt letter: a reaction on the sender's message (which reaches the
1799    /// sender as a low-priority letter) and the mark in `interrupt-status.json`.
1800    fn give_interrupt_receipt(
1801        &mut self,
1802        item: &InboundPlaintext,
1803        from_nick: &str,
1804        receipt: crate::interrupt::InterruptReceipt,
1805        now_ms: i64,
1806    ) {
1807        crate::wake_policy::note_interrupt(self.store_dir(), from_nick, &item.event_id, receipt.name());
1808        let (Ok(room_id), Ok(target)) = (RoomId::parse(&item.room_id), crate::EventId::parse(&item.event_id)) else {
1809            return;
1810        };
1811        let _ = self.dispatch(MessengerCommand::React { room_id, target, key: receipt.emoji().to_string() }, now_ms);
1812    }
1813
1814    /// A one-to-one conversation: at most two joined or invited members and no name.
1815    fn room_is_direct(&self, room_id: &str) -> bool {
1816        let Some(id) = self.driver.core.room_ids().find(|r| r.as_str() == room_id) else { return false };
1817        let Some(state) = self.driver.core.room_state(id) else { return false };
1818        let live = state.members.values().filter(|m| matches!(membership_name(&m.membership), "join" | "invite")).count();
1819        state.name.is_none() && live <= 2
1820    }
1821
1822    /// The letter chain for a store with no chain installed: the letter hook
1823    /// (`M4A_LETTER_HOOK`) first, then g4a's deliver adapter (routine webhook for a web
1824    /// client with a routine URL), then the durable inbox.
1825    fn default_letter_chain(&self) -> (provider::ProviderSession, provider::WakeChain) {
1826        let kind = if self.routine_url.is_some() && self.leader_sock.is_none() {
1827            provider::SessionKind::web(provider::ProviderKind::Grok)
1828        } else {
1829            provider::SessionKind::local(provider::ProviderKind::Grok)
1830        };
1831        let session = provider::ProviderSession {
1832            kind,
1833            session_id: self.session_id.clone(),
1834            nick: self.nick.clone().unwrap_or_default(),
1835            cwd: self.leader_cwd.clone().map(PathBuf::from),
1836            headless: false,
1837        };
1838        let root = self.dir.parent().map(Path::to_path_buf);
1839        let inbox = root.map(|r| provider::registry::inbox_dir(&r, &self.session_id));
1840        let mut config = provider::AdapterConfig::from_env(inbox.clone());
1841        config.routine_url = self.routine_url.clone();
1842        config.routine_bearer = self.routine_bearer.as_ref().map(|b| b.as_str().to_owned());
1843        if let Some(sock) = &self.leader_sock {
1844            config.leader_sock = Some(PathBuf::from(sock));
1845        }
1846        (session.clone(), crate::provider::letter_chain(kind, inbox, &config))
1847    }
1848
1849    /// Next try of a letter's live deliver: 30 s, doubling, at most 15 min apart. No limit on
1850    /// attempts: a letter is never dropped.
1851    fn schedule_wake_retry(&mut self, key: &str) {
1852        let n = self.wake_retry.get(key).map(|(n, _)| *n + 1).unwrap_or(1);
1853        let secs = (30u64 << (n - 1).min(5)).min(900);
1854        self.wake_retry.insert(key.to_owned(), (n, std::time::Instant::now() + std::time::Duration::from_secs(secs)));
1855    }
1856
1857    /// Letters waiting for a live-deliver retry (count).
1858    pub fn pending_wake_retries(&self) -> usize {
1859        self.wake_retry.len()
1860    }
1861
1862    fn wake_inbound(&mut self, now_ms: i64) {
1863        if self.routine_url.is_none() && self.leader_sock.is_none() && self.wake_chain.is_none() {
1864            return;
1865        }
1866        if self.routine_restart_catchup && self.routine_url.is_some() {
1867            // Existing ledger + tip/history catch-up: persist every inbound
1868            // key without handing it over again. Cleared when this drive finishes.
1869            for item in self.inbound_plaintexts() {
1870                if !self.routine_sent.contains(&item.key) {
1871                    self.remember_routine_wake(&item.key);
1872                    self.remember_leader_prompt(&item.key);
1873                }
1874            }
1875        } else if self.routine_woken_seed_pending && self.routine_url.is_some() {
1876            if !self.inbound_plaintexts().is_empty() {
1877                self.seed_routine_woken_from_timeline();
1878            }
1879        }
1880        self.arm_unprompted_newest();
1881        let policy = crate::wake_policy::WakePolicy::load(self.store_dir());
1882        let interrupt_policy = crate::interrupt::InterruptPolicy::load(self.store_dir());
1883        let me_id = self.driver.core.user_id().as_str().to_string();
1884        let mut items = self.inbound_plaintexts();
1885        // The owner's policy: a message that does not qualify is marked handled, so turning the
1886        // wake on later does not replay it.
1887        items.retain(|item| {
1888            let direct = self.room_is_direct(&item.room_id);
1889            let mentioned = crate::wake_policy::addresses(&item.body, self.nick.as_deref(), &me_id);
1890            if policy.allows(direct, mentioned) {
1891                return true;
1892            }
1893            self.remember_routine_wake(&item.key);
1894            self.remember_leader_prompt(&item.key);
1895            false
1896        });
1897        // Reactions to this agent (its own messages, or in its DMs): a low-priority letter each.
1898        let floor = self.reaction_floor.unwrap_or(i64::MAX);
1899        let reactions = self.reaction_items(floor);
1900        items.extend(reactions.into_iter().filter(|r| policy.allows(self.room_is_direct(&r.room_id), true)));
1901        for item in items {
1902            if self.leader_sent.contains(&item.key) || self.routine_sent.contains(&item.key) {
1903                continue;
1904            }
1905            let retry = self.wake_retry.get(&item.key).copied();
1906            if retry.is_some_and(|(_, next)| std::time::Instant::now() < next) {
1907                continue;
1908            }
1909            let (session, mut chain) = match self.wake_chain.take() {
1910                Some(installed) => installed,
1911                None => self.default_letter_chain(),
1912            };
1913            let from_nick = mxid_localpart(&item.from).to_string();
1914            let letter = provider::WakeLetter {
1915                body: &item.body,
1916                from_nick: &from_nick,
1917                event_id: &item.event_id,
1918                room: Some(&item.room_id),
1919                reaction: item.reaction.as_ref().map(|(key, target)| provider::ReactionNote { key, target }),
1920            };
1921            // A letter already in the durable inbox is retried through the live deliver only.
1922            // Retries run the whole chain: the inbox fallback writes by event id, so it never duplicates.
1923            let (fields, interrupt_asked) = self.interrupt_fields(&item, &me_id, interrupt_policy.as_ref());
1924            let interrupt_ok = interrupt_asked && fields.as_ref().is_some_and(|f| f.get(crate::interrupt::INTERRUPT_KEY).is_some());
1925            if interrupt_asked && !interrupt_ok && !self.wake_retry.contains_key(&item.key) {
1926                eprintln!("m4a: interrupt from {} ignored (not on the interrupt list); letter {} goes as an ordinary letter", from_nick, item.event_id);
1927            }
1928            let result = letter_content::scoped(fields, || chain.wake(&session, &letter));
1929            let interrupted = interrupt_ok && letter_content::was_interrupted();
1930            if interrupt_asked && result.as_ref().is_ok_and(|(id, outcome)| {
1931                matches!(outcome, provider::WakeOutcome::Delivered) || chain.ids().first().is_none_or(|first| first == id)
1932            }) {
1933                let receipt = if !interrupt_ok {
1934                    crate::interrupt::InterruptReceipt::Denied
1935                } else if interrupted {
1936                    crate::interrupt::InterruptReceipt::Interrupted
1937                } else {
1938                    crate::interrupt::InterruptReceipt::Delivered
1939                };
1940                self.give_interrupt_receipt(&item, &from_nick, receipt, now_ms);
1941            }
1942            match result {
1943                Ok((id, outcome)) => {
1944                    let delivered = matches!(outcome, provider::WakeOutcome::Delivered);
1945                    // Queued by the fallback link (the live deliver failed): the letter is safe in
1946                    // the inbox, but the agent was not woken. Keep it and try the deliver again.
1947                    let fell_back = !delivered && chain.ids().first().is_some_and(|first| *first != id);
1948                    if fell_back {
1949                        self.schedule_wake_retry(&item.key);
1950                        self.wake_note = Some(format!("wake {} queued; live deliver will be retried", item.event_id));
1951                        crate::wake_policy::note_attempt(self.store_dir(), None);
1952                        self.wake_log.push(WakeAttempt { event_id: item.event_id.clone(), status: None });
1953                    } else {
1954                        self.wake_retry.remove(&item.key);
1955                        self.remember_leader_prompt(&item.key);
1956                        self.remember_routine_wake(&item.key);
1957                        let status = delivered.then_some(200);
1958                        crate::wake_policy::note_attempt(self.store_dir(), status);
1959                        self.wake_log.push(WakeAttempt { event_id: item.event_id.clone(), status });
1960                    }
1961                    self.wake_route = Some(format!("{id} {}", if delivered { "delivered" } else { "queued" }));
1962                }
1963                Err(err) => {
1964                    if retry.is_some() {
1965                        self.schedule_wake_retry(&item.key);
1966                    }
1967                    self.wake_note = Some(clip_public(err.to_string()));
1968                }
1969            }
1970            self.wake_chain = Some((session, chain));
1971        }
1972    }
1973}
1974
1975#[allow(dead_code)]
1976struct InboundPlaintext {
1977    room_id: String,
1978    key: String,
1979    body: String,
1980    from: String,
1981    nick: Option<String>,
1982    event_id: String,
1983    /// `(emoji, target event)` for a reaction letter.
1984    reaction: Option<(String, String)>,
1985    ts: i64,
1986    /// The `fyi.hatchery.*` keys of the source event's content ([`letter_content`]).
1987    hatchery: Option<serde_json::Value>,
1988}
1989
1990fn membership_name(membership: &Membership) -> &'static str {
1991    match membership {
1992        Membership::Invite => "invite",
1993        Membership::Join => "join",
1994        Membership::Knock => "knock",
1995        Membership::Leave => "leave",
1996        Membership::Ban => "ban",
1997        Membership::Unknown => "unknown",
1998    }
1999}
2000
2001fn outcome_name(state: &SendState) -> String {
2002    match state {
2003        SendState::Sent => "sent".to_string(),
2004        SendState::Sending | SendState::LocalEcho => "sending".to_string(),
2005        SendState::Failed { reason } => format!("failed:{reason}"),
2006    }
2007}
2008
2009fn parse_base_url(raw: &str) -> Result<reqwest::Url, ShellError> {
2010    let url = reqwest::Url::parse(raw).map_err(|_| ShellError::BaseUrl)?;
2011    match url.scheme() {
2012        "http" | "https" => {}
2013        _ => return Err(ShellError::BaseUrl),
2014    }
2015    if url.host_str().is_none()
2016        || url.query().is_some()
2017        || url.fragment().is_some()
2018        || !url.username().is_empty()
2019        || url.password().is_some()
2020    {
2021        return Err(ShellError::BaseUrl);
2022    }
2023    Ok(url)
2024}
2025
2026struct RegisteredSession {
2027    user_id: String,
2028    device_id: DeviceId,
2029    bearer: Zeroizing<String>,
2030    /// Identity mode: the nick the operator assigned.
2031    nick: String,
2032    /// The backend that logged in; it executes this session's requests from now on.
2033    backend: Arc<dyn Backend>,
2034}
2035
2036/// Identity mode: the client's own identity logs in by signature (enrolling first when it has to).
2037#[cfg(any(feature = "tier-server", feature = "tier-matrix"))]
2038fn identity_session(config: &SessionConfig, auth: &IdentityAuth) -> Result<RegisteredSession, ShellError> {
2039    at("open identity", &config.store_root, identity_session_inner(config, auth))
2040}
2041
2042#[cfg(any(feature = "tier-server", feature = "tier-matrix"))]
2043fn identity_session_inner(config: &SessionConfig, auth: &IdentityAuth) -> Result<RegisteredSession, ShellError> {
2044    let fail = |e: AgentError| ShellError::Register(clip_public(e.to_string()));
2045    parse_base_url(&config.homeserver_url)?;
2046    let ids = m4a_agent::IdentityStore::new(store_key::vault(&config.store_root)?);
2047    let backend: Arc<dyn Backend> = match auth.tier {
2048        #[cfg(feature = "tier-server")]
2049        m4a_agent::BackendKind::Server => Arc::new(m4a_agent::backend::server::ServerBackend::new(&config.homeserver_url).map_err(fail)?),
2050        // The domain's `.well-known` may name the real server; the spec prefix and sliding sync are
2051        // asked of the server, never assumed.
2052        #[cfg(feature = "tier-matrix")]
2053        m4a_agent::BackendKind::Matrix => Arc::new(m4a_agent::backend::matrix::MatrixBackend::discover(&config.homeserver_url).map_err(fail)?),
2054        #[allow(unreachable_patterns)]
2055        _ => return Err(ShellError::Register("this build does not include that tier".into())),
2056    };
2057    let mut id = ids.resolve(&config.session_id, auth.tier, backend.server_ref()).map_err(fail)?;
2058    if !id.enrolled && id.requested_nick != auth.nick_request {
2059        id.requested_nick = auth.nick_request.clone();
2060    }
2061    let session = backend.ensure_session(&ids, &mut id, auth.invite.as_ref().map(|i| i.as_str())).map_err(fail)?;
2062    // The invite is spent; the file the operator left is not kept.
2063    let _ = std::fs::remove_file(invite_path(&config.store_root, &config.session_id));
2064    ids.adopt_nick(&mut id, &session.nick).map_err(fail)?;
2065    let (user_id, device_raw) = match (&session.user_id, &session.device_id) {
2066        (Some(u), Some(d)) => (u.clone(), d.clone()),
2067        _ => {
2068            backend.whoami().map_err(fail)?
2069        }
2070    };
2071    if user_id.is_empty() || device_raw.is_empty() {
2072        return Err(ShellError::Register("whoami missed an id".into()));
2073    }
2074    Ok(RegisteredSession { user_id, device_id: DeviceId::parse(&device_raw)?, bearer: session.token.clone(), nick: session.nick, backend })
2075}
2076
2077#[cfg(not(any(feature = "tier-server", feature = "tier-matrix")))]
2078fn identity_session(_: &SessionConfig, _: &IdentityAuth) -> Result<RegisteredSession, ShellError> {
2079    Err(ShellError::Register("this build has no tier for identity login".into()))
2080}
2081
2082fn register_session(config: &SessionConfig) -> Result<RegisteredSession, ShellError> {
2083    identity_session(config, &config.identity)
2084}
2085
2086fn clip_public(text: String) -> String {
2087    let mut out = String::new();
2088    for token in text.split_whitespace() {
2089        if token.len() > 80 {
2090            out.push_str("[omitted]");
2091        } else {
2092            out.push_str(token);
2093        }
2094        out.push(' ');
2095        if out.len() > 240 {
2096            break;
2097        }
2098    }
2099    out
2100}
2101
2102/// Names the step and the path (never contents) of an io failure, so "path not found" says which.
2103pub(crate) fn at<T>(step: &str, path: &Path, r: Result<T, ShellError>) -> Result<T, ShellError> {
2104    r.map_err(|e| match e {
2105        ShellError::Io(io) => ShellError::Io(std::io::Error::new(io.kind(), format!("{step} at {}: {io}", path.display()))),
2106        other => other,
2107    })
2108}
2109
2110fn fs_create_dir(dir: &Path) -> Result<(), ShellError> {
2111    std::fs::create_dir_all(dir)?;
2112    Ok(())
2113}
2114
2115/// Why opening the client store, releasing a send, or posting a routine failed.
2116/// The seal key and the device bearer are never included.
2117#[derive(Debug, thiserror::Error)]
2118pub enum ShellError {
2119    /// No session id, so there is nothing to hash.
2120    #[error("session id is empty")]
2121    EmptySession,
2122    /// [`STORE_ROOT_ENV`] is unset outside tests.
2123    #[error("store root is unset")]
2124    StoreRoot,
2125    /// [`HOMESERVER_URL_ENV`] and toml `homeserver_url` are both unset.
2126    #[error("homeserver url is unset")]
2127    HomeserverUrl,
2128    /// [`BOT_NAME_ENV`] is unset. The display name is required for a web session.
2129    #[error("bot display name is unset")]
2130    BotName,
2131    /// The display name did not yield a nick.
2132    #[error("nick could not be derived from the bot display name")]
2133    Nick,
2134    /// The host's session list was missing or refused. No bearer is included.
2135    #[error("session list: {0}")]
2136    SessionList(String),
2137    /// Homeserver register failed. The text is a status and errcode, not a bearer.
2138    #[error("homeserver register failed: {0}")]
2139    Register(String),
2140    /// No session nick matched.
2141    #[error("no session with that nick")]
2142    UnknownNick,
2143    /// A direct room was not opened or its encrypted send did not finish.
2144    #[error("direct room was not ready")]
2145    Dm,
2146    /// Toml for the homeserver URL could not be read.
2147    #[error("config: {0}")]
2148    Config(String),
2149    /// The bearer is empty or not a single header value.
2150    #[error("device token is empty or not a single header value")]
2151    DeviceToken,
2152    /// `base_url` is not an `http` or `https` origin.
2153    #[error("base url must be http or https")]
2154    BaseUrl,
2155    /// A record key tried to leave the store directory.
2156    #[error("record key escapes the store directory")]
2157    BadRecordKey,
2158    /// The routine URL is not an `http` or `https` URL this shell can post to.
2159    #[error("routine url is not an http or https url")]
2160    RoutineUrl,
2161    /// Node CLI open path saw a routine URL or bearer. The value is not included.
2162    #[error("node cli does not take a routine url")]
2163    NodeRoutine,
2164    /// The local gateway did not yield a routine. No token, URL, or key.
2165    #[error("gateway: {0}")]
2166    Gateway(String),
2167    /// The routine bearer is not a single header value. The value is not included.
2168    #[error("routine bearer is empty or not a single header value")]
2169    RoutineBearer,
2170    /// The routine answered once and was not a success. The body is not included.
2171    #[error("routine status {0}")]
2172    RoutineStatus(u16),
2173    /// The routine socket or TLS handshake failed. The body is not included.
2174    #[error("routine post failed: {0}")]
2175    RoutineTransport(String),
2176    /// A homeserver call failed before a status line. The body is not included.
2177    #[error("homeserver http failed: {0}")]
2178    Http(String),
2179    /// The engine rejected a successful HTTP body. Long blobs are omitted.
2180    #[error("homeserver response was not ingested: {0}")]
2181    Ingest(String),
2182    /// Reading or writing the store directory failed.
2183    #[error("io: {0}")]
2184    Io(#[from] std::io::Error),
2185    /// The seal did not authenticate. A different session id produces this.
2186    #[error(transparent)]
2187    Store(#[from] StoreError),
2188    /// The engine rejected the command or could not load the Olm account.
2189    #[error(transparent)]
2190    Messenger(#[from] MessengerError),
2191}
2192
2193impl From<AgentError> for ShellError {
2194    fn from(err: AgentError) -> Self {
2195        match err {
2196            AgentError::Transport(text) => ShellError::Http(text),
2197            AgentError::Ingest(text) => ShellError::Ingest(text),
2198            other => ShellError::Register(clip_public(other.to_string())),
2199        }
2200    }
2201}
2202
2203fn persist(dir: &Path, core: &mut MessengerCore<SealedRecordCodec>) -> Result<(), ShellError> {
2204    while let Some(batch) = core.take_flush_batch() {
2205        for record in &batch.records {
2206            let path = record_path(dir, &record.key)?;
2207            if let Some(parent) = path.parent() {
2208                std::fs::create_dir_all(parent)?;
2209            }
2210            std::fs::write(&path, &record.bytes)?;
2211            #[cfg(unix)]
2212            {
2213                use std::os::unix::fs::PermissionsExt;
2214                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
2215            }
2216        }
2217        for key in &batch.deletes {
2218            let path = record_path(dir, key)?;
2219            if path.exists() {
2220                std::fs::remove_file(path)?;
2221            }
2222        }
2223        core.ack_flush(batch.id);
2224    }
2225    Ok(())
2226}
2227
2228/// Files in a session directory that are not sealed records.
2229const NOT_RECORDS: &[&str] = &[
2230    machine::WAKE_KEYCHAIN_FILE,
2231    machine::STORE_LOCK_FILE,
2232    LEADER_PROMPTED_FILE,
2233    ROUTINE_WOKEN_FILE,
2234];
2235
2236/// Event ids this process has already handed to the leader. Presence in the
2237/// timeline is not a prompt. One line is `room \t event_id`.
2238const LEADER_PROMPTED_FILE: &str = "leader-prompted";
2239/// Persisted `room\tevent_id` lines: routine webhook already woke this key.
2240/// Without it, restart + tip-page re-POSTs historical plaintext to the routine.
2241const ROUTINE_WOKEN_FILE: &str = "routine-woken";
2242
2243fn read_records(dir: &Path) -> Result<Vec<SealedRecord>, ShellError> {
2244    let mut records = Vec::new();
2245    if dir.exists() {
2246        walk(dir, dir, &mut records)?;
2247    }
2248    Ok(records)
2249}
2250
2251fn walk(dir: &Path, root: &Path, records: &mut Vec<SealedRecord>) -> Result<(), ShellError> {
2252    for entry in std::fs::read_dir(dir)? {
2253        let entry = entry?;
2254        let path = entry.path();
2255        if path.is_dir() {
2256            walk(&path, root, records)?;
2257            continue;
2258        }
2259        // Keychain and lock files share the session directory; they are
2260        // not sealed records.
2261        if dir == root
2262            && path
2263                .file_name()
2264                .and_then(|name| name.to_str())
2265                .is_some_and(|name| NOT_RECORDS.contains(&name))
2266        {
2267            continue;
2268        }
2269        let rel = path
2270            .strip_prefix(root)
2271            .map_err(|_| ShellError::BadRecordKey)?;
2272        let key = rel
2273            .components()
2274            .map(|component| component.as_os_str().to_string_lossy())
2275            .collect::<Vec<_>>()
2276            .join("/");
2277        let bytes = std::fs::read(&path)?;
2278        records.push(SealedRecord {
2279            key: RecordKey::new(key),
2280            bytes,
2281        });
2282    }
2283    Ok(())
2284}
2285
2286fn record_path(dir: &Path, key: &RecordKey) -> Result<PathBuf, ShellError> {
2287    let rel = Path::new(key.as_str());
2288    if rel.is_absolute()
2289        || rel.components().any(|component| {
2290            matches!(
2291                component,
2292                Component::ParentDir | Component::RootDir | Component::Prefix(_)
2293            )
2294        })
2295    {
2296        return Err(ShellError::BadRecordKey);
2297    }
2298    Ok(dir.join(rel))
2299}
2300
2301#[cfg(test)]
2302mod tests {
2303    #[allow(unused_imports)]
2304    use std::time::Duration;
2305    use super::*;
2306
2307    #[test]
2308    fn session_config_is_identity_only_and_reads_no_secret() {
2309        let get = |extra: &'static [(&'static str, &'static str)]| {
2310            move |key: &str| {
2311                extra.iter().find(|(k, _)| *k == key).map(|(_, v)| v.to_string()).or_else(|| match key {
2312                    SESSION_ID_ENV => Some("web-session-1".to_string()),
2313                    STORE_ROOT_ENV => Some("/tmp/m4a-root".to_string()),
2314                    // Retired inputs are never consulted, even when present.
2315                    "M4A_PRODUCT_PASSWORD" | "M4A_PRODUCT_TOKEN" | "M4A_DEVICE_TOKEN" => Some("must-not-be-read".to_string()),
2316                    _ => None,
2317                })
2318            }
2319        };
2320        let config = SessionConfig::from_lookup(get(&[(HOMESERVER_URL_ENV, "http://127.0.0.1:9")]), None).expect("config");
2321        assert_eq!(config.store_dir(), session_store_dir(Path::new("/tmp/m4a-root"), "web-session-1"));
2322        assert!(!format!("{config:?}").contains("must-not-be-read"));
2323        assert_eq!(config.identity.tier, m4a_agent::BackendKind::Server);
2324        let matrix = SessionConfig::from_lookup(get(&[(PRODUCT_URL_ENV, "http://127.0.0.1:8"), (TIER_ENV, "matrix")]), None).expect("matrix");
2325        assert_eq!(matrix.identity.tier, m4a_agent::BackendKind::Matrix);
2326        assert!(SessionConfig::from_lookup(get(&[(TIER_ENV, "other"), (PRODUCT_URL_ENV, "http://127.0.0.1:8")]), None).is_err());
2327        let from_toml = SessionConfig::from_lookup(get(&[]), Some("homeserver_url = \"http://127.0.0.1:9\"\nother = \"ignored\"\n")).expect("toml url");
2328        assert_eq!(from_toml.homeserver_url, "http://127.0.0.1:9");
2329        assert!(SessionConfig::from_lookup(get(&[]), None).is_err(), "no server given");
2330    }
2331    use std::io::{Read, Write};
2332    use std::net::TcpListener;
2333    use std::sync::atomic::{AtomicBool, Ordering};
2334    use std::sync::{Arc, Mutex};
2335    use std::thread;
2336    use std::time::Instant;
2337
2338    fn plain(base: &str, token: &str) -> Arc<dyn Backend> {
2339        Arc::new(m4a_agent::backend::attached::AttachedBackend::with_prefix(m4a_agent::BackendKind::Server, base, token, false).expect("backend"))
2340    }
2341
2342    struct TempDir(PathBuf);
2343
2344    impl Drop for TempDir {
2345        fn drop(&mut self) {
2346            let _ = std::fs::remove_dir_all(&self.0);
2347        }
2348    }
2349
2350    fn temp_dir(name: &str) -> TempDir {
2351        let dir = TempDir(std::env::temp_dir().join(format!(
2352            "mail4agent-messenger-shell-{}-{name}",
2353            std::process::id()
2354        )));
2355        let _ = std::fs::remove_dir_all(&dir.0);
2356        dir
2357    }
2358
2359    fn open_alice(dir: &Path, session: &str) -> OpenedStore {
2360        let device = DeviceId::parse("DEVICE1").expect("device id");
2361        OpenedStore::open(
2362            dir,
2363            session,
2364            device,
2365            "@alice:localhost",
2366            "localhost",
2367            plain("http://127.0.0.1:9", "fake-token"),
2368            "fake-token",
2369        )
2370        .expect("open")
2371    }
2372
2373    #[test]
2374    fn same_session_opens_and_a_different_session_fails_the_seal() {
2375        let dir = temp_dir("seal");
2376        open_alice(&dir.0, "session-a");
2377        open_alice(&dir.0, "session-a");
2378
2379        let device = DeviceId::parse("DEVICE1").expect("device id");
2380        match OpenedStore::open(
2381            &dir.0,
2382            "session-b",
2383            device,
2384            "@alice:localhost",
2385            "localhost",
2386            plain("http://127.0.0.1:9", "fake-token"),
2387            "fake-token",
2388        ) {
2389            Err(ShellError::Store(StoreError::CodecOpen { .. })) => {}
2390            Ok(_) => panic!("different session opened the sealed store"),
2391            Err(err) => panic!("expected seal auth failure, got {err}"),
2392        }
2393    }
2394
2395    #[test]
2396    fn two_sessions_under_one_root_seal_and_reopen_only_with_their_own_id() {
2397        let root = temp_dir("isolate");
2398        let dir_a = session_store_dir(&root.0, "session-a");
2399        let dir_b = session_store_dir(&root.0, "session-b");
2400        assert_ne!(dir_a, dir_b);
2401        assert_eq!(dir_a.parent(), Some(root.0.as_path()));
2402        assert_eq!(dir_b.parent(), Some(root.0.as_path()));
2403        assert_eq!(session_store_dir(&root.0, "session-a"), dir_a);
2404        let slipped = session_store_dir(&root.0, "../session-a");
2405        assert_eq!(slipped.parent(), Some(root.0.as_path()));
2406        let name = slipped.file_name().expect("name").to_string_lossy();
2407        assert_eq!(name.len(), 64);
2408        assert!(name.chars().all(|ch| ch.is_ascii_hexdigit()));
2409
2410        let bearer = "isolation-bearer-7c2e";
2411        let device = DeviceId::parse("DEVICE1").expect("device id");
2412        let open_with = |dir: &Path, session: &str| {
2413            OpenedStore::open(
2414                dir,
2415                session,
2416                device.clone(),
2417                "@alice:localhost",
2418                "localhost",
2419                plain("http://127.0.0.1:9", bearer),
2420                bearer,
2421            )
2422        };
2423        open_with(&dir_a, "session-a").expect("seal a");
2424        open_with(&dir_b, "session-b").expect("seal b");
2425        open_with(&dir_a, "session-a").expect("reopen a");
2426        open_with(&dir_b, "session-b").expect("reopen b");
2427
2428        for (dir, session) in [(&dir_a, "session-b"), (&dir_b, "session-a")] {
2429            match open_with(dir, session) {
2430                Err(ShellError::Store(StoreError::CodecOpen { .. })) => {}
2431                Ok(_) => panic!("{session} opened the other sealed store"),
2432                Err(err) => panic!("expected seal auth failure, got {err}"),
2433            }
2434        }
2435
2436        fn contains_bearer(dir: &Path, needle: &str) -> bool {
2437            if !dir.exists() {
2438                return false;
2439            }
2440            for entry in std::fs::read_dir(dir).expect("read") {
2441                let entry = entry.expect("entry");
2442                let path = entry.path();
2443                if path.to_string_lossy().contains(needle) {
2444                    return true;
2445                }
2446                if path.is_dir() {
2447                    if contains_bearer(&path, needle) {
2448                        return true;
2449                    }
2450                } else if std::fs::read(&path)
2451                    .expect("file")
2452                    .windows(needle.len())
2453                    .any(|window| window == needle.as_bytes())
2454                {
2455                    return true;
2456                }
2457            }
2458            false
2459        }
2460        assert!(
2461            !contains_bearer(&root.0, bearer),
2462            "raw bearer was written under the store root"
2463        );
2464    }
2465
2466
2467
2468    #[test]
2469    fn a_base_url_carries_no_fragment_credentials_or_query() {
2470        assert!(parse_base_url("http://127.0.0.1:9").is_ok());
2471        assert!(parse_base_url("http://127.0.0.1:9/#other").is_err());
2472        assert!(parse_base_url("http://u:p@127.0.0.1:9").is_err());
2473        assert!(parse_base_url("ftp://127.0.0.1:9").is_err());
2474    }
2475
2476    #[test]
2477    fn send_message_releases_a_matrix_room_send() {
2478        let dir = temp_dir("send");
2479        let mut store = open_alice(&dir.0, "session-a");
2480        let released = store
2481            .send_room_message("!room:localhost", "hello room", 0)
2482            .expect("release");
2483        let send = released
2484            .iter()
2485            .find(|request| request.kind == OutgoingRequestKind::RoomSend)
2486            .unwrap_or_else(|| {
2487                panic!(
2488                    "engine did not release a RoomSend: {:?}",
2489                    released
2490                        .iter()
2491                        .map(|request| request.kind)
2492                        .collect::<Vec<_>>()
2493                )
2494            });
2495        assert!(
2496            send.path.starts_with("/_matrix/client/v3/rooms/")
2497                && send.path.contains("/send/m.room.message/"),
2498            "not a matrix room send: {}",
2499            send.path
2500        );
2501        assert!(!send.path.contains("/mail/send"), "{}", send.path);
2502        assert!(!send.path.contains("/admin/listener"), "{}", send.path);
2503        let body = send.body.as_ref().expect("room send body");
2504        assert_eq!(body["msgtype"], "m.text");
2505        assert_eq!(body["body"], "hello room");
2506    }
2507
2508    #[test]
2509    fn drive_performs_the_released_request_with_bearer_and_no_matrix_prefix() {
2510        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2511        let addr = listener.local_addr().expect("addr");
2512        let seen = Arc::new(Mutex::new(Vec::<String>::new()));
2513        let seen_worker = Arc::clone(&seen);
2514        listener.set_nonblocking(true).expect("nonblocking");
2515        let server = thread::spawn(move || {
2516            let deadline = std::time::Instant::now() + Duration::from_secs(3);
2517            loop {
2518                if std::time::Instant::now() > deadline {
2519                    break;
2520                }
2521                let sock = match listener.accept() {
2522                    Ok((sock, _)) => sock,
2523                    Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2524                        thread::sleep(Duration::from_millis(20));
2525                        continue;
2526                    }
2527                    Err(_) => break,
2528                };
2529                let mut sock = sock;
2530                let _ = sock.set_nonblocking(false);
2531                let _ = sock.set_read_timeout(Some(Duration::from_secs(2)));
2532                let mut buf = Vec::new();
2533                let mut tmp = [0u8; 2048];
2534                loop {
2535                    let n = sock.read(&mut tmp).unwrap_or(0);
2536                    if n == 0 {
2537                        break;
2538                    }
2539                    buf.extend_from_slice(&tmp[..n]);
2540                    if let Some(header_end) =
2541                        buf.windows(4).position(|window| window == b"\r\n\r\n")
2542                    {
2543                        let headers = String::from_utf8_lossy(&buf[..header_end]).to_string();
2544                        let length = headers
2545                            .lines()
2546                            .find_map(|line| {
2547                                let (name, value) = line.split_once(':')?;
2548                                if name.eq_ignore_ascii_case("content-length") {
2549                                    value.trim().parse::<usize>().ok()
2550                                } else {
2551                                    None
2552                                }
2553                            })
2554                            .unwrap_or(0);
2555                        if buf.len() >= header_end + 4 + length {
2556                            let body = buf[header_end + 4..header_end + 4 + length].to_vec();
2557                            let first = headers.lines().next().unwrap_or("").to_string();
2558                            let bearer_ok = headers.lines().any(|line| {
2559                                let (name, value) = line.split_once(':').unwrap_or(("", ""));
2560                                name.eq_ignore_ascii_case("authorization")
2561                                    && value.trim() == "Bearer fake-token"
2562                            });
2563                            let note = format!(
2564                                "{first} bearer_ok={bearer_ok} body={}",
2565                                String::from_utf8_lossy(&body)
2566                            );
2567                            seen_worker.lock().expect("seen").push(note);
2568                            let request = first;
2569                            let response_body = if request.contains("/send/") {
2570                                br#"{"event_id":"$e:localhost"}"#.to_vec()
2571                            } else if request.contains("/keys/") {
2572                                br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
2573                            } else {
2574                                br#"{"next_batch":"s1","device_one_time_keys_count":{"signed_curve25519":50},"device_unused_fallback_key_types":["signed_curve25519"]}"#.to_vec()
2575                            };
2576                            let head = format!(
2577                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2578                                response_body.len()
2579                            );
2580                            let _ = sock.write_all(head.as_bytes());
2581                            let _ = sock.write_all(&response_body);
2582                            break;
2583                        }
2584                    }
2585                }
2586            }
2587        });
2588
2589        let dir = temp_dir("drive");
2590        let device = DeviceId::parse("DEVICE1").expect("device id");
2591        let mut store = OpenedStore::open(
2592            &dir.0,
2593            "session-a",
2594            device,
2595            "@alice:localhost",
2596            "localhost",
2597            plain(&format!("http://{addr}"), "fake-token"),
2598            "fake-token",
2599        )
2600        .expect("open");
2601        store
2602            .dispatch(
2603                MessengerCommand::SendMessage {
2604                    room_id: RoomId::parse("!room:localhost").expect("room"),
2605                    message: OutgoingMessage {
2606                        kind: MessageKind::Text,
2607                        body: "hello room".to_string(),
2608                        reply_to: None,
2609                        edit_of: None,
2610                    },
2611                    txn_id: None,
2612                },
2613                0,
2614            )
2615            .expect("dispatch");
2616        store.drive(0, false).expect("drive");
2617        thread::sleep(Duration::from_millis(200));
2618        drop(store);
2619        let _ = server.join();
2620        let seen = seen.lock().expect("seen");
2621        assert!(
2622            seen.iter().any(|line| {
2623                line.contains("PUT /client/v3/rooms/")
2624                    && line.contains("/send/m.room.message/")
2625                    && line.contains("bearer_ok=true")
2626                    && line.contains("hello room")
2627                    && !line.contains("/_matrix")
2628            }),
2629            "released room send was not performed: {seen:?}"
2630        );
2631        assert!(
2632            seen.iter().all(|line| !line.contains("/_matrix")),
2633            "prefix was not stripped: {seen:?}"
2634        );
2635    }
2636
2637    #[allow(dead_code)]
2638    struct Hit {
2639        headers: String,
2640        body: Vec<u8>,
2641    }
2642
2643    fn read_http(sock: &mut std::net::TcpStream) -> Option<(String, Vec<u8>)> {
2644        let _ = sock.set_read_timeout(Some(Duration::from_secs(2)));
2645        let mut buf = Vec::new();
2646        let mut tmp = [0u8; 2048];
2647        loop {
2648            let n = sock.read(&mut tmp).unwrap_or(0);
2649            if n == 0 {
2650                break;
2651            }
2652            buf.extend_from_slice(&tmp[..n]);
2653            if let Some(header_end) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
2654                let headers = String::from_utf8_lossy(&buf[..header_end]).to_string();
2655                let length = headers
2656                    .lines()
2657                    .find_map(|line| {
2658                        let (name, value) = line.split_once(':')?;
2659                        if name.eq_ignore_ascii_case("content-length") {
2660                            value.trim().parse::<usize>().ok()
2661                        } else {
2662                            None
2663                        }
2664                    })
2665                    .unwrap_or(0);
2666                if buf.len() >= header_end + 4 + length {
2667                    let body = buf[header_end + 4..header_end + 4 + length].to_vec();
2668                    return Some((headers, body));
2669                }
2670            }
2671        }
2672        None
2673    }
2674
2675    fn write_http(sock: &mut std::net::TcpStream, status: &str, body: &[u8]) {
2676        let head = format!(
2677            "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2678            body.len()
2679        );
2680        let _ = sock.write_all(head.as_bytes());
2681        let _ = sock.write_all(body);
2682    }
2683
2684    fn sync_with_text_nick(body: &str, nick: Option<&str>) -> Vec<u8> {
2685        let mut state = Vec::new();
2686        if let Some(nick) = nick {
2687            state.push(serde_json::json!({
2688                "event_id": "$mem:localhost",
2689                "type": "m.room.member",
2690                "state_key": "@bob:localhost",
2691                "sender": "@bob:localhost",
2692                "origin_server_ts": 1,
2693                "content": { "membership": "join", "displayname": nick }
2694            }));
2695        }
2696        serde_json::json!({
2697            "next_batch": "s1",
2698            "rooms": {
2699                "join": {
2700                    "!r:localhost": {
2701                        "state": { "events": state },
2702                        "timeline": {
2703                            "events": [{
2704                                "event_id": "$m1:localhost",
2705                                "type": "m.room.message",
2706                                "sender": "@bob:localhost",
2707                                "origin_server_ts": 10,
2708                                "content": { "msgtype": "m.text", "body": body }
2709                            }]
2710                        }
2711                    }
2712                }
2713            },
2714            "device_one_time_keys_count": { "signed_curve25519": 50 },
2715            "device_unused_fallback_key_types": ["signed_curve25519"]
2716        })
2717        .to_string()
2718        .into_bytes()
2719    }
2720
2721    fn sync_empty() -> Vec<u8> {
2722        serde_json::json!({
2723            "next_batch": "s2",
2724            "device_one_time_keys_count": { "signed_curve25519": 50 },
2725            "device_unused_fallback_key_types": ["signed_curve25519"]
2726        })
2727        .to_string()
2728        .into_bytes()
2729    }
2730
2731    fn spawn_homeserver(text: &'static str) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
2732        spawn_homeserver_nick(text, None)
2733    }
2734
2735    fn spawn_homeserver_nick(
2736        text: &'static str,
2737        nick: Option<&'static str>,
2738    ) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
2739        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2740        let addr = listener.local_addr().expect("addr");
2741        listener.set_nonblocking(true).expect("nonblocking");
2742        let done = Arc::new(AtomicBool::new(false));
2743        let flag = Arc::clone(&done);
2744        let handle = thread::spawn(move || {
2745            let deadline = Instant::now() + Duration::from_secs(8);
2746            while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
2747                match listener.accept() {
2748                    Ok((mut sock, _)) => {
2749                        let _ = sock.set_nonblocking(false);
2750                        let Some((headers, _)) = read_http(&mut sock) else {
2751                            continue;
2752                        };
2753                        let line = headers.lines().next().unwrap_or("");
2754                        let resp = if line.contains("/sync") && line.contains("since=") {
2755                            sync_empty()
2756                        } else if line.contains("/sync") {
2757                            sync_with_text_nick(text, nick)
2758                        } else if line.contains("/keys/") {
2759                            br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
2760                        } else {
2761                            b"{}".to_vec()
2762                        };
2763                        write_http(&mut sock, "200 OK", &resp);
2764                    }
2765                    Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2766                        thread::sleep(Duration::from_millis(15));
2767                    }
2768                    Err(_) => break,
2769                }
2770            }
2771        });
2772        (format!("http://{addr}"), done, handle)
2773    }
2774
2775    fn spawn_routine() -> (
2776        String,
2777        Arc<Mutex<Vec<Hit>>>,
2778        Arc<AtomicBool>,
2779        thread::JoinHandle<()>,
2780    ) {
2781        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2782        let addr = listener.local_addr().expect("addr");
2783        listener.set_nonblocking(true).expect("nonblocking");
2784        let hits = Arc::new(Mutex::new(Vec::new()));
2785        let recorded = Arc::clone(&hits);
2786        let done = Arc::new(AtomicBool::new(false));
2787        let flag = Arc::clone(&done);
2788        let handle = thread::spawn(move || {
2789            let deadline = Instant::now() + Duration::from_secs(8);
2790            while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
2791                match listener.accept() {
2792                    Ok((mut sock, _)) => {
2793                        let _ = sock.set_nonblocking(false);
2794                        if let Some((headers, body)) = read_http(&mut sock) {
2795                            recorded.lock().expect("hits").push(Hit { headers, body });
2796                            let _ = sock.write_all(
2797                                b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
2798                            );
2799                        }
2800                    }
2801                    Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2802                        thread::sleep(Duration::from_millis(15));
2803                    }
2804                    Err(_) => break,
2805                }
2806            }
2807        });
2808        (format!("http://{addr}/routine"), hits, done, handle)
2809    }
2810
2811    fn stop(flag: &AtomicBool, handle: thread::JoinHandle<()>) {
2812        flag.store(true, Ordering::Relaxed);
2813        let _ = handle.join();
2814    }
2815
2816    fn open_against(base: &str, dir: &Path, session: &str) -> OpenedStore {
2817        let device = DeviceId::parse("DEVICE1").expect("device id");
2818        OpenedStore::open(
2819            dir,
2820            session,
2821            device,
2822            "@alice:localhost",
2823            "localhost",
2824            plain(base, "fake-token"),
2825            "fake-token",
2826        )
2827        .expect("open")
2828    }
2829
2830    #[test]
2831    fn inbound_room_text_goes_through_the_provider_chain_once() {
2832        let (base, home_done, home) = spawn_homeserver("wake-chain");
2833        let dir = temp_dir("wake-chain");
2834        let inbox_dir = dir.0.join("inbox");
2835        let mut store = open_against(&base, &dir.0, "session-c");
2836        let session = provider::ProviderSession {
2837            kind: provider::SessionKind::local(provider::ProviderKind::Codex),
2838            session_id: "thread-c".into(),
2839            nick: "builder".into(),
2840            cwd: None,
2841            headless: false,
2842        };
2843        let chain = provider::letter_chain(session.kind, Some(inbox_dir.clone()), &provider::AdapterConfig::from_env(Some(inbox_dir.clone())));
2844        store.set_wake_chain(session, chain);
2845        assert_eq!(store.wake_chain_ids(), vec!["deliver", "inbox-queue"], "g4a deliver, then the inbox");
2846        store.drive(1_000, false).expect("drive");
2847        store.drive(3_000, false).expect("drive again");
2848        let route = store.take_wake_route();
2849        let note = store.wake_note().unwrap_or("").to_string();
2850        drop(store);
2851        stop(&home_done, home);
2852        // No hook armed and no codex app-server: deliver is unavailable, the durable queue took it, once.
2853        assert_eq!(route.as_deref(), Some("inbox-queue queued"), "note={note}");
2854        let letters = provider::inbox::drain(&inbox_dir);
2855        assert_eq!(letters.len(), 1);
2856        assert!(letters[0].letter.prompt.ends_with("wake-chain"));
2857        assert!(letters[0].letter.prompt.contains("--as builder"));
2858    }
2859
2860
2861
2862
2863    #[test]
2864    fn the_owners_policy_silences_a_wake_and_the_silenced_message_is_never_replayed() {
2865        let (base, home_done, home) = spawn_homeserver("wake-plain");
2866        let (routine, hits, routine_done, routine_thread) = spawn_routine();
2867        let dir = temp_dir("wake-policy");
2868        let mut store = open_against(&base, &dir.0, "session-a");
2869        store.set_wake(SessionWake { routine_url: Some(routine), ..SessionWake::default() });
2870        let off = wake_policy::WakePolicy { enabled: false, mode: wake_policy::WakeMode::Mention };
2871        off.save(store.store_dir()).expect("policy");
2872        store.drive(1_000, false).expect("drive");
2873        assert!(store.texts().iter().any(|t| t.body == "wake-plain"), "the text still arrives and can be read");
2874        assert!(hits.lock().expect("hits").is_empty(), "a disabled wake posted");
2875        // Turning it back on later does not replay what was silenced.
2876        wake_policy::WakePolicy::default().save(store.store_dir()).expect("policy");
2877        store.drive(3_000, false).expect("drive again");
2878        let hits_now = hits.lock().expect("hits").len();
2879        drop(store);
2880        stop(&routine_done, routine_thread);
2881        stop(&home_done, home);
2882        assert_eq!(hits_now, 0, "the silenced message was replayed");
2883    }
2884
2885    #[test]
2886    fn a_web_client_with_a_routine_url_wakes_it_exactly_once_through_g4a() {
2887        let (base, home_done, home) = spawn_homeserver("wake-plain");
2888        let (routine, hits, routine_done, routine_thread) = spawn_routine();
2889        let dir = temp_dir("wake-routine-once");
2890        let mut store = open_against(&base, &dir.0, "session-a");
2891        store.set_wake(SessionWake { routine_url: Some(routine), routine_bearer: Some("k-test".into()), ..SessionWake::default() });
2892        store.drive(1_000, false).expect("drive");
2893        store.drive(3_000, false).expect("drive again");
2894        let route = store.take_wake_route();
2895        let note = store.wake_note().unwrap_or("").to_string();
2896        drop(store);
2897        stop(&routine_done, routine_thread);
2898        stop(&home_done, home);
2899        assert_eq!(hits.lock().expect("hits").len(), 1, "one letter, one routine wake; note={note}");
2900        assert_eq!(route.as_deref(), Some("deliver delivered"), "note={note}");
2901        let inbox = provider::registry::inbox_dir(dir.0.parent().expect("parent"), "session-a");
2902        assert!(provider::inbox::drain(&inbox).is_empty(), "a delivered letter is not also queued");
2903    }
2904
2905    fn spawn_homeserver_events(events: serde_json::Value) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
2906        let first = serde_json::json!({
2907            "next_batch": "s1",
2908            "rooms": { "join": { "!r:localhost": { "state": { "events": [] }, "timeline": { "events": events } } } },
2909            "device_one_time_keys_count": { "signed_curve25519": 50 },
2910            "device_unused_fallback_key_types": ["signed_curve25519"]
2911        }).to_string().into_bytes();
2912        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2913        let addr = listener.local_addr().expect("addr");
2914        listener.set_nonblocking(true).expect("nonblocking");
2915        let done = Arc::new(AtomicBool::new(false));
2916        let flag = Arc::clone(&done);
2917        let handle = thread::spawn(move || {
2918            let deadline = Instant::now() + Duration::from_secs(8);
2919            while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
2920                match listener.accept() {
2921                    Ok((mut sock, _)) => {
2922                        let _ = sock.set_nonblocking(false);
2923                        let Some((headers, _)) = read_http(&mut sock) else { continue };
2924                        let line = headers.lines().next().unwrap_or("");
2925                        let resp = if line.contains("/sync") && line.contains("since=") {
2926                            sync_empty()
2927                        } else if line.contains("/sync") {
2928                            first.clone()
2929                        } else if line.contains("/keys/") {
2930                            br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
2931                        } else {
2932                            b"{}".to_vec()
2933                        };
2934                        write_http(&mut sock, "200 OK", &resp);
2935                    }
2936                    Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => thread::sleep(Duration::from_millis(15)),
2937                    Err(_) => break,
2938                }
2939            }
2940        });
2941        (format!("http://{addr}"), done, handle)
2942    }
2943
2944    fn text_ev(id: &str, sender: &str, ts: i64, body: &str) -> serde_json::Value {
2945        serde_json::json!({ "event_id": id, "type": "m.room.message", "sender": sender, "origin_server_ts": ts, "content": { "msgtype": "m.text", "body": body } })
2946    }
2947    fn redaction_ev(id: &str, sender: &str, ts: i64, target: &str) -> serde_json::Value {
2948        serde_json::json!({ "event_id": id, "type": "m.room.redaction", "sender": sender, "origin_server_ts": ts, "redacts": target, "content": { "redacts": target } })
2949    }
2950    /// A server timestamp after this process started (reactions older than the first run never wake).
2951    fn later(ms: i64) -> i64 {
2952        std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) + 3_600_000 + ms
2953    }
2954    fn reaction_ev(id: &str, sender: &str, ts: i64, target: &str, key: &str) -> serde_json::Value {
2955        serde_json::json!({ "event_id": id, "type": "m.reaction", "sender": sender, "origin_server_ts": ts, "content": { "m.relates_to": { "rel_type": "m.annotation", "event_id": target, "key": key } } })
2956    }
2957
2958    /// Runs one offline catch-up (agent was down while `events` happened) and returns the routine bodies posted.
2959    fn wakes_for(name: &str, events: serde_json::Value, restart: bool) -> Vec<serde_json::Value> {
2960        let (base, home_done, home) = spawn_homeserver_events(events);
2961        let (routine, hits, routine_done, routine_thread) = spawn_routine();
2962        let dir = temp_dir(name);
2963        let mut store = open_against(&base, &dir.0, "session-a");
2964        store.set_wake(SessionWake { routine_url: Some(routine.clone()), ..SessionWake::default() });
2965        store.drive(1_000, false).expect("drive");
2966        store.drive(3_000, false).expect("drive again");
2967        drop(store);
2968        if restart {
2969            let mut store = open_against(&base, &dir.0, "session-a");
2970            store.set_wake(SessionWake { routine_url: Some(routine), ..SessionWake::default() });
2971            store.drive(5_000, false).expect("drive after restart");
2972            store.drive(7_000, false).expect("drive after restart again");
2973        }
2974        stop(&routine_done, routine_thread);
2975        stop(&home_done, home);
2976        let hits = hits.lock().expect("hits");
2977        hits.iter().map(|h| serde_json::from_slice(&h.body).unwrap_or(serde_json::Value::Null)).collect()
2978    }
2979
2980    type SeenLetter = (String, Option<serde_json::Value>, serde_json::Value);
2981
2982    /// Drives one catch-up of `events` through an in-process adapter and returns, per letter,
2983    /// its event id, `letter_content::current()` inside `wake`, and the letter hook JSON.
2984    fn in_process_wakes(name: &str, events: serde_json::Value) -> Vec<SeenLetter> {
2985        in_process_wakes_with(name, events, None).0
2986    }
2987
2988    /// [`in_process_wakes`] with an `interrupt.json` in the store (`interrupt_from`); the adapter
2989    /// marks every interrupt letter it sees as interrupted. Also returns `interrupt-status.json`.
2990    fn in_process_wakes_with(name: &str, events: serde_json::Value, interrupt_from: Option<&[&str]>) -> (Vec<SeenLetter>, serde_json::Value) {
2991        struct Record(Arc<Mutex<Vec<SeenLetter>>>);
2992        impl provider::WakeAdapter for Record {
2993            fn kind(&self) -> provider::SessionKind {
2994                provider::SessionKind::local(provider::ProviderKind::Grok)
2995            }
2996            fn probe(&self, _: &provider::ProviderSession) -> Result<(), provider::WakeError> {
2997                Ok(())
2998            }
2999            fn wake(&mut self, s: &provider::ProviderSession, l: &provider::WakeLetter<'_>) -> Result<provider::WakeOutcome, provider::WakeError> {
3000                let hook_json = crate::letter_hook::letter_event(s, l);
3001                self.0.lock().expect("seen").push((l.event_id.to_string(), crate::letter_content::current(), hook_json));
3002                if crate::letter_content::current_is_interrupt() {
3003                    crate::letter_content::mark_interrupted();
3004                }
3005                Ok(provider::WakeOutcome::Delivered)
3006            }
3007        }
3008        let (base, home_done, home) = spawn_homeserver_events(events);
3009        let dir = temp_dir(name);
3010        let mut store = open_against(&base, &dir.0, "session-h");
3011        if let Some(from) = interrupt_from {
3012            std::fs::write(store.store_dir().join("interrupt.json"), serde_json::json!({ "from": from }).to_string()).expect("interrupt.json");
3013        }
3014        let session = provider::ProviderSession {
3015            kind: provider::SessionKind::local(provider::ProviderKind::Grok),
3016            session_id: "thread-h".into(),
3017            nick: "builder".into(),
3018            cwd: None,
3019            headless: false,
3020        };
3021        let seen = Arc::new(Mutex::new(Vec::new()));
3022        let mut chain = provider::WakeChain::new();
3023        chain.push("record", Box::new(Record(Arc::clone(&seen))));
3024        store.set_wake_chain(session, chain);
3025        store.drive(1_000, false).expect("drive");
3026        store.drive(3_000, false).expect("drive again");
3027        let status = std::fs::read_to_string(store.store_dir().join(crate::wake_policy::INTERRUPT_STATUS_FILE))
3028            .ok()
3029            .and_then(|t| serde_json::from_str(&t).ok())
3030            .unwrap_or(serde_json::Value::Null);
3031        drop(store);
3032        stop(&home_done, home);
3033        assert_eq!(crate::letter_content::current(), None, "the fields do not leak past the wake");
3034        let seen = seen.lock().expect("seen").clone();
3035        (seen, status)
3036    }
3037
3038    fn interrupt_ev(id: &str, sender: &str) -> serde_json::Value {
3039        serde_json::json!({ "event_id": id, "type": "m.room.message", "sender": sender, "origin_server_ts": 10,
3040          "content": { "msgtype": "m.text", "body": "stop and read this", "fyi.hatchery.interrupt": true, "fyi.hatchery.task": 7, "fyi.other": 1 } })
3041    }
3042
3043    #[test]
3044    fn an_in_process_adapter_sees_the_letters_hatchery_fields() {
3045        let (seen, status) = in_process_wakes_with("wake-hatchery-fields", serde_json::json!([interrupt_ev("$i1:localhost", "@bob:localhost")]), Some(&["bob"]));
3046        assert_eq!(seen.len(), 1, "{seen:?}");
3047        let (id, fields, hook) = &seen[0];
3048        assert_eq!(id, "$i1:localhost");
3049        assert_eq!(fields, &Some(serde_json::json!({ "fyi.hatchery.interrupt": true, "fyi.hatchery.task": 7 })), "only fyi.hatchery.* keys");
3050        assert_eq!(hook["content"], serde_json::json!({ "fyi.hatchery.interrupt": true, "fyi.hatchery.task": 7 }));
3051        assert_eq!(status["result"], "interrupted", "{status}");
3052        assert_eq!(status["from"], "bob");
3053    }
3054
3055    #[test]
3056    fn an_interrupt_from_a_sender_off_the_list_is_delivered_as_an_ordinary_letter() {
3057        let (seen, status) = in_process_wakes_with("wake-interrupt-denied", serde_json::json!([interrupt_ev("$d1:localhost", "@mallory:localhost")]), Some(&["bob"]));
3058        assert_eq!(seen.len(), 1, "the letter is still delivered: {seen:?}");
3059        let (_, fields, hook) = &seen[0];
3060        assert_eq!(fields, &Some(serde_json::json!({ "fyi.hatchery.interrupt_denied": true, "fyi.hatchery.task": 7 })), "no adapter can act on a denied flag");
3061        assert!(hook["content"].get("fyi.hatchery.interrupt").is_none(), "{hook}");
3062        assert_eq!(status["result"], "denied", "{status}");
3063    }
3064
3065    #[test]
3066    fn with_no_interrupt_list_nobody_interrupts() {
3067        let (seen, status) = in_process_wakes_with("wake-interrupt-no-list", serde_json::json!([interrupt_ev("$n1:localhost", "@bob:localhost")]), None);
3068        assert_eq!(seen.len(), 1, "{seen:?}");
3069        assert_eq!(seen[0].1, Some(serde_json::json!({ "fyi.hatchery.interrupt_denied": true, "fyi.hatchery.task": 7 })));
3070        assert_eq!(status["result"], "denied", "{status}");
3071    }
3072
3073    #[test]
3074    fn a_plain_letter_has_no_hatchery_fields_and_no_content_key() {
3075        let seen = in_process_wakes("wake-no-hatchery-fields", serde_json::json!([
3076            text_ev("$p1:localhost", "@bob:localhost", 10, "plain letter"),
3077        ]));
3078        assert_eq!(seen.len(), 1, "{seen:?}");
3079        let (_, fields, hook) = &seen[0];
3080        assert_eq!(fields, &None);
3081        assert!(hook.get("content").is_none(), "older readers see the old JSON: {hook}");
3082    }
3083
3084    #[test]
3085    fn a_message_sent_and_redacted_while_the_agent_was_offline_wakes_nothing() {
3086        let w = wakes_for("coalesce-redact", serde_json::json!([
3087            text_ev("$m1:localhost", "@bob:localhost", 10, "oops wrong chat"),
3088            redaction_ev("$x1:localhost", "@bob:localhost", 11, "$m1:localhost"),
3089        ]), false);
3090        assert!(w.is_empty(), "redacted-before-delivery must produce no wake: {w:?}");
3091    }
3092
3093    #[test]
3094    fn an_edit_before_the_wake_delivers_only_the_final_version() {
3095        let w = wakes_for("coalesce-edit", serde_json::json!([
3096            text_ev("$m1:localhost", "@bob:localhost", 10, "draft text"),
3097            { "event_id": "$e1:localhost", "type": "m.room.message", "sender": "@bob:localhost", "origin_server_ts": 11,
3098              "content": { "msgtype": "m.text", "body": "* final text", "m.new_content": { "msgtype": "m.text", "body": "final text" },
3099                           "m.relates_to": { "rel_type": "m.replace", "event_id": "$m1:localhost" } } },
3100        ]), false);
3101        assert_eq!(w.len(), 1, "one wake for the edited message: {w:?}");
3102        assert_eq!(w[0]["body"], "final text");
3103        assert_eq!(w[0]["event_id"], "$m1:localhost");
3104    }
3105
3106    #[test]
3107    fn a_reaction_to_my_message_wakes_once_low_priority_and_mine_never() {
3108        let w = wakes_for("reaction-wake", serde_json::json!([
3109            text_ev("$a1:localhost", "@alice:localhost", 10, "deploy finished"),
3110            reaction_ev("$r1:localhost", "@bob:localhost", later(0), "$a1:localhost", "👍"),
3111            reaction_ev("$r2:localhost", "@alice:localhost", later(1), "$a1:localhost", "🎉"),
3112        ]), true);
3113        assert_eq!(w.len(), 1, "exactly one wake (bob's reaction), none for my own, no replay after restart: {w:?}");
3114        assert_eq!(w[0]["kind"], "reaction");
3115        assert_eq!(w[0]["reaction"], "👍");
3116        assert_eq!(w[0]["target_event_id"], "$a1:localhost");
3117        assert_eq!(w[0]["event_id"], "$r1:localhost");
3118        assert_eq!(w[0]["reply_needed"], false);
3119        assert_eq!(w[0]["priority"], "low");
3120        assert!(w[0].get("reply").is_none());
3121        assert!(w[0]["body"].as_str().unwrap_or("").contains("deploy finished"));
3122    }
3123
3124    #[test]
3125    fn reactions_from_before_the_first_run_never_wake() {
3126        let w = wakes_for("reaction-history", serde_json::json!([
3127            text_ev("$a1:localhost", "@alice:localhost", 10, "old"),
3128            reaction_ev("$r1:localhost", "@bob:localhost", 11, "$a1:localhost", "👍"),
3129        ]), false);
3130        assert!(w.is_empty(), "history replayed: {w:?}");
3131    }
3132
3133    #[test]
3134    fn a_reaction_added_and_removed_before_the_wake_produces_nothing() {
3135        let w = wakes_for("reaction-undo", serde_json::json!([
3136            text_ev("$a1:localhost", "@alice:localhost", 10, "hello"),
3137            reaction_ev("$r1:localhost", "@bob:localhost", later(0), "$a1:localhost", "👍"),
3138            redaction_ev("$x1:localhost", "@bob:localhost", later(1), "$r1:localhost"),
3139        ]), false);
3140        assert!(w.is_empty(), "{w:?}");
3141    }
3142
3143    #[test]
3144    fn no_routine_url_posts_nothing() {
3145        let (base, home_done, home) = spawn_homeserver("wake-plain");
3146        let (_routine, hits, routine_done, routine_thread) = spawn_routine();
3147        let dir = temp_dir("wake-none");
3148        let mut store = open_against(&base, &dir.0, "session-a");
3149        store.set_wake(SessionWake::default());
3150        store.drive(1_000, false).expect("drive");
3151        let saw_text = store.texts().iter().any(|text| text.body == "wake-plain");
3152        let trace = store.http_trace();
3153        drop(store);
3154        stop(&routine_done, routine_thread);
3155        stop(&home_done, home);
3156        assert!(
3157            saw_text,
3158            "missing routine url dropped the inbound text; http={trace:?}"
3159        );
3160        let hits = hits.lock().expect("hits");
3161        assert!(hits.is_empty(), "a post happened with no routine url");
3162    }
3163
3164
3165
3166}
3167
3168/// The server part of a Matrix user id (`@nick:server` -> `server`).
3169fn server_of(user_id: &str) -> &str {
3170    user_id.split_once(':').map(|(_, server)| server).unwrap_or("")
3171}
3172
3173/// Which invites a session joins on its own. Defaults: DMs and group rooms from members of this
3174/// account's own server. Env: `M4A_AUTO_JOIN_DMS=0` / `M4A_AUTO_JOIN_ROOMS=0` turn either off;
3175/// `M4A_AUTO_JOIN_FROM` (comma-separated nicks or user ids) limits room invites to those
3176/// inviters (e.g. the operator).
3177#[derive(Debug, Clone, PartialEq, Eq)]
3178pub struct AutoJoinPolicy {
3179    pub dms: bool,
3180    pub rooms: bool,
3181    /// Allowed room inviters (nick or full user id); empty = any member of our server.
3182    pub room_inviters: Vec<String>,
3183}
3184
3185impl Default for AutoJoinPolicy {
3186    fn default() -> Self {
3187        Self { dms: true, rooms: true, room_inviters: Vec::new() }
3188    }
3189}
3190
3191impl AutoJoinPolicy {
3192    pub fn from_env() -> Self {
3193        let flag = |k: &str| std::env::var(k).map(|v| !matches!(v.trim(), "0" | "false" | "off" | "no")).unwrap_or(true);
3194        let from = std::env::var("M4A_AUTO_JOIN_FROM").unwrap_or_default();
3195        Self {
3196            dms: flag("M4A_AUTO_JOIN_DMS"),
3197            rooms: flag("M4A_AUTO_JOIN_ROOMS"),
3198            room_inviters: from.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
3199        }
3200    }
3201
3202    /// Whether to join one invite. `members` includes `me`.
3203    pub fn accepts<'a>(&self, home: &str, me: &str, is_direct: bool, inviter: Option<&str>, members: impl Iterator<Item = &'a str>) -> bool {
3204        if home.is_empty() {
3205            return false;
3206        }
3207        if is_direct {
3208            let others: Vec<&str> = members.filter(|u| *u != me).collect();
3209            let peers_home = !others.is_empty() && others.iter().all(|u| server_of(u) == home);
3210            return self.dms && peers_home && inviter.is_none_or(|i| server_of(i) == home);
3211        }
3212        let Some(inviter) = inviter else { return false };
3213        if !self.rooms || server_of(inviter) != home || inviter == me {
3214            return false;
3215        }
3216        self.room_inviters.is_empty()
3217            || self.room_inviters.iter().any(|a| a.eq_ignore_ascii_case(inviter) || a.eq_ignore_ascii_case(inviter.trim_start_matches('@').split(':').next().unwrap_or("")))
3218    }
3219}
3220
3221#[cfg(test)]
3222mod auto_join_tests {
3223    use super::AutoJoinPolicy;
3224
3225    const HOME: &str = "example.org";
3226    const ME: &str = "@agent-a:example.org";
3227
3228    fn ok(p: &AutoJoinPolicy, direct: bool, inviter: Option<&str>, members: &[&str]) -> bool {
3229        p.accepts(HOME, ME, direct, inviter, members.iter().copied())
3230    }
3231
3232    #[test]
3233    fn group_room_invite_from_our_server_is_joined() {
3234        let p = AutoJoinPolicy::default();
3235        assert!(ok(&p, false, Some("@operator:example.org"), &[ME, "@operator:example.org", "@x:other.org"]));
3236    }
3237
3238    #[test]
3239    fn group_room_invite_from_another_server_or_unknown_inviter_is_left() {
3240        let p = AutoJoinPolicy::default();
3241        assert!(!ok(&p, false, Some("@evil:other.org"), &[ME, "@evil:other.org"]));
3242        assert!(!ok(&p, false, None, &[ME, "@operator:example.org"]));
3243    }
3244
3245    #[test]
3246    fn dm_policy_is_unchanged() {
3247        let p = AutoJoinPolicy::default();
3248        assert!(ok(&p, true, Some("@agent-b:example.org"), &[ME, "@agent-b:example.org"]));
3249        assert!(ok(&p, true, None, &[ME, "@agent-b:example.org"]));
3250        assert!(!ok(&p, true, Some("@x:other.org"), &[ME, "@x:other.org"]));
3251    }
3252
3253    #[test]
3254    fn switches_and_allow_list() {
3255        let off = AutoJoinPolicy { rooms: false, ..AutoJoinPolicy::default() };
3256        assert!(!ok(&off, false, Some("@operator:example.org"), &[ME]));
3257        let nodm = AutoJoinPolicy { dms: false, ..AutoJoinPolicy::default() };
3258        assert!(!ok(&nodm, true, Some("@agent-b:example.org"), &[ME, "@agent-b:example.org"]));
3259        let only_op = AutoJoinPolicy { room_inviters: vec!["operator".into()], ..AutoJoinPolicy::default() };
3260        assert!(ok(&only_op, false, Some("@operator:example.org"), &[ME]));
3261        assert!(!ok(&only_op, false, Some("@agent-b:example.org"), &[ME]));
3262    }
3263}