trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
//! Which `claude` process registered each session id, from the kernel (#8531).
//!
//! Why: a delegation repair is granted to the session that owns the record,
//! and "owns" must be a fact about a process, not a name or a field a caller
//! can write. A session record's `pid` is PATCHable (`mpm.sessions.set_pid`)
//! and its `tmux_name` can be squatted with `tmux new-session`; the record
//! itself is dropped by the reaper and can then be re-created by anyone who
//! names the id. This registry is none of those things.
//! What: [`SessionClaudes`] records, per session id, the FIRST `SessionStart`
//! the daemon saw for it: the `claude` process (pid + start time) above the
//! kernel-reported socket peer, or [`Announcement::Unproven`] when that first
//! announcement proved no process (HTTP, no peer pid, a failed walk). A
//! recorded id is never cleared, and the reaper never touches it. The one
//! rewrite is #8983's: the `claude` the daemon itself resumed into its own
//! pane rebinds the id (`session_claude_resume`).
//! The registry lives in `<framework root>/session-claudes.json` (mode
//! `0600`, replaced atomically on every new id), so a daemon restart does not
//! reopen an id to a second announcer.
//!
//! A file the daemon cannot trust — unreadable, unparseable, not a regular
//! file, owned by another uid, or open to other users — SEALS the registry:
//! it grants no owner and records no new id until an operator removes or
//! fixes the file. A missing file (first start) is an empty registry: no
//! owner is granted until its `claude` announces itself again.
//!
//! Trade-off (#8531 LOW): the registry is unbounded. Dropping an entry would
//! make its id announceable again, which is the impersonation this module
//! closes, so entries are kept for good: one small JSON entry per session id
//! the daemon ever saw start, and every new id rewrites the whole file. An
//! entry whose process has exited grants nothing (see
//! `delegation_repair_caller::owner_claude`).
//!
//! Residuals (#8531, accepted). Each leaves an owner unable to repair its
//! own records until the stale (6 h) or owner-gone path ends them.
//! - Upgrade window: the first daemon start on this code has no file, so no
//!   session that started before it is bound.
//! - A first `SessionStart` that fell back to HTTP settles its id as
//!   unproven for good, so that owner can never repair its records.
//! - A session resumed outside the daemon (`claude --resume <id>` by hand,
//!   or tm's in-place relaunch) keeps the id in a new process the daemon did
//!   not launch, so it is never rebound (#8983 covers only the daemon's own
//!   resume paths). #9010: it holds its session off the reaper while it runs
//!   (a later announcer) or sends hook events ([`SessionClaudes::reap_hold`]).
//!
//! #8984: an id whose `SessionStart` never reached the daemon (daemon down
//! at start, no tm `SessionStart` hook — doctor check
//! `hooks_missing_tm_group` — or a pre-upgrade session) is settled as
//! unproven by its first event that only follows a `SessionStart`
//! ([`SessionClaudes::settle_after_event`]), unless a socket bind of it is
//! in flight. A sibling can still claim such an id only by announcing it
//! before the session's first turn.
//!
//! Test: `session_claudes_tests.rs`.

use std::collections::{BTreeMap, HashMap};
use std::io::{Read as _, Write as _};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use parking_lot::Mutex;
use serde::{Deserialize, Serialize};

use super::DaemonState;
use super::session_claude_liveness::ClaudeLiveness;
use crate::core::session::SessionId;
use crate::core::twin_arming::{
    ProcessFacts, STATUS_MAX_ANCESTOR_HOPS, nearest_claude_for_status_in, process_facts,
};
use crate::core::twin_identity::ClaudeProcess;

/// The registry file, under the daemon's framework root.
pub(crate) const SESSION_CLAUDES_FILE: &str = "session-claudes.json";

/// The on-disk format version this build reads and writes.
const FORMAT_VERSION: u32 = 1;

/// What a session id's first `SessionStart` proved about its sender (#8531).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Announcement {
    /// The `claude` above the kernel-reported socket peer.
    Claude(ClaudeProcess),
    /// The first announcement proved no process; the id is never bound.
    Unproven,
}

