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