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