/// The first-announcement registry of every session id (#8531).
///
/// Why: see the module doc.
/// What: session id → [`Announcement`], or the reason the registry is
/// sealed. Every new binding is written through to [`SESSION_CLAUDES_FILE`]
/// before it counts; an unproven id counts even when its save fails.
/// Test: `a_session_is_bound_once_first_writer_wins_8531`,
/// `a_restart_keeps_the_binding_8531`.
#[derive(Debug)]
pub struct SessionClaudes {
    path: PathBuf,
    registry: Mutex<Result<HashMap<SessionId, Announcement>, String>>,
    /// #8984: ids with a socket `SessionStart` bind in flight, and how many.
    binding: Mutex<HashMap<SessionId, usize>>,
    /// #8983: ids the daemon itself relaunched with `--resume`, and where.
    resumes: Mutex<HashMap<SessionId, ResumeGrant>>,
    /// #9010: when each id's last hook event arrived, for the reaper.
    events: Mutex<HashMap<SessionId, Instant>>,
    /// #9010: the latest `claude` that announced an id already settled.
    announcers: Mutex<HashMap<SessionId, ClaudeProcess>>,
}

/// Where and when the daemon relaunched a session's `claude` (#8983).
///
/// Why: a resumed `claude` keeps the session id but is a new process, and
/// only the daemon knows it launched one, into a pane it owns.
/// What: the pane the launch line went to and the Unix second before it was
/// sent; a `claude` that started earlier is not the one launched.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResumeGrant {
    /// The managed session's tmux session.
    pub tmux_name: String,
    /// The record's own `%N` pane, when known.
    pub pane_id: Option<String>,
    /// Unix seconds, taken before the launch line was sent.
    pub issued_at: u64,
}

/// How long a resume grant holds its session off the reaper (#9010): five
/// reap intervals. A grant that a failed launch, rebind or HTTP announce
/// never consumed must not keep a dead session for the daemon's life.
pub(crate) const RESUME_HOLD_SECS: u64 = 5 * crate::daemon::REAP_INTERVAL_SECS;

impl ResumeGrant {
    /// Whether this grant still holds its session off the reaper at `now`
    /// (Unix seconds); a clock stepped behind `issued_at` still holds (#9010).
    pub(crate) fn holds_reap(&self, now: u64) -> bool {
        now.saturating_sub(self.issued_at) < RESUME_HOLD_SECS
    }
}

impl SessionClaudes {
    /// The registry persisted under `root`, as the daemon's own uid reads it.
    pub(crate) fn load(root: &Path) -> Self {
        Self::load_as(root.join(SESSION_CLAUDES_FILE), current_uid())
    }

    /// The registry at `path`, trusted only when `uid` owns it (#8531).
    ///
    /// What: [`read_registry`]; a refusal seals the registry and is logged.
    /// Test: `a_corrupt_file_seals_the_registry_8531`,
    /// `a_foreign_owned_file_seals_the_registry_8531`,
    /// `an_unreadable_file_seals_the_registry_8531`.
    pub(crate) fn load_as(path: PathBuf, uid: u32) -> Self {
        let registry = read_registry(&path, uid);
        if let Err(why) = &registry {
            tracing::error!(
                "#8531: the session-claude registry is sealed — no delegation repair is \
                 granted to an owning session until it is fixed or removed: {why}"
            );
        }
        Self {
            path,
            registry: Mutex::new(registry),
            binding: Mutex::new(HashMap::new()),
            resumes: Mutex::new(HashMap::new()),
            events: Mutex::new(HashMap::new()),
            announcers: Mutex::new(HashMap::new()),
        }
    }

    /// The `claude` bound to `session`, if its first announcement proved one.
    /// `None` for an unproven or unknown id, and always while sealed.
    pub fn get(&self, session: SessionId) -> Option<ClaudeProcess> {
        match &*self.registry.lock() {
            Ok(map) => match map.get(&session) {
                Some(Announcement::Claude(claude)) => Some(*claude),
                _ => None,
            },
            Err(_) => None,
        }
    }

    /// Whether nothing more can be recorded for `session`: its first
    /// announcement is recorded, or the registry is sealed.
    pub fn is_settled(&self, session: SessionId) -> bool {
        match &*self.registry.lock() {
            Ok(map) => map.contains_key(&session),
            Err(_) => true,
        }
    }

