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