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