    /// Why the registry is sealed, if it is.
    pub fn sealed(&self) -> Option<String> {
        self.registry.lock().as_ref().err().cloned()
    }

    /// Record `announcement` as `session`'s first (#8531).
    ///
    /// What: `Ok(None)` when it was recorded and saved; `Ok(Some(earlier))`
    /// when `session` already had one, which is left as it was; `Err` when
    /// the registry is sealed, `admit` refuses, or the save fails. On a failed
    /// save an [`Announcement::Claude`] is rolled back, so no unsaved binding
    /// ever grants; an [`Announcement::Unproven`] stays in memory, since it
    /// can only deny, and reaches the file with the next saved id.
    /// Test: `an_unsaved_binding_does_not_count_8531`,
    /// `an_unsaved_unproven_announcement_still_settles_the_id_8531`.
    fn record(
        &self,
        session: SessionId,
        announcement: Announcement,
        admit: impl FnOnce() -> Result<(), String>,
    ) -> Result<Option<Announcement>, String> {
        let mut guard = self.registry.lock();
        let map = guard
            .as_mut()
            .map_err(|why| format!("the session-claude registry is sealed: {why}"))?;
        if let Some(earlier) = map.get(&session) {
            return Ok(Some(*earlier));
        }
        admit()?;
        map.insert(session, announcement);
        if let Err(e) = write_registry(&self.path, map) {
            // #8531: only a grant is rolled back; a deny-only entry keeps the
            // id closed to the next announcer.
            if matches!(announcement, Announcement::Claude(_)) {
                map.remove(&session);
            }
            return Err(format!(
                "the announcement could not be saved to {}: {e}",
                self.path.display()
            ));
        }
        Ok(None)
    }

    /// Mark a socket `SessionStart` bind of `session` in flight (#8984).
    ///
    /// Why: a later event settles an unrecorded id as unproven, and must not
    /// race the legitimate bind already under way for it.
    /// What: counts the bind until the returned guard drops.
    /// Test: `an_in_flight_session_start_still_binds_8984`.
    pub(crate) fn begin_bind(&self, session: SessionId) -> BindInFlight<'_> {
        *self.binding.lock().entry(session).or_insert(0) += 1;
        BindInFlight {
            claudes: self,
            session,
        }
    }

    /// Settle `session` as unproven because it produced an event that only
    /// follows its `SessionStart` (#8984).
    ///
    /// Why: an id whose `SessionStart` never reached the daemon stayed open
    /// to whichever sibling announced it first, for the session's life.
    /// What: `Ok(true)` when [`Announcement::Unproven`] was recorded;
    /// `Ok(false)` when the id was already recorded or a socket bind of it is
    /// in flight — that bind decides it. `Err` as [`Self::record`]; an
    /// unsaved record still settles the id.
    /// Test: `a_forged_session_start_after_another_event_binds_nothing_8984`,
    /// `an_in_flight_session_start_still_binds_8984`,
    /// `an_unsaved_settle_after_an_event_still_refuses_a_bind_8984`.
    pub(crate) fn settle_after_event(&self, session: SessionId) -> Result<bool, String> {
        // #8984: held across the record, so a bind cannot start between them.
        let binding = self.binding.lock();
        if binding.contains_key(&session) {
            return Ok(false);
        }
        Ok(self
            .record(session, Announcement::Unproven, || Ok(()))?
            .is_none())
    }

    /// Record that the daemon is relaunching `session` per `grant` (#8983);
    /// replaces an earlier grant.
    pub(crate) fn grant_resume(&self, session: SessionId, grant: ResumeGrant) {
        self.resumes.lock().insert(session, grant);
    }

    /// The pending resume grant of `session`, if any (#8983).
    pub(crate) fn resume_grant(&self, session: SessionId) -> Option<ResumeGrant> {
        self.resumes.lock().get(&session).cloned()
    }

    /// Drop `session`'s grant when it is still `used`; a newer one stays.
    pub(super) fn revoke_resume(&self, session: SessionId, used: &ResumeGrant) {
        let mut resumes = self.resumes.lock();
        if resumes.get(&session) == Some(used) {
            resumes.remove(&session);
        }
    }

    /// Bind `session` to `claude` over whatever it recorded (#8983).
    ///
    /// Why: the only rewrite of a recorded id, for the `claude` the daemon
    /// itself resumed; see [`DaemonState::rebind_resumed_claude_with`].
    /// What: `Err` when sealed or the save fails; a failed save restores the
    /// earlier entry, so no unsaved binding ever grants.
    /// Test: `an_unsaved_rebind_keeps_the_earlier_binding_8983`.
    pub(super) fn rebind(&self, session: SessionId, claude: ClaudeProcess) -> Result<(), String> {
        let mut guard = self.registry.lock();
        let map = guard
            .as_mut()
            .map_err(|why| format!("the session-claude registry is sealed: {why}"))?;
        let earlier = map.insert(session, Announcement::Claude(claude));
        if let Err(e) = write_registry(&self.path, map) {
            match earlier {
                Some(announcement) => map.insert(session, announcement),
                None => map.remove(&session),
            };
            return Err(format!(
                "the rebinding could not be saved to {}: {e}",
                self.path.display()
            ));
        }
        Ok(())
    }

    /// The nearest `claude` above the kernel-reported peer `pid` (#8531).
    ///
    /// Why: both the binding and the repair check start from a pid the kernel
    /// reported when the request arrived, and the walk runs later; by then
    /// the peer may have exited and its pid been reused.
    /// What: [`peer_claude_with`] over the live process table and the same
    /// `claude` name rule the hook's walk uses.
    /// Test: `peer_claude_with`'s cases.
    pub fn peer_claude(&self, pid: u32, seen_at: u64) -> Result<ClaudeProcess, String> {
        peer_claude_with(
            pid,
            seen_at,
            process_facts,
            crate::core::twin_arming::is_claude,
        )
    }
}

