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
//! Path-containment AND session-identity guards for workspace deletion (#1511,
//! #3764).
//!
//! Why: `SessionManager::decommission` previously `remove_dir_all`'d
//! `workspace_path` unconditionally, which deleted a live user repo when the
//! #1502 local-path spawn set `workspace_path` to a real on-disk directory.
//! This module provides the belt-and-suspenders containment guard that prevents
//! any path OUTSIDE the SM's managed workspace root from being deleted —
//! regardless of the `workspace_owned` flag. #3764 widens the module's
//! question from "is this path safe to touch at all" to "does anyone ELSE
//! also claim it": the #3715 incident's precursor was a 3-way cwd collision
//! (#1744) — three `Active` session records canonicalizing to one worktree
//! path — hours before that path was destroyed. Path containment alone never
//! sees that; it only ever asks about ONE path and ONE managed root.
//! What: [`is_safe_to_remove`] canonicalizes both paths and verifies that the
//! workspace is strictly INSIDE the managed root, rejecting: path == root, path
//! outside root, paths with too few components, and `$HOME`.
//! [`foreign_active_claim`] answers the session-identity question: does any
//! `Active` record OTHER than the one being acted on also canonicalize to the
//! same directory?
//! Test: `is_safe_to_remove_*` and `foreign_active_claim_*` unit tests below.

use std::path::{Component, Path, PathBuf};

use tracing::warn;

use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord};

/// Canonicalize `path`, resolving through the nearest EXISTING ancestor and
/// lexically normalizing the remainder when the full path itself does not
/// exist (#3764 review fix).
///
/// Why: shared by both directions of the comparison
/// [`foreign_active_claim`] makes — the candidate path AND every other
/// record's `workspace_path`/`cwd` — so a canonicalize failure on either side
/// must still produce a comparable value. A path that no longer exists on
/// disk (already destroyed) must still be comparABLE by its last-known
/// spelling. The original fallback (`path.to_path_buf()` verbatim) only
/// achieved that for BYTE-IDENTICAL spellings: a phantom `Active` record
/// naming the same directory via a `..` segment, or via a symlinked alias of
/// a real ancestor directory, compared unequal to a cleanly-spelled candidate
/// and silently slipped past the guard — fail-open in a check whose entire
/// job is catching same-directory collisions the store does not enforce.
/// What: if `path` exists, delegates to `fs::canonicalize` directly (resolves
/// symlinks and normalizes `.`/`..` via the OS). If it does not, walks
/// [`Path::ancestors`] to find the nearest ancestor that DOES exist,
/// canonicalizes THAT ancestor (resolving any symlink in it), and re-appends
/// the non-existent remainder — so a symlinked parent is still resolved even
/// though the leaf under it is gone. Either way, lexically normalizes
/// ([`normalize_lexically`]) the result to collapse `.` segments, `..`
/// segments, duplicate separators, and a trailing separator — covering the
/// case where no ancestor at all exists on this host (a fully phantom path),
/// which has nothing to canonicalize against.
/// Test: `foreign_active_claim_catches_a_nonexistent_path_spelled_with_a_trailing_slash`,
/// `foreign_active_claim_catches_a_nonexistent_path_with_dot_dot_segments`,
/// `foreign_active_claim_catches_a_nonexistent_path_through_a_symlinked_parent`.
fn canon_or_raw(path: &Path) -> PathBuf {
    if let Ok(canon) = std::fs::canonicalize(path) {
        return canon;
    }
    // `ancestors()` yields `path` itself first (already tried above and
    // failed), then each parent up to the root — skip(1) starts at the
    // nearest candidate PARENT.
    for ancestor in path.ancestors().skip(1) {
        if let (Ok(canon_ancestor), Ok(remainder)) =
            (std::fs::canonicalize(ancestor), path.strip_prefix(ancestor))
        {
            return normalize_lexically(&canon_ancestor.join(remainder));
        }
    }
    // Nothing on disk to canonicalize against at all — lexical normalization
    // is the only comparison basis left.
    normalize_lexically(path)
}

