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