/// A socket `SessionStart` bind in flight; see [`SessionClaudes::begin_bind`].
#[derive(Debug)]
pub(crate) struct BindInFlight<'a> {
    claudes: &'a SessionClaudes,
    session: SessionId,
}

impl Drop for BindInFlight<'_> {
    fn drop(&mut self) {
        let mut binding = self.claudes.binding.lock();
        if let Some(count) = binding.get_mut(&self.session) {
            *count -= 1;
            if *count == 0 {
                binding.remove(&self.session);
            }
        }
    }
}

/// [`SessionClaudes::peer_claude`] over an injected process table (#8531).
///
/// What: `Err` when the peer's process cannot be read, when it started after
/// `seen_at` (a reused pid), when the ancestry cannot be read, or when no
/// `claude` runs within [`STATUS_MAX_ANCESTOR_HOPS`] of it. Otherwise that
/// `claude`, with its start time.
/// Test: `a_peer_started_after_the_request_is_refused_8531`,
/// `an_unreadable_peer_is_refused_8531`,
/// `a_peer_with_no_claude_above_it_is_refused_8531`,
/// `the_peer_claude_is_the_nearest_claude_ancestor_8531`.
pub(crate) fn peer_claude_with(
    pid: u32,
    seen_at: u64,
    facts: impl Fn(u32) -> Result<ProcessFacts, String>,
    is_claude: impl Fn(u32) -> Result<bool, String>,
) -> Result<ClaudeProcess, String> {
    // #8531: a peer that started after the request arrived holds a reused pid.
    let started = facts(pid)
        .map_err(|e| format!("the calling process (pid {pid}) could not be read: {e}"))?
        .start_time;
    if started > seen_at {
        return Err(format!(
            "pid {pid} now names a process started after the request arrived"
        ));
    }
    match nearest_claude_for_status_in(pid, facts, is_claude) {
        Ok(Some(claude)) => Ok(claude),
        Ok(None) => Err(format!(
            "no claude session process runs within {STATUS_MAX_ANCESTOR_HOPS} parent hops \
             of the calling process (pid {pid})"
        )),
        Err(e) => Err(format!(
            "the calling process's ancestry (pid {pid}) could not be read: {e}"
        )),
    }
}

