Skip to main content

mail4agent_messenger_shell/
machine.rs

1//! Web machine client: one process for every bot session on this machine.
2//!
3//! This is not the homeserver, and it is not the node CLI
4//! ([`crate::OpenedStore::connect_node_from_env`]). Discovery uses
5//! [`AGENTS_DIR_ENV`] only when that variable names a directory. The home
6//! Grok Bot directory is not opened by default, and neither is its gateway
7//! file. Each child folder is a Grok Bot agent id and `profile.json`
8//! carries the display name. The mail session id is that agent id, unless
9//! [`SESSION_IDS_ENV`] maps the agent to an existing session id. The host
10//! may still pass a list, or this process reads [`SESSIONS_DIR_ENV`] when
11//! no agents directory was named. A session record is the bot display name, the mail session id,
12//! and the Grok Bot agent id when this session has one. A routine URL or a
13//! bearer in the file is refused.
14//!
15//! Wake routines. Every bot here is server-hosted, so only the bot itself
16//! can put a routine on the backend, with its own `UpdateRoutine`. The
17//! bot's nick, the routine's name, and its folder id are one string
18//! (`privet-mir`): the nick already follows the host slug rule, so
19//! [`crate::routine_folder_id`] leaves it unchanged. A homeserver that
20//! still enforces the older `[A-Za-z0-9_]` nick rule refuses such a nick
21//! at register (400) until it is redeployed; users already registered under
22//! underscore nicks are left as they are. Its backend id is
23//! `stableAutomationId(agentId, folderId)`. The local gateway hands out a
24//! webhook key only for a routine it finds locally, and mints it for that
25//! same id, so [`MachineClient::from_env`] keeps a disabled local mirror
26//! (webhook trigger, same folder) per bot through `createAgentAutomation`
27//! and then calls `getAutomationWebhookCredential`. A null key means the
28//! bot has not created its routine yet: logged, retried by
29//! [`MachineClient::poll_agent_directory`], and never answered with another
30//! routine. A mirror that lands in any other folder is deleted again.
31//! [`SKIP_NICKS_ENV`] lists bots to leave alone. A ready URL and key stay
32//! in memory and in the session's keychain file ([`WAKE_KEYCHAIN_FILE`],
33//! mode 0600, under the sealed store root); they are never logged. When a
34//! bot first becomes ready, this client POSTs `kind=peer_joined` once to
35//! every other ready bot's webhook ([`crate::post_routine_json`]); URLs
36//! and keys stay out of the log. A record with no agent id gets no wake.
37//! One URL is not shared across sessions.
38//! [`ensure_agent_webhook_routines`] is the same path without opening
39//! sealed stores. [`crate::LEADER_SOCK_ENV`] is not this path. The node
40//! CLI does not create a routine.
41//!
42//! Each session still seals under [`crate::session_store_dir`]. Olm pickles
43//! are not shared. While [`MachineClient::set_local_delivery`] is set, the
44//! existing drive performs requests against an in-process bus instead of
45//! the homeserver. Turn it off to reach a session that is not in the list.
46//! The bus is called from that drive. It is not a second sync loop.
47//! Room text from the homeserver is not a second sync loop either.
48//! [`MachineClient::open`] opens one socket for every session it
49//! registered. The homeserver pushes an event down that socket. This
50//! process delivers it to that session. A routine POST is the later
51//! drive, and only for a session that has its own webhook.
52//! [`MachineClient::tick`] is that loop step; the `m4a-web-client` binary
53//! runs it.
54//!
55//! Wake without a gateway agent. A session record may name `routine_file`, a path to a file of mode
56//! 0600 that the owner placed and that holds `{"url": "...", "key": "..."}`, the routine's webhook.
57//! The client reads it itself when it opens; the URL and the key are not logged, not shown in
58//! `Debug`, and never given to the agent, and a file that others can read is refused. The record
59//! itself cannot carry a URL or a key. A session with an `agent_id` and a gateway
60//! (`M4A_GATEWAY_FILE`, `{"port","scheme","token"}`) still gets its webhook created and read
61//! through the gateway as before; the gateway needs the Grok Bot agent id and is not used without
62//! one. A session with neither has no wake: its mail arrives and is read on demand.
63//!
64//! Session bearers. Each session logs in by signature with its own vaulted identity; the
65//! bearer lives in memory only and is never stored or handed in.
66
67//! the device. With [`KEYCHAIN_DIR_ENV`] set, [`MachineClient::from_env`]
68
69use std::fs::File;
70use std::collections::{HashMap, HashSet};
71use std::path::{Path, PathBuf};
72use std::sync::Arc;
73use zeroize::Zeroizing;
74
75use crate::ipc::{SendListener, SendStream};
76pub(crate) use crate::local_bus::{ensure_server_name, LocalBus};
77use crate::{
78    clip_public, register_session, DeviceId, OpenedStore,
79    SessionConfig, SessionWake, ShellError, HOMESERVER_URL_ENV, STORE_ROOT_ENV,
80};
81
82/// Directory of session records. Each `*.json` file is `bot_name`,
83/// `session_id`, and an optional `agent_id`. Used when no agents directory
84/// is available. Unset means the host passed the list to
85/// [`MachineClient::open`] instead.
86pub const SESSIONS_DIR_ENV: &str = "M4A_SESSIONS_DIR";
87
88/// Live Grok Bot agents on this machine. Each child folder name is an
89/// agent id; `profile.json` has the display `name`. [`MachineClient::from_env`]
90/// prefers this over [`SESSIONS_DIR_ENV`] when the directory exists.
91pub const AGENTS_DIR_ENV: &str = "M4A_AGENTS_DIR";
92
93/// Default agents directory, relative to the user's home (`$HOME` or
94/// `%USERPROFILE%`). The webhook command uses it when [`AGENTS_DIR_ENV`]
95/// is unset and this path is a directory. The web client does not.
96pub const DEFAULT_AGENTS_DIR: &str = "agent-data/agents";
97
98/// `<home>/<rel>`, with the home taken from the environment; a bare relative path when none is set.
99pub(crate) fn under_home(rel: &str) -> PathBuf {
100    match std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
101        Ok(h) if !h.is_empty() => PathBuf::from(h).join(rel),
102        _ => PathBuf::from(rel),
103    }
104}
105
106/// Optional rescan period in seconds for [`MachineClient::poll_agent_directory`].
107/// Unset or `0` means the caller decides when to poll; open still scans once.
108/// `1`/`on`: the sessions this client holds reach each other through the in-process bus and the
109/// homeserver is not called after opening (registration at open still uses it). A peer that is not a
110/// session of this client is then unreachable; leave it off for ordinary operation.
111pub const LOCAL_BUS_ENV: &str = "M4A_LOCAL_BUS";
112pub const AGENT_RESCAN_SECS_ENV: &str = "M4A_AGENT_RESCAN_SECS";
113
114/// One bot session the host says lives on this machine.
115///
116/// `routine_url` and `routine_bearer` are optional and stay in memory.
117/// They are not part of a session record on disk.
118pub struct HostSession {
119    /// Display name the host already shows (`Alice`, `Привет мир`).
120    pub bot_name: String,
121    /// Session id the host already assigned. This is the mail session, not
122    /// the Grok Bot agent id.
123    pub session_id: String,
124    /// Grok Bot agent id for [`createAgentAutomation`]. Absent means this
125    /// session does not get a webhook routine.
126    pub agent_id: Option<String>,
127    /// Routine URL for this session, if the host has one. Not a file.
128    pub routine_url: Option<String>,
129    /// Bearer for that routine POST. Memory only.
130    pub routine_bearer: Option<String>,
131    /// The operator's one-time invite code, needed only until this session's identity is
132    /// enrolled. Read by the client, never given to the agent.
133    pub invite: Option<String>,
134    /// Path of an owner-placed file (mode 0600) with the routine's webhook `{"url","key"}`. The
135    /// client reads it itself at open; the values never go to the agent or the log.
136    pub routine_file: Option<PathBuf>,
137    /// `server` tier (default) or `matrix`.
138    pub tier: m4a_agent::BackendKind,
139}
140
141impl HostSession {
142    /// A session with no routine and no stored device bearer.
143    pub fn new(bot_name: impl Into<String>, session_id: impl Into<String>) -> Self {
144        Self {
145            bot_name: bot_name.into(),
146            session_id: session_id.into(),
147            agent_id: None,
148            routine_url: None,
149            routine_bearer: None,
150            invite: None,
151            routine_file: None,
152            tier: m4a_agent::BackendKind::Server,
153        }
154    }
155
156    /// A routine webhook the owner put in a file (see [`Self::routine_file`]).
157    pub fn with_routine_file(mut self, path: impl Into<PathBuf>) -> Self {
158        self.routine_file = Some(path.into());
159        self
160    }
161
162    pub(crate) fn config(&self, url: &str, store_root: &Path) -> Result<SessionConfig, ShellError> {
163        Ok(SessionConfig::new_identity(url, self.tier, &self.session_id, store_root, self.invite.clone())?.with_nick_request(&self.bot_name))
164    }
165
166    /// The operator's invite for this session's first login.
167    pub fn with_invite(mut self, invite: impl Into<String>) -> Self {
168        self.invite = Some(invite.into());
169        self
170    }
171
172    /// Which tier this session logs in on.
173    pub fn with_tier(mut self, tier: m4a_agent::BackendKind) -> Self {
174        self.tier = tier;
175        self
176    }
177
178    /// Attaches a routine target. The bearer is kept only as this value.
179    pub fn with_routine(mut self, url: impl Into<String>, bearer: Option<String>) -> Self {
180        self.routine_url = Some(url.into());
181        self.routine_bearer = bearer.filter(|token| !token.is_empty());
182        self
183    }
184
185}
186
187impl std::fmt::Debug for HostSession {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.debug_struct("HostSession")
190            .field("bot_name", &self.bot_name)
191            .field("session_id", &self.session_id)
192            .field("agent_id", &self.agent_id)
193            .field("routine_url", &self.routine_url.as_ref().map(|_| "[set]"))
194            .field(
195                "routine_bearer",
196                &self.routine_bearer.as_ref().map(|_| "[redacted]"),
197            )
198            .finish()
199    }
200}
201
202#[derive(serde::Deserialize)]
203#[serde(deny_unknown_fields)]
204struct SessionFile {
205    bot_name: String,
206    session_id: String,
207    /// Grok Bot agent id. Missing or blank skips routine creation.
208    #[serde(default)]
209    agent_id: Option<String>,
210    /// Path of a file (mode 0600) that holds the routine's webhook `{"url","key"}`. Only the path
211    /// is in the record; the secret is in the file, which the owner places.
212    #[serde(default)]
213    routine_file: Option<String>,
214    /// `server` or `matrix`: the tier this session logs in on. Missing: `M4A_TIER`, else `server`.
215    #[serde(default)]
216    tier: Option<String>,
217}
218
219/// Reads `*.json` session records from `dir`. A file that carries anything
220/// besides `bot_name`, `session_id`, and `agent_id` is refused, so a
221/// webhook URL or a bearer cannot ride along in a world-readable record.
222pub fn load_session_records(dir: &Path) -> Result<Vec<HostSession>, ShellError> {
223    if !dir.is_dir() {
224        return Err(ShellError::SessionList(
225            "session directory is missing".to_string(),
226        ));
227    }
228    let mut paths = Vec::new();
229    for entry in std::fs::read_dir(dir)? {
230        let entry = entry?;
231        let path = entry.path();
232        if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
233            continue;
234        }
235        paths.push(path);
236    }
237    paths.sort();
238    if paths.is_empty() {
239        return Err(ShellError::SessionList(
240            "session directory has no records".to_string(),
241        ));
242    }
243    let mut out = Vec::with_capacity(paths.len());
244    for path in paths {
245        let text = std::fs::read_to_string(&path)
246            .map_err(|err| ShellError::SessionList(clip_public(err.to_string())))?;
247        let file: SessionFile = serde_json::from_str(&text)
248            .map_err(|err| ShellError::SessionList(clip_public(err.to_string())))?;
249        let mut session = HostSession::new(file.bot_name, file.session_id);
250        session.agent_id = file
251            .agent_id
252            .map(|id| id.trim().to_string())
253            .filter(|id| !id.is_empty());
254        session.tier = match file.tier.or_else(|| std::env::var("M4A_TIER").ok()).as_deref().map(str::trim) {
255            None | Some("") | Some("server") => m4a_agent::BackendKind::Server,
256            Some("matrix") => m4a_agent::BackendKind::Matrix,
257            Some(other) => return Err(ShellError::SessionList(format!("tier {other:?} is not server or matrix"))),
258        };
259        session.routine_file = file.routine_file.map(|p| PathBuf::from(p.trim())).filter(|p| !p.as_os_str().is_empty());
260        out.push(session);
261    }
262    Ok(out)
263}
264
265#[derive(serde::Deserialize)]
266struct AgentProfileFile {
267    name: String,
268}
269
270/// Reads each agent folder under `dir`. Folder name is the agent id and the
271/// mail session id. `profile.json` supplies the display name. Folders
272/// without a usable profile are skipped. This does not hardcode agent ids.
273pub fn load_agents_dir(dir: &Path) -> Result<Vec<HostSession>, ShellError> {
274    if !dir.is_dir() {
275        return Err(ShellError::SessionList(
276            "agents directory is missing".to_string(),
277        ));
278    }
279    let mut ids = Vec::new();
280    for entry in std::fs::read_dir(dir)? {
281        let entry = entry?;
282        if !entry.file_type()?.is_dir() {
283            continue;
284        }
285        let name = entry.file_name();
286        let Some(id) = name.to_str() else {
287            continue;
288        };
289        if id.is_empty() || id.starts_with('.') {
290            continue;
291        }
292        ids.push(id.to_string());
293    }
294    ids.sort();
295    if ids.is_empty() {
296        return Err(ShellError::SessionList(
297            "agents directory has no agents".to_string(),
298        ));
299    }
300    let mut out = Vec::with_capacity(ids.len());
301    for id in ids {
302        let profile_path = dir.join(&id).join("profile.json");
303        if !profile_path.is_file() {
304            continue;
305        }
306        let text = std::fs::read_to_string(&profile_path)
307            .map_err(|err| ShellError::SessionList(clip_public(err.to_string())))?;
308        let profile: AgentProfileFile = serde_json::from_str(&text)
309            .map_err(|err| ShellError::SessionList(clip_public(err.to_string())))?;
310        let bot_name = profile.name.trim().to_string();
311        if bot_name.is_empty() {
312            continue;
313        }
314        let mut session = HostSession::new(bot_name, id.clone());
315        session.agent_id = Some(id);
316        out.push(session);
317    }
318    if out.is_empty() {
319        return Err(ShellError::SessionList(
320            "agents directory has no profiles".to_string(),
321        ));
322    }
323    Ok(out)
324}
325
326/// Agents directory for the web client. Only [`AGENTS_DIR_ENV`], and only
327/// when that path is a directory. An unset variable does not fall back to
328/// the home Grok Bot directory: that directory belongs to another product
329/// on the same box.
330fn explicit_agents_dir(mut get: impl FnMut(&str) -> Option<String>) -> Option<PathBuf> {
331    let path = PathBuf::from(get(AGENTS_DIR_ENV)?);
332    path.is_dir().then_some(path)
333}
334
335/// Resolves the agents directory from the environment lookup. Prefers
336/// [`AGENTS_DIR_ENV`], then [`DEFAULT_AGENTS_DIR`] when that path is a
337/// directory. The webhook command uses this. The web client does not.
338fn resolve_agents_dir(mut get: impl FnMut(&str) -> Option<String>) -> Option<PathBuf> {
339    if let Some(path) = get(AGENTS_DIR_ENV).filter(|value| !value.is_empty()) {
340        let path = PathBuf::from(path);
341        if path.is_dir() {
342            return Some(path);
343        }
344        return None;
345    }
346    let default = under_home(DEFAULT_AGENTS_DIR);
347    if default.is_dir() {
348        Some(default)
349    } else {
350        None
351    }
352}
353
354/// Session records plus one wake per session that has an agent id.
355///
356/// `get` is the process environment on [`MachineClient::from_env`]. A
357/// shared [`crate::ROUTINE_URL_ENV`] is not copied onto every session.
358/// [`GATEWAY_FILE_ENV`] names the gateway file. When that lookup is empty,
359/// this does not look for a gateway, so a test that did not point at one
360/// does not create a mirror. The json files are not rewritten.
361fn load_web_sessions(
362    dir: &Path,
363    mut get: impl FnMut(&str) -> Option<String>,
364) -> Result<Vec<HostSession>, ShellError> {
365    let sessions = load_session_records(dir)?;
366    let mut sessions = drop_skipped(sessions, &skip_nicks(&mut get));
367    attach_routines_from_env(&mut sessions, &mut get)?;
368    Ok(sessions)
369}
370
371/// [`load_agents_dir`] minus [`SKIP_NICKS_ENV`], plus one wake per agent.
372fn load_web_agents(
373    dir: &Path,
374    mut get: impl FnMut(&str) -> Option<String>,
375) -> Result<Vec<HostSession>, ShellError> {
376    let sessions = load_agents_dir(dir)?;
377    let mut sessions = drop_skipped(sessions, &skip_nicks(&mut get));
378    apply_session_ids(
379        &mut sessions,
380        &parse_session_ids(get(SESSION_IDS_ENV).as_deref().unwrap_or("")),
381    );
382    attach_routines_from_env(&mut sessions, &mut get)?;
383    Ok(sessions)
384}
385
386fn attach_routines_from_env(
387    sessions: &mut [HostSession],
388    get: &mut impl FnMut(&str) -> Option<String>,
389) -> Result<(), ShellError> {
390    let store_root = get(STORE_ROOT_ENV).map(PathBuf::from);
391    let Some(path) = get(GATEWAY_FILE_ENV).filter(|value| !value.is_empty()) else {
392        return Ok(());
393    };
394    let token = get(GATEWAY_TOKEN_ENV).filter(|value| !value.is_empty());
395    attach_webhook_routines(
396        sessions,
397        Path::new(&path),
398        token.as_deref(),
399        store_root.as_deref(),
400    )?;
401    Ok(())
402}
403
404/// Sessions in `found`, minus skipped nicks, with session-id aliases
405/// applied, that no open session holds (by store dir or nick). Each comes
406/// with its nick.
407fn unopened_agent_sessions(
408    found: Vec<HostSession>,
409    skip: &[String],
410    aliases: &[(String, String)],
411    store_root: &Path,
412    held: &[(PathBuf, Option<String>)],
413) -> Vec<(HostSession, String)> {
414    let mut sessions = drop_skipped(found, skip);
415    apply_session_ids(&mut sessions, aliases);
416    sessions
417        .into_iter()
418        .filter_map(|session| {
419            let nick = routine_name_for(&session)?;
420            let dir = crate::session_store_dir(store_root, &session.session_id);
421            let taken = held.iter().any(|(held_dir, held_nick)| {
422                *held_dir == dir
423                    || held_nick
424                        .as_deref()
425                        .is_some_and(|held| held.eq_ignore_ascii_case(&nick))
426            });
427            (!taken).then_some((session, nick))
428        })
429        .collect()
430}
431
432/// Comma-separated `agent_id=session_id` pairs. An agent listed here uses
433/// that mail session id instead of its agent id, for a bot whose session
434/// was registered before agent-id sessions. Machine-specific, so it lives
435/// in the environment.
436pub const SESSION_IDS_ENV: &str = "M4A_SESSION_IDS";
437
438pub(crate) fn parse_session_ids(raw: &str) -> Vec<(String, String)> {
439    raw.split(',')
440        .filter_map(|pair| {
441            let (agent, session) = pair.split_once('=')?;
442            let (agent, session) = (agent.trim(), session.trim());
443            (!agent.is_empty() && !session.is_empty())
444                .then(|| (agent.to_string(), session.to_string()))
445        })
446        .collect()
447}
448
449pub(crate) fn apply_session_ids(sessions: &mut [HostSession], aliases: &[(String, String)]) {
450    for session in sessions.iter_mut() {
451        let Some(agent_id) = session.agent_id.as_deref() else {
452            continue;
453        };
454        if let Some((_, alias)) = aliases.iter().find(|(agent, _)| agent == agent_id) {
455            session.session_id = alias.clone();
456        }
457    }
458}
459
460/// Comma-separated nicks this client leaves alone: no session, no mirror,
461/// no credential call. Machine-specific, so it lives in the environment
462/// and not in source.
463pub const SKIP_NICKS_ENV: &str = "M4A_SKIP_NICKS";
464
465fn skip_nicks(get: &mut impl FnMut(&str) -> Option<String>) -> Vec<String> {
466    parse_skip_nicks(get(SKIP_NICKS_ENV).as_deref().unwrap_or(""))
467}
468
469fn parse_skip_nicks(raw: &str) -> Vec<String> {
470    raw.split(',')
471        .map(|item| item.trim().to_ascii_lowercase())
472        .filter(|item| !item.is_empty())
473        .collect()
474}
475
476fn drop_skipped(sessions: Vec<HostSession>, skip: &[String]) -> Vec<HostSession> {
477    if skip.is_empty() {
478        return sessions;
479    }
480    sessions
481        .into_iter()
482        .filter(|session| match routine_name_for(session) {
483            Some(nick) => !skip.iter().any(|item| item.eq_ignore_ascii_case(&nick)),
484            None => true,
485        })
486        .collect()
487}
488
489/// Where one bot's wake stands after [`ensure_agent_webhook_routines`].
490/// Carries no URL and no key.
491#[derive(Debug, Clone, PartialEq, Eq)]
492pub enum WakeStatus {
493    /// Local mirror present and the gateway returned a URL and a key.
494    Ready,
495    /// Local mirror present, key null: the bot has not created its own
496    /// routine with this folder id yet. Retried on the next poll. No
497    /// second routine is made.
498    AwaitingBackend,
499    /// Nothing usable. The text names the reason, never a secret.
500    Failed(String),
501}
502
503/// One bot's wake routine, by nick and folder id.
504#[derive(Debug, Clone, PartialEq, Eq)]
505pub struct RoutineReport {
506    /// Grok Bot agent id.
507    pub agent_id: String,
508    /// Bot nick.
509    pub nick: String,
510    /// Routine name and folder id: [`crate::routine_folder_id`] of the
511    /// nick, which equals the nick.
512    pub folder_id: String,
513    /// Outcome.
514    pub status: WakeStatus,
515}
516
517/// Knobs for [`ensure_agent_webhook_routines`]. All machine-specific, so
518/// they come from the environment ([`WakeOptions::from_lookup`]).
519#[derive(Debug, Clone, Default)]
520pub struct WakeOptions {
521    /// Nicks to leave alone ([`SKIP_NICKS_ENV`]).
522    pub skip_nicks: Vec<String>,
523    /// Sealed store root for the per-session keychain file.
524    pub store_root: Option<PathBuf>,
525    /// Write the bootstrap note into the profile description of a bot that
526    /// has no routine yet ([`PROFILE_NOTE_ENV`]). Off unless asked for.
527    pub profile_note: bool,
528    /// `agent_id -> session_id` overrides ([`SESSION_IDS_ENV`]).
529    pub session_ids: Vec<(String, String)>,
530}
531
532impl WakeOptions {
533    /// [`SKIP_NICKS_ENV`], [`STORE_ROOT_ENV`], [`PROFILE_NOTE_ENV`].
534    pub fn from_lookup(mut get: impl FnMut(&str) -> Option<String>) -> Self {
535        Self {
536            skip_nicks: skip_nicks(&mut get),
537            store_root: get(STORE_ROOT_ENV).map(PathBuf::from),
538            profile_note: get(PROFILE_NOTE_ENV).as_deref() == Some("1"),
539            session_ids: parse_session_ids(get(SESSION_IDS_ENV).as_deref().unwrap_or("")),
540        }
541    }
542}
543
544/// Ensures each agent's local mirror (webhook trigger, disabled, folder =
545/// [`crate::routine_folder_id`] of the nick) and asks the gateway for its
546/// credential. Bots whose nick is in `skip_nicks` are not touched. Does
547/// not open sealed stores, does not register on the homeserver, and does
548/// not log or return URLs or keys. When `store_root` is set, a ready URL
549/// and key are written to that session's keychain file. With
550/// `profile_note`, a bot still waiting gets the bootstrap note.
551pub fn ensure_agent_webhook_routines(
552    agents_dir: &Path,
553    gateway_file: &Path,
554    token_override: Option<&str>,
555    options: &WakeOptions,
556) -> Result<Vec<RoutineReport>, ShellError> {
557    let store_root = options.store_root.as_deref();
558    let mut sessions = drop_skipped(load_agents_dir(agents_dir)?, &options.skip_nicks);
559    apply_session_ids(&mut sessions, &options.session_ids);
560    let Some(gate) = open_gateway(gateway_file, token_override)? else {
561        return Err(ShellError::Gateway("gateway file is missing".to_string()));
562    };
563    let mut reports = Vec::new();
564    for session in &sessions {
565        let Some(agent_id) = session.agent_id.as_deref().filter(|id| !id.is_empty()) else {
566            continue;
567        };
568        let Some(nick) = routine_name_for(session) else {
569            continue;
570        };
571        let outcome = ensure_wake(&gate, agent_id, &nick);
572        let folder_id = crate::nick::routine_folder_id(&nick).unwrap_or_default();
573        if let (WakeOutcome::Ready { url, key }, Some(root)) = (&outcome, store_root) {
574            save_wake(root, &session.session_id, &folder_id, url, key);
575        }
576        log_outcome(&nick, &folder_id, &outcome);
577        if options.profile_note && matches!(outcome, WakeOutcome::AwaitingBackend) {
578            note_profile(&gate, agents_dir, agent_id, &nick);
579        }
580        reports.push(RoutineReport {
581            agent_id: agent_id.to_string(),
582            nick,
583            folder_id,
584            status: outcome.status(),
585        });
586    }
587    Ok(reports)
588}
589
590/// [`ensure_agent_webhook_routines`] using the host gateway file, the
591/// agents directory from the environment (or [`DEFAULT_AGENTS_DIR`]),
592/// [`SKIP_NICKS_ENV`], and [`STORE_ROOT_ENV`] when set.
593pub fn ensure_agent_webhook_routines_from_env() -> Result<Vec<RoutineReport>, ShellError> {
594    let mut get = |key: &str| std::env::var(key).ok().filter(|v| !v.is_empty());
595    let agents_dir = resolve_agents_dir(&mut get)
596        .ok_or_else(|| ShellError::SessionList("agents directory is missing".to_string()))?;
597    let gateway = get(GATEWAY_FILE_ENV).unwrap_or_else(|| DEFAULT_GATEWAY_FILE.to_string());
598    let token = get(GATEWAY_TOKEN_ENV);
599    let options = WakeOptions::from_lookup(&mut get);
600    ensure_agent_webhook_routines(&agents_dir, Path::new(&gateway), token.as_deref(), &options)
601}
602
603/// Set to `1` to write the bootstrap note into the profile description of
604/// each bot that has no routine yet, through the gateway's `updateAgent`
605/// (the host pushes profile edits to the server copy of the bot). The note
606/// asks the bot to create its own routine named
607/// [`crate::routine_folder_id`] of its nick on its next turn. Off by default: it edits a description the owner wrote.
608pub const PROFILE_NOTE_ENV: &str = "M4A_BOOTSTRAP_PROFILE_NOTE";
609
610const NOTE_OPEN: &str = "<!-- mail4agent:wake -->";
611const NOTE_CLOSE: &str = "<!-- /mail4agent:wake -->";
612/// Host limit on a profile description.
613const DESCRIPTION_MAX: usize = 20_000;
614
615/// The bootstrap note for routine name `routine` (already hyphenated). No
616/// host name, no key, no agent id.
617fn wake_note(routine: &str) -> String {
618    format!(
619        "{NOTE_OPEN}\nmail4agent wake: keep exactly one routine named \"{routine}\" with a webhook trigger, enabled. If it is missing, create it with UpdateRoutine: name \"{routine}\", trigger webhook, prompt \"A mail4agent room message woke this routine. The webhook JSON has body, from, event_id, and nick. Read that message.\" Do not rename it and do not create a second one.\n{NOTE_CLOSE}"
620    )
621}
622
623/// `description` with the note for `routine` in place: an older note block is
624/// replaced, otherwise the note is appended after a blank line. `None`
625/// when the description already carries exactly this note.
626fn with_wake_note(description: &str, routine: &str) -> Option<String> {
627    let note = wake_note(routine);
628    if description.contains(&note) {
629        return None;
630    }
631    if let (Some(start), Some(end)) = (description.find(NOTE_OPEN), description.find(NOTE_CLOSE)) {
632        if end > start {
633            let mut out = String::with_capacity(description.len() + note.len());
634            out.push_str(&description[..start]);
635            out.push_str(&note);
636            out.push_str(&description[end + NOTE_CLOSE.len()..]);
637            return Some(out);
638        }
639    }
640    let base = description.trim_end();
641    Some(if base.is_empty() {
642        note
643    } else {
644        format!("{base}\n\n{note}")
645    })
646}
647
648/// Writes the note into one bot's profile description through the
649/// gateway. Reads the rest of the profile from `profile.json` so name,
650/// title, and avatar are sent back unchanged. Failures are logged only.
651fn note_profile(gate: &GatewayConn, agents_dir: &Path, agent_id: &str, nick: &str) {
652    match ensure_profile_note(gate, agents_dir, agent_id, nick) {
653        Ok(true) => eprintln!("mail4agent: wake {nick}: bootstrap note written to the profile"),
654        Ok(false) => {}
655        Err(err) => eprintln!("mail4agent: wake {nick}: bootstrap note not written ({err})"),
656    }
657}
658
659fn ensure_profile_note(
660    gate: &GatewayConn,
661    agents_dir: &Path,
662    agent_id: &str,
663    nick: &str,
664) -> Result<bool, ShellError> {
665    let text = std::fs::read_to_string(agents_dir.join(agent_id).join("profile.json"))
666        .map_err(|_| ShellError::Gateway("profile is unreadable".to_string()))?;
667    let profile: serde_json::Value = serde_json::from_str(&text)
668        .map_err(|_| ShellError::Gateway("profile is unreadable".to_string()))?;
669    let field = |key: &str| profile.get(key).and_then(|value| value.as_str());
670    let Some(name) = field("name").filter(|name| !name.trim().is_empty()) else {
671        return Err(ShellError::Gateway("profile has no name".to_string()));
672    };
673    let routine = crate::nick::routine_folder_id(nick)
674        .ok_or_else(|| ShellError::Gateway("nick has no routine name".to_string()))?;
675    let Some(description) = with_wake_note(field("description").unwrap_or(""), &routine) else {
676        return Ok(false);
677    };
678    if description.chars().count() > DESCRIPTION_MAX {
679        return Err(ShellError::Gateway(
680            "description would be too long".to_string(),
681        ));
682    }
683    let mut body = serde_json::json!({ "name": name, "description": description });
684    for key in ["title", "avatarShape", "avatarColor"] {
685        if let Some(value) = field(key) {
686            body[key] = serde_json::Value::String(value.to_string());
687        }
688    }
689    let (status, _) = gateway_post(
690        gate,
691        "updateAgent",
692        &serde_json::json!({ "id": agent_id, "profile": body }),
693    )?;
694    if status != 200 {
695        return Err(ShellError::Gateway(format!(
696            "gateway profile status {status}"
697        )));
698    }
699    Ok(true)
700}
701
702/// Gateway file the host already runs. [`GATEWAY_FILE_ENV`] overrides it.
703/// The listener is loopback; the `host` field in the file is not used.
704const DEFAULT_GATEWAY_FILE: &str = "agent-data/gateway.json";
705
706/// Path of the gateway file. Unset on [`MachineClient::from_env`] means
707/// the web client does not open a gateway file. The webhook command still
708/// defaults to [`DEFAULT_GATEWAY_FILE`] when this is unset.
709pub const GATEWAY_FILE_ENV: &str = "M4A_GATEWAY_FILE";
710
711/// Gateway bearer. When set, this replaces the token in the gateway file.
712/// It is never written to disk.
713pub const GATEWAY_TOKEN_ENV: &str = "M4A_GATEWAY_TOKEN";
714
715/// Saved prompt of the local mirror. The mirror is disabled and never runs:
716/// the backend routine the bot made itself is the one the webhook wakes.
717/// No host name and no key.
718const MIRROR_ROUTINE_PROMPT: &str = "mail4agent wake mirror. Disabled on purpose: it only lets the local gateway hand out the webhook key for the routine with this folder id, which the bot creates itself with UpdateRoutine.";
719
720/// Per-session keychain file under the session's sealed directory
721/// ([`crate::session_store_dir`]). Mode 0600. Holds the folder id, the
722/// webhook URL, and its key. Outside any repository; never logged.
723pub const WAKE_KEYCHAIN_FILE: &str = "routine-wake.json";
724
725#[derive(serde::Deserialize)]
726struct GatewayFile {
727    port: u16,
728    scheme: String,
729    #[serde(default)]
730    token: Option<String>,
731}
732
733#[derive(serde::Deserialize)]
734struct AutomationCard {
735    id: String,
736    name: String,
737    #[serde(default)]
738    trigger: Option<serde_json::Value>,
739}
740
741impl AutomationCard {
742    /// Whether the card fires on a webhook. A card without a trigger field
743    /// is not judged here; the credential call refuses it if it is not.
744    fn is_webhook(&self) -> bool {
745        fn has_webhook(value: &serde_json::Value) -> bool {
746            match value {
747                serde_json::Value::Array(items) => items.iter().any(has_webhook),
748                serde_json::Value::Object(map) => {
749                    map.get("type").and_then(|kind| kind.as_str()) == Some("webhook")
750                        || map
751                            .values()
752                            .any(|inner| inner.is_array() && has_webhook(inner))
753                }
754                _ => false,
755            }
756        }
757        self.trigger.as_ref().map(has_webhook).unwrap_or(true)
758    }
759}
760
761#[derive(serde::Deserialize)]
762struct WebhookCredential {
763    url: String,
764    key: Option<String>,
765}
766
767#[derive(serde::Serialize, serde::Deserialize)]
768#[serde(deny_unknown_fields)]
769struct StoredWake {
770    folder_id: String,
771    url: String,
772    key: String,
773}
774
775/// The bot's nick ([`crate::nick_from_display_name`] of the display name,
776/// so `Alice` -> `alice`, `Привет мир` -> `privet-mir`).
777/// The routine name and folder are the same string.
778/// `None` when no nick derives.
779pub(crate) fn routine_name_for(session: &HostSession) -> Option<String> {
780    crate::nick::nick_from_display_name(session.bot_name.trim()).ok()
781}
782
783struct GatewayConn {
784    client: reqwest::blocking::Client,
785    base: String,
786    token: String,
787}
788
789fn open_gateway(
790    gateway_file: &Path,
791    token_override: Option<&str>,
792) -> Result<Option<GatewayConn>, ShellError> {
793    if !gateway_file.is_file() {
794        return Ok(None);
795    }
796    let text = std::fs::read_to_string(gateway_file)
797        .map_err(|_| ShellError::Gateway("gateway file is unreadable".to_string()))?;
798    let file: GatewayFile = serde_json::from_str(&text)
799        .map_err(|_| ShellError::Gateway("gateway file is unreadable".to_string()))?;
800    let scheme = file.scheme.trim();
801    if scheme != "http" && scheme != "https" {
802        return Err(ShellError::Gateway(
803            "gateway scheme is not http or https".to_string(),
804        ));
805    }
806    if file.port == 0 {
807        return Err(ShellError::Gateway("gateway port is unset".to_string()));
808    }
809    let token = token_override
810        .map(str::trim)
811        .filter(|value| !value.is_empty())
812        .map(str::to_string)
813        .or(file.token.filter(|value| !value.trim().is_empty()));
814    let Some(token) = token else {
815        return Err(ShellError::Gateway("gateway token is unset".to_string()));
816    };
817    // The gateway listens on loopback only. Do not use the file's host.
818    let base = format!("{scheme}://127.0.0.1:{}", file.port);
819    Ok(Some(GatewayConn {
820        client: gateway_client()?,
821        base,
822        token,
823    }))
824}
825
826/// Result of [`ensure_wake`] for one agent. Holds the secret only in
827/// memory, on its way to the session and the keychain file.
828enum WakeOutcome {
829    Ready { url: String, key: String },
830    AwaitingBackend,
831    Failed(String),
832}
833
834impl WakeOutcome {
835    fn status(&self) -> WakeStatus {
836        match self {
837            WakeOutcome::Ready { .. } => WakeStatus::Ready,
838            WakeOutcome::AwaitingBackend => WakeStatus::AwaitingBackend,
839            WakeOutcome::Failed(reason) => WakeStatus::Failed(reason.clone()),
840        }
841    }
842}
843
844fn log_outcome(nick: &str, folder_id: &str, outcome: &WakeOutcome) {
845    match outcome {
846        WakeOutcome::Ready { .. } => eprintln!("mail4agent: wake {nick} ({folder_id}): ready"),
847        WakeOutcome::AwaitingBackend => eprintln!(
848            "mail4agent: wake {nick} ({folder_id}): no key yet; the bot has not created its own routine with this folder id; will retry"
849        ),
850        WakeOutcome::Failed(reason) => {
851            eprintln!("mail4agent: wake {nick} ({folder_id}): {reason}")
852        }
853    }
854}
855
856/// One agent: make sure the disabled local mirror exists in exactly the
857/// folder the bot's own routine uses, then read the credential.
858///
859/// The gateway mints a key only for a routine it can find locally, and it
860/// mints it for `stableAutomationId(agentId, folderId)`, which is the
861/// backend id of the bot's own routine with that folder id. A null key
862/// means that backend routine does not exist yet. This never creates a
863/// second routine: a mirror that lands in another folder (`-2`) is deleted
864/// again and reported.
865fn ensure_wake(gate: &GatewayConn, agent_id: &str, nick: &str) -> WakeOutcome {
866    let Some(folder) = crate::nick::routine_folder_id(nick) else {
867        return WakeOutcome::Failed("nick has no routine folder id".to_string());
868    };
869    let before = match list_agent_automations(gate, agent_id) {
870        Ok(cards) => cards,
871        Err(err) => return WakeOutcome::Failed(err.to_string()),
872    };
873    match before.iter().find(|card| card.id == folder) {
874        Some(card) if !card.is_webhook() => {
875            return WakeOutcome::Failed(
876                "a routine in this folder is not webhook-triggered; left as is".to_string(),
877            );
878        }
879        Some(_) => {}
880        None => {
881            let after = match create_mirror_routine(gate, agent_id, &folder) {
882                Ok(cards) => cards,
883                Err(err) => return WakeOutcome::Failed(err.to_string()),
884            };
885            if !after.iter().any(|card| card.id == folder) {
886                for stray in after.iter().filter(|card| {
887                    card.name == folder && !before.iter().any(|old| old.id == card.id)
888                }) {
889                    let _ = delete_agent_automation(gate, agent_id, &stray.id);
890                }
891                return WakeOutcome::Failed(
892                    "the mirror did not land in the expected folder; removed it".to_string(),
893                );
894            }
895        }
896    }
897    match read_webhook_credential(gate, agent_id, &folder) {
898        Ok(CredentialRead::Ready { url, key }) => WakeOutcome::Ready { url, key },
899        Ok(CredentialRead::MintFailed) => WakeOutcome::AwaitingBackend,
900        Ok(CredentialRead::Missing) => {
901            WakeOutcome::Failed("the mirror is gone from the gateway".to_string())
902        }
903        Err(err) => WakeOutcome::Failed(err.to_string()),
904    }
905}
906
907/// Sets each session's wake. With a gateway: [`ensure_wake`], and a ready
908/// URL and key go into the session and, when `store_root` is set, into the
909/// keychain file. Without a gateway file: the keychain file, if it holds a
910/// wake for this nick's folder. A session with no agent id is left alone.
911fn attach_webhook_routines(
912    sessions: &mut [HostSession],
913    gateway_file: &Path,
914    token_override: Option<&str>,
915    store_root: Option<&Path>,
916) -> Result<(), ShellError> {
917    let gate = open_gateway(gateway_file, token_override)?;
918    for session in sessions.iter_mut() {
919        if session.routine_url.is_some() && session.routine_bearer.is_some() {
920            continue;
921        }
922        // The gateway id is the Grok Bot agent, not the mail session id.
923        let Some(agent_id) = session.agent_id.clone().filter(|id| !id.is_empty()) else {
924            continue;
925        };
926        let Some(nick) = routine_name_for(session) else {
927            continue;
928        };
929        let Some(folder) = crate::nick::routine_folder_id(&nick) else {
930            continue;
931        };
932        let Some(gate) = gate.as_ref() else {
933            if let Some(root) = store_root {
934                if let Some((url, key)) = load_wake(root, &session.session_id, &folder) {
935                    session.routine_url = Some(url);
936                    session.routine_bearer = Some(key);
937                }
938            }
939            continue;
940        };
941        let outcome = ensure_wake(gate, &agent_id, &nick);
942        log_outcome(&nick, &folder, &outcome);
943        match outcome {
944            WakeOutcome::Ready { url, key } => {
945                if let Some(root) = store_root {
946                    save_wake(root, &session.session_id, &folder, &url, &key);
947                    crate::wake_policy::note_state(&crate::session_store_dir(root, &session.session_id), "ready", "gateway");
948                }
949                session.routine_url = Some(url);
950                session.routine_bearer = Some(key);
951            }
952            WakeOutcome::AwaitingBackend => {
953                // A key kept from before is not trusted once the gateway
954                // says the routine has none.
955                if let Some(root) = store_root {
956                    clear_wake(root, &session.session_id);
957                    crate::wake_policy::note_state(&crate::session_store_dir(root, &session.session_id), "awaiting", "gateway");
958                }
959            }
960            WakeOutcome::Failed(_) => {
961                if let Some(root) = store_root {
962                    crate::wake_policy::note_state(&crate::session_store_dir(root, &session.session_id), "failed", "gateway");
963                }
964            }
965        }
966    }
967    Ok(())
968}
969
970#[derive(serde::Deserialize)]
971#[serde(deny_unknown_fields)]
972struct RoutineFile {
973    url: String,
974    key: String,
975}
976
977/// Reads the owner-placed routine file. The error text names the problem, never a value.
978pub(crate) fn read_routine_file(path: &Path) -> Result<(String, String), String> {
979    #[cfg(unix)]
980    {
981        use std::os::unix::fs::PermissionsExt;
982        let meta = std::fs::metadata(path).map_err(|_| "the routine file cannot be read".to_string())?;
983        if meta.permissions().mode() & 0o077 != 0 {
984            return Err("the routine file must be mode 0600 (owner only)".to_string());
985        }
986    }
987    let text = std::fs::read_to_string(path).map_err(|_| "the routine file cannot be read".to_string())?;
988    let file: RoutineFile = serde_json::from_str(&text).map_err(|_| "the routine file is not {\"url\",\"key\"} json".to_string())?;
989    let (url, key) = (file.url.trim().to_string(), file.key.trim().to_string());
990    if key.is_empty() || !crate::webhook::url_ok(&url) {
991        return Err("the routine file has no usable url and key".to_string());
992    }
993    Ok((url, key))
994}
995
996/// Gives every session that names a `routine_file` its wake from that file. A session that already
997/// holds a URL and a bearer is left alone. A refusal is logged without values and leaves the
998/// session without a wake (messages still arrive and can be read).
999fn attach_routine_files(sessions: &mut [HostSession], store_root: Option<&Path>) {
1000    for session in sessions.iter_mut() {
1001        let Some(path) = session.routine_file.clone() else { continue };
1002        if session.routine_url.is_some() && session.routine_bearer.is_some() {
1003            continue;
1004        }
1005        let label = routine_name_for(session).unwrap_or_else(|| session.session_id.clone());
1006        match read_routine_file(&path) {
1007            Ok((url, key)) => {
1008                session.routine_url = Some(url);
1009                session.routine_bearer = Some(key);
1010                if let Some(root) = store_root {
1011                    crate::wake_policy::note_state(&crate::session_store_dir(root, &session.session_id), "ready", "file");
1012                }
1013                eprintln!("mail4agent: wake {label}: routine file read");
1014            }
1015            Err(reason) => eprintln!("mail4agent: wake {label}: {reason}"),
1016        }
1017    }
1018}
1019
1020/// The vault label under which `m4a-agent wake import` keeps a session's webhook `{"url","key"}`.
1021pub(crate) fn wake_label(session_id: &str) -> String {
1022    format!("wake/{session_id}")
1023}
1024
1025/// Gives every session that still has no wake the one the owner imported into the vault. The
1026/// secret never leaves the vault except into this process. A vault that cannot open is logged
1027/// without values and leaves the sessions as they are.
1028fn attach_vault_wakes(sessions: &mut [HostSession], store_root: &Path) {
1029    let pending = |s: &HostSession| !(s.routine_url.is_some() && s.routine_bearer.is_some());
1030    if !sessions.iter().any(pending) {
1031        return;
1032    }
1033    let vault = match crate::store_key::vault(store_root) {
1034        Ok(v) => v,
1035        Err(err) => {
1036            eprintln!("mail4agent: wake vault not read: {err}");
1037            return;
1038        }
1039    };
1040    for session in sessions.iter_mut().filter(|s| pending(s)) {
1041        let Ok(Some(raw)) = vault.get(&wake_label(&session.session_id)) else { continue };
1042        #[derive(serde::Deserialize)]
1043        struct Held {
1044            url: String,
1045            key: String,
1046        }
1047        match serde_json::from_slice::<Held>(&raw) {
1048            Ok(h) if !h.key.is_empty() && crate::webhook::url_ok(&h.url) => {
1049                session.routine_url = Some(h.url);
1050                session.routine_bearer = Some(h.key);
1051                crate::wake_policy::note_state(&crate::session_store_dir(store_root, &session.session_id), "ready", "vault");
1052                eprintln!("mail4agent: wake {}: taken from the vault", routine_name_for(session).unwrap_or_else(|| session.session_id.clone()));
1053            }
1054            _ => eprintln!("mail4agent: wake {}: the vault entry is unusable", session.session_id),
1055        }
1056    }
1057}
1058
1059#[cfg(test)]
1060pub(crate) fn attach_vault_wakes_for_test(sessions: &mut [HostSession], store_root: &Path) {
1061    attach_vault_wakes(sessions, store_root)
1062}
1063
1064/// Lock file in each sealed session directory. [`MachineClient::open`]
1065/// holds an exclusive lock on it for as long as the client lives, so a
1066/// second process (another client, or `m4a-send` opening the store itself)
1067/// cannot write the same Olm state at the same time.
1068pub const STORE_LOCK_FILE: &str = ".lock";
1069
1070fn now_ms() -> i64 {
1071    std::time::SystemTime::now()
1072        .duration_since(std::time::UNIX_EPOCH)
1073        .map(|elapsed| elapsed.as_millis() as i64)
1074        .unwrap_or(0)
1075}
1076
1077pub(crate) fn lock_store(dir: &Path) -> Result<File, ShellError> {
1078    std::fs::create_dir_all(dir)?;
1079    let file = std::fs::OpenOptions::new()
1080        .create(true)
1081        .truncate(false)
1082        .write(true)
1083        .open(dir.join(STORE_LOCK_FILE))?;
1084    match file.try_lock() {
1085        Ok(()) => Ok(file),
1086        Err(_) => Err(ShellError::SessionList(
1087            "session store is in use by another process".to_string(),
1088        )),
1089    }
1090}
1091
1092/// Writes `bytes` to `path` atomically, file 0600, parent created 0700.
1093fn write_secret_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1094    let dir = path
1095        .parent()
1096        .ok_or_else(|| std::io::Error::other("no parent"))?;
1097    let mut builder = std::fs::DirBuilder::new();
1098    builder.recursive(true);
1099    #[cfg(unix)]
1100    {
1101        use std::os::unix::fs::DirBuilderExt;
1102        builder.mode(0o700);
1103    }
1104    builder.create(dir)?;
1105    let name = path
1106        .file_name()
1107        .and_then(|name| name.to_str())
1108        .unwrap_or("secret");
1109    let tmp = dir.join(format!("{name}.tmp"));
1110    let mut options = std::fs::OpenOptions::new();
1111    options.write(true).create(true).truncate(true);
1112    #[cfg(unix)]
1113    {
1114        use std::os::unix::fs::OpenOptionsExt;
1115        options.mode(0o600);
1116    }
1117    let mut file = options.open(&tmp)?;
1118    std::io::Write::write_all(&mut file, bytes)?;
1119    file.sync_all()?;
1120    drop(file);
1121    std::fs::rename(&tmp, path)
1122}
1123
1124fn keychain_path(store_root: &Path, session_id: &str) -> PathBuf {
1125    crate::session_store_dir(store_root, session_id).join(WAKE_KEYCHAIN_FILE)
1126}
1127
1128/// Writes the wake atomically with mode 0600. A failure is logged without
1129/// the values and does not stop the client: the gateway still has the key.
1130fn save_wake(store_root: &Path, session_id: &str, folder_id: &str, url: &str, key: &str) {
1131    let stored = StoredWake {
1132        folder_id: folder_id.to_string(),
1133        url: url.to_string(),
1134        key: key.to_string(),
1135    };
1136    let result = serde_json::to_vec(&stored)
1137        .map_err(std::io::Error::other)
1138        .and_then(|bytes| write_secret_file(&keychain_path(store_root, session_id), &bytes));
1139    if result.is_err() {
1140        eprintln!("mail4agent: wake keychain write failed for folder {folder_id}");
1141    }
1142}
1143
1144fn load_wake(store_root: &Path, session_id: &str, folder_id: &str) -> Option<(String, String)> {
1145    let text = std::fs::read_to_string(keychain_path(store_root, session_id)).ok()?;
1146    let stored: StoredWake = serde_json::from_str(&text).ok()?;
1147    if stored.folder_id != folder_id || stored.url.is_empty() || stored.key.is_empty() {
1148        return None;
1149    }
1150    Some((stored.url, stored.key))
1151}
1152
1153fn clear_wake(store_root: &Path, session_id: &str) {
1154    let _ = std::fs::remove_file(keychain_path(store_root, session_id));
1155}
1156
1157fn list_agent_automations(
1158    gate: &GatewayConn,
1159    agent_id: &str,
1160) -> Result<Vec<AutomationCard>, ShellError> {
1161    let (status, body) = gateway_post(
1162        gate,
1163        "getAgentAutomations",
1164        &serde_json::json!({ "id": agent_id }),
1165    )?;
1166    if status != 200 {
1167        return Err(ShellError::Gateway(format!("gateway list status {status}")));
1168    }
1169    serde_json::from_slice(&body)
1170        .map_err(|_| ShellError::Gateway("gateway list was not understood".to_string()))
1171}
1172
1173enum CredentialRead {
1174    Ready {
1175        url: String,
1176        key: String,
1177    },
1178    /// The mirror exists and the gateway did not return a key.
1179    MintFailed,
1180    Missing,
1181}
1182
1183fn gateway_client() -> Result<reqwest::blocking::Client, ShellError> {
1184    reqwest::blocking::Client::builder()
1185        .timeout(std::time::Duration::from_secs(8))
1186        .redirect(reqwest::redirect::Policy::none())
1187        .http1_only()
1188        .build()
1189        .map_err(|_| ShellError::Gateway("gateway client could not start".to_string()))
1190}
1191
1192fn gateway_post(
1193    gate: &GatewayConn,
1194    method: &str,
1195    body: &serde_json::Value,
1196) -> Result<(u16, Vec<u8>), ShellError> {
1197    let url = format!("{}/api/{method}", gate.base);
1198    let authorization = crate::bearer_header(&gate.token).map_err(|_| {
1199        ShellError::Gateway("gateway token is not a single header value".to_string())
1200    })?;
1201    let bytes = serde_json::to_vec(body)
1202        .map_err(|_| ShellError::Gateway("gateway request was not json".to_string()))?;
1203    let response = gate
1204        .client
1205        .post(&url)
1206        .header(reqwest::header::AUTHORIZATION, authorization)
1207        .header(reqwest::header::CONTENT_TYPE, "application/json")
1208        .body(bytes)
1209        .send()
1210        .map_err(|_| ShellError::Gateway("gateway request failed".to_string()))?;
1211    let status = response.status().as_u16();
1212    let body = response
1213        .bytes()
1214        .map_err(|_| ShellError::Gateway("gateway response failed".to_string()))?;
1215    Ok((status, body.to_vec()))
1216}
1217
1218fn read_webhook_credential(
1219    gate: &GatewayConn,
1220    agent_id: &str,
1221    automation_id: &str,
1222) -> Result<CredentialRead, ShellError> {
1223    let (status, body) = gateway_post(
1224        gate,
1225        "getAutomationWebhookCredential",
1226        &serde_json::json!({
1227            "id": agent_id,
1228            "automationId": automation_id,
1229        }),
1230    )?;
1231    if status == 200 {
1232        let parsed: WebhookCredential = serde_json::from_slice(&body).map_err(|_| {
1233            ShellError::Gateway("gateway credential was not understood".to_string())
1234        })?;
1235        let key = parsed.key.filter(|key| !key.is_empty());
1236        if parsed.url.is_empty() {
1237            return Err(ShellError::Gateway(
1238                "gateway credential was not understood".to_string(),
1239            ));
1240        }
1241        return Ok(match key {
1242            Some(key) => CredentialRead::Ready {
1243                url: parsed.url,
1244                key,
1245            },
1246            None => CredentialRead::MintFailed,
1247        });
1248    }
1249    if credential_is_missing(status, &body) {
1250        return Ok(CredentialRead::Missing);
1251    }
1252    Err(ShellError::Gateway(format!(
1253        "gateway credential status {status}"
1254    )))
1255}
1256
1257/// A missing routine is not a 200. The gateway reports that as 404 or as
1258/// 500 with its not-found error.
1259fn credential_is_missing(status: u16, body: &[u8]) -> bool {
1260    if status == 404 {
1261        return true;
1262    }
1263    if status != 500 {
1264        return false;
1265    }
1266    std::str::from_utf8(body)
1267        .map(|text| text.contains("Automation not found"))
1268        .unwrap_or(false)
1269}
1270
1271/// `routine` is the routine name, equal to its folder id.
1272fn create_mirror_routine(
1273    gate: &GatewayConn,
1274    agent_id: &str,
1275    routine: &str,
1276) -> Result<Vec<AutomationCard>, ShellError> {
1277    let (status, body) = gateway_post(
1278        gate,
1279        "createAgentAutomation",
1280        &serde_json::json!({
1281            "id": agent_id,
1282            "spec": {
1283                "name": routine,
1284                "prompt": MIRROR_ROUTINE_PROMPT,
1285                "trigger": { "type": "webhook" },
1286                "isEnabled": false,
1287            },
1288        }),
1289    )?;
1290    if status != 200 {
1291        return Err(ShellError::Gateway(format!(
1292            "gateway create status {status}"
1293        )));
1294    }
1295    serde_json::from_slice(&body)
1296        .map_err(|_| ShellError::Gateway("gateway create was not understood".to_string()))
1297}
1298
1299fn delete_agent_automation(
1300    gate: &GatewayConn,
1301    agent_id: &str,
1302    automation_id: &str,
1303) -> Result<(), ShellError> {
1304    let (status, _) = gateway_post(
1305        gate,
1306        "deleteAgentAutomation",
1307        &serde_json::json!({ "id": agent_id, "automationId": automation_id }),
1308    )?;
1309    if status != 200 {
1310        return Err(ShellError::Gateway(format!(
1311            "gateway delete status {status}"
1312        )));
1313    }
1314    Ok(())
1315}
1316
1317pub(crate) struct Prepared {
1318    pub(crate) config: SessionConfig,
1319    pub(crate) nick: String,
1320    pub(crate) user_id: String,
1321    pub(crate) device_id: DeviceId,
1322    pub(crate) bearer: Zeroizing<String>,
1323    pub(crate) backend: Arc<dyn m4a_agent::Backend>,
1324    pub(crate) routine_url: Option<String>,
1325    pub(crate) routine_bearer: Option<String>,
1326}
1327
1328/// The sessions on one machine, and the in-process bus they use when the
1329/// peer is one of them.
1330pub struct MachineClient {
1331    /// Product-session mode: one push socket per session (a handshake is vouched for one identity).
1332    product_mode: bool,
1333    sessions: Vec<OpenedStore>,
1334    bus: Arc<LocalBus>,
1335    push: m4a_agent::engine::PushLink,
1336    /// When set, [`Self::poll_agent_directory`] rescans this folder for new
1337    /// bots and creates their webhook routines. Session stores already open
1338    /// are left alone; a new process picks up new sealed stores.
1339    agents_dir: Option<PathBuf>,
1340    gateway_file: Option<PathBuf>,
1341    gateway_token: Option<String>,
1342    last_agent_poll: std::time::Instant,
1343    agent_rescan_secs: u64,
1344    /// The session record directory this client was opened from (when not from an agents directory).
1345    sessions_dir: Option<PathBuf>,
1346    /// Root for per-session keychain files ([`WAKE_KEYCHAIN_FILE`]).
1347    store_root: Option<PathBuf>,
1348    /// [`SKIP_NICKS_ENV`] as read at open.
1349    skip_nicks: Vec<String>,
1350    /// [`PROFILE_NOTE_ENV`] as read at open.
1351    profile_note: bool,
1352    /// [`SESSION_IDS_ENV`] as read at open.
1353    session_ids: Vec<(String, String)>,
1354    /// When every session was last driven without a push.
1355    last_full_drive: std::time::Instant,
1356    /// Open sessions whose bot has no key yet. Retried on each poll.
1357    pending_wakes: Vec<PendingWake>,
1358    /// Agent ids whose wake is already set on an open session or reported
1359    /// ready. Not asked again.
1360    ready_agents: HashSet<String>,
1361    /// Exclusive locks on every open session directory ([`STORE_LOCK_FILE`]).
1362    _locks: Vec<File>,
1363    /// Local socket `m4a-send` writes to ([`MachineClient::listen_for_sends`]).
1364    send_listener: Option<SendListener>,
1365    send_sock: Option<PathBuf>,
1366    /// Sends waiting for the peer to join the DM.
1367    send_queue: Vec<PendingSend>,
1368    /// Homeserver the sessions registered on; a bot found by
1369    /// [`Self::poll_agent_directory`] registers there too.
1370    homeserver_url: String,
1371    /// Next local-bus user row for a session opened after start.
1372    bus_next_user: i64,
1373    /// Session ids whose late open failed, and when. Retried after
1374    /// [`LATE_OPEN_RETRY_SECS`].
1375    failed_opens: HashMap<String, std::time::Instant>,
1376}
1377
1378/// How long [`MachineClient::poll_agent_directory`] waits before trying
1379/// again to open a newly found bot's session after a failure.
1380const LATE_OPEN_RETRY_SECS: u64 = 60;
1381
1382/// One `m4a-send` request still in progress.
1383struct PendingSend {
1384    stream: SendStream,
1385    request: crate::SendRequest,
1386    started: std::time::Instant,
1387    room: Option<String>,
1388    peer: Option<String>,
1389}
1390
1391/// How long a send waits for the recipient to join a new DM.
1392const SEND_JOIN_WAIT_SECS: u64 = 120;
1393
1394impl Drop for MachineClient {
1395    fn drop(&mut self) {
1396        if let Some(path) = self.send_sock.take() {
1397            let _ = std::fs::remove_file(path);
1398        }
1399    }
1400}
1401
1402/// What one [`MachineClient::tick`] did. Nicks, event ids, room ids, and
1403/// error texts only.
1404#[derive(Debug, Default)]
1405pub struct TickReport {
1406    /// (nick, event id) for each event the push socket delivered.
1407    pub pushed: Vec<(String, String)>,
1408    /// (nick, room id) for each DM invite joined.
1409    pub joined: Vec<(String, String)>,
1410    /// (nick, error) for each session whose drive failed.
1411    pub errors: Vec<(String, String)>,
1412    /// (from nick, to nick, answer) for each `m4a-send` request finished.
1413    pub sent: Vec<(String, String, crate::SendReply)>,
1414    /// `(nick, text)` peer key-change alerts (M3).
1415    pub alerts: Vec<(String, String)>,
1416}
1417
1418/// An open session still waiting for its bot's own routine.
1419struct PendingWake {
1420    store_dir: PathBuf,
1421    agent_id: String,
1422}
1423
1424impl MachineClient {
1425    /// Registers `sessions` and opens each sealed store under `store_root`.
1426    ///
1427    /// The host built `sessions`. This does not look for bot processes.
1428    /// The first drive publishes public keys to the homeserver and mirrors
1429    /// them onto the in-process bus. A long-poll left by that drive is
1430    /// dropped so it is not a second sync loop.
1431    pub fn open(
1432        homeserver_url: &str,
1433        store_root: &Path,
1434        sessions: Vec<HostSession>,
1435    ) -> Result<Self, ShellError> {
1436        Self::open_with(homeserver_url, store_root, sessions, false)
1437    }
1438
1439    /// [`Self::open`]; with `lenient`, a session that fails to register or
1440    /// whose store is locked is logged and left out instead of failing the
1441    /// whole open (at least one session must open).
1442    fn open_with(
1443        homeserver_url: &str,
1444        store_root: &Path,
1445        sessions: Vec<HostSession>,
1446        lenient: bool,
1447    ) -> Result<Self, ShellError> {
1448        let mut sessions = sessions;
1449        attach_routine_files(&mut sessions, Some(store_root));
1450        attach_vault_wakes(&mut sessions, store_root);
1451        if sessions.is_empty() {
1452            return Err(ShellError::SessionList("session list is empty".to_string()));
1453        }
1454        let mut prepared = Vec::with_capacity(sessions.len());
1455        let mut seen_ids = Vec::new();
1456        let mut seen_nicks = Vec::new();
1457        let mut locks = Vec::new();
1458        for session in sessions {
1459            let config = session.config(homeserver_url, store_root)?;
1460            if seen_ids.iter().any(|id: &String| id == config.session_id()) {
1461                return Err(ShellError::SessionList("duplicate session id".to_string()));
1462            }
1463            seen_ids.push(config.session_id().to_string());
1464            let lock = match lock_store(&config.store_dir()) {
1465                Ok(lock) => lock,
1466                Err(err) if lenient => {
1467                    eprintln!("mail4agent: session {} left out: {err}", config.session_id());
1468                    continue;
1469                }
1470                Err(err) => return Err(err),
1471            };
1472            let registered = match register_session(&config) {
1473                Ok(registered) => registered,
1474                Err(err) if lenient => {
1475                    eprintln!("mail4agent: session {} left out: {err}", config.session_id());
1476                    continue;
1477                }
1478                Err(err) => return Err(err),
1479            };
1480            if seen_nicks.iter().any(|nick: &String| nick.eq_ignore_ascii_case(&registered.nick)) {
1481                return Err(ShellError::SessionList("duplicate nick".to_string()));
1482            }
1483            seen_nicks.push(registered.nick.clone());
1484            locks.push(lock);
1485            prepared.push(Prepared {
1486                config,
1487                nick: registered.nick,
1488                user_id: registered.user_id,
1489                device_id: registered.device_id,
1490                bearer: registered.bearer,
1491                backend: registered.backend,
1492                routine_url: session.routine_url,
1493                routine_bearer: session.routine_bearer,
1494            });
1495        }
1496        if prepared.is_empty() {
1497            return Err(ShellError::SessionList(
1498                "no session could be opened".to_string(),
1499            ));
1500        }
1501        let server_name = server_name_of(&prepared[0].user_id)?;
1502        for item in &prepared[1..] {
1503            if server_name_of(&item.user_id)? != server_name {
1504                return Err(ShellError::SessionList(
1505                    "sessions disagree on the homeserver name".to_string(),
1506                ));
1507            }
1508        }
1509        ensure_server_name(server_name)?;
1510        let bus = Arc::new(LocalBus::open(&prepared)?);
1511        let peers: Vec<(String, String)> = prepared
1512            .iter()
1513            .map(|item| (item.nick.to_string(), item.user_id.clone()))
1514            .collect();
1515        let mut opened = Vec::with_capacity(prepared.len());
1516        for item in &prepared {
1517            let server_name = server_name_of(&item.user_id)?;
1518            let mut store = OpenedStore::open(
1519                &item.config.store_dir(),
1520                item.config.session_id(),
1521                item.device_id.clone(),
1522                &item.user_id,
1523                server_name,
1524                Arc::clone(&item.backend),
1525                item.bearer.as_str(),
1526            )?;
1527            store.set_registered_nick(item.nick.to_string());
1528            store.attach_bus(Arc::clone(&bus));
1529            store.set_local_peers(
1530                peers
1531                    .iter()
1532                    .filter(|(nick, _)| *nick != item.nick)
1533                    .cloned()
1534                    .collect(),
1535            );
1536            store.set_wake(SessionWake {
1537                routine_url: item.routine_url.clone(),
1538                routine_bearer: item.routine_bearer.clone(),
1539                leader_sock: None,
1540                leader_cwd: None,
1541            });
1542            if item.routine_url.is_none() {
1543                attach_detected_chain(&mut store, &item.config, &item.nick);
1544            }
1545            store.drive(1_000, false)?;
1546            store.abandon_inflight_sync(1_000)?;
1547            opened.push(store);
1548        }
1549        let tokens: Vec<String> = prepared
1550            .iter()
1551            .map(|item| item.bearer.as_str().to_string())
1552            .collect();
1553        let product_mode = true;
1554        let push = m4a_agent::engine::PushLink::open(&prepared[0].config.homeserver_url, prepared[0].backend.keep_prefix(), tokens, product_mode)?;
1555        Ok(Self {
1556            product_mode,
1557            sessions: opened,
1558            bus,
1559            push,
1560            agents_dir: None,
1561            gateway_file: None,
1562            gateway_token: None,
1563            last_agent_poll: std::time::Instant::now(),
1564            agent_rescan_secs: 0,
1565            sessions_dir: None,
1566            store_root: None,
1567            skip_nicks: Vec::new(),
1568            profile_note: false,
1569            session_ids: Vec::new(),
1570            last_full_drive: std::time::Instant::now(),
1571            pending_wakes: Vec::new(),
1572            ready_agents: HashSet::new(),
1573            _locks: locks,
1574            send_listener: None,
1575            send_sock: None,
1576            send_queue: Vec::new(),
1577            homeserver_url: homeserver_url.to_string(),
1578            bus_next_user: prepared.len() as i64 + 1,
1579            failed_opens: HashMap::new(),
1580        })
1581    }
1582
1583    /// [`load_session_records`] then [`Self::open`]. Records have no bearer
1584    /// and no routine URL; those stay unset.
1585    pub fn open_session_dir(
1586        homeserver_url: &str,
1587        store_root: &Path,
1588        sessions_dir: &Path,
1589    ) -> Result<Self, ShellError> {
1590        let sessions = load_session_records(sessions_dir)?;
1591        Self::open(homeserver_url, store_root, sessions)
1592    }
1593
1594    /// Web machine client open path.
1595    ///
1596    /// [`HOMESERVER_URL_ENV`], [`STORE_ROOT_ENV`]. Discovery uses
1597    /// [`AGENTS_DIR_ENV`] only when it names a directory; otherwise
1598    /// [`SESSIONS_DIR_ENV`]. The home Grok Bot directory is not a default.
1599    /// A webhook routine is read only when [`GATEWAY_FILE_ENV`] is set.
1600    /// The token is the file's token, or [`GATEWAY_TOKEN_ENV`] when the
1601    /// host injected one. A missing file leaves routines unset and does
1602    /// not fail this open. URLs and keys are not written back.
1603    /// [`crate::LEADER_SOCK_ENV`] is not read.
1604    pub fn from_env() -> Result<Self, ShellError> {
1605        Self::from_env_filtered(None)
1606    }
1607
1608    /// [`Self::from_env`] with only the session whose nick is `nick`: the
1609    /// path `m4a-send` takes when no client is running. Does not listen
1610    /// for sends.
1611    pub fn from_env_for(nick: &str) -> Result<Self, ShellError> {
1612        Self::from_env_filtered(Some(nick))
1613    }
1614
1615    fn from_env_filtered(only: Option<&str>) -> Result<Self, ShellError> {
1616        let homeserver_url = std::env::var(HOMESERVER_URL_ENV)
1617            .ok()
1618            .filter(|value| !value.is_empty())
1619            .ok_or(ShellError::HomeserverUrl)?;
1620        let store_root = std::env::var(STORE_ROOT_ENV)
1621            .ok()
1622            .filter(|value| !value.is_empty())
1623            .ok_or(ShellError::StoreRoot)?;
1624        let mut get = |key: &str| -> Option<String> {
1625            std::env::var(key).ok().filter(|value| !value.is_empty())
1626        };
1627        let agents_dir = explicit_agents_dir(&mut get);
1628        let mut sessions_dir_seen: Option<PathBuf> = None;
1629        let (sessions, agents_dir) = if let Some(dir) = agents_dir {
1630            (load_web_agents(&dir, &mut get)?, Some(dir))
1631        } else {
1632            let sessions_dir = get(SESSIONS_DIR_ENV)
1633                .ok_or_else(|| ShellError::SessionList("session directory is unset".to_string()))?;
1634            sessions_dir_seen = Some(PathBuf::from(&sessions_dir));
1635            (load_web_sessions(Path::new(&sessions_dir), &mut get)?, None)
1636        };
1637        let sessions = match only {
1638            Some(nick) => {
1639                let needle = crate::nick::lookup_nick(nick)?;
1640                let kept: Vec<HostSession> = sessions
1641                    .into_iter()
1642                    .filter(|session| {
1643                        routine_name_for(session)
1644                            .is_some_and(|name| name.eq_ignore_ascii_case(&needle))
1645                    })
1646                    .collect();
1647                if kept.is_empty() {
1648                    return Err(ShellError::UnknownNick);
1649                }
1650                kept
1651            }
1652            None => sessions,
1653        };
1654        // A wake from a file or the vault is known now, so such a session is not "pending".
1655        let mut sessions = sessions;
1656        attach_routine_files(&mut sessions, Some(Path::new(&store_root)));
1657        attach_vault_wakes(&mut sessions, Path::new(&store_root));
1658        let gateway_file = get(GATEWAY_FILE_ENV).map(PathBuf::from);
1659        let gateway_token = get(GATEWAY_TOKEN_ENV);
1660        let agent_rescan_secs = get(AGENT_RESCAN_SECS_ENV)
1661            .and_then(|value| value.parse::<u64>().ok())
1662            .unwrap_or(0);
1663        let mut pending_wakes = Vec::new();
1664        let mut ready_agents = HashSet::new();
1665        for session in &sessions {
1666            let Some(agent_id) = session.agent_id.clone() else {
1667                continue;
1668            };
1669            if session.routine_url.is_some() && session.routine_bearer.is_some() {
1670                ready_agents.insert(agent_id);
1671            } else {
1672                pending_wakes.push(PendingWake {
1673                    store_dir: crate::session_store_dir(
1674                        Path::new(&store_root),
1675                        &session.session_id,
1676                    ),
1677                    agent_id,
1678                });
1679            }
1680        }
1681        let options = WakeOptions::from_lookup(&mut get);
1682        let mut client = Self::open_with(&homeserver_url, Path::new(&store_root), sessions, true)?;
1683        client.store_root = Some(PathBuf::from(&store_root));
1684        client.skip_nicks = options.skip_nicks;
1685        client.profile_note = options.profile_note;
1686        client.session_ids = options.session_ids;
1687        client.pending_wakes = pending_wakes;
1688        client.ready_agents = ready_agents;
1689        client.agents_dir = agents_dir;
1690        client.gateway_file = gateway_file;
1691        client.gateway_token = gateway_token;
1692        client.agent_rescan_secs = agent_rescan_secs;
1693        client.sessions_dir = sessions_dir_seen;
1694        if get(LOCAL_BUS_ENV).is_some_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")) {
1695            #[cfg(feature = "local-bus")]
1696            client.set_local_delivery(true);
1697            #[cfg(not(feature = "local-bus"))]
1698            return Err(ShellError::SessionList("M4A_LOCAL_BUS needs a build with feature local-bus".to_string()));
1699        }
1700        client.last_agent_poll = std::time::Instant::now();
1701        Ok(client)
1702    }
1703
1704    /// Rescans the agents directory when this client was opened from one.
1705    ///
1706    /// For every bot not skipped and not ready yet: ensures the local
1707    /// mirror and asks for the credential again. A bot that just created
1708    /// its own routine turns ready here; when that bot has an open session,
1709    /// its wake is set in place and saved to the keychain file. A bot that
1710    /// appeared after open gets its mirror now; its sealed store opens with
1711    /// the next process. When [`AGENT_RESCAN_SECS_ENV`] is set and greater
1712    /// than zero, returns without scanning until that many seconds have
1713    /// passed since the last poll. Reports carry no URL and no key.
1714    pub fn poll_agent_directory(&mut self) -> Result<Vec<RoutineReport>, ShellError> {
1715        let Some(agents_dir) = self.agents_dir.clone() else {
1716            return self.poll_session_dir_wakes();
1717        };
1718        if self.agent_rescan_secs > 0 {
1719            let elapsed = self.last_agent_poll.elapsed().as_secs();
1720            if elapsed < self.agent_rescan_secs {
1721                return Ok(Vec::new());
1722            }
1723        }
1724        self.last_agent_poll = std::time::Instant::now();
1725        self.open_new_agent_sessions(&agents_dir);
1726        let gateway = self
1727            .gateway_file
1728            .clone()
1729            .unwrap_or_else(|| under_home(DEFAULT_GATEWAY_FILE));
1730        let Some(gate) = open_gateway(&gateway, self.gateway_token.as_deref())? else {
1731            return Ok(Vec::new());
1732        };
1733        let mut sessions = drop_skipped(load_agents_dir(&agents_dir)?, &self.skip_nicks);
1734        apply_session_ids(&mut sessions, &self.session_ids);
1735        let mut reports = Vec::new();
1736        for session in &sessions {
1737            let Some(agent_id) = session.agent_id.clone() else {
1738                continue;
1739            };
1740            let Some(nick) = routine_name_for(session) else {
1741                continue;
1742            };
1743            let folder_id = crate::nick::routine_folder_id(&nick).unwrap_or_default();
1744            if self.ready_agents.contains(&agent_id) {
1745                reports.push(RoutineReport {
1746                    agent_id,
1747                    nick,
1748                    folder_id,
1749                    status: WakeStatus::Ready,
1750                });
1751                continue;
1752            }
1753            let outcome = ensure_wake(&gate, &agent_id, &nick);
1754            log_outcome(&nick, &folder_id, &outcome);
1755            if self.profile_note && matches!(outcome, WakeOutcome::AwaitingBackend) {
1756                note_profile(&gate, &agents_dir, &agent_id, &nick);
1757            }
1758            if let WakeOutcome::Ready { url, key } = &outcome {
1759                if let Some(root) = &self.store_root {
1760                    save_wake(root, &session.session_id, &folder_id, url, key);
1761                }
1762                if let Some(index) = self
1763                    .pending_wakes
1764                    .iter()
1765                    .position(|pending| pending.agent_id == agent_id)
1766                {
1767                    let pending = self.pending_wakes.remove(index);
1768                    if let Some(store) = self
1769                        .sessions
1770                        .iter_mut()
1771                        .find(|store| store.store_dir() == pending.store_dir)
1772                    {
1773                        store.set_wake(SessionWake {
1774                            routine_url: Some(url.clone()),
1775                            routine_bearer: Some(key.clone()),
1776                            leader_sock: None,
1777                            leader_cwd: None,
1778                        });
1779                    }
1780                }
1781                self.ready_agents.insert(agent_id.clone());
1782                if let Some(user_id) = self
1783                    .sessions
1784                    .iter()
1785                    .find(|store| {
1786                        store
1787                            .nick()
1788                            .map(|n| n.eq_ignore_ascii_case(&nick))
1789                            .unwrap_or(false)
1790                    })
1791                    .map(|store| store.user_id().to_string())
1792                {
1793                    self.announce_peer_joined(&nick, &user_id);
1794                }
1795            }
1796            reports.push(RoutineReport {
1797                agent_id,
1798                nick,
1799                folder_id,
1800                status: outcome.status(),
1801            });
1802        }
1803        Ok(reports)
1804    }
1805
1806    /// For a client opened from a session directory: asks the gateway again, at the rescan interval
1807    /// (60 s when none is set), for every session whose agent has no wake yet, so a routine whose key
1808    /// the owner (or the agent) provides later starts waking without a restart. Without a gateway
1809    /// file or token nothing is asked. Reports carry no URL and no key.
1810    fn poll_session_dir_wakes(&mut self) -> Result<Vec<RoutineReport>, ShellError> {
1811        let Some(dir) = self.sessions_dir.clone() else {
1812            return Ok(Vec::new());
1813        };
1814        if self.pending_wakes.is_empty() {
1815            return Ok(Vec::new());
1816        }
1817        let every = if self.agent_rescan_secs > 0 { self.agent_rescan_secs } else { 60 };
1818        if self.last_agent_poll.elapsed().as_secs() < every {
1819            return Ok(Vec::new());
1820        }
1821        self.last_agent_poll = std::time::Instant::now();
1822        let Some(gateway) = self.gateway_file.clone() else {
1823            return Ok(Vec::new());
1824        };
1825        let Some(gate) = open_gateway(&gateway, self.gateway_token.as_deref())? else {
1826            return Ok(Vec::new());
1827        };
1828        let mut sessions = load_session_records(&dir)?;
1829        apply_session_ids(&mut sessions, &self.session_ids);
1830        let mut reports = Vec::new();
1831        for session in &sessions {
1832            let Some(agent_id) = session.agent_id.clone() else { continue };
1833            if !self.pending_wakes.iter().any(|p| p.agent_id == agent_id) {
1834                continue;
1835            }
1836            let Some(nick) = routine_name_for(session) else { continue };
1837            let folder_id = crate::nick::routine_folder_id(&nick).unwrap_or_default();
1838            let outcome = ensure_wake(&gate, &agent_id, &nick);
1839            log_outcome(&nick, &folder_id, &outcome);
1840            let dir_of = self.store_root.as_ref().map(|r| crate::session_store_dir(r, &session.session_id));
1841            match &outcome {
1842                WakeOutcome::Ready { url, key } => {
1843                    if let Some(root) = &self.store_root {
1844                        save_wake(root, &session.session_id, &folder_id, url, key);
1845                    }
1846                    if let Some(d) = &dir_of {
1847                        crate::wake_policy::note_state(d, "ready", "gateway");
1848                    }
1849                    if let Some(index) = self.pending_wakes.iter().position(|p| p.agent_id == agent_id) {
1850                        let pending = self.pending_wakes.remove(index);
1851                        if let Some(store) = self.sessions.iter_mut().find(|s| s.store_dir() == pending.store_dir) {
1852                            store.set_wake(SessionWake { routine_url: Some(url.clone()), routine_bearer: Some(key.clone()), leader_sock: None, leader_cwd: None });
1853                        }
1854                    }
1855                    self.ready_agents.insert(agent_id.clone());
1856                }
1857                WakeOutcome::AwaitingBackend => {
1858                    if let Some(d) = &dir_of {
1859                        crate::wake_policy::note_state(d, "awaiting", "gateway");
1860                    }
1861                }
1862                WakeOutcome::Failed(_) => {
1863                    if let Some(d) = &dir_of {
1864                        crate::wake_policy::note_state(d, "failed", "gateway");
1865                    }
1866                }
1867            }
1868            reports.push(RoutineReport { agent_id, nick, folder_id, status: outcome.status() });
1869        }
1870        Ok(reports)
1871    }
1872
1873    /// Registers and opens a session for every bot in `agents_dir` that is
1874    /// not skipped and has no open session yet, the same way open does:
1875    /// nick = slug, sealed store under the store root, device bearer to the
1876    /// keychain dir. A failure is logged and retried after
1877    /// [`LATE_OPEN_RETRY_SECS`].
1878    fn open_new_agent_sessions(&mut self, agents_dir: &Path) {
1879        let Some(store_root) = self.store_root.clone() else {
1880            return;
1881        };
1882        let found = match load_agents_dir(agents_dir) {
1883            Ok(found) => found,
1884            Err(err) => {
1885                eprintln!("mail4agent: agent rescan: {err}");
1886                return;
1887            }
1888        };
1889        let held: Vec<(PathBuf, Option<String>)> = self
1890            .sessions
1891            .iter()
1892            .map(|store| {
1893                (
1894                    store.store_dir().to_path_buf(),
1895                    store.nick().map(str::to_string),
1896                )
1897            })
1898            .collect();
1899        let fresh = unopened_agent_sessions(
1900            found,
1901            &self.skip_nicks,
1902            &self.session_ids,
1903            &store_root,
1904            &held,
1905        );
1906        for (session, nick) in fresh {
1907            if let Some(failed_at) = self.failed_opens.get(&session.session_id) {
1908                if failed_at.elapsed().as_secs() < LATE_OPEN_RETRY_SECS {
1909                    continue;
1910                }
1911            }
1912            let session_id = session.session_id.clone();
1913            match self.open_late_session(&store_root, session, &nick) {
1914                Ok(()) => {
1915                    self.failed_opens.remove(&session_id);
1916                    println!("mail4agent: session {nick} registered and open");
1917                }
1918                Err(err) => {
1919                    eprintln!("mail4agent: session {nick} not opened: {err}");
1920                    self.failed_opens
1921                        .insert(session_id, std::time::Instant::now());
1922                }
1923            }
1924        }
1925    }
1926
1927    fn open_late_session(
1928        &mut self,
1929        store_root: &Path,
1930        mut session: HostSession,
1931        nick: &str,
1932    ) -> Result<(), ShellError> {
1933        if session.routine_url.is_none() || session.routine_bearer.is_none() {
1934            if let Some(folder) = crate::nick::routine_folder_id(nick) {
1935                if let Some((url, key)) = load_wake(store_root, &session.session_id, &folder) {
1936                    session.routine_url = Some(url);
1937                    session.routine_bearer = Some(key);
1938                }
1939            }
1940        }
1941        let config = session.config(&self.homeserver_url, store_root)?;
1942        let lock = lock_store(&config.store_dir())?;
1943        let registered = register_session(&config)?;
1944        if let Some(first) = self.sessions.first() {
1945            if server_name_of(&registered.user_id)? != server_name_of(first.user_id())? {
1946                return Err(ShellError::SessionList(
1947                    "sessions disagree on the homeserver name".to_string(),
1948                ));
1949            }
1950        }
1951        let item = Prepared {
1952            config,
1953            nick: registered.nick,
1954            user_id: registered.user_id,
1955            device_id: registered.device_id,
1956            bearer: registered.bearer,
1957                backend: registered.backend,
1958            routine_url: session.routine_url.clone(),
1959            routine_bearer: session.routine_bearer.clone(),
1960        };
1961        self.bus.seed(self.bus_next_user, &item)?;
1962        self.bus_next_user += 1;
1963        let mut store = OpenedStore::open(
1964            &item.config.store_dir(),
1965            item.config.session_id(),
1966            item.device_id.clone(),
1967            &item.user_id,
1968            server_name_of(&item.user_id)?,
1969            Arc::clone(&item.backend),
1970            item.bearer.as_str(),
1971        )?;
1972        store.set_registered_nick(item.nick.to_string());
1973        store.attach_bus(Arc::clone(&self.bus));
1974        store.set_wake(SessionWake {
1975            routine_url: item.routine_url.clone(),
1976            routine_bearer: item.routine_bearer.clone(),
1977            leader_sock: None,
1978            leader_cwd: None,
1979        });
1980        if item.routine_url.is_none() {
1981            attach_detected_chain(&mut store, &item.config, &item.nick);
1982        }
1983        store.drive(1_000, false)?;
1984        store.abandon_inflight_sync(1_000)?;
1985        self.sessions.push(store);
1986        self._locks.push(lock);
1987        self.refresh_local_peers();
1988        let tokens: Vec<String> = self
1989            .sessions
1990            .iter()
1991            .map(|store| store.device_bearer().to_string())
1992            .collect();
1993        match m4a_agent::engine::PushLink::open(&self.homeserver_url, self.sessions.first().is_some_and(|s| s.keep_prefix()), tokens, self.product_mode) {
1994            Ok(push) => {
1995                let old = std::mem::replace(&mut self.push, push);
1996                for (recipient, event) in old.drain() {
1997                    if let Some(store) = self
1998                        .sessions
1999                        .iter_mut()
2000                        .find(|store| store.user_id() == recipient)
2001                    {
2002                        store.record_push(event);
2003                    }
2004                }
2005            }
2006            Err(err) => {
2007                eprintln!("mail4agent: push socket not reopened for {nick}: {err}");
2008            }
2009        }
2010        if let Some(agent_id) = session.agent_id.clone() {
2011            if item.routine_url.is_some() && item.routine_bearer.is_some() {
2012                self.ready_agents.insert(agent_id);
2013                self.announce_peer_joined(nick, &item.user_id);
2014            } else {
2015                // Ask the gateway again so the wake lands on this session.
2016                self.ready_agents.remove(&agent_id);
2017                if !self.pending_wakes.iter().any(|p| p.agent_id == agent_id) {
2018                    self.pending_wakes.push(PendingWake {
2019                        store_dir: item.config.store_dir(),
2020                        agent_id,
2021                    });
2022                }
2023            }
2024        }
2025        Ok(())
2026    }
2027
2028    /// Every open session sees every other one as a local peer.
2029    fn refresh_local_peers(&mut self) {
2030        let peers: Vec<(String, String)> = self
2031            .sessions
2032            .iter()
2033            .filter_map(|store| Some((store.nick()?.to_string(), store.user_id().to_string())))
2034            .collect();
2035        for store in self.sessions.iter_mut() {
2036            let own = store.nick().unwrap_or("").to_string();
2037            store.set_local_peers(
2038                peers
2039                    .iter()
2040                    .filter(|(nick, _)| *nick != own)
2041                    .cloned()
2042                    .collect(),
2043            );
2044        }
2045    }
2046
2047    /// POSTs `kind=peer_joined` once to every other ready bot's webhook.
2048    /// Called when `joined_nick` first becomes ready. Failures are logged
2049    /// without the URL or key.
2050    fn announce_peer_joined(&self, joined_nick: &str, joined_user_id: &str) {
2051        let body = serde_json::json!({
2052            "kind": "peer_joined",
2053            "nick": joined_nick,
2054            "user_id": joined_user_id,
2055        });
2056        for store in &self.sessions {
2057            let Some(nick) = store.nick() else {
2058                continue;
2059            };
2060            if nick.eq_ignore_ascii_case(joined_nick) {
2061                continue;
2062            }
2063            if !store.has_routine() {
2064                continue;
2065            }
2066            let Some((url, bearer)) = store.routine_target() else {
2067                continue;
2068            };
2069            match crate::post_routine_json(&url, &body, bearer.as_deref()) {
2070                Ok(()) => {
2071                    eprintln!("mail4agent: peer_joined {joined_nick} -> {nick} status=200")
2072                }
2073                Err(err) => {
2074                    eprintln!("mail4agent: peer_joined {joined_nick} -> {nick}: {err}")
2075                }
2076            }
2077        }
2078    }
2079
2080    /// Agent ids of open sessions that still have no wake.
2081    pub fn pending_wake_agents(&self) -> Vec<String> {
2082        self.pending_wakes
2083            .iter()
2084            .map(|pending| pending.agent_id.clone())
2085            .collect()
2086    }
2087
2088    /// While `enabled`, drive does not call the homeserver. Requests are
2089    /// answered by the in-process bus. Turn this on for an exchange whose
2090    /// peer is a session [`Self::open`] holds, and off again before reaching
2091    /// a session that exists only on the homeserver.
2092    ///
2093    /// A public channel is not given a separate transport: with this off,
2094    /// create and send use the homeserver, which is what a public channel
2095    /// and a group with a remote member already do. An encrypted direct
2096    /// room between two local sessions uses the bus while this is on, and
2097    /// the room stays encrypted.
2098    pub fn set_local_delivery(&self, enabled: bool) {
2099        self.bus.set_local_only(enabled);
2100    }
2101
2102    /// Homeserver calls counted across every session since [`Self::open`].
2103    /// In-process bus calls are not included. Registration before the first
2104    /// drive is not included either.
2105    pub fn homeserver_hits(&self) -> u64 {
2106        self.bus.hits()
2107    }
2108
2109    /// The open session whose nick matches `name_or_nick`.
2110    pub fn session_mut(&mut self, name_or_nick: &str) -> Result<&mut OpenedStore, ShellError> {
2111        let needle = crate::nick::lookup_nick(name_or_nick)?;
2112        self.sessions
2113            .iter_mut()
2114            .find(|store| {
2115                store
2116                    .nick()
2117                    .is_some_and(|nick| nick.eq_ignore_ascii_case(&needle))
2118            })
2119            .ok_or(ShellError::UnknownNick)
2120    }
2121
2122    /// Sealed directory for that nick.
2123    pub fn store_dir(&self, name_or_nick: &str) -> Result<PathBuf, ShellError> {
2124        let needle = crate::nick::lookup_nick(name_or_nick)?;
2125        self.sessions
2126            .iter()
2127            .find(|store| {
2128                store
2129                    .nick()
2130                    .is_some_and(|nick| nick.eq_ignore_ascii_case(&needle))
2131            })
2132            .map(|store| store.store_dir().to_path_buf())
2133            .ok_or(ShellError::UnknownNick)
2134    }
2135
2136    /// Hands pushed room text to the session named by `recipient`.
2137    /// Another session on this client does not receive it. This does not
2138    /// start `/sync` and it does not post a routine.
2139    pub fn deliver_pushed(&mut self) -> usize {
2140        let batch = self.push.drain();
2141        let mut delivered = 0;
2142        for (recipient, event) in batch {
2143            let Some(session) = self
2144                .sessions
2145                .iter_mut()
2146                .find(|store| store.user_id() == recipient)
2147            else {
2148                continue;
2149            };
2150            session.record_push(event);
2151            delivered += 1;
2152        }
2153        delivered
2154    }
2155
2156    /// One step of the long-running client loop.
2157    ///
2158    /// Drains the push socket and hands each pushed event to its session,
2159    /// then drives exactly those sessions (waiting for the `/sync` the push
2160    /// announced), which decrypts the text and POSTs the wake to that
2161    /// session's own routine. Every `full_drive_secs` it also drives every
2162    /// session once, which is how DM invites get joined
2163    /// ([`OpenedStore::accept_direct_invites`]) and how a missed push is
2164    /// caught up. Errors are returned per session, without secrets.
2165    pub fn tick(&mut self, now_ms: i64, full_drive_secs: u64) -> TickReport {
2166        let mut report = TickReport::default();
2167        let mut due: Vec<usize> = Vec::new();
2168        for (recipient, event) in self.push.drain() {
2169            let Some(index) = self
2170                .sessions
2171                .iter()
2172                .position(|store| store.user_id() == recipient)
2173            else {
2174                continue;
2175            };
2176            let nick = self.sessions[index].nick().unwrap_or("").to_string();
2177            report.pushed.push((nick, event.event_id.clone()));
2178            self.sessions[index].record_push(event);
2179            if !due.contains(&index) {
2180                due.push(index);
2181            }
2182        }
2183        // With the in-process bus nothing waits on a network, so every tick is a full drive: an answer
2184        // needs several rounds (invite, join, device keys, room key) and one round per period took minutes.
2185        let full = self.last_full_drive.elapsed().as_secs() >= full_drive_secs || self.bus.local_only();
2186        if full {
2187            self.last_full_drive = std::time::Instant::now();
2188        }
2189        for index in 0..self.sessions.len() {
2190            let pushed = due.contains(&index);
2191            if !pushed && !full {
2192                continue;
2193            }
2194            let store = &mut self.sessions[index];
2195            let nick = store.nick().unwrap_or("").to_string();
2196            let drove = store.drive(now_ms, pushed);
2197            for text in store.take_security_alerts() {
2198                report.alerts.push((nick.clone(), text));
2199            }
2200            if let Err(err) = drove {
2201                report.errors.push((nick.clone(), err.to_string()));
2202                continue;
2203            }
2204            match store.accept_direct_invites(now_ms) {
2205                Ok(joined) => {
2206                    for room in joined {
2207                        report.joined.push((nick.clone(), room));
2208                    }
2209                }
2210                Err(err) => report.errors.push((nick, err.to_string())),
2211            }
2212        }
2213        self.serve_sends(now_ms, &mut report);
2214        if self.bus.local_only() {
2215            // A send or a join just changed the local room: let every session see it now.
2216            for round in 1..=4 {
2217                for store in self.sessions.iter_mut() {
2218                    let _ = store.drive(now_ms + round, false);
2219                    let _ = store.accept_direct_invites(now_ms + round);
2220                }
2221            }
2222        }
2223        report
2224    }
2225
2226    /// Listens on `path` for `m4a-send` requests (one JSON line each, see
2227    /// [`crate::SendRequest`]). A stale socket file is replaced; a live
2228    /// listener is refused. Unix is mode 0600. Windows is loopback TCP and
2229    /// the file holds `127.0.0.1:{port}`. The file is removed when the
2230    /// client drops. [`Self::tick`] answers requests: the `as` session
2231    /// opens (or reuses) the encrypted DM with `to`, waits up to two
2232    /// minutes for `to` to join, and sends.
2233    pub fn listen_for_sends(&mut self, path: &Path) -> Result<(), ShellError> {
2234        let listener = SendListener::bind(path).map_err(|err| {
2235            if err.kind() == std::io::ErrorKind::AlreadyExists {
2236                ShellError::SessionList(
2237                    "another client already listens on the send socket".to_string(),
2238                )
2239            } else {
2240                ShellError::Io(err)
2241            }
2242        })?;
2243        listener.set_nonblocking(true)?;
2244        self.send_listener = Some(listener);
2245        self.send_sock = Some(path.to_path_buf());
2246        Ok(())
2247    }
2248
2249    /// [`Self::listen_for_sends`] on [`crate::SEND_SOCK_ENV`], or
2250    /// [`crate::DEFAULT_SOCK_NAME`] under the store root. Returns the path.
2251    pub fn listen_for_sends_from_env(&mut self) -> Result<PathBuf, ShellError> {
2252        let root = self.store_root.clone().ok_or(ShellError::StoreRoot)?;
2253        let path = crate::send_sock_path(
2254            |key| std::env::var(key).ok().filter(|value| !value.is_empty()),
2255            &root,
2256        );
2257        self.listen_for_sends(&path)?;
2258        Ok(path)
2259    }
2260
2261    /// Sends `text` from session `as_nick` to `to` in their encrypted DM,
2262    /// driving until `to` has joined (up to `wait`). The direct path of
2263    /// `m4a-send` when no client is running.
2264    pub fn send_blocking(
2265        &mut self,
2266        as_nick: &str,
2267        to: &str,
2268        text: &str,
2269        wait: std::time::Duration,
2270    ) -> crate::SendReply {
2271        let started = std::time::Instant::now();
2272        let mut room = None;
2273        let mut peer = None;
2274        loop {
2275            let now = now_ms();
2276            match self.try_send(as_nick, to, text, now, &mut room, &mut peer) {
2277                Some(reply) => return reply,
2278                None if started.elapsed() >= wait => {
2279                    return crate::SendReply {
2280                        room,
2281                        ..crate::SendReply::failed(format!("{to} has not joined the DM yet"))
2282                    }
2283                }
2284                None => {
2285                    if let Ok(store) = self.session_mut(as_nick) {
2286                        let _ = store.drive(now, false);
2287                    }
2288                    std::thread::sleep(std::time::Duration::from_millis(500));
2289                }
2290            }
2291        }
2292    }
2293
2294    /// One attempt. `None` means the DM exists but `to` has not joined yet.
2295    fn try_send(
2296        &mut self,
2297        as_nick: &str,
2298        to: &str,
2299        text: &str,
2300        now_ms: i64,
2301        room: &mut Option<String>,
2302        peer: &mut Option<String>,
2303    ) -> Option<crate::SendReply> {
2304        let store = match self.session_mut(as_nick) {
2305            Ok(store) => store,
2306            Err(_) => {
2307                return Some(crate::SendReply::failed(format!(
2308                    "{as_nick} is not a session on this client"
2309                )))
2310            }
2311        };
2312        if peer.is_none() {
2313            // Local peers first, then the homeserver user directory
2314            // (find_nick); the directory answer may need another drive.
2315            let mut last_err = None;
2316            for attempt in 0..3 {
2317                if attempt > 0 {
2318                    let _ = store.drive(now_ms, false);
2319                }
2320                match store.find_nick(to, now_ms) {
2321                    Ok(found) => {
2322                        *peer = Some(found.user_id);
2323                        last_err = None;
2324                        break;
2325                    }
2326                    Err(err) => last_err = Some(err),
2327                }
2328            }
2329            if let Some(err) = last_err {
2330                return Some(crate::SendReply::failed(format!("find {to}: {err}")));
2331            }
2332        }
2333        if room.is_none() {
2334            match store.ensure_dm(to, now_ms) {
2335                Ok(room_id) => *room = Some(room_id),
2336                Err(err) => return Some(crate::SendReply::failed(format!("open DM: {err}"))),
2337            }
2338        }
2339        let (room_id, peer_id) = (room.clone()?, peer.clone()?);
2340        if !store.member_joined(&room_id, &peer_id) {
2341            return None;
2342        }
2343        match store.write_to_nick(to, text, now_ms) {
2344            Ok(room_id) => {
2345                let event_id = store
2346                    .texts()
2347                    .into_iter()
2348                    .rev()
2349                    .find(|row| row.room_id == room_id && row.body == text)
2350                    .and_then(|row| row.event_id);
2351                Some(crate::SendReply {
2352                    ok: true,
2353                    room: Some(room_id),
2354                    event_id,
2355                    error: None,
2356                })
2357            }
2358            Err(err) => Some(crate::SendReply {
2359                room: Some(room_id),
2360                ..crate::SendReply::failed(format!("send: {err}"))
2361            }),
2362        }
2363    }
2364
2365    fn serve_sends(&mut self, now_ms: i64, report: &mut TickReport) {
2366        let mut cmds: Vec<(crate::ipc::SendStream, crate::CmdRequest)> = Vec::new();
2367        if let Some(listener) = &self.send_listener {
2368            loop {
2369                match listener.accept() {
2370                    Ok(mut stream) => match crate::send::read_incoming(&mut stream) {
2371                        Ok(crate::send::Incoming::Send(request)) => self.send_queue.push(PendingSend {
2372                            stream,
2373                            request,
2374                            started: std::time::Instant::now(),
2375                            room: None,
2376                            peer: None,
2377                        }),
2378                        Ok(crate::send::Incoming::Cmd(cmd)) => cmds.push((stream, cmd)),
2379                        Err(err) => {
2380                            crate::send::write_reply(&mut stream, &crate::SendReply::failed(err))
2381                        }
2382                    },
2383                    Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break,
2384                    Err(_) => break,
2385                }
2386            }
2387        }
2388        for (mut stream, cmd) in cmds {
2389            let reply = match self.sessions.iter_mut().find(|store| {
2390                store.nick().is_some_and(|n| n.eq_ignore_ascii_case(cmd.as_nick.trim()))
2391            }) {
2392                Some(store) => store.run_command(&cmd, now_ms),
2393                None => crate::CmdReply::failed(format!("{} is not a session on this client", cmd.as_nick)),
2394            };
2395            crate::send::write_cmd_reply(&mut stream, &reply);
2396        }
2397        let queue = std::mem::take(&mut self.send_queue);
2398        for mut pending in queue {
2399            let (as_nick, to, text) = (
2400                pending.request.as_nick.clone(),
2401                pending.request.to.clone(),
2402                pending.request.text.clone(),
2403            );
2404            let outcome = self.try_send(
2405                &as_nick,
2406                &to,
2407                &text,
2408                now_ms,
2409                &mut pending.room,
2410                &mut pending.peer,
2411            );
2412            let reply = match outcome {
2413                Some(reply) => reply,
2414                None if pending.started.elapsed().as_secs() >= SEND_JOIN_WAIT_SECS => {
2415                    crate::SendReply {
2416                        room: pending.room.clone(),
2417                        ..crate::SendReply::failed(format!("{to} has not joined the DM yet"))
2418                    }
2419                }
2420                None => {
2421                    self.send_queue.push(pending);
2422                    continue;
2423                }
2424            };
2425            crate::send::write_reply(&mut pending.stream, &reply);
2426            report.sent.push((as_nick, to, reply));
2427        }
2428    }
2429
2430    /// Every routine POST attempted by every session, as (nick, attempt).
2431    /// Event ids and HTTP statuses only.
2432    pub fn wake_log(&self) -> Vec<(String, crate::WakeAttempt)> {
2433        self.sessions
2434            .iter()
2435            .flat_map(|store| {
2436                let nick = store.nick().unwrap_or("").to_string();
2437                store
2438                    .wake_log()
2439                    .iter()
2440                    .cloned()
2441                    .map(move |attempt| (nick.clone(), attempt))
2442            })
2443            .collect()
2444    }
2445
2446    /// Whether `name_or_nick` is a session this client holds.
2447    pub fn holds(&self, name_or_nick: &str) -> bool {
2448        let Ok(needle) = crate::nick::lookup_nick(name_or_nick) else {
2449            return false;
2450        };
2451        self.sessions.iter().any(|store| {
2452            store
2453                .nick()
2454                .is_some_and(|nick| nick.eq_ignore_ascii_case(&needle))
2455        })
2456    }
2457}
2458
2459fn server_name_of(mxid: &str) -> Result<&str, ShellError> {
2460    mxid.split_once(':')
2461        .map(|(_, server)| server)
2462        .filter(|server| !server.is_empty())
2463        .ok_or_else(|| ShellError::Register("user id has no server".to_string()))
2464}
2465
2466
2467/// A web session without a webhook routine, on a detected vendor host
2468/// (Claude web container, Codex cloud, Cursor cloud): install the
2469/// provider wake chain (hooks in the open session first, last-resort
2470/// spawn only when headless). Nothing changes on the Grok Bot box or for
2471/// a session that has a routine.
2472fn attach_detected_chain(store: &mut OpenedStore, config: &SessionConfig, nick: &str) {
2473    if let Some((session, chain)) = crate::provider::chain::detected_web_chain(
2474        config.session_id(),
2475        nick,
2476        &config.store_root,
2477    ) {
2478        store.set_wake_chain(session, chain);
2479    }
2480}
2481
2482#[cfg(test)]
2483mod tests {
2484    #[cfg(unix)]
2485    #[test]
2486    fn a_routine_file_gives_a_session_without_an_agent_id_its_wake_and_leaks_nothing() {
2487        use std::os::unix::fs::PermissionsExt;
2488        let dir = tempfile::tempdir().expect("dir");
2489        let file = dir.path().join("wake.json");
2490        let write = |text: &str, mode: u32| {
2491            std::fs::write(&file, text).expect("write");
2492            std::fs::set_permissions(&file, std::fs::Permissions::from_mode(mode)).expect("mode");
2493        };
2494        let mut set = vec![HostSession::new("Courier", "web-courier").with_routine_file(&file)];
2495
2496        // Owner-only file with a good pair: the session gets URL and bearer.
2497        write(r#"{"url":"http://127.0.0.1:9/hook","key":"k-secret"}"#, 0o600);
2498        attach_routine_files(&mut set, None);
2499        assert_eq!(set[0].routine_url.as_deref(), Some("http://127.0.0.1:9/hook"));
2500        assert_eq!(set[0].routine_bearer.as_deref(), Some("k-secret"));
2501        assert!(!format!("{:?}", set[0]).contains("k-secret"), "Debug must not show the key");
2502
2503        // A file others can read, a file of another shape, a blank key: no wake, and the error names no value.
2504        for (text, mode) in [(r#"{"url":"http://127.0.0.1:9/hook","key":"k-secret"}"#, 0o644), (r#"{"url":"http://127.0.0.1:9/hook","key":"k-secret","extra":1}"#, 0o600), (r#"{"url":"http://127.0.0.1:9/hook","key":" "}"#, 0o600), (r#"{"url":"not a url","key":"k-secret"}"#, 0o600)] {
2505            write(text, mode);
2506            let err = read_routine_file(&file).expect_err("refused");
2507            assert!(!err.contains("k-secret") && !err.contains("127.0.0.1"), "{err}");
2508            let mut one = vec![HostSession::new("Courier", "web-courier").with_routine_file(&file)];
2509            attach_routine_files(&mut one, None);
2510            assert!(one[0].routine_url.is_none() && one[0].routine_bearer.is_none());
2511        }
2512        // A session that already holds a wake keeps it.
2513        write(r#"{"url":"http://127.0.0.1:9/other","key":"k2"}"#, 0o600);
2514        let mut held = vec![HostSession::new("Courier", "web-courier").with_routine_file(&file).with_routine("http://127.0.0.1:9/mine", Some("mine".into()))];
2515        attach_routine_files(&mut held, None);
2516        assert_eq!(held[0].routine_url.as_deref(), Some("http://127.0.0.1:9/mine"));
2517    }
2518
2519    #[cfg(unix)]
2520    #[test]
2521    fn a_wake_from_a_routine_file_is_recorded_with_its_source() {
2522        use std::os::unix::fs::PermissionsExt;
2523        let dir = tempfile::tempdir().expect("dir");
2524        let file = dir.path().join("wake.json");
2525        std::fs::write(&file, r#"{"url":"http://127.0.0.1:9/hook","key":"k"}"#).expect("write");
2526        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).expect("mode");
2527        let mut set = vec![HostSession::new("Courier", "web-courier").with_routine_file(&file)];
2528        attach_routine_files(&mut set, Some(dir.path()));
2529        let status = crate::wake_policy::WakeStatusFile::load(&crate::session_store_dir(dir.path(), "web-courier")).expect("status");
2530        assert_eq!((status.state.as_str(), status.source.as_str()), ("ready", "file"));
2531    }
2532
2533    #[test]
2534    fn a_session_record_may_name_a_routine_file_but_never_carry_the_secret() {
2535        let dir = tempfile::tempdir().expect("dir");
2536        std::fs::write(dir.path().join("a.json"), r#"{"bot_name":"Courier","session_id":"web-courier","routine_file":"/some/where/wake.json"}"#).expect("write");
2537        let loaded = load_session_records(dir.path()).expect("records");
2538        assert_eq!(loaded[0].routine_file.as_deref(), Some(Path::new("/some/where/wake.json")));
2539        assert!(loaded[0].routine_url.is_none());
2540        std::fs::write(dir.path().join("a.json"), r#"{"bot_name":"Courier","session_id":"web-courier","routine_url":"http://127.0.0.1:9/h"}"#).expect("write");
2541        assert!(load_session_records(dir.path()).is_err(), "a URL in the record is still refused");
2542    }
2543
2544    use super::*;
2545
2546    #[test]
2547    fn web_client_agents_dir_is_only_the_env_path() {
2548        assert!(explicit_agents_dir(|_| None).is_none());
2549        let missing = std::env::temp_dir().join(format!(
2550            "m4a-missing-agents-{}",
2551            std::process::id()
2552        ));
2553        let _ = std::fs::remove_dir_all(&missing);
2554        assert!(explicit_agents_dir(|key| {
2555            (key == AGENTS_DIR_ENV).then(|| missing.display().to_string())
2556        })
2557        .is_none());
2558        let present = std::env::temp_dir().join(format!(
2559            "m4a-present-agents-{}-{}",
2560            std::process::id(),
2561            std::time::SystemTime::now()
2562                .duration_since(std::time::UNIX_EPOCH)
2563                .expect("clock")
2564                .as_nanos()
2565        ));
2566        std::fs::create_dir_all(&present).expect("dir");
2567        let got = explicit_agents_dir(|key| {
2568            (key == AGENTS_DIR_ENV).then(|| present.display().to_string())
2569        });
2570        assert_eq!(got.as_deref(), Some(present.as_path()));
2571        let _ = std::fs::remove_dir_all(&present);
2572    }
2573
2574    #[test]
2575    fn rescan_opens_only_bots_without_a_session() {
2576        let root = Path::new("/tmp/m4a-rescan-test");
2577        let agent = |name: &str, id: &str| {
2578            let mut session = HostSession::new(name, id);
2579            session.agent_id = Some(id.to_string());
2580            session
2581        };
2582        let found = vec![
2583            agent("alice", "a-hatch"),
2584            agent("m4a-proba2", "a-proba2"),
2585            agent("skipme", "a-skip"),
2586            agent("aliased", "a-alias"),
2587        ];
2588        let held = vec![
2589            (crate::session_store_dir(root, "a-hatch"), Some("alice".to_string())),
2590            (crate::session_store_dir(root, "old-alias"), None),
2591        ];
2592        let fresh = unopened_agent_sessions(
2593            found,
2594            &["skipme".to_string()],
2595            &[("a-alias".to_string(), "old-alias".to_string())],
2596            root,
2597            &held,
2598        );
2599        let nicks: Vec<&str> = fresh.iter().map(|(_, nick)| nick.as_str()).collect();
2600        assert_eq!(nicks, vec!["m4a-proba2"]);
2601        assert_eq!(fresh[0].0.session_id, "a-proba2");
2602    }
2603
2604    #[test]
2605    fn session_directory_is_name_and_id_only() {
2606        let dir = std::env::temp_dir().join(format!(
2607            "m4a-sessions-{}-{}",
2608            std::process::id(),
2609            std::time::SystemTime::now()
2610                .duration_since(std::time::UNIX_EPOCH)
2611                .expect("clock")
2612                .as_nanos()
2613        ));
2614        std::fs::create_dir_all(&dir).expect("dir");
2615        std::fs::write(
2616            dir.join("alice.json"),
2617            r#"{"bot_name":"Alice","session_id":"web-alice"}"#,
2618        )
2619        .expect("write");
2620        std::fs::write(
2621            dir.join("chief.json"),
2622            "{\"bot_name\":\"Привет мир\",\"session_id\":\"web-chief\"}",
2623        )
2624        .expect("write");
2625        let loaded = load_session_records(&dir).expect("records");
2626        assert_eq!(loaded.len(), 2);
2627        assert_eq!(loaded[1].bot_name, "Привет мир");
2628        assert_eq!(loaded[0].bot_name, "Alice");
2629        assert!(loaded[1].agent_id.is_none());
2630        assert!(loaded[0].agent_id.is_none());
2631        assert!(loaded[1].routine_url.is_none());
2632        assert!(loaded[1].routine_bearer.is_none());
2633        assert!(loaded[1].invite.is_none());
2634
2635        std::fs::write(
2636            dir.join("leaked.json"),
2637            r#"{"bot_name":"Courier","session_id":"web-courier","routine_url":"http://127.0.0.1/hook","routine_bearer":"not-a-file"}"#,
2638        )
2639        .expect("write");
2640        let refused = load_session_records(&dir).expect_err("webhook file");
2641        let text = refused.to_string();
2642        assert!(!text.contains("not-a-file"));
2643        assert!(!text.contains("127.0.0.1/hook"));
2644        let _ = std::fs::remove_dir_all(&dir);
2645    }
2646
2647    #[test]
2648    fn web_client_does_not_copy_one_routine_onto_every_session_and_node_cli_refuses_it() {
2649        let dir = std::env::temp_dir().join(format!(
2650            "m4a-split-{}-{}",
2651            std::process::id(),
2652            std::time::SystemTime::now()
2653                .duration_since(std::time::UNIX_EPOCH)
2654                .expect("clock")
2655                .as_nanos()
2656        ));
2657        std::fs::create_dir_all(&dir).expect("dir");
2658        let record = dir.join("alice.json");
2659        let body = r#"{"bot_name":"Alice","session_id":"web-alice"}"#;
2660        std::fs::write(&record, body).expect("write");
2661        let routine = "http://127.0.0.1:9/routine";
2662        let bearer = "host-injected-bearer";
2663        let sessions = load_web_sessions(&dir, |key| match key {
2664            crate::ROUTINE_URL_ENV => Some(routine.to_string()),
2665            crate::ROUTINE_BEARER_ENV => Some(bearer.to_string()),
2666            crate::LEADER_SOCK_ENV => Some("/tmp/leader.sock".to_string()),
2667            _ => None,
2668        })
2669        .expect("web sessions");
2670        assert_eq!(sessions.len(), 1);
2671        assert!(sessions[0].routine_url.is_none());
2672        assert!(sessions[0].routine_bearer.is_none());
2673        assert_eq!(std::fs::read_to_string(&record).expect("reread"), body);
2674        let wake = crate::SessionWake::web_from_lookup(|key| match key {
2675            crate::ROUTINE_URL_ENV => Some(routine.to_string()),
2676            crate::LEADER_SOCK_ENV => Some("/tmp/leader.sock".to_string()),
2677            _ => None,
2678        });
2679        assert!(wake.leader_sock.is_none());
2680        assert!(wake.leader_cwd.is_none());
2681
2682        std::fs::write(
2683            dir.join("leaked.json"),
2684            r#"{"bot_name":"Courier","session_id":"web-courier","routine_url":"http://127.0.0.1:9/from-file","routine_bearer":"file-bearer"}"#,
2685        )
2686        .expect("leak");
2687        let refused = load_web_sessions(&dir, |_| None).expect_err("json routine");
2688        let text = refused.to_string();
2689        assert!(!text.contains("from-file"));
2690        assert!(!text.contains("file-bearer"));
2691
2692        let node = crate::OpenedStore::connect_node_from_lookup(
2693            |key| match key {
2694                crate::ROUTINE_URL_ENV => Some(routine.to_string()),
2695                crate::ROUTINE_BEARER_ENV => Some(bearer.to_string()),
2696                crate::LEADER_SOCK_ENV => Some("/tmp/leader.sock".to_string()),
2697                _ => None,
2698            },
2699            None,
2700        );
2701        let err = match node {
2702            Ok(_) => panic!("node cli accepted a routine url"),
2703            Err(err) => err,
2704        };
2705        assert!(matches!(err, crate::ShellError::NodeRoutine));
2706        let text = err.to_string();
2707        assert!(!text.contains(routine));
2708        assert!(!text.contains(bearer));
2709        assert!(!text.contains("leader.sock"));
2710
2711        let node = crate::SessionWake::node_from_lookup(|key| match key {
2712            crate::LEADER_SOCK_ENV => Some("/tmp/node-leader.sock".to_string()),
2713            crate::LEADER_CWD_ENV => Some("/tmp/node".to_string()),
2714            _ => None,
2715        })
2716        .expect("node leader");
2717        assert!(node.routine_url.is_none());
2718        assert!(node.routine_bearer.is_none());
2719        assert_eq!(
2720            node.leader_sock.as_deref(),
2721            Some(std::path::Path::new("/tmp/node-leader.sock"))
2722        );
2723        assert_eq!(node.leader_cwd.as_deref(), Some("/tmp/node"));
2724        let _ = std::fs::remove_dir_all(&dir);
2725    }
2726
2727    #[test]
2728    fn mirrors_follow_the_nick_folder_and_keys_wait_for_the_bot_routine() {
2729        use std::collections::HashSet;
2730        use std::io::{Read, Write};
2731        use std::net::TcpListener;
2732        use std::sync::atomic::{AtomicBool, Ordering};
2733        use std::sync::{Arc, Mutex};
2734        use std::thread;
2735
2736        struct Card {
2737            agent: String,
2738            id: String,
2739            name: String,
2740            trigger: &'static str,
2741            enabled: bool,
2742        }
2743        #[derive(Default)]
2744        struct Gateway {
2745            creates: usize,
2746            deletes: usize,
2747            cards: Vec<Card>,
2748            // (agent, folder) pairs whose routine the bot created itself.
2749            backend: HashSet<(String, String)>,
2750            // Agents whose next mirror lands in `<folder>-2`.
2751            clash: HashSet<String>,
2752            agents_seen: HashSet<String>,
2753            profiles: Vec<(String, serde_json::Value)>,
2754        }
2755
2756        fn read_http(sock: &mut std::net::TcpStream) -> Option<(String, Vec<u8>)> {
2757            let _ = sock.set_read_timeout(Some(std::time::Duration::from_secs(2)));
2758            let mut buf = Vec::new();
2759            let mut tmp = [0u8; 2048];
2760            loop {
2761                let n = sock.read(&mut tmp).unwrap_or(0);
2762                if n == 0 {
2763                    return None;
2764                }
2765                buf.extend_from_slice(&tmp[..n]);
2766                let Some(end) = buf.windows(4).position(|w| w == b"\r\n\r\n") else {
2767                    continue;
2768                };
2769                let headers = String::from_utf8_lossy(&buf[..end]).to_string();
2770                let length = headers
2771                    .lines()
2772                    .find_map(|line| {
2773                        let (name, value) = line.split_once(':')?;
2774                        name.eq_ignore_ascii_case("content-length")
2775                            .then(|| value.trim().parse::<usize>().ok())
2776                            .flatten()
2777                    })
2778                    .unwrap_or(0);
2779                if buf.len() >= end + 4 + length {
2780                    return Some((headers, buf[end + 4..end + 4 + length].to_vec()));
2781                }
2782            }
2783        }
2784        fn reply(status: &str, body: &serde_json::Value) -> Vec<u8> {
2785            let payload = serde_json::to_vec(body).expect("json");
2786            let mut out = format!(
2787                "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2788                payload.len()
2789            )
2790            .into_bytes();
2791            out.extend(payload);
2792            out
2793        }
2794        fn cards_of(gate: &Gateway, agent: &str) -> serde_json::Value {
2795            serde_json::Value::Array(
2796                gate.cards
2797                    .iter()
2798                    .filter(|card| card.agent == agent)
2799                    .map(|card| {
2800                        serde_json::json!({
2801                            "id": card.id,
2802                            "name": card.name,
2803                            "trigger": {"type": card.trigger},
2804                            "isEnabled": card.enabled,
2805                        })
2806                    })
2807                    .collect(),
2808            )
2809        }
2810
2811        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2812        listener.set_nonblocking(true).expect("nonblocking");
2813        let port = listener.local_addr().expect("addr").port();
2814        let state = Arc::new(Mutex::new(Gateway::default()));
2815        {
2816            let mut gate = state.lock().expect("gate");
2817            gate.backend.insert(("agent-h".into(), "alice".into()));
2818            gate.clash.insert("agent-x".into());
2819            gate.cards.push(Card {
2820                agent: "agent-k".into(),
2821                id: "cron-bot".into(),
2822                name: "cron_bot".into(),
2823                trigger: "cron",
2824                enabled: true,
2825            });
2826        }
2827        let shared = Arc::clone(&state);
2828        let token = "gw-test-token";
2829        let done = Arc::new(AtomicBool::new(false));
2830        let flag = Arc::clone(&done);
2831        let server = thread::spawn(move || {
2832            while !flag.load(Ordering::Relaxed) {
2833                let (mut sock, _) = match listener.accept() {
2834                    Ok(pair) => pair,
2835                    Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2836                        thread::sleep(std::time::Duration::from_millis(5));
2837                        continue;
2838                    }
2839                    Err(_) => break,
2840                };
2841                let _ = sock.set_nonblocking(false);
2842                let Some((headers, body)) = read_http(&mut sock) else {
2843                    continue;
2844                };
2845                let lower = headers.to_ascii_lowercase();
2846                let authorized = lower.contains(&format!("authorization: bearer {token}"));
2847                let request: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
2848                let agent = request["id"].as_str().unwrap_or("").to_string();
2849                let mut gate = shared.lock().expect("gate");
2850                gate.agents_seen.insert(agent.clone());
2851                let response = if !authorized {
2852                    reply("401 Unauthorized", &serde_json::json!({}))
2853                } else if lower.starts_with("post /api/getagentautomations ") {
2854                    reply("200 OK", &cards_of(&gate, &agent))
2855                } else if lower.starts_with("post /api/createagentautomation ") {
2856                    let spec = &request["spec"];
2857                    assert_eq!(spec["trigger"]["type"], "webhook");
2858                    assert_eq!(spec["isEnabled"], false, "mirror must be disabled");
2859                    let name = spec["name"].as_str().unwrap_or("").to_string();
2860                    let prompt = spec["prompt"].as_str().unwrap_or("");
2861                    assert!(!prompt.is_empty());
2862                    assert!(!prompt.contains("http"));
2863                    gate.creates += 1;
2864                    let folder = crate::nick::routine_folder_id(&name).expect("slug");
2865                    let id = if gate.clash.contains(&agent) {
2866                        format!("{folder}-2")
2867                    } else {
2868                        folder
2869                    };
2870                    gate.cards.push(Card {
2871                        agent: agent.clone(),
2872                        id,
2873                        name,
2874                        trigger: "webhook",
2875                        enabled: false,
2876                    });
2877                    reply("200 OK", &cards_of(&gate, &agent))
2878                } else if lower.starts_with("post /api/updateagent ") {
2879                    gate.profiles
2880                        .push((agent.clone(), request["profile"].clone()));
2881                    reply("200 OK", &serde_json::json!({"id": agent}))
2882                } else if lower.starts_with("post /api/deleteagentautomation ") {
2883                    let id = request["automationId"].as_str().unwrap_or("");
2884                    gate.deletes += 1;
2885                    gate.cards
2886                        .retain(|card| !(card.agent == agent && card.id == id));
2887                    reply("200 OK", &cards_of(&gate, &agent))
2888                } else if lower.starts_with("post /api/getautomationwebhookcredential ") {
2889                    let id = request["automationId"].as_str().unwrap_or("").to_string();
2890                    let local = gate
2891                        .cards
2892                        .iter()
2893                        .find(|card| card.agent == agent && card.id == id);
2894                    match local {
2895                        None => reply(
2896                            "500 Internal Server Error",
2897                            &serde_json::json!({"error": format!("Automation not found: {id}")}),
2898                        ),
2899                        Some(card) if card.trigger != "webhook" => reply(
2900                            "500 Internal Server Error",
2901                            &serde_json::json!({"error": "Automation is not webhook-triggered"}),
2902                        ),
2903                        Some(_) => {
2904                            let minted = gate.backend.contains(&(agent.clone(), id.clone()));
2905                            reply(
2906                                "200 OK",
2907                                &serde_json::json!({
2908                                    "url": format!("https://backend.invalid/automations/webhook/{agent}-{id}"),
2909                                    "key": minted.then(|| format!("key-{agent}-{id}")),
2910                                }),
2911                            )
2912                        }
2913                    }
2914                } else {
2915                    reply("404 Not Found", &serde_json::json!({}))
2916                };
2917                drop(gate);
2918                let _ = sock.write_all(&response);
2919            }
2920        });
2921
2922        let dir = std::env::temp_dir().join(format!(
2923            "m4a-mirror-{}-{}",
2924            std::process::id(),
2925            std::time::SystemTime::now()
2926                .duration_since(std::time::UNIX_EPOCH)
2927                .expect("clock")
2928                .as_nanos()
2929        ));
2930        let agents = dir.join("agents");
2931        let store_root = dir.join("stores");
2932        let describe = |id: &str| {
2933            if id == "agent-c" {
2934                "about the chief"
2935            } else {
2936                ""
2937            }
2938        };
2939        for (id, name) in [
2940            ("agent-h", "Alice"),
2941            ("agent-c", "Привет мир"),
2942            ("agent-s", "Свой браузер"),
2943            ("agent-x", "Clash Bot"),
2944            ("agent-k", "Cron Bot"),
2945        ] {
2946            std::fs::create_dir_all(agents.join(id)).expect("agent dir");
2947            std::fs::write(
2948                agents.join(id).join("profile.json"),
2949                serde_json::to_vec(&serde_json::json!({"name": name, "description": describe(id)}))
2950                    .expect("profile"),
2951            )
2952            .expect("profile");
2953        }
2954        let gateway = dir.join("gateway-file.json");
2955        std::fs::write(
2956            &gateway,
2957            format!(r#"{{"host":"192.0.2.1","port":{port},"scheme":"http","token":"{token}"}}"#),
2958        )
2959        .expect("gateway");
2960        let skip = parse_skip_nicks(" svoi-brauzer , ");
2961        assert_eq!(skip, vec!["svoi-brauzer".to_string()]);
2962        let options = WakeOptions {
2963            skip_nicks: skip.clone(),
2964            store_root: Some(store_root.clone()),
2965            profile_note: false,
2966            session_ids: Vec::new(),
2967        };
2968
2969        let status_of = |reports: &[RoutineReport], agent: &str| {
2970            reports
2971                .iter()
2972                .find(|report| report.agent_id == agent)
2973                .map(|report| report.status.clone())
2974        };
2975        let first =
2976            ensure_agent_webhook_routines(&agents, &gateway, None, &options).expect("first pass");
2977        assert_eq!(status_of(&first, "agent-h"), Some(WakeStatus::Ready));
2978        assert_eq!(
2979            status_of(&first, "agent-c"),
2980            Some(WakeStatus::AwaitingBackend)
2981        );
2982        assert!(matches!(
2983            status_of(&first, "agent-x"),
2984            Some(WakeStatus::Failed(_))
2985        ));
2986        assert!(matches!(
2987            status_of(&first, "agent-k"),
2988            Some(WakeStatus::Failed(_))
2989        ));
2990        assert_eq!(status_of(&first, "agent-s"), None);
2991        let chief = first
2992            .iter()
2993            .find(|r| r.agent_id == "agent-c")
2994            .expect("chief");
2995        assert_eq!(chief.nick, "privet-mir");
2996        assert_eq!(chief.folder_id, "privet-mir");
2997        let shown = format!("{first:?}");
2998        assert!(!shown.contains("key-"));
2999        assert!(!shown.contains("automations/webhook"));
3000        {
3001            let gate = state.lock().expect("gate");
3002            // alice, privet-mir, clash-bot. Not the skipped bot,
3003            // not the folder that already holds a cron routine.
3004            assert_eq!(gate.creates, 3);
3005            assert_eq!(gate.deletes, 1);
3006            assert!(!gate.agents_seen.contains("agent-s"));
3007            assert!(gate.cards.iter().all(|card| card.agent != "agent-x"));
3008            let cron = gate
3009                .cards
3010                .iter()
3011                .find(|card| card.agent == "agent-k")
3012                .expect("cron");
3013            assert_eq!(cron.trigger, "cron");
3014            assert!(cron.enabled);
3015            let mirror = gate
3016                .cards
3017                .iter()
3018                .find(|card| card.agent == "agent-h")
3019                .expect("mirror");
3020            assert_eq!(
3021                (mirror.id.as_str(), mirror.name.as_str()),
3022                ("alice", "alice")
3023            );
3024            let chief_mirror = gate
3025                .cards
3026                .iter()
3027                .find(|card| card.agent == "agent-c")
3028                .expect("chief mirror");
3029            // Nick == routine name == folder id, all with hyphens.
3030            assert_eq!(chief_mirror.name, "privet-mir");
3031            assert_eq!(chief_mirror.id, "privet-mir");
3032            assert!(!mirror.enabled);
3033        }
3034        // Keychain: only the ready bot, mode 0600, its folder recorded.
3035        let alice_file = keychain_path(&store_root, "agent-h");
3036        let stored: serde_json::Value =
3037            serde_json::from_slice(&std::fs::read(&alice_file).expect("keychain"))
3038                .expect("keychain json");
3039        assert_eq!(stored["folder_id"], "alice");
3040        assert_eq!(stored["key"], "key-agent-h-alice");
3041        #[cfg(unix)]
3042        {
3043            use std::os::unix::fs::PermissionsExt;
3044            let mode = std::fs::metadata(&alice_file)
3045                .expect("meta")
3046                .permissions()
3047                .mode();
3048            assert_eq!(mode & 0o777, 0o600);
3049        }
3050        assert!(!keychain_path(&store_root, "agent-c").exists());
3051
3052        // Second pass: nothing new is created; the chief still waits.
3053        let second =
3054            ensure_agent_webhook_routines(&agents, &gateway, None, &options).expect("second pass");
3055        assert_eq!(
3056            status_of(&second, "agent-c"),
3057            Some(WakeStatus::AwaitingBackend)
3058        );
3059        assert_eq!(
3060            state.lock().expect("gate").creates,
3061            4,
3062            "only the clash bot retries"
3063        );
3064        assert!(state.lock().expect("gate").profiles.is_empty());
3065        // Opt-in bootstrap note: only the waiting bot's profile is edited,
3066        // name kept, original description kept, note appended.
3067        let noted = WakeOptions {
3068            profile_note: true,
3069            ..options.clone()
3070        };
3071        ensure_agent_webhook_routines(&agents, &gateway, None, &noted).expect("note pass");
3072        {
3073            let gate = state.lock().expect("gate");
3074            assert_eq!(gate.profiles.len(), 1);
3075            let (agent, profile) = &gate.profiles[0];
3076            assert_eq!(agent, "agent-c");
3077            assert_eq!(profile["name"], "Привет мир");
3078            let description = profile["description"].as_str().unwrap_or("");
3079            assert!(description.starts_with("about the chief"));
3080            assert!(description.contains("routine named \"privet-mir\""));
3081            assert!(!description.contains("http"));
3082        }
3083        state.lock().expect("gate").creates = 4;
3084        // The chief creates its own routine; the next pass picks the key up
3085        // without another mirror.
3086        state
3087            .lock()
3088            .expect("gate")
3089            .backend
3090            .insert(("agent-c".into(), "privet-mir".into()));
3091        let third =
3092            ensure_agent_webhook_routines(&agents, &gateway, None, &options).expect("third pass");
3093        assert_eq!(status_of(&third, "agent-c"), Some(WakeStatus::Ready));
3094        assert_eq!(state.lock().expect("gate").creates, 5);
3095        assert!(keychain_path(&store_root, "agent-c").exists());
3096
3097        // The env path: the skipped bot is not a session at all.
3098        let gateway_path = gateway.display().to_string();
3099        let root_text = store_root.display().to_string();
3100        let sessions = load_web_agents(&agents, |key| match key {
3101            GATEWAY_FILE_ENV => Some(gateway_path.clone()),
3102            SKIP_NICKS_ENV => Some("svoi-brauzer".to_string()),
3103            crate::STORE_ROOT_ENV => Some(root_text.clone()),
3104            _ => None,
3105        })
3106        .expect("web agents");
3107        assert_eq!(sessions.len(), 4);
3108        let alice = sessions
3109            .iter()
3110            .find(|s| s.session_id == "agent-h")
3111            .expect("h");
3112        assert!(alice
3113            .routine_url
3114            .as_deref()
3115            .unwrap_or("")
3116            .starts_with("https://"));
3117        assert!(alice.routine_bearer.is_some());
3118        assert!(!format!("{alice:?}").contains("key-"));
3119
3120        // No gateway file: the keychain answers, but only for the same folder.
3121        let mut offline = vec![HostSession::new("Alice", "agent-h")];
3122        offline[0].agent_id = Some("agent-h".to_string());
3123        let mut renamed = HostSession::new("Alice Two", "agent-h");
3124        renamed.agent_id = Some("agent-h".to_string());
3125        offline.push(renamed);
3126        attach_webhook_routines(
3127            &mut offline,
3128            &dir.join("absent.json"),
3129            None,
3130            Some(&store_root),
3131        )
3132        .expect("offline");
3133        assert_eq!(
3134            offline[0].routine_bearer.as_deref(),
3135            Some("key-agent-h-alice")
3136        );
3137        assert!(offline[1].routine_url.is_none());
3138        let mut bare = vec![HostSession::new("Alice", "agent-h")];
3139        attach_webhook_routines(&mut bare, &dir.join("absent.json"), None, None).expect("bare");
3140        assert!(bare[0].routine_url.is_none());
3141
3142        done.store(true, Ordering::Relaxed);
3143        let _ = server.join();
3144        let _ = std::fs::remove_dir_all(&dir);
3145    }
3146
3147    #[test]
3148    fn wake_note_is_appended_once_and_replaced_in_place() {
3149        let first = with_wake_note("Runs the alice.", "alice").expect("added");
3150        assert!(first.starts_with("Runs the alice.\n\n<!-- mail4agent:wake -->"));
3151        assert!(first.ends_with("<!-- /mail4agent:wake -->"));
3152        assert!(with_wake_note(&first, "alice").is_none());
3153        let renamed = with_wake_note(&first, "alice-two").expect("replaced");
3154        assert_eq!(renamed.matches("<!-- mail4agent:wake -->").count(), 1);
3155        assert!(renamed.contains("\"alice-two\""));
3156        assert!(!renamed.contains("\"alice\""));
3157        assert!(renamed.starts_with("Runs the alice."));
3158        assert_eq!(with_wake_note("", "carol").expect("empty"), wake_note("carol"));
3159    }
3160
3161    #[test]
3162    fn agents_directory_becomes_sessions_without_hardcoded_ids() {
3163        let dir = std::env::temp_dir().join(format!(
3164            "m4a-agents-{}-{}",
3165            std::process::id(),
3166            std::time::SystemTime::now()
3167                .duration_since(std::time::UNIX_EPOCH)
3168                .expect("clock")
3169                .as_nanos()
3170        ));
3171        let alice = dir.join("agent-alice");
3172        let chief = dir.join("agent-chief");
3173        std::fs::create_dir_all(&alice).expect("dir");
3174        std::fs::create_dir_all(&chief).expect("dir");
3175        std::fs::write(
3176            alice.join("profile.json"),
3177            r#"{"name":"Alice","description":"x"}"#,
3178        )
3179        .expect("profile");
3180        std::fs::write(
3181            chief.join("profile.json"),
3182            concat!(
3183                "{\"name\":\"",
3184                "Привет мир",
3185                "\",\"description\":\"x\"}"
3186            ),
3187        )
3188        .expect("profile");
3189        std::fs::write(dir.join("active-agent.json"), "{}").expect("skip file");
3190        let loaded = load_agents_dir(&dir).expect("agents");
3191        assert_eq!(loaded.len(), 2);
3192        assert_eq!(loaded[1].session_id, "agent-chief");
3193        assert_eq!(loaded[1].agent_id.as_deref(), Some("agent-chief"));
3194        assert_eq!(loaded[1].bot_name, "Привет мир");
3195        assert_eq!(
3196            routine_name_for(&loaded[1]).as_deref(),
3197            Some("privet-mir")
3198        );
3199        assert_ne!(
3200            routine_name_for(&loaded[1]).as_deref(),
3201            Some(loaded[1].session_id.as_str())
3202        );
3203        assert_eq!(loaded[0].bot_name, "Alice");
3204        assert_eq!(routine_name_for(&loaded[0]).as_deref(), Some("alice"));
3205        assert!(loaded.iter().all(|session| session.routine_url.is_none()));
3206        let _ = std::fs::remove_dir_all(&dir);
3207    }
3208}