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