impl SessionClaudes {
    /// Note that a hook event for `session` arrived now (#9010).
    pub(crate) fn note_event(&self, session: SessionId) {
        self.events.lock().insert(session, Instant::now());
    }

    /// Note `claude`, kernel-verified, as a later announcer of the settled
    /// `session` (#9010); it never binds, it only holds off the reaper.
    pub(crate) fn note_announcer(&self, session: SessionId, claude: ClaudeProcess) {
        self.announcers.lock().insert(session, claude);
    }

    /// Why `session`, whose bound `claude` was probed and proven gone, must
    /// still be kept (#9010).
    ///
    /// Why: the reaper's walk and probe run on a snapshot, and a session can
    /// outlive its first `claude` — a resume rebinds it, a daemon resume is
    /// still announcing, or `claude --resume <id>` was run by hand.
    /// What: `Some(reason)` when a resume grant younger than
    /// [`RESUME_HOLD_SECS`] is pending, the binding is no longer `probed`, a
    /// hook event for `session` arrived within `quiet`, or its latest
    /// announcer is not proven gone by `probe`. `None` lets the reaper
    /// remove it.
    /// Test: `a_settled_session_with_a_recent_event_is_kept_9010`,
    /// `a_session_rebound_during_the_reap_is_kept_9010`,
    /// `a_resume_grant_holds_a_reap_only_for_a_bounded_time_9010`.
    pub(crate) fn reap_hold(
        &self,
        session: SessionId,
        probed: ClaudeProcess,
        quiet: Duration,
        probe: impl Fn(ClaudeProcess) -> ClaudeLiveness,
    ) -> Option<String> {
        // #9010: the grant BEFORE the binding. `rebind_resumed_claude_with`
        // writes the binding, then revokes the grant, so a grant read as
        // revoked here means the binding read below is already the rebound
        // one. An expired grant holds nothing.
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map_or(0, |d| d.as_secs());
        if self
            .resume_grant(session)
            .is_some_and(|g| g.holds_reap(now))
        {
            return Some("the daemon is resuming it".to_string());
        }
        if self.get(session) != Some(probed) {
            return Some("it was rebound after the probe".to_string());
        }
        if self
            .events
            .lock()
            .get(&session)
            .is_some_and(|at| at.elapsed() < quiet)
        {
            return Some("a hook event for it arrived within the last reap interval".to_string());
        }
        let announcer = self.announcers.lock().get(&session).copied();
        match announcer.map(&probe) {
            Some(ClaudeLiveness::Alive) => Some("a later announcer still runs".to_string()),
            Some(ClaudeLiveness::Unknown(why)) => Some(format!("a later announcer: {why}")),
            Some(ClaudeLiveness::Gone(_)) | None => None,
        }
    }

    /// Drop every event older than `quiet`, which can hold no reap (#9010).
    pub(crate) fn forget_events_before(&self, quiet: Duration) {
        self.events.lock().retain(|_, at| at.elapsed() < quiet);
    }
}

impl DaemonState {
    /// Remove each settled session in `bound` whose `claude` is proven gone
    /// and that nothing else holds (#9010).
    ///
    /// What: probes each `claude`; on proof it is gone, re-checks
    /// [`SessionClaudes::reap_hold`] just before removing the session and
    /// staling its live delegations. Returns how many it removed.
    /// Test: `a_settled_session_whose_claude_exited_is_reaped_9010`,
    /// `a_settled_session_with_a_recent_event_is_kept_9010`.
    pub(super) fn reap_settled_sessions(
        &self,
        bound: Vec<(SessionId, ClaudeProcess)>,
        probe: impl Fn(ClaudeProcess) -> ClaudeLiveness,
    ) -> usize {
        let quiet = Duration::from_secs(crate::daemon::REAP_INTERVAL_SECS);
        self.session_claudes.forget_events_before(quiet);
        let mut reaped = 0;
        for (id, claude) in bound {
            if !probe(claude).proves_gone(id) {
                continue;
            }
            if let Some(why) = self.session_claudes.reap_hold(id, claude, quiet, &probe) {
                tracing::info!(session = ?id, "kept a settled session: {why} (#9010)");
                continue;
            }
            self.remove_session(id);
            self.session_claudes.announcers.lock().remove(&id);
            // #6497: the session's agents cannot outlive it.
            self.stale_delegations_of_dead_session(id);
            reaped += 1;
        }
        reaped
    }
}