/// Lexically collapse `.` segments, `..` segments, and duplicate/trailing
/// separators — WITHOUT touching the filesystem.
///
/// Why: [`canon_or_raw`] needs this both as its last-resort fallback (nothing
/// on disk to canonicalize against) and to normalize the remainder re-appended
/// after a partial (ancestor-only) canonicalization, which has not itself been
/// through any `..`-collapsing.
/// What: rebuilds `path` component-by-component: a `CurDir` (`.`) is dropped;
/// a `ParentDir` (`..`) pops the previously-pushed component (mirroring the
/// well-known cargo `normalize_path` lexical algorithm — never touches disk,
/// so it can't tell a real directory from a dangling one, but that's exactly
/// what makes it safe to run on a path that doesn't exist); every other
/// component is pushed verbatim. Rebuilding via `Path::components()` already
/// collapses duplicate and trailing separators, since that iterator never
/// yields an empty segment.
/// Test: exercised indirectly through `canon_or_raw`'s callers — a private
/// helper with no externally-observable contract of its own.
fn normalize_lexically(path: &Path) -> PathBuf {
    let mut components = path.components().peekable();
    let mut out = if let Some(prefix @ Component::Prefix(_)) = components.peek().copied() {
        components.next();
        PathBuf::from(prefix.as_os_str())
    } else {
        PathBuf::new()
    };

    for component in components {
        match component {
            Component::Prefix(_) => unreachable!("a Prefix can only ever be the first component"),
            Component::RootDir => out.push(component.as_os_str()),
            Component::CurDir => {}
            Component::ParentDir => {
                out.pop();
            }
            Component::Normal(segment) => out.push(segment),
        }
    }
    out
}

/// Does any `Active` session record OTHER than `self_id` also claim
/// `workspace_path` (#3764)?
///
/// Why: `is_safe_to_remove` answers "is this path inside the managed root",
/// never "does a SIBLING session also believe this is theirs". The #3715
/// incident's own precursor state (#1744: three `Active` records sharing one
/// cwd) is invisible to path containment by construction — it is a
/// same-path, cross-RECORD question, not a path-vs-root one. This check
/// closes that hole at the one place every daemon-routed removal already
/// passes through: before `SessionManager::decommission_with_root` mutates
/// disk, it now asks this question first, unconditionally — unlike
/// [`super::manager::ManagedError::WorktreeOwnerMismatch`]'s gate, which only
/// fires when a caller identifies itself, this runs regardless of caller,
/// because the hazard is a store inconsistency (two `Active` records naming
/// one directory), not a caller impersonating an owner.
///
/// What: canonicalizes `workspace_path` (falling back to the raw path on a
/// canonicalize failure — a destroyed directory must still compare) and scans
/// `records` for the first `Active` record whose id is not `self_id` and
/// whose `workspace_path` OR `cwd` canonicalizes (or, on failure, compares
/// raw) to the same path. Returns that record's id.
///
/// A record in any state other than `Active` is never a conflict — a
/// `Stopped`/`Errored`/terminal record's directory is exactly what the
/// caller's own worktree-reclaim machinery ([`super::worktree_reclaim`]'s
/// gate 2) already expects to reclaim, and treating it as a live claim here
/// would refuse ordinary, safe cleanup.
/// Test: `foreign_active_claim_finds_a_colliding_workspace_path`,
/// `foreign_active_claim_finds_a_colliding_cwd`,
/// `foreign_active_claim_ignores_a_non_active_record`,
/// `foreign_active_claim_ignores_the_records_own_id`,
/// `foreign_active_claim_returns_none_when_unclaimed`.
pub(crate) fn foreign_active_claim(
    workspace_path: &Path,
    self_id: &ManagedSessionId,
    records: &[SessionRecord],
) -> Option<ManagedSessionId> {
    let canon_target = canon_or_raw(workspace_path);
    records
        .iter()
        .find(|r| {
            if r.id == *self_id || r.state != ManagedSessionState::Active {
                return false;
            }
            let ws_matches = r
                .workspace_path
                .as_deref()
                .is_some_and(|p| canon_or_raw(p) == canon_target || p == workspace_path);
            let cwd_matches = canon_or_raw(&r.cwd) == canon_target || r.cwd == workspace_path;
            ws_matches || cwd_matches
        })
        .map(|r| r.id)
}

