Skip to main content

mail4agent_messenger_shell/
session_account.rs

1//! Per-session accounts (owner model): provider = a branch of accounts; session = nick = one key =
2//! one role. The environment (local launcher or hatchery node) issues an account per provider
3//! session automatically; the server accepts it only with an invite; no session may load another
4//! session's key.
5//!
6//! This module is the client-side registry and binding, first slice:
7//! - [`SessionIdentity`] (provider + the provider's own session/thread id) names an account by a
8//!   stable [`SessionIdentity::account_ref`]; the provider session id itself is never stored in
9//!   clear in file names.
10//! - [`AccountRegistry`] (`<base>/.m4a-accounts/registry.json`, 0600, dir 0700) provisions one local
11//!   account per identity with a unique nick, tracks its server state (local -> invite pending ->
12//!   enrolled), and keeps each account in its own store root ([`AccountRegistry::store_root`]), so
13//!   each account has its own vault and the directory can get its own OS owner/ACL.
14//! - A per-session capability ([`AccountRegistry::issue_capability`]): random 32 bytes, only its
15//!   SHA-256 is stored. The environment injects it into that session's process env only; the
16//!   client daemon that holds the vaults requires it before acting for the account
17//!   ([`AccountRegistry::authorize`]). Wiring into the send socket / inbox is the next slice.
18//!
19//! Nothing here holds an invite or a server token.
20
21use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25use sha2::{Digest, Sha256};
26
27use crate::provider::ProviderKind;
28
29/// Env var the environment sets in a session's process: its capability (hex).
30pub const SESSION_CAP_ENV: &str = "M4A_SESSION_CAP";
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct SessionIdentity {
34    pub provider: ProviderKind,
35    /// Provider-native session/thread id.
36    pub provider_session_id: String,
37}
38
39impl SessionIdentity {
40    pub fn new(provider: ProviderKind, provider_session_id: &str) -> Result<Self, AccountError> {
41        let id = provider_session_id.trim();
42        if id.is_empty() || id.len() > 256 || id.chars().any(char::is_control) {
43            return Err(AccountError::BadIdentity);
44        }
45        Ok(Self { provider, provider_session_id: id.to_string() })
46    }
47
48    /// Stable, non-reversible account reference: `<provider>-<16 hex>`.
49    pub fn account_ref(&self) -> String {
50        let h = Sha256::digest(format!("m4a-session-account/v1\0{}\0{}", self.provider.id(), self.provider_session_id).as_bytes());
51        format!("{}-{}", self.provider.id(), hex(&h[..8]))
52    }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum ServerState {
58    /// Only on this machine (local bus); no server identity yet.
59    Local,
60    /// The owner/operator was asked for an invite for this nick.
61    InvitePending,
62    /// Enrolled on the server with this nick (key held by this account's vault).
63    Enrolled,
64    /// Retired: the nick is never reused for another identity.
65    Retired,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct AccountEntry {
70    pub nick: String,
71    pub provider: String,
72    pub state: ServerState,
73    /// SHA-256 (hex) of the session capability; none until issued.
74    #[serde(default)]
75    pub cap_sha256: Option<String>,
76    /// OS user that owns the session (unix uid); when set, the daemon also requires the caller
77    /// on the send socket to be this uid (SO_PEERCRED / getpeereid).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub os_uid: Option<u32>,
80}
81
82#[derive(Debug, Default, Serialize, Deserialize)]
83struct RegistryFile {
84    /// account_ref -> entry
85    accounts: BTreeMap<String, AccountEntry>,
86}
87
88#[derive(Debug, thiserror::Error, PartialEq, Eq)]
89pub enum AccountError {
90    #[error("session identity is empty or malformed")]
91    BadIdentity,
92    #[error("nick is not a valid slug")]
93    BadNick,
94    #[error("no account for this session")]
95    Unknown,
96    #[error("capability refused")]
97    Refused,
98    #[error("session capability missing")]
99    Missing,
100    #[error("caller is not the session's OS user")]
101    WrongCaller,
102    #[error("account registry io: {0}")]
103    Io(String),
104}
105
106pub struct AccountRegistry {
107    base: PathBuf,
108    file: RegistryFile,
109}
110
111impl AccountRegistry {
112    pub fn open(base: &Path) -> Result<Self, AccountError> {
113        let dir = base.join(".m4a-accounts");
114        create_private_dir(&dir)?;
115        let path = dir.join("registry.json");
116        let file = match std::fs::read(&path) {
117            Ok(b) => serde_json::from_slice(&b).map_err(|e| AccountError::Io(e.to_string()))?,
118            Err(e) if e.kind() == std::io::ErrorKind::NotFound => RegistryFile::default(),
119            Err(e) => return Err(AccountError::Io(e.to_string())),
120        };
121        Ok(Self { base: base.to_path_buf(), file })
122    }
123
124    /// Account of `identity`, provisioning a new local one if missing. The nick is
125    /// `base_nick` (slugged) or `<provider>-<n>`; never one used by another identity.
126    pub fn provision(&mut self, identity: &SessionIdentity, base_nick: Option<&str>) -> Result<AccountEntry, AccountError> {
127        let r = identity.account_ref();
128        if let Some(e) = self.file.accounts.get(&r) {
129            return Ok(e.clone());
130        }
131        let stem = match base_nick {
132            Some(n) => slug(n).ok_or(AccountError::BadNick)?,
133            None => identity.provider.id().to_string(),
134        };
135        let taken = |n: &str| self.file.accounts.values().any(|e| e.nick == n);
136        let nick = if base_nick.is_some() && !taken(&stem) {
137            stem
138        } else {
139            (1..).map(|i| format!("{stem}-{i}")).find(|n| !taken(n)).expect("free nick")
140        };
141        let entry = AccountEntry { nick, provider: identity.provider.id().to_string(), state: ServerState::Local, cap_sha256: None, os_uid: None };
142        self.file.accounts.insert(r, entry.clone());
143        self.save()?;
144        create_private_dir(&self.store_root(identity))?;
145        Ok(entry)
146    }
147
148    /// Renames a not-yet-registered account to `nick` (a node auto-nick such as
149    /// `claude-3.hatchery-box.zeng3ld`). Refused when another identity holds it or the account is
150    /// already on the server.
151    pub fn renick(&mut self, identity: &SessionIdentity, nick: &str) -> Result<AccountEntry, AccountError> {
152        let ok = !nick.is_empty() && nick.len() <= 64 && nick.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'));
153        if !ok {
154            return Err(AccountError::BadNick);
155        }
156        let r = identity.account_ref();
157        if self.file.accounts.iter().any(|(k, e)| *k != r && e.nick.eq_ignore_ascii_case(nick)) {
158            return Err(AccountError::BadNick);
159        }
160        let e = self.file.accounts.get_mut(&r).ok_or(AccountError::Unknown)?;
161        if e.nick != nick {
162            if e.state == ServerState::Enrolled {
163                return Err(AccountError::Refused);
164            }
165            e.nick = nick.to_string();
166        }
167        let out = e.clone();
168        self.save()?;
169        Ok(out)
170    }
171
172    pub fn get(&self, identity: &SessionIdentity) -> Option<&AccountEntry> {
173        self.file.accounts.get(&identity.account_ref())
174    }
175
176    pub fn set_state(&mut self, identity: &SessionIdentity, state: ServerState) -> Result<(), AccountError> {
177        let e = self.file.accounts.get_mut(&identity.account_ref()).ok_or(AccountError::Unknown)?;
178        e.state = state;
179        self.save()
180    }
181
182    /// Store root of this account only (its own vault lives under it).
183    pub fn store_root(&self, identity: &SessionIdentity) -> PathBuf {
184        self.base.join("accounts").join(identity.account_ref())
185    }
186
187    /// New capability for the session (returned once, hex). Replaces any earlier one.
188    pub fn issue_capability(&mut self, identity: &SessionIdentity) -> Result<String, AccountError> {
189        let e = self.file.accounts.get_mut(&identity.account_ref()).ok_or(AccountError::Unknown)?;
190        let raw = m4a_agent::vault::random_bytes(32);
191        let cap = hex(&raw);
192        e.cap_sha256 = Some(hex(&Sha256::digest(cap.as_bytes())));
193        self.save()?;
194        Ok(cap)
195    }
196
197    /// Ok only when `cap` is this identity's current capability.
198    pub fn authorize(&self, identity: &SessionIdentity, cap: &str) -> Result<&AccountEntry, AccountError> {
199        let e = self.get(identity).ok_or(AccountError::Unknown)?;
200        let want = e.cap_sha256.as_deref().ok_or(AccountError::Refused)?;
201        let got = hex(&Sha256::digest(cap.trim().as_bytes()));
202        if ct_eq(want, &got) && e.state != ServerState::Retired {
203            Ok(e)
204        } else {
205            Err(AccountError::Refused)
206        }
207    }
208
209    /// Every account: (account_ref, entry).
210    pub fn accounts(&self) -> impl Iterator<Item = (&str, &AccountEntry)> {
211        self.file.accounts.iter().map(|(r, e)| (r.as_str(), e))
212    }
213
214    /// Store root of the account `account_ref`.
215    pub fn store_root_of(&self, account_ref: &str) -> PathBuf {
216        self.base.join("accounts").join(account_ref)
217    }
218
219    /// Sets the server state of `account_ref`.
220    pub fn set_state_ref(&mut self, account_ref: &str, state: ServerState) -> Result<(), AccountError> {
221        let e = self.file.accounts.get_mut(account_ref).ok_or(AccountError::Unknown)?;
222        e.state = state;
223        self.save()
224    }
225
226    /// The account that carries `nick` (account_ref, entry).
227    pub fn find_by_nick(&self, nick: &str) -> Option<(&str, &AccountEntry)> {
228        self.file.accounts.iter().find(|(_, e)| e.nick.eq_ignore_ascii_case(nick.trim())).map(|(r, e)| (r.as_str(), e))
229    }
230
231    /// Records the OS user that owns the account (unix uid).
232    pub fn set_os_uid(&mut self, identity: &SessionIdentity, uid: Option<u32>) -> Result<(), AccountError> {
233        let e = self.file.accounts.get_mut(&identity.account_ref()).ok_or(AccountError::Unknown)?;
234        e.os_uid = uid;
235        self.save()
236    }
237
238    /// The daemon's gate for one request acting as `nick`: Ok only when `cap` is that account's
239    /// current capability and (when the account has an OS owner and the caller's uid is known)
240    /// the caller is that OS user. A nick that is not a session account (operator/legacy bots
241    /// such as the web client's own) passes only when `require` is false.
242    pub fn gate(&self, nick: &str, cap: Option<&str>, peer_uid: Option<u32>, require: bool) -> Result<(), AccountError> {
243        let Some((_, e)) = self.find_by_nick(nick) else {
244            return if require { Err(AccountError::Unknown) } else { Ok(()) };
245        };
246        if e.state == ServerState::Retired {
247            return Err(AccountError::Refused);
248        }
249        let want = e.cap_sha256.as_deref().ok_or(AccountError::Refused)?;
250        let cap = cap.map(str::trim).filter(|c| !c.is_empty()).ok_or(AccountError::Missing)?;
251        if !ct_eq(want, &hex(&Sha256::digest(cap.as_bytes()))) {
252            return Err(AccountError::Refused);
253        }
254        match (e.os_uid, peer_uid) {
255            (Some(owner), Some(caller)) if owner != caller => Err(AccountError::WrongCaller),
256            _ => Ok(()),
257        }
258    }
259
260    fn save(&self) -> Result<(), AccountError> {
261        let dir = self.base.join(".m4a-accounts");
262        let tmp = dir.join("registry.json.tmp");
263        let bytes = serde_json::to_vec_pretty(&self.file).map_err(|e| AccountError::Io(e.to_string()))?;
264        write_private(&tmp, &bytes)?;
265        std::fs::rename(&tmp, dir.join("registry.json")).map_err(|e| AccountError::Io(e.to_string()))
266    }
267}
268
269/// What the environment hands one session at spawn/registration.
270pub struct Provisioned {
271    pub account_ref: String,
272    pub entry: AccountEntry,
273    /// This account's own store root (own vault, own key, own server token).
274    pub store_root: PathBuf,
275    /// Fresh capability, only when one was issued by this call (first time or rotation).
276    pub cap: Option<zeroize::Zeroizing<String>>,
277}
278
279impl Provisioned {
280    /// Env for THAT session's process only: its local login session id (the account ref, so no
281    /// two sessions share a login/token), its nick, its own store root and, when issued, its cap.
282    pub fn session_env(&self) -> Vec<(&'static str, String)> {
283        let mut v = vec![
284            ("M4A_SESSION_ID", self.account_ref.clone()),
285            ("M4A_NICK", self.entry.nick.clone()),
286            ("M4A_BOT_NAME", self.entry.nick.clone()),
287            ("M4A_STORE_ROOT", self.store_root.to_string_lossy().into_owned()),
288        ];
289        if let Some(cap) = &self.cap {
290            v.push((SESSION_CAP_ENV, cap.to_string()));
291        }
292        v
293    }
294}
295
296/// One call for launchers, `inbox register` and the hatchery node: the account of
297/// (provider, session) under `base`, created if missing; a capability is issued when the account
298/// has none yet or `rotate` is set. Nothing here prints the capability.
299pub fn provision_session(base: &Path, provider: ProviderKind, provider_session_id: &str, nick: Option<&str>, rotate: bool) -> Result<Provisioned, AccountError> {
300    let identity = SessionIdentity::new(provider, provider_session_id)?;
301    let mut reg = AccountRegistry::open(base)?;
302    let mut entry = reg.provision(&identity, nick)?;
303    let cap = if rotate || entry.cap_sha256.is_none() {
304        let c = reg.issue_capability(&identity)?;
305        entry = reg.get(&identity).cloned().ok_or(AccountError::Unknown)?;
306        Some(zeroize::Zeroizing::new(c))
307    } else {
308        None
309    };
310    Ok(Provisioned { account_ref: identity.account_ref(), store_root: reg.store_root(&identity), entry, cap })
311}
312
313/// `account renick`: the local account of (provider, session) takes `nick` before it is registered.
314pub fn renick_session(base: &Path, provider: ProviderKind, provider_session_id: &str, nick: &str) -> Result<AccountEntry, AccountError> {
315    let identity = SessionIdentity::new(provider, provider_session_id)?;
316    let mut reg = AccountRegistry::open(base)?;
317    reg.renick(&identity, nick)
318}
319
320/// Appends `KEY=VALUE` lines to an env file the provider reads into that session only
321/// (e.g. `CLAUDE_ENV_FILE` of a SessionStart hook). Owner-only on unix.
322pub fn append_env_file(path: &Path, pairs: &[(&str, String)]) -> Result<(), AccountError> {
323    use std::io::Write;
324    let mut o = std::fs::OpenOptions::new();
325    o.create(true).append(true);
326    #[cfg(unix)]
327    {
328        use std::os::unix::fs::OpenOptionsExt;
329        o.mode(0o600);
330    }
331    let mut f = o.open(path).map_err(|e| AccountError::Io(e.to_string()))?;
332    for (k, v) in pairs {
333        if v.contains('\n') {
334            return Err(AccountError::Io("newline in env value".into()));
335        }
336        writeln!(f, "export {k}={v}").map_err(|e| AccountError::Io(e.to_string()))?;
337    }
338    Ok(())
339}
340
341/// Keys a session file may carry: the session identity and where its own account lives
342/// (store root, messenger URLs, display name). No other variable rides along.
343pub const SESSION_FILE_KEYS: [&str; 7] = ["M4A_NICK", "M4A_SESSION_ID", SESSION_CAP_ENV, "M4A_BOT_NAME", "M4A_STORE_ROOT", "M4A_PRODUCT_URL", "M4A_HOMESERVER_URL"];
344
345/// Parses a node-written session file (`KEY=VALUE` lines, optional `export `), keeping only
346/// [`SESSION_FILE_KEYS`]. Used when a provider runs tools in a process that did not inherit
347/// the session's environment.
348pub fn parse_session_file(text: &str) -> Vec<(&'static str, String)> {
349    let mut out = Vec::new();
350    for line in text.lines() {
351        let line = line.trim();
352        let line = line.strip_prefix("export ").unwrap_or(line);
353        let Some((k, v)) = line.split_once('=') else { continue };
354        if let Some(key) = SESSION_FILE_KEYS.iter().find(|key| **key == k.trim()) {
355            let v = v.trim();
356            if !v.is_empty() && !v.chars().any(char::is_control) {
357                out.push((*key, v.to_owned()));
358            }
359        }
360    }
361    out
362}
363
364/// Applies a session file to this process's environment. Values already in the environment win.
365pub fn apply_session_file(path: &Path) -> Result<(), AccountError> {
366    let text = std::fs::read_to_string(path).map_err(|e| AccountError::Io(e.kind().to_string()))?;
367    let pairs = parse_session_file(&text);
368    if pairs.is_empty() {
369        return Err(AccountError::Io("no session identity in file".into()));
370    }
371    for (k, v) in pairs {
372        if std::env::var_os(k).filter(|x| !x.is_empty()).is_none() {
373            std::env::set_var(k, v);
374        }
375    }
376    Ok(())
377}
378
379/// After `m4a-agent enroll` succeeded with an account's own store root
380/// (`<base>/accounts/<account_ref>`), records the account as enrolled. Returns the ref, or `None`
381/// when that root is not a session account's.
382pub fn mark_enrolled(store_root: &Path) -> Result<Option<String>, AccountError> {
383    let Some(r) = store_root.file_name().and_then(|n| n.to_str()).map(str::to_string) else { return Ok(None) };
384    let Some(base) = store_root.parent().filter(|p| p.file_name().is_some_and(|n| n == "accounts")).and_then(Path::parent) else { return Ok(None) };
385    if !base.join(".m4a-accounts").join("registry.json").exists() {
386        return Ok(None);
387    }
388    let mut reg = AccountRegistry::open(base)?;
389    if reg.file.accounts.contains_key(&r) {
390        reg.set_state_ref(&r, ServerState::Enrolled)?;
391        Ok(Some(r))
392    } else {
393        Ok(None)
394    }
395}
396
397fn ct_eq(a: &str, b: &str) -> bool {
398    a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
399}
400
401fn hex(b: &[u8]) -> String {
402    b.iter().map(|x| format!("{x:02x}")).collect()
403}
404
405fn slug(s: &str) -> Option<String> {
406    crate::nick::routine_folder_id(s).filter(|n| !n.is_empty() && n.len() <= 64)
407}
408
409fn create_private_dir(p: &Path) -> Result<(), AccountError> {
410    std::fs::create_dir_all(p).map_err(|e| AccountError::Io(e.to_string()))?;
411    #[cfg(unix)]
412    {
413        use std::os::unix::fs::PermissionsExt;
414        std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o700)).map_err(|e| AccountError::Io(e.to_string()))?;
415    }
416    Ok(())
417}
418
419fn write_private(p: &Path, b: &[u8]) -> Result<(), AccountError> {
420    #[cfg(unix)]
421    {
422        use std::io::Write;
423        use std::os::unix::fs::OpenOptionsExt;
424        let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(p).map_err(|e| AccountError::Io(e.to_string()))?;
425        f.write_all(b).map_err(|e| AccountError::Io(e.to_string()))
426    }
427    #[cfg(not(unix))]
428    {
429        std::fs::write(p, b).map_err(|e| AccountError::Io(e.to_string()))
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    fn base(tag: &str) -> PathBuf {
438        let p = std::env::temp_dir().join(format!("m4a-acct-{tag}-{}", std::process::id()));
439        let _ = std::fs::remove_dir_all(&p);
440        p
441    }
442    fn id(p: ProviderKind, s: &str) -> SessionIdentity { SessionIdentity::new(p, s).unwrap() }
443
444    #[test]
445    fn one_account_per_session_and_stable_across_reopen() {
446        let b = base("stable");
447        let mut r = AccountRegistry::open(&b).unwrap();
448        let a = r.provision(&id(ProviderKind::Codex, "thread-a"), None).unwrap();
449        let again = r.provision(&id(ProviderKind::Codex, "thread-a"), None).unwrap();
450        assert_eq!(a, again);
451        let other = r.provision(&id(ProviderKind::Codex, "thread-b"), None).unwrap();
452        assert_ne!(a.nick, other.nick, "two sessions of one provider get two accounts");
453        assert_eq!(AccountRegistry::open(&b).unwrap().get(&id(ProviderKind::Codex, "thread-a")).unwrap().nick, a.nick);
454        let _ = std::fs::remove_dir_all(&b);
455    }
456
457    #[test]
458    fn nick_is_never_shared_between_identities() {
459        let b = base("nick");
460        let mut r = AccountRegistry::open(&b).unwrap();
461        let x = r.provision(&id(ProviderKind::Codex, "t1"), Some("Home Codex")).unwrap();
462        let y = r.provision(&id(ProviderKind::Codex, "t2"), Some("home codex")).unwrap();
463        assert_eq!(x.nick, "home-codex");
464        assert_eq!(y.nick, "home-codex-1");
465        let k = r.provision(&id(ProviderKind::KimiCode, "t1"), None).unwrap();
466        assert_ne!(k.nick, x.nick, "same native id under another provider is another account");
467        let _ = std::fs::remove_dir_all(&b);
468    }
469
470    #[test]
471    fn foreign_capability_is_refused() {
472        let b = base("cap");
473        let mut r = AccountRegistry::open(&b).unwrap();
474        let (a, c) = (id(ProviderKind::ClaudeCode, "s-a"), id(ProviderKind::ClaudeCode, "s-c"));
475        r.provision(&a, None).unwrap();
476        r.provision(&c, None).unwrap();
477        let cap_a = r.issue_capability(&a).unwrap();
478        assert!(r.authorize(&a, &cap_a).is_ok());
479        assert_eq!(r.authorize(&c, &cap_a).unwrap_err(), AccountError::Refused, "c has no capability yet");
480        let cap_c = r.issue_capability(&c).unwrap();
481        assert_eq!(r.authorize(&a, &cap_c).unwrap_err(), AccountError::Refused);
482        let rotated = r.issue_capability(&a).unwrap();
483        assert!(r.authorize(&a, &cap_a).is_err() && r.authorize(&a, &rotated).is_ok());
484        r.set_state(&a, ServerState::Retired).unwrap();
485        assert!(r.authorize(&a, &rotated).is_err());
486        let raw = std::fs::read_to_string(b.join(".m4a-accounts/registry.json")).unwrap();
487        assert!(!raw.contains(&rotated) && !raw.contains("s-a"), "no capability or native id in clear");
488        let _ = std::fs::remove_dir_all(&b);
489    }
490
491    #[test]
492    fn each_account_has_its_own_private_store_root() {
493        let b = base("root");
494        let mut r = AccountRegistry::open(&b).unwrap();
495        let (a, c) = (id(ProviderKind::Grok, "g1"), id(ProviderKind::Grok, "g2"));
496        r.provision(&a, None).unwrap();
497        r.provision(&c, None).unwrap();
498        assert_ne!(r.store_root(&a), r.store_root(&c));
499        #[cfg(unix)]
500        {
501            use std::os::unix::fs::PermissionsExt;
502            assert_eq!(std::fs::metadata(r.store_root(&a)).unwrap().permissions().mode() & 0o777, 0o700);
503            assert_eq!(std::fs::metadata(b.join(".m4a-accounts/registry.json")).unwrap().permissions().mode() & 0o777, 0o600);
504        }
505        let _ = std::fs::remove_dir_all(&b);
506    }
507
508    #[test]
509    fn identity_rejects_empty_and_control_chars() {
510        assert!(SessionIdentity::new(ProviderKind::Codex, " ").is_err());
511        assert!(SessionIdentity::new(ProviderKind::Codex, "a\nb").is_err());
512    }
513
514    #[test]
515    fn two_sessions_same_provider_get_distinct_accounts_roots_and_logins() {
516        let dir = base("s2-two");
517        let a = provision_session(&dir, ProviderKind::Codex, "thread-a", Some("codex"), false).unwrap();
518        let b = provision_session(&dir, ProviderKind::Codex, "thread-b", Some("codex"), false).unwrap();
519        assert_ne!(a.entry.nick, b.entry.nick);
520        assert_ne!(a.store_root, b.store_root);
521        assert_ne!(a.account_ref, b.account_ref);
522        let env_a: Vec<_> = a.session_env().into_iter().map(|(k, _)| k).collect();
523        assert!(env_a.contains(&SESSION_CAP_ENV));
524        // re-registration does not hand the cap out again
525        let a2 = provision_session(&dir, ProviderKind::Codex, "thread-a", Some("codex"), false).unwrap();
526        assert!(a2.cap.is_none());
527        assert_eq!(a2.entry.nick, a.entry.nick);
528    }
529
530    #[test]
531    fn gate_refuses_foreign_missing_and_wrong_caller() {
532        let dir = base("s2-gate");
533        let a = provision_session(&dir, ProviderKind::Codex, "thread-a", None, false).unwrap();
534        let b = provision_session(&dir, ProviderKind::Codex, "thread-b", None, false).unwrap();
535        let (ca, cb) = (a.cap.unwrap(), b.cap.unwrap());
536        let mut reg = AccountRegistry::open(&dir).unwrap();
537        assert_eq!(reg.gate(&a.entry.nick, Some(&ca), None, true), Ok(()));
538        assert_eq!(reg.gate(&a.entry.nick, Some(&cb), None, true), Err(AccountError::Refused));
539        assert_eq!(reg.gate(&a.entry.nick, None, None, false), Err(AccountError::Missing));
540        assert_eq!(reg.gate("operator-bot", None, None, false), Ok(()));
541        assert_eq!(reg.gate("operator-bot", None, None, true), Err(AccountError::Unknown));
542        let ida = SessionIdentity::new(ProviderKind::Codex, "thread-a").unwrap();
543        reg.set_os_uid(&ida, Some(1000)).unwrap();
544        assert_eq!(reg.gate(&a.entry.nick, Some(&ca), Some(1001), true), Err(AccountError::WrongCaller));
545        assert_eq!(reg.gate(&a.entry.nick, Some(&ca), Some(1000), true), Ok(()));
546        reg.set_state(&ida, ServerState::Retired).unwrap();
547        assert_eq!(reg.gate(&a.entry.nick, Some(&ca), Some(1000), true), Err(AccountError::Refused));
548    }
549
550    #[test]
551    fn enroll_marks_only_that_account_and_daemon_lists_it_from_its_own_root() {
552        let dir = base("s3-enroll");
553        let a = provision_session(&dir, ProviderKind::KimiCode, "a", Some("kimi"), false).unwrap();
554        let b = provision_session(&dir, ProviderKind::KimiCode, "b", Some("kimi"), false).unwrap();
555        assert!(crate::account_sessions(&dir).is_empty(), "local accounts are not opened on the server");
556        assert_eq!(mark_enrolled(&a.store_root).unwrap(), Some(a.account_ref.clone()));
557        assert_eq!(mark_enrolled(&dir.join("elsewhere")).unwrap(), None);
558        let listed = crate::account_sessions(&dir);
559        assert_eq!(listed.len(), 1);
560        assert_eq!(listed[0].bot_name, a.entry.nick);
561        assert_eq!(listed[0].session_id, a.account_ref);
562        assert_eq!(listed[0].store_root.as_deref(), Some(a.store_root.as_path()));
563        assert_ne!(listed[0].store_root.as_deref(), Some(b.store_root.as_path()));
564    }
565
566    #[test]
567    fn session_file_keeps_only_identity_keys() {
568        let got = parse_session_file("export M4A_NICK=a-1\nM4A_SESSION_ID=s\nPATH=/x\nM4A_SESSION_CAP=\n# c\n");
569        assert_eq!(parse_session_file("M4A_STORE_ROOT=/r\nM4A_PRODUCT_URL=u\nLD_PRELOAD=x\n"), vec![("M4A_STORE_ROOT", "/r".to_owned()), ("M4A_PRODUCT_URL", "u".to_owned())]);
570        assert_eq!(got, vec![("M4A_NICK", "a-1".to_owned()), ("M4A_SESSION_ID", "s".to_owned())]);
571    }
572
573    #[test]
574    fn env_file_is_private_and_rejects_newlines() {
575        let dir = base("s2-env");
576        std::fs::create_dir_all(&dir).unwrap();
577        let p = dir.join("env");
578        append_env_file(&p, &[("M4A_NICK", "x".into())]).unwrap();
579        assert!(append_env_file(&p, &[("M4A_NICK", "x\ny".into())]).is_err());
580        assert_eq!(std::fs::read_to_string(&p).unwrap(), "export M4A_NICK=x\n");
581        #[cfg(unix)]
582        {
583            use std::os::unix::fs::PermissionsExt;
584            assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600);
585        }
586    }
587}