/// The registry file's shape: a version and one entry per session id.
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredRegistry {
    version: u32,
    sessions: BTreeMap<String, StoredAnnouncement>,
}

/// One entry of [`StoredRegistry`].
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
enum StoredAnnouncement {
    Claude { pid: u32, start_time: u64 },
    Unproven,
}

/// Read the registry at `path`, trusting it only when `uid` owns it (#8531).
///
/// Why: the file decides who may end another session's delegation, so a file
/// another user could have written, or one this build cannot read, must not
/// decide anything.
/// What: an absent file is an empty registry. `Err` naming the reason when
/// the path cannot be opened (a symlink is refused, not followed), is not a
/// regular file, is owned by another uid, has any group or other permission
/// bit, cannot be read, does not parse, carries another format version, or
/// names a malformed session id.
/// Test: `a_corrupt_file_seals_the_registry_8531`,
/// `a_foreign_owned_file_seals_the_registry_8531`,
/// `an_unreadable_file_seals_the_registry_8531`,
/// `a_file_open_to_other_users_seals_the_registry_8531`,
/// `a_missing_file_is_an_empty_registry_8531`.
// #8980: crate-visible so the `session_claudes` doctor row reads the file
// through the daemon's own trust rules.
pub(crate) fn read_registry(
    path: &Path,
    uid: u32,
) -> Result<HashMap<SessionId, Announcement>, String> {
    let shown = path.display();
    let mut file = match open_no_follow(path) {
        Ok(file) => file,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
        Err(e) => return Err(format!("{shown} could not be opened: {e}")),
    };
    let meta = file
        .metadata()
        .map_err(|e| format!("{shown} could not be inspected: {e}"))?;
    if !meta.is_file() {
        return Err(format!("{shown} is not a regular file"));
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::MetadataExt as _;
        if meta.uid() != uid {
            return Err(format!(
                "{shown} is owned by uid {}, not the daemon's uid {uid}",
                meta.uid()
            ));
        }
        if meta.mode() & 0o077 != 0 {
            return Err(format!(
                "{shown} has mode {:o}; only its owner may read or write it",
                meta.mode() & 0o777
            ));
        }
    }
    #[cfg(not(unix))]
    let _ = uid;
    let mut text = String::new();
    file.read_to_string(&mut text)
        .map_err(|e| format!("{shown} could not be read: {e}"))?;
    let stored: StoredRegistry =
        serde_json::from_str(&text).map_err(|e| format!("{shown} does not parse: {e}"))?;
    if stored.version != FORMAT_VERSION {
        return Err(format!(
            "{shown} is format version {}, this daemon reads {FORMAT_VERSION}",
            stored.version
        ));
    }
    let mut map = HashMap::with_capacity(stored.sessions.len());
    for (id, entry) in stored.sessions {
        let session = uuid::Uuid::parse_str(&id)
            .map(SessionId)
            .map_err(|e| format!("{shown} names a malformed session id {id:?}: {e}"))?;
        let announcement = match entry {
            StoredAnnouncement::Claude { pid, start_time } => {
                Announcement::Claude(ClaudeProcess { pid, start_time })
            }
            StoredAnnouncement::Unproven => Announcement::Unproven,
        };
        map.insert(session, announcement);
    }
    Ok(map)
}

/// Open `path` for reading without following a symlink or blocking on a FIFO.
fn open_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
    let mut options = std::fs::OpenOptions::new();
    options.read(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt as _;
        options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
    }
    options.open(path)
}

