Skip to main content

wire/
session.rs

1//! Multi-session wire on one machine (v0.5.16).
2//!
3//! Problem: multiple Claude Code (or any agent harness) sessions on the
4//! same machine share a single `WIRE_HOME`, which means they share the
5//! same DID, same relay slot, same inbox JSONL, and same daemon. Peers
6//! have no way to address a specific session, and the operator can't
7//! tell which session sent what.
8//!
9//! Solution: a `wire session` subcommand that bootstraps **isolated**
10//! per-session `WIRE_HOME` trees. Each session gets its own identity,
11//! handle, relay slot, daemon, and inbox/outbox. Sessions pair with each
12//! other through the public relay (`wireup.net`) like any other peer —
13//! no protocol changes. The bilateral-pair gate from v0.5.14 still
14//! applies in both directions.
15//!
16//! Storage layout:
17//!
18//! ```text
19//! ~/.local/state/wire/sessions/
20//!   registry.json                — cwd → session_name map
21//!   <session-name>/               — full WIRE_HOME tree per session
22//!     config/wire/...
23//!     state/wire/...
24//! ```
25//!
26//! Naming: derived from `basename(cwd)` so re-opening the same project
27//! reuses the same session identity. Collisions across two different
28//! paths with the same basename get a 4-char SHA-256 path-hash suffix.
29
30use anyhow::{Context, Result, anyhow};
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33use sha2::{Digest, Sha256};
34use std::collections::HashMap;
35use std::path::{Path, PathBuf};
36
37use crate::endpoints::{Endpoint, EndpointScope, self_endpoints};
38
39/// Root directory under which all session WIRE_HOMEs live.
40///
41/// Honors `WIRE_HOME` for testing (sessions root becomes
42/// `$WIRE_HOME/sessions/`); otherwise:
43///   - Linux: `$XDG_STATE_HOME/wire/sessions/` (typically
44///     `~/.local/state/wire/sessions/`).
45///   - macOS / other Unix without XDG: falls back to
46///     `dirs::data_local_dir() / wire / sessions /`, which on macOS is
47///     `~/Library/Application Support/wire/sessions/`. This mirrors
48///     `config::state_dir`'s fallback so the two surfaces resolve to
49///     compatible roots on every platform.
50pub fn sessions_root() -> Result<PathBuf> {
51    if let Ok(home_str) = std::env::var("WIRE_HOME") {
52        let home = PathBuf::from(&home_str);
53        let direct = home.join("sessions");
54        if direct.exists() {
55            return Ok(direct);
56        }
57        // v0.6.4: inside-session fallback. When WIRE_HOME is set by the
58        // MCP auto-detect or `wire session env`, it points at one
59        // session's home (`<root>/sessions/<name>`) — *not* the root
60        // holding every session. Without this fallback, `wire mesh
61        // status` / `mesh role list` / `mesh broadcast` invoked from
62        // inside a session see zero sister sessions even though the
63        // operator can plainly see them with `wire session list`.
64        //
65        // Walk up to the nearest ancestor named `sessions` and return it.
66        // Handles BOTH the legacy `sessions/<name>` layout (parent named
67        // `sessions`) and the v0.13 `sessions/by-key/<hash>` layout (parent
68        // `by-key`, grandparent `sessions`). The old one-level parent check
69        // matched only the legacy layout, so an inside-session WIRE_HOME on
70        // v0.13 made sessions_root() point at a nonexistent nested dir —
71        // list-local / mesh / pair-all-local then saw zero sisters even
72        // though they were on disk. A WIRE_HOME with no `sessions` ancestor
73        // (plain test dir, custom location) falls through to the v0.6.3
74        // `<WIRE_HOME>/sessions/` behavior.
75        let mut anc = Some(home.as_path());
76        while let Some(p) = anc {
77            if p.file_name().and_then(|s| s.to_str()) == Some("sessions") {
78                return Ok(p.to_path_buf());
79            }
80            anc = p.parent();
81        }
82        return Ok(direct);
83    }
84    let state = dirs::state_dir()
85        .or_else(dirs::data_local_dir)
86        .ok_or_else(|| {
87            anyhow!(
88                "could not resolve XDG_STATE_HOME (or platform-equivalent local data dir) — \
89                 set WIRE_HOME or run on a platform with `dirs` support"
90            )
91        })?;
92    Ok(state.join("wire").join("sessions"))
93}
94
95/// Full filesystem path for the named session's WIRE_HOME root.
96/// Inside this dir the standard wire layout applies: `config/wire/...`
97/// and `state/wire/...`.
98pub fn session_dir(name: &str) -> Result<PathBuf> {
99    Ok(sessions_root()?.join(sanitize_name(name)))
100}
101
102/// Registry tracks `cwd → session_name` so repeated `wire session new`
103/// from the same project reuses the same identity instead of creating
104/// a fresh one each time. Lives at `<sessions_root>/registry.json`.
105pub fn registry_path() -> Result<PathBuf> {
106    Ok(sessions_root()?.join("registry.json"))
107}
108
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct SessionRegistry {
111    /// `cwd_absolute_path → session_name`. Absent if cwd has not been
112    /// associated with a session yet.
113    #[serde(default)]
114    pub by_cwd: HashMap<String, String>,
115}
116
117pub fn read_registry() -> Result<SessionRegistry> {
118    let path = registry_path()?;
119    if !path.exists() {
120        return Ok(SessionRegistry::default());
121    }
122    let bytes =
123        std::fs::read(&path).with_context(|| format!("reading session registry {path:?}"))?;
124    serde_json::from_slice(&bytes).with_context(|| format!("parsing session registry {path:?}"))
125}
126
127pub fn write_registry(reg: &SessionRegistry) -> Result<()> {
128    let path = registry_path()?;
129    if let Some(parent) = path.parent() {
130        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
131    }
132    let body = serde_json::to_vec_pretty(reg)?;
133    // v0.7.0-alpha.8 (review-fix #7): atomic write via tmp+rename so
134    // concurrent unflocked readers (detect_session_wire_home,
135    // list_sessions, cmd_peers) never observe a 0-byte / truncated
136    // registry mid-write. Pre-alpha.8 used std::fs::write which
137    // truncates first — race window where readers saw empty JSON and
138    // fell back to default identity for the write duration.
139    let tmp = path.with_extension("json.tmp");
140    std::fs::write(&tmp, body).with_context(|| format!("writing tmp session registry {tmp:?}"))?;
141    std::fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
142    Ok(())
143}
144
145/// v0.7.0-alpha.3: flock'd read-modify-write of the session registry.
146///
147/// `write_registry` alone is not safe under concurrency — multiple MCP
148/// processes auto-initing in parallel each read an old snapshot, mutate
149/// their copy, and write back, losing N-1 updates. This helper acquires
150/// an exclusive flock on a sibling lockfile, re-reads inside the lock,
151/// applies the caller's modifier, writes atomically, and releases.
152///
153/// Modeled on `config::update_relay_state`. Lock contention is bounded:
154/// modifications are pure HashMap operations, write is whole-file at
155/// roughly the registry size (KBs, not MBs).
156pub fn update_registry<F>(modifier: F) -> Result<()>
157where
158    F: FnOnce(&mut SessionRegistry) -> Result<()>,
159{
160    use fs2::FileExt;
161    let path = registry_path()?;
162    if let Some(parent) = path.parent() {
163        std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
164    }
165    let lock_path = path.with_extension("lock");
166    let lock_file = std::fs::OpenOptions::new()
167        .create(true)
168        .truncate(false)
169        .read(true)
170        .write(true)
171        .open(&lock_path)
172        .with_context(|| format!("opening {lock_path:?}"))?;
173    lock_file
174        .lock_exclusive()
175        .with_context(|| format!("flock {lock_path:?}"))?;
176    // Re-read INSIDE the lock — any prior snapshot would race.
177    let mut reg = read_registry().unwrap_or_default();
178    let result = modifier(&mut reg);
179    let write_result = if result.is_ok() {
180        write_registry(&reg)
181    } else {
182        Ok(())
183    };
184    let _ = fs2::FileExt::unlock(&lock_file);
185    result?;
186    write_result?;
187    Ok(())
188}
189
190/// Sanitize an arbitrary string to a session-name-safe form: lowercase
191/// ASCII alphanumeric + `-` + `_`, replace other chars with `-`,
192/// dedupe consecutive dashes, trim leading/trailing dashes, max 32 chars.
193pub fn sanitize_name(raw: &str) -> String {
194    let mut out = String::with_capacity(raw.len());
195    let mut prev_dash = false;
196    for c in raw.chars() {
197        let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
198        let ch = if ok { c.to_ascii_lowercase() } else { '-' };
199        if ch == '-' {
200            if !prev_dash && !out.is_empty() {
201                out.push('-');
202            }
203            prev_dash = true;
204        } else {
205            out.push(ch);
206            prev_dash = false;
207        }
208    }
209    let trimmed = out.trim_matches('-').to_string();
210    if trimmed.is_empty() {
211        return "wire-session".to_string();
212    }
213    if trimmed.len() > 32 {
214        return trimmed[..32].trim_end_matches('-').to_string();
215    }
216    trimmed
217}
218
219/// Short hash suffix derived from the full absolute path of the cwd.
220/// Used to disambiguate two different projects whose basenames collide
221/// (e.g. `~/Source/wire` and `~/Archive/wire`).
222fn path_hash_suffix(cwd: &Path) -> String {
223    let bytes = cwd.as_os_str().to_string_lossy().into_owned();
224    let mut h = Sha256::new();
225    h.update(bytes.as_bytes());
226    let digest = h.finalize();
227    hex::encode(&digest[..2]) // 4 hex chars
228}
229
230/// v0.13.6: case-insensitive cwd-registry key on Windows.
231///
232/// Issue #30 (Willard repro): on Windows, two terminals in the "same"
233/// project under different drive/path casing (`C:\Foo\Bar` vs
234/// `C:\foo\bar`) hashed to DIFFERENT registry keys — the second
235/// terminal's `wire whoami` missed the registry lookup, derived a
236/// phantom name, and silently fell back to the legacy default identity
237/// (e.g. `did:wire:willard`). Both terminals collapsed onto one shared
238/// DID, every pairing attempt between them was a self-pair, and
239/// bilateral handshake could never complete.
240///
241/// Fix: on Windows, lowercase the cwd before reading from OR writing to
242/// the cwd→session map. Two paths that resolve to the same on-disk
243/// directory now produce the same registry key regardless of how the
244/// shell / launcher capitalized them.
245///
246/// On case-sensitive filesystems (Linux / macOS HFS+ / case-sensitive
247/// APFS / NTFS in case-sensitive mode) the path is returned as-is —
248/// distinct casings legitimately point at distinct directories.
249///
250/// Used at every read and write of `SessionRegistry.by_cwd` so old
251/// non-canonical entries written by v0.13.5 still resolve under v0.13.6+
252/// later, and new entries written under v0.13.6+ are immediately canonical.
253pub fn normalize_cwd_key(path: &Path) -> String {
254    let s = path.to_string_lossy().into_owned();
255    if cfg!(windows) { s.to_lowercase() } else { s }
256}
257
258/// Derive a stable session name for the given cwd. Resolution order:
259///
260/// 1. If the registry already maps this cwd → name, return that name.
261/// 2. Else: candidate = sanitize(basename(cwd)). If the candidate is
262///    already mapped to a DIFFERENT cwd in the registry, append a
263///    4-char path-hash suffix to avoid collision.
264/// 3. If still a collision: append a numeric suffix `-2`, `-3`, ...
265///    until unique.
266pub fn derive_name_from_cwd(cwd: &Path, registry: &SessionRegistry) -> String {
267    let cwd_key = normalize_cwd_key(cwd);
268    // Backward compat: O(n) normalized scan on read-miss.
269    //
270    // Per @laulpogan / coral-weasel correction on #67: a verbatim fallback
271    // (try the raw lookup string if the normalized lookup misses) only
272    // handles consistent-casing upgraders — it can't recover a
273    // mixed-case stored key (`C:\Users\Willard\...`) from a different-
274    // case lookup (`c:\users\willard\...`) because both raw and
275    // normalized lookup strings derive from the LOOKUP path; the
276    // stored key's original casing is unrecoverable from the lookup
277    // alone.
278    //
279    // The O(n) scan handles both cases:
280    //   - Consistent casing: normalize(stored) == cwd_key on the FIRST
281    //     `.get` (no scan needed; happy path is O(1)).
282    //   - Cross casing: stored "C:\Users\Willard" normalizes to
283    //     "c:\users\willard" == cwd_key → the scan resolves it.
284    //
285    // O(n) is over the per-machine session count (typically <20),
286    // hit only on the rare upgrader-misses-normalized-lookup case.
287    // New writes are normalized (see cli.rs insert sites) so the
288    // scan-cost shrinks to zero as old entries get touched.
289    if let Some(existing) = registry.by_cwd.get(&cwd_key).or_else(|| {
290        registry
291            .by_cwd
292            .iter()
293            .find(|(k, _)| normalize_cwd_key(Path::new(k)) == cwd_key)
294            .map(|(_, v)| v)
295    }) {
296        return existing.clone();
297    }
298    let base = cwd
299        .file_name()
300        .and_then(|s| s.to_str())
301        .map(sanitize_name)
302        .unwrap_or_else(|| "wire-session".to_string());
303    let occupied: std::collections::HashSet<String> = registry.by_cwd.values().cloned().collect();
304    if !occupied.contains(&base) {
305        return base;
306    }
307    let with_hash = format!("{}-{}", base, path_hash_suffix(cwd));
308    if !occupied.contains(&with_hash) {
309        return with_hash;
310    }
311    // Highly unlikely (would require a SHA-256 prefix collision plus an
312    // existing entry to claim it). Numeric tiebreaker as final fallback.
313    for n in 2..1000 {
314        let candidate = format!("{base}-{n}");
315        if !occupied.contains(&candidate) {
316            return candidate;
317        }
318    }
319    // Pathological fallback — every numbered slot is taken.
320    format!("{base}-{}-overflow", path_hash_suffix(cwd))
321}
322
323/// Summary of one on-disk session for `wire session list`.
324#[derive(Debug, Clone, Serialize)]
325pub struct SessionInfo {
326    pub name: String,
327    /// First cwd associated with this session in the registry. `None`
328    /// if the session was created without registry tracking (manual
329    /// `wire session new <name>`).
330    pub cwd: Option<String>,
331    pub home_dir: PathBuf,
332    pub did: Option<String>,
333    pub handle: Option<String>,
334    /// True if a `daemon.pid` file exists AND the recorded PID is
335    /// actually a live process (best-effort, not POSIX-portable but
336    /// matches the existing `wire status` / `wire doctor` checks).
337    pub daemon_running: bool,
338    /// Display character (nickname + emoji + color palette) derived from
339    /// the session's DID. `None` when the session has no agent-card yet
340    /// (pre-init). Lazy-computed at read time; never persisted to disk.
341    pub character: Option<crate::character::Character>,
342}
343
344/// Enumerate every on-disk session by reading `sessions_root()`. Cross-
345/// references the registry so each entry's `cwd` is filled in when known.
346/// v0.7.4: true iff the URL targets a loopback host (127.0.0.0/8 or
347/// [::1] or `localhost`). Used to detect "this Federation-scope slot
348/// is actually on a loopback relay" — those sessions are local-mesh
349/// candidates even though they're not tagged `local`.
350///
351/// Best-effort string match; we don't need full URL parsing for this
352/// because the relay URL is wire-controlled and follows a predictable
353/// shape (`http://<host>[:<port>][/path]`).
354fn url_is_loopback(url: &str) -> bool {
355    let lower = url.to_ascii_lowercase();
356    let after_scheme = match lower.split_once("://") {
357        Some((_, rest)) => rest,
358        None => lower.as_str(),
359    };
360    // Bracketed IPv6 literal: `[::1]:8771` keeps brackets in host slice.
361    if let Some(rest) = after_scheme.strip_prefix('[') {
362        return rest
363            .split_once(']')
364            .map(|(host, _)| host == "::1")
365            .unwrap_or(false);
366    }
367    let host = after_scheme.split(['/', ':']).next().unwrap_or("");
368    host == "localhost" || host == "127.0.0.1" || host.starts_with("127.")
369}
370
371/// v0.7.4: resolve an operator-typed name to a local sister session.
372/// Input may be the session NAME (e.g. `slancha-api`), the card
373/// HANDLE (usually equal to the name), or the character NICKNAME
374/// (e.g. `noble-slate`). Returns the session NAME suitable for the
375/// `--local-sister` add path. Case-insensitive. None on no match.
376///
377/// Designed for `wire add <input>` ergonomics — the operator should
378/// be able to type whatever face wire put on the peer (statusline
379/// nickname, session list emoji+name) and have wire find it.
380pub fn resolve_local_sister(input: &str) -> Option<String> {
381    let needle = input.trim();
382    if needle.is_empty() {
383        return None;
384    }
385    let sessions = list_sessions().ok()?;
386    for s in &sessions {
387        if s.name.eq_ignore_ascii_case(needle) {
388            return Some(s.name.clone());
389        }
390        if let Some(h) = &s.handle
391            && h.eq_ignore_ascii_case(needle)
392        {
393            return Some(s.name.clone());
394        }
395        if let Some(ch) = &s.character
396            && ch.nickname.eq_ignore_ascii_case(needle)
397        {
398            return Some(s.name.clone());
399        }
400    }
401    None
402}
403
404pub fn list_sessions() -> Result<Vec<SessionInfo>> {
405    let root = sessions_root()?;
406    if !root.exists() {
407        return Ok(Vec::new());
408    }
409    let registry = read_registry().unwrap_or_default();
410    // Reverse lookup: name → cwd. Used to annotate each SessionInfo.
411    let mut name_to_cwd: HashMap<String, String> = HashMap::new();
412    for (cwd, name) in &registry.by_cwd {
413        name_to_cwd.insert(name.clone(), cwd.clone());
414    }
415
416    // Build a SessionInfo from a home dir, labeled `name`. v0.11: character
417    // is purely DID-derived (local display.json overrides removed).
418    let mk = |path: PathBuf, name: String| -> SessionInfo {
419        let card_path = path.join("config").join("wire").join("agent-card.json");
420        let (did, handle) = read_card_identity(&card_path);
421        let daemon_running = check_daemon_live(&path);
422        let character = did.as_deref().map(crate::character::Character::from_did);
423        SessionInfo {
424            cwd: name_to_cwd.get(&name).cloned(),
425            name,
426            home_dir: path,
427            did,
428            handle,
429            daemon_running,
430            character,
431        }
432    };
433
434    let mut out = Vec::new();
435    for entry in std::fs::read_dir(&root)?.flatten() {
436        let path = entry.path();
437        if !path.is_dir() {
438            continue;
439        }
440        let name = match path.file_name().and_then(|s| s.to_str()) {
441            Some(s) => s.to_string(),
442            None => continue,
443        };
444        // Skip the registry sidecar.
445        if name == "registry.json" {
446            continue;
447        }
448        // v0.13: session homes live under `by-key/<hash>`, not at the top
449        // level. Descend one level so same-box discovery (`list-local` /
450        // `pair-all-local`) sees them — the `by-key` dir itself is a
451        // container, not a session. Without this, EVERY v0.13 session was
452        // invisible to the local mesh, silently forcing same-box sisters
453        // onto federation instead of fast loopback routing.
454        if name == "by-key" {
455            for sub in std::fs::read_dir(&path)?.flatten() {
456                let sub_path = sub.path();
457                if !sub_path.is_dir() {
458                    continue;
459                }
460                let hash = sub_path
461                    .file_name()
462                    .and_then(|s| s.to_str())
463                    .unwrap_or("?")
464                    .to_string();
465                let mut info = mk(sub_path, hash);
466                // E8 (v0.13.2): skip uninitialized by-key homes. maybe_adopt_
467                // session_wire_home creates the home dir on first resolution —
468                // before any identity exists — so transient/probe session keys
469                // that never `wire up` leave empty or agent-card-less homes.
470                // Without this filter they surfaced as phantom "?"-handle
471                // sisters in list-local, degrading the very discovery rc3
472                // fixed. No DID == no identity == not a session.
473                if info.did.is_none() {
474                    continue;
475                }
476                // Prefer the persona handle as the display name when the home
477                // is initialized; fall back to the by-key hash otherwise.
478                if let Some(h) = info.handle.clone() {
479                    info.name = h;
480                }
481                out.push(info);
482            }
483            continue;
484        }
485        out.push(mk(path, name));
486    }
487    out.sort_by(|a, b| a.name.cmp(&b.name));
488    Ok(out)
489}
490
491fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
492    let bytes = match std::fs::read(card_path) {
493        Ok(b) => b,
494        Err(_) => return (None, None),
495    };
496    let v: serde_json::Value = match serde_json::from_slice(&bytes) {
497        Ok(v) => v,
498        Err(_) => return (None, None),
499    };
500    let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
501    let handle = v
502        .get("handle")
503        .and_then(|x| x.as_str())
504        .map(str::to_string)
505        .or_else(|| {
506            did.as_ref()
507                .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
508        });
509    (did, handle)
510}
511
512/// Read a session home's daemon pid from `<home>/state/wire/daemon.pid`
513/// (path-based; does NOT consult WIRE_HOME). None if absent/corrupt. Used to
514/// enumerate which daemon pids legitimately belong to a session so orphan
515/// detection doesn't flag a sibling session's daemon (A2).
516pub fn session_daemon_pid(session_home: &Path) -> Option<u32> {
517    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
518    let bytes = std::fs::read(&pidfile).ok()?;
519    // Structured form first, then legacy integer.
520    if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
521        v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
522    } else {
523        String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
524    }
525}
526
527fn check_daemon_live(session_home: &Path) -> bool {
528    session_daemon_pid(session_home)
529        .map(is_process_live)
530        .unwrap_or(false)
531}
532
533fn is_process_live(pid: u32) -> bool {
534    // v0.7.3: delegate to the shared platform helper. The previous
535    // implementation shelled out to `kill -0` on non-Linux, which
536    // unconditionally failed on Windows (no `kill` binary) and made
537    // `wire session list` report every daemon as `down` regardless of
538    // actual liveness.
539    crate::platform::process_alive(pid)
540}
541
542/// Read a session's `relay.json` and return its `self.endpoints[]`
543/// array (v0.5.17 dual-slot). Empty Vec on any read/parse error — this
544/// is a best-effort discovery helper, not a verification tool. A pre-
545/// v0.5.17 session writes only the legacy flat fields; `self_endpoints`
546/// promotes those to a federation-only Endpoint, so the result is
547/// still meaningful for legacy sessions.
548///
549/// v0.5.20 BUG FIX: this used to join `relay-state.json`, which is
550/// not the canonical filename (`config::relay_state_path` returns
551/// `relay.json`). The mis-named read silently no-op'd and
552/// `list-local` always returned an empty `local` map as a result.
553/// Companion to the `cli.rs::try_allocate_local_slot` filename fix
554/// in the same release — that helper had the symmetric write-side
555/// bug, so the local endpoint never got persisted in the first place.
556pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
557    let path = session_home.join("config").join("wire").join("relay.json");
558    let bytes = match std::fs::read(&path) {
559        Ok(b) => b,
560        Err(_) => return Vec::new(),
561    };
562    let val: Value = match serde_json::from_slice(&bytes) {
563        Ok(v) => v,
564        Err(_) => return Vec::new(),
565    };
566    self_endpoints(&val)
567}
568
569/// Stripped view of a Local endpoint for tooling output. Drops
570/// `slot_token` because it is a bearer credential — exposing it
571/// through `wire session list-local --json` would risk accidental
572/// leak via logs, screenshots, or piped output. Routing code uses
573/// the full `Endpoint` from `relay.json` directly; this type
574/// is for human/JSON observation only.
575#[derive(Debug, Clone, Serialize)]
576pub struct LocalEndpointView {
577    pub relay_url: String,
578    pub slot_id: String,
579}
580
581/// One row of `wire session list-local` output: a session that has a
582/// Local-scope endpoint plus metadata to render it.
583#[derive(Debug, Clone, Serialize)]
584pub struct LocalSessionView {
585    pub name: String,
586    pub handle: Option<String>,
587    pub did: Option<String>,
588    pub cwd: Option<String>,
589    pub home_dir: PathBuf,
590    pub daemon_running: bool,
591    /// All Local-scope endpoints this session advertises (token redacted).
592    /// Most sessions have exactly one; multiple is permitted for multi-
593    /// relay setups.
594    pub local_endpoints: Vec<LocalEndpointView>,
595}
596
597/// Sessions with no Local endpoint — shown separately so the operator
598/// knows they exist but are federation-only.
599#[derive(Debug, Clone, Serialize)]
600pub struct FederationOnlySessionView {
601    pub name: String,
602    pub handle: Option<String>,
603    pub cwd: Option<String>,
604}
605
606/// Result shape for `wire session list-local`. `local` is grouped by
607/// the local-relay URL so output can render each cluster of mutually-
608/// reachable sister sessions together. `federation_only` lists the rest.
609#[derive(Debug, Clone, Serialize)]
610pub struct LocalSessionListing {
611    pub local: HashMap<String, Vec<LocalSessionView>>,
612    pub federation_only: Vec<FederationOnlySessionView>,
613}
614
615/// Build the listing for `wire session list-local` from current on-disk
616/// state. Read-only; no daemon contact, no relay probe.
617pub fn list_local_sessions() -> Result<LocalSessionListing> {
618    let sessions = list_sessions()?;
619    let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
620    let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
621
622    for s in sessions {
623        let endpoints = read_session_endpoints(&s.home_dir);
624        let local_eps: Vec<Endpoint> = endpoints
625            .into_iter()
626            .filter(|e| {
627                // v0.7.4: include any session whose endpoint URL is a
628                // loopback address even if it's tagged Federation, not
629                // Local. This catches the legitimate-but-misshapen case
630                // where `wire init --relay http://127.0.0.1:8771` was run
631                // without `--with-local`, leaving the session with a
632                // loopback federation slot that's effectively local-mesh-
633                // reachable. Pre-v0.7.4 the strict scope-only filter
634                // silently excluded those sessions from `pair-all-local`,
635                // making nickname-based pairing fail for no operator-
636                // visible reason.
637                matches!(e.scope, EndpointScope::Local)
638                    || (matches!(e.scope, EndpointScope::Federation)
639                        && url_is_loopback(&e.relay_url))
640            })
641            .collect();
642        if local_eps.is_empty() {
643            federation_only.push(FederationOnlySessionView {
644                name: s.name.clone(),
645                handle: s.handle.clone(),
646                cwd: s.cwd.clone(),
647            });
648            continue;
649        }
650        // Redacted view: drop slot_token before exposing through CLI.
651        let redacted: Vec<LocalEndpointView> = local_eps
652            .iter()
653            .map(|e| LocalEndpointView {
654                relay_url: e.relay_url.clone(),
655                slot_id: e.slot_id.clone(),
656            })
657            .collect();
658        // Group by relay_url. A session with two Local endpoints (rare —
659        // would mean two loopback relays) appears under each.
660        for ep in &local_eps {
661            local
662                .entry(ep.relay_url.clone())
663                .or_default()
664                .push(LocalSessionView {
665                    name: s.name.clone(),
666                    handle: s.handle.clone(),
667                    did: s.did.clone(),
668                    cwd: s.cwd.clone(),
669                    home_dir: s.home_dir.clone(),
670                    daemon_running: s.daemon_running,
671                    local_endpoints: redacted.clone(),
672                });
673        }
674    }
675    // Sort each group by session name so output is deterministic.
676    for group in local.values_mut() {
677        group.sort_by(|a, b| a.name.cmp(&b.name));
678    }
679    federation_only.sort_by(|a, b| a.name.cmp(&b.name));
680    Ok(LocalSessionListing {
681        local,
682        federation_only,
683    })
684}
685
686/// v0.6.7: cwd → session WIRE_HOME lookup. Read-only.
687///
688/// When `WIRE_HOME` isn't set in env, look up `cwd` in the session
689/// registry. If a session is registered for this cwd AND its home
690/// directory still exists, return that home dir; otherwise None.
691///
692/// Used by both `wire mcp` (v0.6.1) and the CLI entry point (v0.6.7)
693/// so a `wire whoami` / `wire monitor` invocation from a project cwd
694/// adopts that project's session identity automatically, instead of
695/// silently falling back to the machine default. The CLI parity is
696/// load-bearing: without it, the user-visible identity diverges
697/// between MCP and the terminal, and monitors pull machine-wide
698/// inboxes when the operator expected a per-session view.
699pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option<PathBuf> {
700    let registry = read_registry().ok()?;
701    // v0.7.0-alpha.2: walk up parent dirs. Subdirs of a registered cwd
702    // inherit their parent's wire identity (e.g.
703    // `~/Source/slancha-business/tools/recon` → `slancha-business` session).
704    // Without this, subdirs all fell back to the machine-wide default
705    // identity, which silently collapsed multiple Claude sessions onto the
706    // same DID + character.
707    let mut probe: Option<&std::path::Path> = Some(cwd);
708    while let Some(path) = probe {
709        // Same O(n) normalized scan as derive_name_from_cwd: handles both
710        // consistent-casing and cross-casing upgraders. See the comment
711        // on derive_name_from_cwd for the rationale.
712        let path_str = normalize_cwd_key(path);
713        if let Some(session_name) = registry.by_cwd.get(&path_str).or_else(|| {
714            registry
715                .by_cwd
716                .iter()
717                .find(|(k, _)| normalize_cwd_key(Path::new(k)) == path_str)
718                .map(|(_, v)| v)
719        }) {
720            let session_home = session_dir(session_name).ok()?;
721            if session_home.exists() {
722                return Some(session_home);
723            }
724        }
725        probe = path.parent();
726    }
727    None
728}
729
730/// v0.13: resolve a stable per-session key — host-agnostic, with a Claude
731/// Code adapter and the path left open for other hosts. Order:
732///   1. `WIRE_SESSION_ID` — explicit universal override (any harness).
733///   2. `CLAUDE_CODE_SESSION_ID` — Claude Code adapter (stable per
734///      conversation; the same id the auto-memory system keys off).
735///   3. `CODEX_SESSION_ID` — OpenAI Codex CLI adapter. Stable per Codex
736///      thread (the same UUIDv7 emitted in `thread.started` and used as
737///      the rollout-file suffix under `$CODEX_HOME/sessions/`). Codex
738///      does not yet forward this var to MCP children out of the box —
739///      operators must set it via `[mcp_servers.<name>.env]` in
740///      `~/.codex/config.toml` (or upstream Codex must add it to the
741///      MCP child env). Wiring the name in advance means once Codex
742///      ships the env, wire picks it up with zero further code change.
743///   4. `COPILOT_AGENT_SESSION_ID` — GitHub Copilot CLI (`gh copilot` /
744///      `copilot`) adapter. Set by the Copilot CLI host for every
745///      session; stable per conversation; UUID-shaped.
746///   5. `VSCODE_GIT_REPOSITORY_ROOT` — VS Code/GitHub Copilot workspace-based
747///      identity (stable per workspace).
748///   6. `None` — caller falls back to legacy cwd-detect (bare CLI /
749///      pre-v0.13 hosts). Future host adapters slot in before this.
750///
751/// Returns `(key, source-label)`.
752pub fn resolve_session_key() -> Option<(String, &'static str)> {
753    for (var, source) in [
754        ("WIRE_SESSION_ID", "override"),
755        ("CLAUDE_CODE_SESSION_ID", "claude-code"),
756        ("CODEX_SESSION_ID", "codex-cli"),
757        ("COPILOT_AGENT_SESSION_ID", "copilot-cli"),
758        ("VSCODE_GIT_REPOSITORY_ROOT", "vscode-workspace"),
759    ] {
760        if let Ok(v) = std::env::var(var)
761            && valid_session_key(&v)
762        {
763            return Some((v.trim().to_string(), source));
764        }
765    }
766    // Claude Code adapter (host-agnostic fallback). On some platforms the MCP
767    // server process does not inherit CLAUDE_CODE_SESSION_ID and the MCP
768    // `initialize` handshake carries no session id, so the env checks above
769    // miss. Claude Code, however, writes `~/.claude/sessions/<pid>.json`
770    // ({"sessionId":..., "cwd":...}) for each live session, named by the
771    // owning `claude` process PID. Walk our parent-process chain to that
772    // process and read its sessionId — deterministic, race-free, env-free.
773    if let Some(sid) = claude_code_session_from_pidfile() {
774        return Some((sid, "claude-code-pidfile"));
775    }
776
777    None
778}
779
780/// A session key from the environment is usable only if it is non-empty and is
781/// NOT an unexpanded `${...}` placeholder. A host that writes
782/// `"env": {"WIRE_SESSION_ID": "${CLAUDE_CODE_SESSION_ID}"}` but doesn't expand
783/// it (Windows Claude Code passes the literal when the var is absent) would
784/// otherwise have wire hash the literal — collapsing every session onto one
785/// identity. Treat any `${...}` value as unset so resolution falls through to
786/// the PID-file adapter / per-process mint instead of a shared bogus persona.
787fn valid_session_key(v: &str) -> bool {
788    let v = v.trim();
789    !v.is_empty() && !v.contains("${")
790}
791
792/// Recover the Claude Code session id from the per-session PID-file when it
793/// isn't available via the environment. Claude Code writes
794/// `~/.claude/sessions/<pid>.json` = `{"sessionId": "...", "cwd": "...", ...}`
795/// for each live session, keyed by the owning `claude` process PID. The MCP
796/// server we run inside is a descendant of that process, so we walk our
797/// parent chain and return the `sessionId` of the first ancestor that has a
798/// PID-file. Cross-platform: the file exists on macOS/Linux/Windows alike.
799fn claude_code_session_from_pidfile() -> Option<String> {
800    let dir = dirs::home_dir()?.join(".claude").join("sessions");
801    let mut pid = std::process::id();
802    // Chains are shallow (MCP server -> launcher -> claude); 16 is generous.
803    for _ in 0..16 {
804        let f = dir.join(format!("{pid}.json"));
805        if let Ok(txt) = std::fs::read_to_string(&f)
806            && let Ok(v) = serde_json::from_str::<Value>(&txt)
807            && let Some(s) = v.get("sessionId").and_then(Value::as_str)
808        {
809            let s = s.trim();
810            if !s.is_empty() {
811                return Some(s.to_string());
812            }
813        }
814        pid = parent_pid(pid)?;
815    }
816    None
817}
818
819/// Best-effort parent-PID lookup. Linux: `/proc/<pid>/status`. macOS: `ps`.
820/// Windows: PowerShell CIM (no extra crate). Returns `None` on any failure,
821/// which simply ends the walk.
822#[cfg(target_os = "linux")]
823fn parent_pid(pid: u32) -> Option<u32> {
824    let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
825    for line in status.lines() {
826        if let Some(rest) = line.strip_prefix("PPid:") {
827            return rest.trim().parse().ok();
828        }
829    }
830    None
831}
832
833#[cfg(target_os = "macos")]
834fn parent_pid(pid: u32) -> Option<u32> {
835    let out = std::process::Command::new("ps")
836        .args(["-o", "ppid=", "-p", &pid.to_string()])
837        .output()
838        .ok()?;
839    String::from_utf8_lossy(&out.stdout).trim().parse().ok()
840}
841
842#[cfg(target_os = "windows")]
843fn parent_pid(pid: u32) -> Option<u32> {
844    use std::os::windows::process::CommandExt;
845    const CREATE_NO_WINDOW: u32 = 0x0800_0000;
846    let out = std::process::Command::new("powershell")
847        .args([
848            "-NoProfile",
849            "-NonInteractive",
850            "-Command",
851            &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').ParentProcessId"),
852        ])
853        .creation_flags(CREATE_NO_WINDOW)
854        .output()
855        .ok()?;
856    String::from_utf8_lossy(&out.stdout).trim().parse().ok()
857}
858
859#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
860fn parent_pid(_pid: u32) -> Option<u32> {
861    None
862}
863
864/// v0.13: the WIRE_HOME for a resolved session key —
865/// `<sessions_root>/by-key/<hash>` where `hash` is the first 16 hex of
866/// SHA-256(key). Deterministic and cwd-independent, so two sessions never
867/// collide and there is no path-string to mis-normalize (the Windows bug
868/// cannot occur). 64 bits is collision-safe at this scale.
869pub fn session_home_for_key(key: &str) -> Result<PathBuf> {
870    let mut h = Sha256::new();
871    h.update(key.as_bytes());
872    let digest = h.finalize();
873    let hash = hex::encode(&digest[..8]); // 16 hex chars / 64 bits
874    Ok(sessions_root()?.join("by-key").join(hash))
875}
876
877/// Long-running `wire <subcommand>` invocations that own the inbox
878/// cursor and therefore race each other under a shared `WIRE_HOME`.
879/// Keep this list in sync with [`warn_on_identity_collision`]'s pgrep
880/// predicate and the call-site list in `cli::run` / `mcp::run`.
881///
882/// `pair-host*` is intentionally absent: it works against the
883/// per-pair relay slot, not the shared inbox cursor, and is meant
884/// to coexist with a `wire daemon` that advances the queued pair —
885/// warning there would be a false positive for the documented
886/// "queue + daemon" pattern.
887///
888/// Short-lived commands (`whoami`, `status`, `send`, `peers`, …) are
889/// intentionally absent — they write atomically and don't race, and
890/// warning on every one would spam any operator running scripts.
891pub const INBOX_OWNING_SUBCOMMANDS: &[&str] = &["mcp", "daemon", "monitor", "notify"];
892
893/// v0.6.10: warn at MCP/CLI startup if another long-running `wire`
894/// process is already running with the same effective `WIRE_HOME`.
895/// Closes the "two Claudes in same cwd silently share an identity"
896/// failure mode that wasted hours of operator debugging time: today
897/// the collision is invisible (both Claudes resolve to the same wire
898/// session via v0.6.7 auto-detect, race the inbox cursor, "look
899/// identical" from the operator's view). This surfaces it explicitly
900/// with a clear remediation path.
901///
902/// `role` is the calling subcommand label (`"mcp"`, `"daemon"`,
903/// `"monitor"`, …) — used in the warning's leading tag so operators
904/// can tell which surface is observing the collision. Detection
905/// itself spans every inbox-owning role: a `wire daemon` colliding
906/// with an existing `wire mcp` warns just the same as an mcp/mcp
907/// pair.
908///
909/// Best-effort: any subprocess / env-read failure is silent (the
910/// collision check should never block startup). Cross-platform via
911/// `ps -E -p <pid>` on macOS, `/proc/<pid>/environ` on Linux. Windows
912/// returns empty (no collision detected).
913pub fn warn_on_identity_collision(self_pid: u32, role: &str) {
914    let our_wire_home = match std::env::var("WIRE_HOME") {
915        Ok(h) => h,
916        Err(_) => return,
917    };
918
919    // Single pgrep call with an alternation predicate. `pgrep -f`
920    // matches against the full argv string, so `wire (mcp|daemon|…)`
921    // catches every inbox-owning subcommand in one shot. Falls back to
922    // silent no-op on platforms without pgrep (Windows) — the env-read
923    // path below also returns None there, so detection is end-to-end
924    // unsupported on Windows. Future: a powershell adapter for
925    // identity collisions, tracked in #29 / #30.
926    let predicate = format!("wire ({})", INBOX_OWNING_SUBCOMMANDS.join("|"));
927    let pgrep_out = match std::process::Command::new("pgrep")
928        .args(["-f", &predicate])
929        .output()
930    {
931        Ok(o) if o.status.success() => o,
932        _ => return,
933    };
934
935    let other_pids: Vec<u32> = String::from_utf8_lossy(&pgrep_out.stdout)
936        .split_whitespace()
937        .filter_map(|s| s.parse::<u32>().ok())
938        .filter(|&p| p != self_pid)
939        .collect();
940
941    let other_homes: Vec<(u32, Option<String>)> = other_pids
942        .iter()
943        .map(|p| (*p, read_wire_home_from_pid(*p)))
944        .collect();
945
946    let colliders = find_colliders(&our_wire_home, &other_homes);
947
948    if colliders.is_empty() {
949        return;
950    }
951
952    emit_collision_warning(role, &our_wire_home, &colliders);
953}
954
955/// Pure decision: from a snapshot of `(pid, their_wire_home)` for
956/// every other wire process on the host, return the pids whose
957/// `WIRE_HOME` exactly matches ours. Missing-home entries (process
958/// died, env unreadable on this platform) are skipped, never counted.
959pub(crate) fn find_colliders(
960    our_wire_home: &str,
961    other_homes: &[(u32, Option<String>)],
962) -> Vec<u32> {
963    other_homes
964        .iter()
965        .filter_map(|(pid, their_home)| match their_home {
966            Some(h) if h == our_wire_home => Some(*pid),
967            _ => None,
968        })
969        .collect()
970}
971
972/// Render the collision warning. Extracted so the format is unit-
973/// testable without mocking a real pgrep / cross-process env read.
974pub(crate) fn emit_collision_warning(role: &str, our_wire_home: &str, colliders: &[u32]) {
975    eprintln!(
976        "wire {role}: WARNING — {} other wire process(es) already using WIRE_HOME=`{}` (pid {})",
977        colliders.len(),
978        our_wire_home,
979        colliders
980            .iter()
981            .map(|p| p.to_string())
982            .collect::<Vec<_>>()
983            .join(", ")
984    );
985    eprintln!(
986        "  Multiple agents sharing one identity will race the inbox cursor; messages may be lost."
987    );
988    eprintln!("  To use a separate identity:");
989    eprintln!("    1. Close the other agent(s), OR");
990    eprintln!("    2. `wire session new <name> --local-only` to create a fresh identity, then");
991    eprintln!(
992        "    3. Restart THIS agent's launcher with `export WIRE_HOME=<path printed by step 2>`"
993    );
994}
995
996/// Best-effort cross-platform read of another process's `WIRE_HOME`.
997/// Linux: parses `/proc/<pid>/environ` (NUL-separated KEY=VAL).
998/// macOS: `ps -E -p <pid>` (whitespace-separated KEY=VAL prefix).
999/// Windows / other: returns `None` (collision detection no-ops).
1000fn read_wire_home_from_pid(pid: u32) -> Option<String> {
1001    #[cfg(target_os = "linux")]
1002    {
1003        let path = format!("/proc/{pid}/environ");
1004        let bytes = std::fs::read(&path).ok()?;
1005        for entry in bytes.split(|&b| b == 0) {
1006            let s = match std::str::from_utf8(entry) {
1007                Ok(s) => s,
1008                Err(_) => continue,
1009            };
1010            if let Some(val) = s.strip_prefix("WIRE_HOME=") {
1011                return Some(val.to_string());
1012            }
1013        }
1014        None
1015    }
1016
1017    #[cfg(target_os = "macos")]
1018    {
1019        let output = std::process::Command::new("ps")
1020            .args(["-E", "-p", &pid.to_string(), "-o", "command="])
1021            .output()
1022            .ok()?;
1023        let s = String::from_utf8_lossy(&output.stdout);
1024        for tok in s.split_whitespace() {
1025            if let Some(val) = tok.strip_prefix("WIRE_HOME=") {
1026                return Some(val.to_string());
1027            }
1028        }
1029        None
1030    }
1031
1032    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1033    {
1034        let _ = pid;
1035        None
1036    }
1037}
1038
1039/// v0.6.7: apply `detect_session_wire_home` for the current process.
1040///
1041/// If `WIRE_HOME` is unset and the current cwd maps to an existing
1042/// session, set `WIRE_HOME` for the rest of this process and emit a
1043/// one-liner to stderr so the operator knows which identity is in
1044/// use. Noop when `WIRE_HOME` is already set (explicit override wins).
1045///
1046/// `label` distinguishes the caller in the stderr line (`mcp` vs
1047/// `cli`). Set `WIRE_QUIET_AUTOSESSION=1` to suppress the stderr line
1048/// while keeping the env-var application active.
1049///
1050/// MUST be called BEFORE any worker thread or async task spawns —
1051/// `env::set_var` is unsafe in Rust 2024 because of thread-safety
1052/// guarantees, and our use is safe only at process entry.
1053pub fn maybe_adopt_session_wire_home(label: &str) {
1054    if std::env::var("WIRE_HOME").is_ok() {
1055        return;
1056    }
1057    // v0.13: prefer the host-agnostic session key (WIRE_SESSION_ID >
1058    // CLAUDE_CODE_SESSION_ID). Each session gets its own WIRE_HOME under
1059    // `by-key/<hash>` — no cwd lookup, no shared default, no Windows path
1060    // collapse. Falls back to legacy cwd-detect only when no session key is
1061    // present (bare CLI / pre-v0.13 hosts).
1062    let (home, why) = if let Some((key, source)) = resolve_session_key() {
1063        match session_home_for_key(&key) {
1064            Ok(h) => {
1065                // v0.13.2 (E8): do NOT create the home here. Creating it
1066                // unconditionally on every resolution — before any identity
1067                // exists — left a permanent empty home for every transient /
1068                // probe session key that never `wire up`d, accumulating
1069                // forever and surfacing as phantom "?" sisters in list-local.
1070                // The home is created lazily by `ensure_dirs` on the first
1071                // real write (init / claim / send), so an uninitialized
1072                // session leaves no trace on disk. (Write paths already
1073                // tolerate a non-existent WIRE_HOME — the test harness runs
1074                // every test against one.)
1075                (h, format!("session key ({source})"))
1076            }
1077            Err(_) => return,
1078        }
1079    } else if label == "mcp" {
1080        // v0.13.4 (operator directive: per-session ONLY, never cwd). The MCP
1081        // server must NEVER cwd-resolve — that fallback is what collapsed every
1082        // Claude session sharing a launch dir (`~/Source`, `C:\Users\<user>`)
1083        // onto a single persona. A stdio MCP server is one process per Claude
1084        // session, so when no session id reached us (the
1085        // `${CLAUDE_CODE_SESSION_ID}` env-forward is missing or didn't expand)
1086        // we MINT a per-process key: distinct per session, never a shared cwd
1087        // identity. With the env-forward in place this branch isn't reached —
1088        // the session id resolves above.
1089        let minted = format!(
1090            "mcp-proc-{:016x}{:016x}",
1091            rand::random::<u64>(),
1092            rand::random::<u64>()
1093        );
1094        match session_home_for_key(&minted) {
1095            Ok(h) => {
1096                // Pin it for the process so every later resolve is consistent.
1097                unsafe {
1098                    std::env::set_var("WIRE_SESSION_ID", &minted);
1099                }
1100                (
1101                    h,
1102                    "minted per-process key (no session id; cwd disabled for MCP)".to_string(),
1103                )
1104            }
1105            Err(_) => return,
1106        }
1107    } else {
1108        // CLI with no session id. Per the per-session-only directive we do NOT
1109        // cwd-resolve here either — cwd identity is the collision trap (agents
1110        // shell out to the CLI, and any cwd-derived identity risks the wrong /
1111        // shared persona). Under Claude Code the CLI always carries
1112        // CLAUDE_CODE_SESSION_ID (resolved above), so this only hits a bare
1113        // terminal outside an agent host — which gets the stable machine-default
1114        // identity (set WIRE_SESSION_ID / WIRE_HOME for an explicit one). No cwd.
1115        return;
1116    };
1117    // v0.9.1: emit the chatter ONLY when stderr is an interactive TTY.
1118    // When wire is invoked from a non-interactive parent (Claude Code's
1119    // Bash tool, scripts, daemons), the auto-detect line is captured
1120    // alongside command output and pollutes both — wasting agent
1121    // context tokens and breaking JSON parsers that read combined
1122    // streams. WIRE_VERBOSE=1 forces the line on; WIRE_QUIET_AUTOSESSION
1123    // still forces it off for back-compat with v0.9 scripts.
1124    use std::io::IsTerminal;
1125    let quiet_env = std::env::var("WIRE_QUIET_AUTOSESSION").is_ok();
1126    let verbose_env = std::env::var("WIRE_VERBOSE").is_ok();
1127    let interactive = std::io::stderr().is_terminal();
1128    if !quiet_env && (interactive || verbose_env) {
1129        eprintln!(
1130            "wire {label}: adopted {why} → WIRE_HOME=`{}`",
1131            home.display()
1132        );
1133    }
1134    // SAFETY: caller contract is "before any thread spawn." All
1135    // production sites (cli::run, mcp::run) call this as the first
1136    // step in their respective entry points.
1137    unsafe {
1138        std::env::set_var("WIRE_HOME", &home);
1139    }
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144    use super::*;
1145
1146    #[test]
1147    fn valid_session_key_rejects_empty_and_unexpanded_placeholder() {
1148        assert!(valid_session_key("4129275d-cc5c-4d2a"));
1149        assert!(valid_session_key("mcp-proc-deadbeef"));
1150        assert!(!valid_session_key(""));
1151        assert!(!valid_session_key("   "));
1152        // The load-bearing guard: an unexpanded MCP-config placeholder must NOT
1153        // be hashed — that's the all-sessions-collapse (soft-spruce) bug.
1154        assert!(!valid_session_key("${CLAUDE_CODE_SESSION_ID}"));
1155        assert!(!valid_session_key("  ${CLAUDE_CODE_SESSION_ID}  "));
1156    }
1157
1158    #[test]
1159    fn resolve_session_key_vscode_adapter_and_placeholder_guard() {
1160        // Per-adapter test for the VS Code / GitHub Copilot path added in #59.
1161        // Holds two invariants the integration depends on:
1162        //
1163        //   (a) When VSCODE_GIT_REPOSITORY_ROOT is set to a real workspace
1164        //       path, that key wins resolution and two distinct workspace
1165        //       paths produce two distinct session homes — proves the
1166        //       per-workspace-identity contract documented in
1167        //       docs/integrations/GITHUB_COPILOT.md.
1168        //
1169        //   (b) When the env entry is the unexpanded literal "${workspaceFolder}"
1170        //       (host failed to substitute), the ${} guard rejects it and the
1171        //       fn falls through — proves the safe-degradation property
1172        //       (no-identity, NOT cross-workspace collision).
1173        //
1174        // Mirrors the WIRE_SESSION_ID / CLAUDE_CODE_SESSION_ID semantics so any
1175        // future adapter added to the env-check loop inherits the same gates.
1176        let _guard = crate::config::test_support::ENV_LOCK
1177            .lock()
1178            .unwrap_or_else(|p| p.into_inner());
1179
1180        // Snapshot + clear every env var resolve_session_key consults so this
1181        // test is hermetic regardless of the harness environment.
1182        let prev_override = std::env::var_os("WIRE_SESSION_ID");
1183        let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
1184        let prev_codex = std::env::var_os("CODEX_SESSION_ID");
1185        let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
1186        let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
1187        // SAFETY: ENV_LOCK is held, serializing all env access.
1188        unsafe {
1189            std::env::remove_var("WIRE_SESSION_ID");
1190            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1191            std::env::remove_var("CODEX_SESSION_ID");
1192            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1193            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1194        }
1195
1196        // (a) Two distinct workspace paths -> two distinct, stable session homes.
1197        unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/frontend") };
1198        let r1 = resolve_session_key();
1199        assert!(
1200            matches!(&r1, Some((k, src)) if k == "/home/dev/frontend" && *src == "vscode-workspace"),
1201            "VSCODE_GIT_REPOSITORY_ROOT must win resolution and be labeled vscode-workspace; got {r1:?}"
1202        );
1203        let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
1204
1205        unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/backend") };
1206        let r2 = resolve_session_key();
1207        let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
1208        assert_ne!(
1209            home_a, home_b,
1210            "distinct workspace roots must map to distinct session homes (no cross-workspace persona collision)"
1211        );
1212
1213        // Same path again -> same home (resume stability).
1214        unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/frontend") };
1215        let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap();
1216        assert_eq!(
1217            home_a, home_a2,
1218            "same workspace root must yield the same home across calls"
1219        );
1220
1221        // (b) Unexpanded ${workspaceFolder} literal MUST NOT be accepted.
1222        //     With every other adapter still cleared, resolution must fall
1223        //     through to None (or the claude pidfile path, which is absent in
1224        //     this test env) — never hash the literal.
1225        unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "${workspaceFolder}") };
1226        let r_guard = resolve_session_key();
1227        assert!(
1228            !matches!(&r_guard, Some((k, _)) if k.contains("${")),
1229            "unexpanded ${{workspaceFolder}} literal must be rejected by the ${{}} guard; got {r_guard:?}"
1230        );
1231        // Same guard for the other adapter slots.
1232        unsafe {
1233            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1234            std::env::set_var("WIRE_SESSION_ID", "${workspaceFolder}");
1235        }
1236        let r_guard2 = resolve_session_key();
1237        assert!(
1238            !matches!(&r_guard2, Some((k, _)) if k.contains("${")),
1239            "unexpanded ${{workspaceFolder}} in WIRE_SESSION_ID must also be rejected; got {r_guard2:?}"
1240        );
1241
1242        // Restore any env we displaced.
1243        // SAFETY: ENV_LOCK still held.
1244        unsafe {
1245            std::env::remove_var("WIRE_SESSION_ID");
1246            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1247            std::env::remove_var("CODEX_SESSION_ID");
1248            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1249            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1250            if let Some(v) = prev_override {
1251                std::env::set_var("WIRE_SESSION_ID", v);
1252            }
1253            if let Some(v) = prev_claude {
1254                std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
1255            }
1256            if let Some(v) = prev_codex {
1257                std::env::set_var("CODEX_SESSION_ID", v);
1258            }
1259            if let Some(v) = prev_copilot {
1260                std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
1261            }
1262            if let Some(v) = prev_vscode {
1263                std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
1264            }
1265        }
1266    }
1267
1268    #[test]
1269    fn resolve_session_key_copilot_cli_adapter_and_priority() {
1270        // Per-adapter test for the GitHub Copilot CLI path (Phase 2 of #59):
1271        // resolve_session_key reads COPILOT_AGENT_SESSION_ID (set by the
1272        // `gh copilot` / `copilot` CLI host on every session) as a TARGETED
1273        // env adapter — exactly like CLAUDE_CODE_SESSION_ID. Holds three
1274        // invariants:
1275        //
1276        //   (a) Set to a real id -> that key wins resolution and two distinct
1277        //       conversations map to two distinct session homes (per-
1278        //       conversation identity contract).
1279        //   (b) WIRE_SESSION_ID overrides COPILOT_AGENT_SESSION_ID (priority
1280        //       1 trumps priority 3).
1281        //   (c) Unexpanded ${...} literal is rejected by the ${} guard —
1282        //       falls through to the None path, never hashed (mirrors the
1283        //       guard inherited from CLAUDE_CODE_SESSION_ID / WIRE_SESSION_ID
1284        //       / VSCODE_GIT_REPOSITORY_ROOT).
1285        let _guard = crate::config::test_support::ENV_LOCK
1286            .lock()
1287            .unwrap_or_else(|p| p.into_inner());
1288
1289        // Snapshot every env var resolve_session_key consults so the test is
1290        // hermetic regardless of harness environment (this test literally
1291        // runs under Copilot CLI, where COPILOT_AGENT_SESSION_ID is set).
1292        let prev_override = std::env::var_os("WIRE_SESSION_ID");
1293        let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
1294        let prev_codex = std::env::var_os("CODEX_SESSION_ID");
1295        let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
1296        let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
1297        // SAFETY: ENV_LOCK is held, serializing all env access.
1298        unsafe {
1299            std::env::remove_var("WIRE_SESSION_ID");
1300            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1301            std::env::remove_var("CODEX_SESSION_ID");
1302            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1303            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1304        }
1305
1306        // (a) COPILOT_AGENT_SESSION_ID set -> wins resolution; distinct ids
1307        //     map to distinct session homes.
1308        unsafe {
1309            std::env::set_var(
1310                "COPILOT_AGENT_SESSION_ID",
1311                "3869478a-33cc-4c33-82ee-b6403a24d734",
1312            )
1313        };
1314        let r1 = resolve_session_key();
1315        assert!(
1316            matches!(&r1, Some((k, src)) if k == "3869478a-33cc-4c33-82ee-b6403a24d734" && *src == "copilot-cli"),
1317            "COPILOT_AGENT_SESSION_ID must win resolution and be labeled copilot-cli; got {r1:?}"
1318        );
1319        let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
1320
1321        unsafe {
1322            std::env::set_var(
1323                "COPILOT_AGENT_SESSION_ID",
1324                "deadbeef-0000-0000-0000-000000000000",
1325            )
1326        };
1327        let r2 = resolve_session_key();
1328        let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
1329        assert_ne!(
1330            home_a, home_b,
1331            "distinct Copilot CLI session ids must map to distinct session homes"
1332        );
1333
1334        // (b) WIRE_SESSION_ID at priority 1 overrides COPILOT_AGENT_SESSION_ID
1335        //     at priority 3. Operator's explicit universal override always wins.
1336        unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") };
1337        let r_override = resolve_session_key();
1338        assert!(
1339            matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"),
1340            "WIRE_SESSION_ID must beat COPILOT_AGENT_SESSION_ID; got {r_override:?}"
1341        );
1342        unsafe { std::env::remove_var("WIRE_SESSION_ID") };
1343
1344        // (c) Unexpanded ${...} literal is rejected by the ${} guard.
1345        //     `gh copilot` shouldn't ship literal placeholders in
1346        //     COPILOT_AGENT_SESSION_ID, but if some future config-forwarding
1347        //     path does, the guard must reject it (same as for the other
1348        //     adapters) so we never hash the literal and collapse sessions.
1349        unsafe { std::env::set_var("COPILOT_AGENT_SESSION_ID", "${SOME_PLACEHOLDER}") };
1350        let r_guard = resolve_session_key();
1351        assert!(
1352            !matches!(&r_guard, Some((k, _)) if k.contains("${")),
1353            "unexpanded ${{...}} in COPILOT_AGENT_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}"
1354        );
1355
1356        // Restore any env we displaced.
1357        // SAFETY: ENV_LOCK still held.
1358        unsafe {
1359            std::env::remove_var("WIRE_SESSION_ID");
1360            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1361            std::env::remove_var("CODEX_SESSION_ID");
1362            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1363            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1364            if let Some(v) = prev_override {
1365                std::env::set_var("WIRE_SESSION_ID", v);
1366            }
1367            if let Some(v) = prev_claude {
1368                std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
1369            }
1370            if let Some(v) = prev_codex {
1371                std::env::set_var("CODEX_SESSION_ID", v);
1372            }
1373            if let Some(v) = prev_copilot {
1374                std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
1375            }
1376            if let Some(v) = prev_vscode {
1377                std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
1378            }
1379        }
1380    }
1381
1382    #[test]
1383    fn resolve_session_key_codex_cli_adapter_and_priority() {
1384        // Per-adapter test for the OpenAI Codex CLI path (#__pr_codex__).
1385        // resolve_session_key reads CODEX_SESSION_ID as a TARGETED env adapter
1386        // — exactly like CLAUDE_CODE_SESSION_ID and COPILOT_AGENT_SESSION_ID.
1387        // Until Codex itself forwards the thread id to MCP child env, operators
1388        // wire it via `[mcp_servers.<name>.env]` in `~/.codex/config.toml`;
1389        // landing the adapter now means once Codex ships the env it works
1390        // with zero further code change. Holds three invariants:
1391        //
1392        //   (a) Set to a real thread id -> that key wins resolution and two
1393        //       distinct threads map to two distinct session homes
1394        //       (per-thread identity contract).
1395        //   (b) WIRE_SESSION_ID overrides CODEX_SESSION_ID (priority 1
1396        //       trumps priority 3); CLAUDE_CODE_SESSION_ID also outranks
1397        //       CODEX_SESSION_ID (priority 2 trumps priority 3) — the
1398        //       Codex adapter slots between Claude Code and Copilot.
1399        //   (c) Unexpanded ${...} literal is rejected by the ${} guard,
1400        //       falling through rather than collapsing all sessions
1401        //       (mirrors the guard inherited from every other adapter).
1402        let _guard = crate::config::test_support::ENV_LOCK
1403            .lock()
1404            .unwrap_or_else(|p| p.into_inner());
1405
1406        // Snapshot every env var resolve_session_key consults so the test is
1407        // hermetic regardless of harness environment.
1408        let prev_override = std::env::var_os("WIRE_SESSION_ID");
1409        let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
1410        let prev_codex = std::env::var_os("CODEX_SESSION_ID");
1411        let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
1412        let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
1413        // SAFETY: ENV_LOCK is held, serializing all env access.
1414        unsafe {
1415            std::env::remove_var("WIRE_SESSION_ID");
1416            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1417            std::env::remove_var("CODEX_SESSION_ID");
1418            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1419            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1420        }
1421
1422        // (a) CODEX_SESSION_ID set -> wins resolution over the no-id baseline;
1423        //     distinct thread ids map to distinct session homes.
1424        unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66ad-277e-7be3-bdd9-b7708e069f3b") };
1425        let r1 = resolve_session_key();
1426        assert!(
1427            matches!(&r1, Some((k, src)) if k == "019e66ad-277e-7be3-bdd9-b7708e069f3b" && *src == "codex-cli"),
1428            "CODEX_SESSION_ID must win resolution and be labeled codex-cli; got {r1:?}"
1429        );
1430        let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
1431
1432        unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66b6-14de-7142-b43a-1861fe59e945") };
1433        let r2 = resolve_session_key();
1434        let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
1435        assert_ne!(
1436            home_a, home_b,
1437            "distinct Codex thread ids must map to distinct session homes"
1438        );
1439
1440        // Same id again -> same home (resume stability — same thread reconnects
1441        // to the same persona).
1442        unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66ad-277e-7be3-bdd9-b7708e069f3b") };
1443        let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap();
1444        assert_eq!(
1445            home_a, home_a2,
1446            "same Codex thread id must yield the same home across calls"
1447        );
1448
1449        // (b) WIRE_SESSION_ID at priority 1 overrides CODEX_SESSION_ID at
1450        //     priority 3 (operator explicit override always wins).
1451        unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") };
1452        let r_override = resolve_session_key();
1453        assert!(
1454            matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"),
1455            "WIRE_SESSION_ID must beat CODEX_SESSION_ID; got {r_override:?}"
1456        );
1457        unsafe { std::env::remove_var("WIRE_SESSION_ID") };
1458
1459        // CLAUDE_CODE_SESSION_ID at priority 2 also beats CODEX_SESSION_ID at
1460        // priority 3. (Earlier adapters get to claim the host they were
1461        // designed for; Codex slots in after Claude Code.)
1462        unsafe { std::env::set_var("CLAUDE_CODE_SESSION_ID", "claude-wins-over-codex") };
1463        let r_claude_wins = resolve_session_key();
1464        assert!(
1465            matches!(&r_claude_wins, Some((k, src)) if k == "claude-wins-over-codex" && *src == "claude-code"),
1466            "CLAUDE_CODE_SESSION_ID must beat CODEX_SESSION_ID; got {r_claude_wins:?}"
1467        );
1468        unsafe { std::env::remove_var("CLAUDE_CODE_SESSION_ID") };
1469
1470        // (c) Unexpanded ${...} literal is rejected by the ${} guard.
1471        //     If a host's config-forwarding ever ships a literal placeholder,
1472        //     the guard rejects it (same as for every other adapter) so we
1473        //     never hash the literal and collapse sessions.
1474        unsafe { std::env::set_var("CODEX_SESSION_ID", "${SOME_PLACEHOLDER}") };
1475        let r_guard = resolve_session_key();
1476        assert!(
1477            !matches!(&r_guard, Some((k, _)) if k.contains("${")),
1478            "unexpanded ${{...}} in CODEX_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}"
1479        );
1480
1481        // Restore any env we displaced.
1482        // SAFETY: ENV_LOCK still held.
1483        unsafe {
1484            std::env::remove_var("WIRE_SESSION_ID");
1485            std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1486            std::env::remove_var("CODEX_SESSION_ID");
1487            std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1488            std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1489            if let Some(v) = prev_override {
1490                std::env::set_var("WIRE_SESSION_ID", v);
1491            }
1492            if let Some(v) = prev_claude {
1493                std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
1494            }
1495            if let Some(v) = prev_codex {
1496                std::env::set_var("CODEX_SESSION_ID", v);
1497            }
1498            if let Some(v) = prev_copilot {
1499                std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
1500            }
1501            if let Some(v) = prev_vscode {
1502                std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
1503            }
1504        }
1505    }
1506
1507    #[test]
1508    fn list_sessions_sees_by_key_homes_and_root_resolves_from_inside() {
1509        // Regression (v0.13.2): v0.13 moved session homes under
1510        // `sessions/by-key/<hash>`, but (1) list_sessions only scanned the
1511        // top level so by-key homes were invisible, and (2) sessions_root()'s
1512        // inside-session fallback only walked ONE level up (expecting parent
1513        // `sessions`), so an inside-session WIRE_HOME resolved to a bogus
1514        // nested dir. Together they made same-box discovery (list-local /
1515        // pair-all-local) return zero sisters under v0.13.
1516        let _guard = crate::config::test_support::ENV_LOCK
1517            .lock()
1518            .unwrap_or_else(|p| p.into_inner());
1519        let tmp = std::env::temp_dir().join(format!("wire-bykey-{}", rand::random::<u32>()));
1520        let _ = std::fs::remove_dir_all(&tmp);
1521        let root = tmp.join("sessions");
1522        let home = root.join("by-key").join("abc123def4567890");
1523        let cfg = home.join("config").join("wire");
1524        std::fs::create_dir_all(&cfg).unwrap();
1525        std::fs::write(
1526            cfg.join("agent-card.json"),
1527            r#"{"did":"did:wire:test-persona-6e301ab1","handle":"test-persona","verify_keys":{}}"#,
1528        )
1529        .unwrap();
1530
1531        // (1) sessions_root() must find the real root even when WIRE_HOME
1532        //     points INSIDE the by-key home.
1533        // SAFETY: ENV_LOCK is held, serializing all env access.
1534        unsafe { std::env::set_var("WIRE_HOME", &home) };
1535        assert_eq!(
1536            sessions_root().unwrap(),
1537            root,
1538            "sessions_root must resolve the root from inside a by-key home"
1539        );
1540
1541        // (2) list_sessions() must enumerate the by-key home, labeled by handle.
1542        let sessions = list_sessions().unwrap();
1543        let found = sessions
1544            .iter()
1545            .any(|s| s.handle.as_deref() == Some("test-persona"));
1546        unsafe { std::env::remove_var("WIRE_HOME") };
1547        let _ = std::fs::remove_dir_all(&tmp);
1548        assert!(
1549            found,
1550            "by-key home must be enumerated: {:?}",
1551            sessions.iter().map(|s| &s.name).collect::<Vec<_>>()
1552        );
1553    }
1554
1555    #[test]
1556    fn session_home_for_key_is_deterministic_distinct_and_well_formed() {
1557        // session_home_for_key reads WIRE_HOME (via sessions_root); hold the
1558        // shared env lock so a parallel env-mutating test can't change it
1559        // between calls and make a1 != a2 (flaky race).
1560        let _guard = crate::config::test_support::ENV_LOCK
1561            .lock()
1562            .unwrap_or_else(|p| p.into_inner());
1563        let a1 = session_home_for_key("sess-aaa").unwrap();
1564        let a2 = session_home_for_key("sess-aaa").unwrap();
1565        let b = session_home_for_key("sess-bbb").unwrap();
1566        assert_eq!(a1, a2, "same key -> same home (resume stability)");
1567        assert_ne!(a1, b, "distinct keys -> distinct homes (no collision)");
1568        let leaf = a1.file_name().unwrap().to_str().unwrap();
1569        assert_eq!(leaf.len(), 16, "16 hex chars / 64 bits");
1570        assert!(leaf.chars().all(|c| c.is_ascii_hexdigit()));
1571        assert_eq!(
1572            a1.parent().unwrap().file_name().unwrap().to_str().unwrap(),
1573            "by-key"
1574        );
1575    }
1576
1577    #[test]
1578    fn url_is_loopback_recognises_v4_v6_and_localhost_v0_7_4() {
1579        assert!(url_is_loopback("http://127.0.0.1:8771"));
1580        assert!(url_is_loopback("http://127.1.2.3"));
1581        assert!(url_is_loopback("http://localhost:9000"));
1582        assert!(url_is_loopback("https://localhost/v1"));
1583        assert!(url_is_loopback("http://[::1]:8771"));
1584        // Case-insensitive.
1585        assert!(url_is_loopback("HTTP://LOCALHOST:8771"));
1586        // Non-loopback negatives — must NOT be flagged.
1587        assert!(!url_is_loopback("https://wireup.net"));
1588        assert!(!url_is_loopback("http://192.168.1.50:8771"));
1589        assert!(!url_is_loopback("http://10.0.0.5"));
1590        assert!(!url_is_loopback("https://relay.example.com"));
1591    }
1592
1593    #[test]
1594    fn sanitize_handles_unicode_and_long_names() {
1595        assert_eq!(sanitize_name("paul-mac"), "paul-mac");
1596        assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
1597        assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); // ascii-only fallback
1598        assert_eq!(sanitize_name(""), "wire-session");
1599        assert_eq!(sanitize_name("---"), "wire-session");
1600        let long: String = "a".repeat(100);
1601        assert_eq!(sanitize_name(&long).len(), 32);
1602    }
1603
1604    #[test]
1605    fn derive_name_returns_basename_when_no_collision() {
1606        let reg = SessionRegistry::default();
1607        assert_eq!(
1608            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
1609            "wire"
1610        );
1611        assert_eq!(
1612            derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), &reg),
1613            "slancha-mesh"
1614        );
1615    }
1616
1617    #[test]
1618    fn derive_name_returns_stored_name_when_cwd_already_registered() {
1619        let mut reg = SessionRegistry::default();
1620        reg.by_cwd.insert(
1621            "/Users/paul/Source/wire".to_string(),
1622            "wire-special".to_string(),
1623        );
1624        assert_eq!(
1625            derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), &reg),
1626            "wire-special"
1627        );
1628    }
1629
1630    #[test]
1631    fn normalize_cwd_key_case_handling_matches_platform_filesystem() {
1632        // Issue #30 Willard repro: on Windows, two terminals in the "same"
1633        // project under different casings of the same path
1634        // (`C:\Foo\Bar` vs `C:\foo\bar`) hashed to DIFFERENT registry keys
1635        // pre-fix → the second terminal missed the registry lookup, fell
1636        // back to the legacy default identity, and both terminals collapsed
1637        // onto a shared DID. Fix: normalize the cwd key case-insensitively
1638        // on Windows, case-sensitively elsewhere (so distinct-on-disk paths
1639        // on case-sensitive filesystems remain distinct).
1640        let upper = Path::new("/Users/paul/Source/WIRE");
1641        let lower = Path::new("/Users/paul/Source/wire");
1642        if cfg!(windows) {
1643            assert_eq!(
1644                normalize_cwd_key(upper),
1645                normalize_cwd_key(lower),
1646                "on Windows, distinct casings of the same path MUST normalize \
1647                 to the same key (NTFS is case-insensitive by default)"
1648            );
1649        } else {
1650            assert_ne!(
1651                normalize_cwd_key(upper),
1652                normalize_cwd_key(lower),
1653                "on case-sensitive filesystems, distinct casings ARE distinct \
1654                 directories and MUST stay distinct keys"
1655            );
1656        }
1657        // Trivial sanity: same input always produces same output.
1658        assert_eq!(normalize_cwd_key(lower), normalize_cwd_key(lower));
1659    }
1660
1661    #[test]
1662    fn derive_name_no_regression_exact_match_still_resolves() {
1663        // Cross-platform no-regression check for the v0.13.6 lookup
1664        // changes: an exact-match (same casing stored AND looked up)
1665        // entry MUST continue to resolve on the fast path — the new
1666        // O(n) normalized-scan fallback is only reached on the initial
1667        // .get miss.
1668        //
1669        // Honest scope (per coral-weasel's #67 review): this test does
1670        // NOT exercise the case-folding fallback on Linux/macOS — the
1671        // normalizer is a no-op there, so the first `.get` hits and
1672        // the scan never runs. The case-folding behavior is inherently
1673        // Windows-only; that path is covered by
1674        // derive_name_finds_registered_cwd_under_alternate_casing_on_windows
1675        // which executes on Windows CI.
1676        let mut reg = SessionRegistry::default();
1677        let stored = "/Users/Paul/Source/Wire-v0_13_5-Era";
1678        reg.by_cwd
1679            .insert(stored.to_string(), "wire-legacy".to_string());
1680
1681        // Lookup under the EXACT stored path: must resolve on the
1682        // fast `.get` path regardless of platform.
1683        assert_eq!(
1684            derive_name_from_cwd(Path::new(stored), &reg),
1685            "wire-legacy",
1686            "exact-match v0.13.5 entry MUST still resolve under v0.13.6+"
1687        );
1688    }
1689
1690    #[test]
1691    fn derive_name_scan_fallback_runs_when_initial_get_misses() {
1692        // Cross-platform proof that the O(n) normalized-scan fallback
1693        // engages on a .get miss. We can't trigger the *case-folding*
1694        // case on Linux/macOS (normalizer is a no-op), but we CAN
1695        // exercise the scan branch by storing under a key the
1696        // normalized lookup definitely won't hit, and verifying that
1697        // the .find()-based fallback resolves it.
1698        //
1699        // Setup: store under a key that's identical to the lookup
1700        // BUT with a trailing slash difference (so `.get` exact-match
1701        // misses, but our normalize_cwd_key — which preserves the
1702        // trailing slash — also misses; then we rely on the .find()
1703        // iterator). This is a contrived setup that proves the scan
1704        // branch is reachable; it does NOT test case-folding (Windows
1705        // only).
1706        //
1707        // A simpler way to exercise the same logic: store under one
1708        // path, look up under a different path that normalizes to the
1709        // SAME key. Without case-folding, the only way to do that is
1710        // to mutate normalize_cwd_key. Since we can't do that in a
1711        // test, this test instead pins the *no-false-positive* side:
1712        // a path with no matching stored entry must NOT resolve.
1713        let mut reg = SessionRegistry::default();
1714        reg.by_cwd.insert(
1715            "/Users/paul/Source/project-a".to_string(),
1716            "project-a".to_string(),
1717        );
1718
1719        // Distinct path → no match → falls through to basename
1720        // derivation. Proves the scan doesn't fabricate matches.
1721        let derived = derive_name_from_cwd(Path::new("/Users/paul/Source/project-b"), &reg);
1722        assert_eq!(
1723            derived, "project-b",
1724            "non-matching lookup must fall through to basename derivation, \
1725             NOT fabricate a match via the scan"
1726        );
1727    }
1728
1729    #[cfg(windows)]
1730    #[test]
1731    fn derive_name_finds_registered_cwd_under_alternate_casing_on_windows() {
1732        // Direct integration check for the Willard repro on Windows: an
1733        // existing registry entry written under one casing MUST resolve
1734        // when the lookup arrives under a different casing of the same
1735        // path.
1736        //
1737        // Trace through the v0.13.6 read-side O(n) normalized scan:
1738        //   - Stored key: "C:\Users\Willard\ComfyUI\claude-integration"
1739        //   - Lookup cwd: "c:\users\willard\comfyui\claude-integration"
1740        //   - cwd_key  = normalize(lookup) = "c:\users\..." (already lower)
1741        //   - .get(&cwd_key)  → MISS (stored has mixed casing)
1742        //   - .iter().find(normalize(stored) == cwd_key) → HIT
1743        //     (normalize("C:\Users\...") == "c:\users\..." == cwd_key)
1744        //   - Returns "claude-integration" ← the fix.
1745        //
1746        // Pre-fix this returned the basename → phantom hash-suffix → identity
1747        // collision (the original Willard report).
1748        let mut reg = SessionRegistry::default();
1749        reg.by_cwd.insert(
1750            r"C:\Users\Willard\ComfyUI\claude-integration".to_string(),
1751            "claude-integration".to_string(),
1752        );
1753        let from_lower_cwd = Path::new(r"c:\users\willard\comfyui\claude-integration");
1754        assert_eq!(
1755            derive_name_from_cwd(from_lower_cwd, &reg),
1756            "claude-integration",
1757            "Windows lookup MUST find the registered entry regardless of \
1758             how the shell capitalized the cwd, via the normalized scan"
1759        );
1760    }
1761
1762    #[test]
1763    fn read_session_endpoints_handles_missing_relay_state() {
1764        let tmp = tempfile::tempdir().unwrap();
1765        // No relay.json under <home>/config/wire/ — should yield empty.
1766        let endpoints = read_session_endpoints(tmp.path());
1767        assert!(endpoints.is_empty());
1768    }
1769
1770    #[test]
1771    fn read_session_endpoints_parses_dual_slot_form() {
1772        let tmp = tempfile::tempdir().unwrap();
1773        let cfg = tmp.path().join("config").join("wire");
1774        std::fs::create_dir_all(&cfg).unwrap();
1775        let body = serde_json::json!({
1776            "self": {
1777                "relay_url": "https://wireup.net",
1778                "slot_id": "fed-slot",
1779                "slot_token": "fed-tok",
1780                "endpoints": [
1781                    {
1782                        "relay_url": "https://wireup.net",
1783                        "slot_id": "fed-slot",
1784                        "slot_token": "fed-tok",
1785                        "scope": "federation"
1786                    },
1787                    {
1788                        "relay_url": "http://127.0.0.1:8771",
1789                        "slot_id": "loop-slot",
1790                        "slot_token": "loop-tok",
1791                        "scope": "local"
1792                    }
1793                ]
1794            }
1795        });
1796        std::fs::write(cfg.join("relay.json"), serde_json::to_vec(&body).unwrap()).unwrap();
1797        let endpoints = read_session_endpoints(tmp.path());
1798        assert_eq!(endpoints.len(), 2);
1799        let local_count = endpoints
1800            .iter()
1801            .filter(|e| matches!(e.scope, EndpointScope::Local))
1802            .count();
1803        assert_eq!(local_count, 1);
1804        let local = endpoints
1805            .iter()
1806            .find(|e| matches!(e.scope, EndpointScope::Local))
1807            .unwrap();
1808        assert_eq!(local.relay_url, "http://127.0.0.1:8771");
1809        assert_eq!(local.slot_id, "loop-slot");
1810    }
1811
1812    // NOTE: list_local_sessions is integration-tested via tests/cli.rs
1813    // using a subprocess that sets WIRE_HOME per-process. We do not test
1814    // it in-module because env mutation races other parallel unit tests
1815    // (Rust 2024 marks std::env::set_var unsafe for that reason). The
1816    // grouping logic is straightforward enough that the integration
1817    // test plus the read_session_endpoints unit tests above provide
1818    // adequate coverage.
1819
1820    #[test]
1821    fn derive_name_appends_path_hash_when_basename_collides() {
1822        let mut reg = SessionRegistry::default();
1823        reg.by_cwd
1824            .insert("/Users/paul/Source/wire".to_string(), "wire".to_string());
1825        // Different cwd, same basename → must get a hash suffix.
1826        let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), &reg);
1827        assert!(name.starts_with("wire-"));
1828        assert_eq!(name.len(), "wire-".len() + 4); // 4 hex chars
1829        assert_ne!(name, "wire");
1830    }
1831
1832    // ---------- identity-collision warning (issue #29/#30 — broaden to
1833    // every inbox-cursor-owning subcommand, not just `wire mcp`). ----------
1834
1835    #[test]
1836    fn inbox_owning_subcommands_covers_each_runtime_role() {
1837        // Lock the role list down — any addition / removal here must
1838        // come with an updated call site (cli::cmd_daemon, cmd_monitor,
1839        // cmd_notify, mcp::run) and an updated rendezvous in the pgrep
1840        // predicate. The pgrep predicate is built from this list at
1841        // call time, so adding "watch" here automatically extends
1842        // detection — but the warning is only fired if a call site
1843        // also invokes warn_on_identity_collision with that role.
1844        assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"mcp"));
1845        assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"daemon"));
1846        assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"monitor"));
1847        assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"notify"));
1848        // Pair-host is documented-coexist with daemon — must NOT be in
1849        // the list or operators get a false positive on every queued
1850        // pair flow.
1851        assert!(!INBOX_OWNING_SUBCOMMANDS.contains(&"pair-host"));
1852    }
1853
1854    #[test]
1855    fn find_colliders_returns_only_same_home_pids() {
1856        let our_home = "/tmp/wire-home-A";
1857        let others = vec![
1858            (101, Some("/tmp/wire-home-A".to_string())), // collide
1859            (102, Some("/tmp/wire-home-B".to_string())), // distinct home
1860            (103, None),                                 // env-unreadable, skip
1861            (104, Some("/tmp/wire-home-A".to_string())), // collide
1862        ];
1863        let colliders = find_colliders(our_home, &others);
1864        assert_eq!(colliders, vec![101, 104]);
1865    }
1866
1867    #[test]
1868    fn find_colliders_no_match_returns_empty() {
1869        let our_home = "/tmp/wire-home-A";
1870        let others = vec![
1871            (101, Some("/tmp/wire-home-B".to_string())),
1872            (102, Some("/tmp/wire-home-C".to_string())),
1873            (103, None),
1874        ];
1875        assert!(find_colliders(our_home, &others).is_empty());
1876    }
1877
1878    #[test]
1879    fn find_colliders_empty_input_is_empty() {
1880        assert!(find_colliders("/tmp/anywhere", &[]).is_empty());
1881    }
1882
1883    #[test]
1884    fn find_colliders_ignores_substring_matches() {
1885        // `WIRE_HOME=/wire-A` must NOT collide with `WIRE_HOME=/wire-A/sub`.
1886        // Exact-match semantics protect against parent/child confusion.
1887        let our_home = "/tmp/wire-A";
1888        let others = vec![
1889            (201, Some("/tmp/wire-A/sub".to_string())),
1890            (202, Some("/wire-A".to_string())), // distinct path
1891            (203, Some("/tmp/wire-A".to_string())), // real collision
1892        ];
1893        assert_eq!(find_colliders(our_home, &others), vec![203]);
1894    }
1895
1896    #[test]
1897    fn collision_warning_format_includes_role_home_and_pids() {
1898        // Sanity-check the first warning line by reconstructing it
1899        // exactly the way `emit_collision_warning` does. If anyone
1900        // changes the format, this test must change with it — that's
1901        // the point: the format is a documented operator-facing
1902        // surface (Willard's #30 cited the older wording verbatim
1903        // when filing the bug).
1904        let role = "daemon";
1905        let home = "/tmp/by-key/abc123";
1906        let colliders = vec![4242u32, 4243u32];
1907        let expected_head = format!(
1908            "wire {role}: WARNING — {n} other wire process(es) already using WIRE_HOME=`{home}` (pid {pids})",
1909            n = colliders.len(),
1910            pids = colliders
1911                .iter()
1912                .map(u32::to_string)
1913                .collect::<Vec<_>>()
1914                .join(", "),
1915        );
1916        assert_eq!(
1917            expected_head,
1918            "wire daemon: WARNING — 2 other wire process(es) already using WIRE_HOME=`/tmp/by-key/abc123` (pid 4242, 4243)"
1919        );
1920        // Exercise the renderer so it can't bit-rot via dead-code
1921        // pruning. Output goes to stderr; under libtest it's captured.
1922        emit_collision_warning(role, home, &colliders);
1923    }
1924}