/// Decide whether `workspace_path` is safe to `remove_dir_all` (#1511).
///
/// Why: even an `workspace_owned = true` record should only be deleted when the
/// path is strictly INSIDE the SM's managed workspaces root. This prevents
/// decommission from deleting a directory if `workspace_owned` were stale or if a
/// bug let a real path slip through as "owned". The guard rejects: path == root,
/// path outside root, path with too few components (filesystem / volume root), and
/// the user's home directory.
///
/// What: canonicalizes both paths and checks that `workspace_path` starts with
/// `managed_root` AND is not equal to it (strictly INSIDE). Also rejects any path
/// that is `$HOME` or has fewer than 3 components (e.g. `/`, `/tmp`) as an extra
/// safety net against catastrophic deletion. Returns `false` on any I/O error
/// during canonicalization (e.g. path does not exist) — never errors out.
///
/// Test: `is_safe_to_remove_rejects_outside_root`, `is_safe_to_remove_rejects_root_itself`,
/// `is_safe_to_remove_rejects_home`, `is_safe_to_remove_rejects_shallow_path`,
/// `is_safe_to_remove_accepts_valid_child` in this module.
pub(crate) fn is_safe_to_remove(workspace_path: &Path, managed_root: &Path) -> bool {
    // Reject suspiciously shallow ABSOLUTE paths before canonicalizing.
    // This counts components from the filesystem root (e.g. `/` counts 1,
    // `/tmp` counts 2) as a coarse guard against catastrophic paths like `/`
    // or `/tmp` — NOT as a measure of depth relative to the managed root.
    // The real containment check (canonicalize + starts_with) follows below.
    let component_count = workspace_path.components().count();
    if component_count < 3 {
        warn!(
            path = %workspace_path.display(),
            components = component_count,
            "is_safe_to_remove: rejecting — too few path components"
        );
        return false;
    }

    // Reject $HOME outright — no managed session should ever BE the home dir.
    if dirs::home_dir().is_some_and(|home| workspace_path == home) {
        warn!(
            path = %workspace_path.display(),
            "is_safe_to_remove: rejecting — path is $HOME"
        );
        return false;
    }

    // Canonicalize both paths to resolve symlinks and `..` so a symlink into
    // the managed root (or vice-versa) cannot trick the prefix check.
    let canon_ws = match workspace_path.canonicalize() {
        Ok(p) => p,
        Err(e) => {
            warn!(
                path = %workspace_path.display(),
                "is_safe_to_remove: cannot canonicalize workspace path ({e}); skipping deletion"
            );
            return false;
        }
    };
    let canon_root = match managed_root.canonicalize() {
        Ok(p) => p,
        Err(e) => {
            warn!(
                root = %managed_root.display(),
                "is_safe_to_remove: cannot canonicalize managed root ({e}); skipping deletion"
            );
            return false;
        }
    };

    // Must be strictly INSIDE the root (starts_with AND not equal to root).
    if canon_ws == canon_root {
        warn!(
            path = %workspace_path.display(),
            "is_safe_to_remove: rejecting — path IS the managed root"
        );
        return false;
    }
    if !canon_ws.starts_with(&canon_root) {
        warn!(
            path = %workspace_path.display(),
            root = %managed_root.display(),
            "is_safe_to_remove: rejecting — path is outside the managed root"
        );
        return false;
    }
    true
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::{
        ManagedSessionId, ManagedSessionState, SessionRecord, foreign_active_claim,
        is_safe_to_remove,
    };

    /// Build a bare [`SessionRecord`] naming `workspace_path` and `state`, for
    /// the [`foreign_active_claim`] tests below. Mirrors the field list
    /// `decommission_tests::owned_record` uses for the same purpose.
    fn record_at(
        id: ManagedSessionId,
        state: ManagedSessionState,
        workspace_path: PathBuf,
    ) -> SessionRecord {
        SessionRecord {
            id,
            tmux_name: format!("tm-foreign-claim-{id}"),
            cwd: PathBuf::from("/tmp/unrelated-cwd"),
            task: "task".into(),
            state,
            created_at: chrono::Utc::now(),
            last_activity_at: None,
            workspace_path: Some(workspace_path),
            repo_url: None,
            branch: None,
            pending_decision: None,
            proposed_default: None,
            correlation: Default::default(),
            runtime: Default::default(),
            ephemeral: false,
            workspace_owned: false,
            source_id: None,
            claude_session_id: None,
            scrollback_path: None,
            last_cwd: None,
            deliverable_id: None,
            pane_id: None,
            tmux_server: None,
            injection_status: Default::default(),
            worktree_owner: None,
            terminal_at: None,
            stop_cause: None,
            kind: Default::default(),
        }
    }

    // ── foreign_active_claim unit tests (#3764) ─────────────────────────────

    /// Another `Active` record whose `workspace_path` matches the candidate is
    /// a conflict.
    ///
    /// Why: this is the #1744 precursor shape — two `Active` records naming
    /// one directory.
    /// Test: this function IS the test.
    #[test]
    fn foreign_active_claim_finds_a_colliding_workspace_path() {
        let root = crate::test_support::hermetic_temp_dir();
        let shared = root.path().join("shared-worktree");
        std::fs::create_dir_all(&shared).unwrap();

        let self_id = ManagedSessionId::new();
        let other_id = ManagedSessionId::new();
        let records = vec![record_at(
            other_id,
            ManagedSessionState::Active,
            shared.clone(),
        )];

        assert_eq!(
            foreign_active_claim(&shared, &self_id, &records),
            Some(other_id),
            "an Active sibling record naming the same path must be reported"
        );
    }

    /// A collision on `cwd` (not `workspace_path`) is also caught.
    ///
    /// Why: the #1744 collision this guards against was keyed on `cwd`, not
    /// `workspace_path` — a record can claim a directory as its `cwd` before a
    /// `workspace_path` is ever recorded for it.
    /// Test: this function IS the test.
    #[test]
    fn foreign_active_claim_finds_a_colliding_cwd() {
        let root = crate::test_support::hermetic_temp_dir();
        let shared = root.path().join("shared-cwd");
        std::fs::create_dir_all(&shared).unwrap();

        let self_id = ManagedSessionId::new();
        let other_id = ManagedSessionId::new();
        let mut other = record_at(
            other_id,
            ManagedSessionState::Active,
            root.path().join("elsewhere"),
        );
        other.cwd = shared.clone();
        let records = vec![other];

        assert_eq!(
            foreign_active_claim(&shared, &self_id, &records),
            Some(other_id),
            "an Active sibling record whose cwd matches must be reported"
        );
    }

    /// A record in any non-`Active` state is never a conflict.
    ///
    /// Why: a `Stopped`/`Decommissioned` record's directory is exactly what
    /// ordinary reclaim is FOR — treating a terminal record as a live claim
    /// would refuse safe cleanup, not just unsafe cleanup.
    /// Test: this function IS the test.
    #[test]
    fn foreign_active_claim_ignores_a_non_active_record() {
        let root = crate::test_support::hermetic_temp_dir();
        let shared = root.path().join("shared-terminal");
        std::fs::create_dir_all(&shared).unwrap();

        let self_id = ManagedSessionId::new();
        let other_id = ManagedSessionId::new();
        let records = vec![record_at(
            other_id,
            ManagedSessionState::Decommissioned,
            shared.clone(),
        )];

        assert_eq!(
            foreign_active_claim(&shared, &self_id, &records),
            None,
            "a terminal-state record must never block removal"
        );
    }

    /// The record's own id is never reported as a conflict with itself.
    ///
    /// Why: `records` passed to this function typically includes the record
    /// being decommissioned itself; excluding `self_id` is what makes an
    /// ordinary, uncontested decommission possible at all.
    /// Test: this function IS the test.
    #[test]
    fn foreign_active_claim_ignores_the_records_own_id() {
        let root = crate::test_support::hermetic_temp_dir();
        let shared = root.path().join("own-workspace");
        std::fs::create_dir_all(&shared).unwrap();

        let self_id = ManagedSessionId::new();
        let records = vec![record_at(
            self_id,
            ManagedSessionState::Active,
            shared.clone(),
        )];

        assert_eq!(
            foreign_active_claim(&shared, &self_id, &records),
            None,
            "a record must never conflict with itself"
        );
    }

    /// No record at all claiming the path returns `None`.
    ///
    /// Why: this is the ordinary, uncontested case that must stay fast and
    /// silent — most decommissions have no sibling collision.
    /// Test: this function IS the test.
    #[test]
    fn foreign_active_claim_returns_none_when_unclaimed() {
        let root = crate::test_support::hermetic_temp_dir();
        let unclaimed = root.path().join("nobody-here");
        std::fs::create_dir_all(&unclaimed).unwrap();

        let self_id = ManagedSessionId::new();
        assert_eq!(foreign_active_claim(&unclaimed, &self_id, &[]), None);
    }

    // ── `canon_or_raw` fail-open regression coverage (#3764 review fix) ─────
    //
    // Every test below points the CANDIDATE (the path `foreign_active_claim`
    // is asked about) at a leaf that never exists on disk — mirroring
    // production, where `check_no_foreign_active_claim` is asked about the
    // very record being decommissioned, whose `workspace_path` may already be
    // gone. The old `canon_or_raw` fell back to the raw, un-normalized
    // `PathBuf` on a canonicalize failure, so it only ever matched a phantom
    // sibling record spelled BYTE-IDENTICALLY. Each test spells the sibling's
    // `workspace_path` differently — a `..` segment, a trailing separator (via
    // a symlinked alias so the difference survives `PathBuf`'s own trailing-
    // slash normalization), and a symlinked-alias ancestor with no other
    // spelling quirk — and asserts the collision is still caught.

    /// A phantom sibling spelled with a trailing separator, reached through a
    /// symlinked alias of the candidate's real ancestor, is still caught.
    ///
    /// Why: `PathBuf`'s own `Eq` already normalizes a bare trailing slash
    /// away, so a trailing slash alone never distinguished old from new code.
    /// Routing the trailing-slash spelling through a symlinked alias (built
    /// the same way as
    /// `foreign_active_claim_catches_a_nonexistent_path_through_a_symlinked_parent`)
    /// keeps the two RAW strings genuinely different — `.../parent-alias/
    /// workspace/` vs `.../parent-real/workspace` — so the old raw fallback
    /// provably fails this case while the fix's ancestor canonicalization
    /// (which resolves the symlink before comparing) provably catches it.
    /// Test: this function IS the test.
    #[test]
    fn foreign_active_claim_catches_a_nonexistent_path_spelled_with_a_trailing_slash() {
        let root = crate::test_support::hermetic_temp_dir();
        let parent_real = root.path().join("parent-real");
        std::fs::create_dir_all(&parent_real).unwrap();
        let parent_alias = root.path().join("parent-alias");
        std::os::unix::fs::symlink(&parent_real, &parent_alias).unwrap();

        // Neither leaf ever exists — both sides fall back to `canon_or_raw`'s
        // failure branch.
        let candidate = parent_real.join("workspace");
        let phantom_spelling =
            PathBuf::from(format!("{}/", parent_alias.join("workspace").display()));

        let self_id = ManagedSessionId::new();
        let other_id = ManagedSessionId::new();
        let records = vec![record_at(
            other_id,
            ManagedSessionState::Active,
            phantom_spelling,
        )];

        assert_eq!(
            foreign_active_claim(&candidate, &self_id, &records),
            Some(other_id),
            "a phantom record reached via a symlinked alias, spelled with a \
             trailing slash, must still be caught as the same directory"
        );
    }

    /// A phantom sibling spelled with a `..` segment is still caught.
    ///
    /// Why: unlike a trailing slash, `PathBuf`'s own `Eq` never collapses a
    /// literal `..` component — `/a/b/../c` and `/a/c` compare unequal even
    /// though they name the same location. The old raw fallback therefore
    /// missed this collision outright, with no symlink needed to prove it:
    /// `sub` need not even exist for the phantom record's stored path to be
    /// spelled through it.
    /// What: `root` is the nearest EXISTING ancestor for both the candidate
    /// (`root/target`) and the phantom (`root/sub/../target`, where `sub`
    /// never exists) — the fix canonicalizes `root` for both and lexically
    /// collapses the phantom's `..` segment, landing on the same value.
    /// Test: this function IS the test.
    #[test]
    fn foreign_active_claim_catches_a_nonexistent_path_with_dot_dot_segments() {
        let root = crate::test_support::hermetic_temp_dir();
        let candidate = root.path().join("target");
        let phantom_spelling = root.path().join("sub").join("..").join("target");

        let self_id = ManagedSessionId::new();
        let other_id = ManagedSessionId::new();
        let records = vec![record_at(
            other_id,
            ManagedSessionState::Active,
            phantom_spelling,
        )];

        assert_eq!(
            foreign_active_claim(&candidate, &self_id, &records),
            Some(other_id),
            "a phantom record spelled with a `..` segment must still be \
             caught as the same directory"
        );
    }

    /// A phantom sibling reached through a symlinked PARENT directory — no
    /// spelling quirk beyond the alias itself — is still caught.
    ///
    /// Why: this isolates the ancestor-canonicalization half of the fix from
    /// the lexical-normalization half the two tests above exercise. The
    /// candidate's own ancestor, `parent-real`, is a real directory; the
    /// phantom record names the SAME directory via `parent-alias`, a symlink
    /// to `parent-real`, then a workspace leaf that never exists under
    /// either name. Byte-for-byte, `.../parent-real/workspace` and
    /// `.../parent-alias/workspace` are different strings — the old raw
    /// fallback could not tell they name one physical directory.
    /// Test: this function IS the test.
    #[test]
    fn foreign_active_claim_catches_a_nonexistent_path_through_a_symlinked_parent() {
        let root = crate::test_support::hermetic_temp_dir();
        let parent_real = root.path().join("parent-real");
        std::fs::create_dir_all(&parent_real).unwrap();
        let parent_alias = root.path().join("parent-alias");
        std::os::unix::fs::symlink(&parent_real, &parent_alias).unwrap();

        let candidate = parent_real.join("workspace");
        let phantom_spelling = parent_alias.join("workspace");

        let self_id = ManagedSessionId::new();
        let other_id = ManagedSessionId::new();
        let records = vec![record_at(
            other_id,
            ManagedSessionState::Active,
            phantom_spelling,
        )];

        assert_eq!(
            foreign_active_claim(&candidate, &self_id, &records),
            Some(other_id),
            "a phantom record reached via a symlinked parent directory must \
             still be caught as the same directory"
        );
    }

    // ── is_safe_to_remove unit tests (#1511) ────────────────────────────────

    /// A valid child of the managed root passes the guard.
    ///
    /// Why: this is the happy path — an SM-provisioned workspace is always
    /// nested under the managed root.
    /// Test: this function IS the test.
    #[test]
    fn is_safe_to_remove_accepts_valid_child() {
        let root = crate::test_support::hermetic_temp_dir();
        let child = root.path().join("owner").join("repo").join("session-abc");
        std::fs::create_dir_all(&child).unwrap();
        assert!(
            is_safe_to_remove(&child, root.path()),
            "a path strictly inside the managed root must pass"
        );
    }

    /// A path equal to the managed root is rejected.
    ///
    /// Why: deleting the root itself would wipe all managed workspaces at once.
    /// Test: this function IS the test.
    #[test]
    fn is_safe_to_remove_rejects_root_itself() {
        let root = crate::test_support::hermetic_temp_dir();
        assert!(
            !is_safe_to_remove(root.path(), root.path()),
            "the managed root itself must be rejected"
        );
    }

    /// A path outside the managed root is rejected even if it exists.
    ///
    /// Why: a stale or stale `workspace_owned` flag must not cause out-of-root
    /// deletion.
    /// Test: this function IS the test.
    #[test]
    fn is_safe_to_remove_rejects_outside_root() {
        let root = crate::test_support::hermetic_temp_dir();
        let outside = crate::test_support::hermetic_temp_dir(); // different temp dir
        let outside_child = outside.path().join("some").join("path");
        std::fs::create_dir_all(&outside_child).unwrap();
        assert!(
            !is_safe_to_remove(&outside_child, root.path()),
            "a path outside the managed root must be rejected"
        );
    }

    /// A path with fewer than 3 components is rejected.
    ///
    /// Why: `/`, `/tmp`, or a single-segment path is never a valid SM workspace.
    /// Test: this function IS the test.
    #[test]
    fn is_safe_to_remove_rejects_shallow_path() {
        let root = crate::test_support::hermetic_temp_dir();
        // A 1-component path like "/" or a 2-component path like "/tmp" must be
        // rejected before even reaching canonicalization.
        let shallow = PathBuf::from("/tmp");
        assert!(
            !is_safe_to_remove(&shallow, root.path()),
            "a shallow path (/tmp) must be rejected by the component-count guard"
        );
    }

    /// `$HOME` is rejected outright.
    ///
    /// Why: deleting the user's home directory is catastrophic and must be
    /// impossible regardless of what the managed root is set to.
    /// Test: this function IS the test.
    ///
    /// Why serial (issue #2461 sweep): this test reads `dirs::home_dir()`
    /// itself AND `is_safe_to_remove` reads it again internally — two
    /// separate reads of the process-wide `HOME` env var that must observe
    /// the same value. Serialized against other `HOME`-redirecting tests in
    /// this binary for the same reason as the `core::paths` sweep.
    #[serial_test::serial]
    #[test]
    fn is_safe_to_remove_rejects_home() {
        if let Some(home) = dirs::home_dir() {
            // Use home as both path and root — even if "home is inside home" the
            // home-directory check fires first and rejects.
            assert!(
                !is_safe_to_remove(&home, &home),
                "$HOME must always be rejected"
            );
        }
    }

    /// A non-existent path returns false (canonicalize fails).
    ///
    /// Why: a workspace that has already been deleted must not cause a panic;
    /// the guard should simply return false so decommission logs and moves on.
    /// Test: this function IS the test.
    #[test]
    fn is_safe_to_remove_returns_false_for_nonexistent_path() {
        let root = crate::test_support::hermetic_temp_dir();
        let nonexistent = root.path().join("ghost").join("nope").join("absent");
        // path does not exist → canonicalize fails → returns false
        assert!(
            !is_safe_to_remove(&nonexistent, root.path()),
            "a non-existent workspace path must return false (canonicalize fails)"
        );
    }
}