/// Replace `path` with `map`, atomically, mode `0600` (#8531).
///
/// What: a `0600` temp file beside `path`, written and synced, renamed over
/// it, then the directory synced. A failure leaves `path` as it was.
/// Test: `a_restart_keeps_the_binding_8531` (mode and round trip).
fn write_registry(path: &Path, map: &HashMap<SessionId, Announcement>) -> std::io::Result<()> {
    crate::core::home_write_fence::check(path); // #8545
    let parent = path
        .parent()
        .filter(|p| !p.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));
    std::fs::create_dir_all(parent)?;
    let sessions = map
        .iter()
        .map(|(session, announcement)| {
            let entry = match announcement {
                Announcement::Claude(c) => StoredAnnouncement::Claude {
                    pid: c.pid,
                    start_time: c.start_time,
                },
                Announcement::Unproven => StoredAnnouncement::Unproven,
            };
            (session.0.to_string(), entry)
        })
        .collect();
    let stored = StoredRegistry {
        version: FORMAT_VERSION,
        sessions,
    };
    let bytes = serde_json::to_vec(&stored).map_err(std::io::Error::other)?;
    let mut tmp = tempfile::Builder::new()
        .prefix(".session-claudes.")
        .suffix(".tmp")
        .tempfile_in(parent)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        tmp.as_file()
            .set_permissions(std::fs::Permissions::from_mode(0o600))?;
    }
    tmp.write_all(&bytes)?;
    tmp.as_file().sync_all()?;
    tmp.persist(path).map_err(|e| e.error)?;
    #[cfg(unix)]
    if let Ok(dir) = std::fs::File::open(parent) {
        let _ = dir.sync_all();
    }
    Ok(())
}

/// The daemon's effective uid; `0` off Unix, where no owner check runs.
pub(crate) fn current_uid() -> u32 {
    #[cfg(unix)]
    {
        // SAFETY: `geteuid` takes no arguments, reads a process property and
        // cannot fail.
        unsafe { libc::geteuid() }
    }
    #[cfg(not(unix))]
    {
        0
    }
}

impl DaemonState {
    /// The kernel-bound `claude` registry (#8531).
    pub fn session_claudes(&self) -> &SessionClaudes {
        &self.session_claudes
    }

    /// Bind `session` to the `claude` that announced it (#8531).
    ///
    /// Why: the binding is what a later repair compares the caller against,
    /// so it can be written once only, and never for an id whose records
    /// predate it — an id that already owns delegations was announced to the
    /// daemon by someone this call cannot identify.
    /// What: `Ok` when `session` had no recorded announcement, no delegation
    /// names it, and the binding was saved; `Err` naming why otherwise.
    /// Never overwrites.
    /// Test: `a_session_is_bound_once_first_writer_wins_8531`,
    /// `a_session_that_already_owns_records_is_not_bound_8531`,
    /// `an_unproven_session_is_never_bound_8531`.
    pub fn bind_session_claude(
        &self,
        session: SessionId,
        claude: ClaudeProcess,
    ) -> Result<(), String> {
        let admit = || {
            if self.delegations.iter().any(|d| d.session == session) {
                return Err(
                    "the session already owns delegation records, so the process that \
                     announced it first is unknown"
                        .to_string(),
                );
            }
            Ok(())
        };
        match self
            .session_claudes
            .record(session, Announcement::Claude(claude), admit)?
        {
            None => Ok(()),
            Some(Announcement::Claude(_)) => {
                Err("the session is already bound to its claude".to_string())
            }
            Some(Announcement::Unproven) => Err(
                "the session was first announced without a kernel-verified claude, so it is \
                 never bound"
                    .to_string(),
            ),
        }
    }

    /// Record that `session`'s first announcement proved no process (#8531).
    ///
    /// Why: an id first announced over HTTP, or by a socket `SessionStart`
    /// that could not be bound, would otherwise stay open to whichever
    /// process announces it next over the socket.
    /// What: records [`Announcement::Unproven`] when `session` has nothing
    /// recorded; a no-op when it has. `Err` when sealed or the save fails;
    /// an unsaved record still settles the id for this daemon's lifetime.
    /// Test: `an_unproven_session_is_never_bound_8531`,
    /// `an_unsaved_unproven_announcement_still_settles_the_id_8531`.
    pub fn settle_unproven_session(&self, session: SessionId) -> Result<(), String> {
        self.session_claudes
            .record(session, Announcement::Unproven, || Ok(()))
            .map(|_| ())
    }
}

#[cfg(test)]
#[path = "session_claudes_tests.rs"]
mod session_claudes_tests;