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 the JSON `{"pid": <n>, ...}` form (v0.5.11+). Anything
576 // else reads as "no daemon".
577 serde_json::from_slice::<serde_json::Value>(&bytes)
578 .ok()
579 .and_then(|v| v.get("pid").and_then(|p| p.as_u64()))
580 .map(|p| p as u32)
581}
582
583fn check_daemon_live(session_home: &Path) -> bool {
584 session_daemon_pid(session_home)
585 .map(is_process_live)
586 .unwrap_or(false)
587}
588
589/// Walk every initialized session and read its `daemon.pid`; return a
590/// map from `pid → session_name`. Used by `wire status`'s orphan-pid
591/// annotation (#173 follow-up) so a supervisor child's pid — which
592/// no longer carries `--session <name>` in its cmdline post-#174 — is
593/// still correctly attributed to the session whose home it serves.
594///
595/// Cost: one filesystem read per session per status invocation. On a
596/// 133-session box that's 133 small reads (a few ms total) — bounded
597/// + acceptable. The map is fresh per call; no caching, no staleness.
598pub fn pid_to_session_map() -> HashMap<u32, String> {
599 let mut out = HashMap::new();
600 let sessions = match list_sessions() {
601 Ok(v) => v,
602 Err(_) => return out,
603 };
604 for info in sessions {
605 if let Some(pid) = session_daemon_pid(&info.home_dir) {
606 out.insert(pid, info.name);
607 }
608 }
609 out
610}
611
612fn is_process_live(pid: u32) -> bool {
613 // v0.7.3: delegate to the shared platform helper. The previous
614 // implementation shelled out to `kill -0` on non-Linux, which
615 // unconditionally failed on Windows (no `kill` binary) and made
616 // `wire session list` report every daemon as `down` regardless of
617 // actual liveness.
618 crate::platform::process_alive(pid)
619}
620
621/// Read a session's `relay.json` and return its `self.endpoints[]`
622/// array (v0.5.17 dual-slot). Empty Vec on any read/parse error — this
623/// is a best-effort discovery helper, not a verification tool. A pre-
624/// v0.5.17 session writes only the legacy flat fields; `self_endpoints`
625/// promotes those to a federation-only Endpoint, so the result is
626/// still meaningful for legacy sessions.
627///
628/// v0.5.20 BUG FIX: this used to join `relay-state.json`, which is
629/// not the canonical filename (`config::relay_state_path` returns
630/// `relay.json`). The mis-named read silently no-op'd and
631/// `list-local` always returned an empty `local` map as a result.
632/// Companion to the `cli.rs::try_allocate_local_slot` filename fix
633/// in the same release — that helper had the symmetric write-side
634/// bug, so the local endpoint never got persisted in the first place.
635pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
636 let path = session_home.join("config").join("wire").join("relay.json");
637 let bytes = match std::fs::read(&path) {
638 Ok(b) => b,
639 Err(_) => return Vec::new(),
640 };
641 let val: Value = match serde_json::from_slice(&bytes) {
642 Ok(v) => v,
643 Err(_) => return Vec::new(),
644 };
645 self_endpoints(&val)
646}
647
648/// Stripped view of a Local endpoint for tooling output. Drops
649/// `slot_token` because it is a bearer credential — exposing it
650/// through `wire session list-local --json` would risk accidental
651/// leak via logs, screenshots, or piped output. Routing code uses
652/// the full `Endpoint` from `relay.json` directly; this type
653/// is for human/JSON observation only.
654#[derive(Debug, Clone, Serialize)]
655pub struct LocalEndpointView {
656 pub relay_url: String,
657 pub slot_id: String,
658}
659
660/// One row of `wire session list-local` output: a session that has a
661/// Local-scope endpoint plus metadata to render it.
662#[derive(Debug, Clone, Serialize)]
663pub struct LocalSessionView {
664 pub name: String,
665 pub handle: Option<String>,
666 pub did: Option<String>,
667 pub cwd: Option<String>,
668 pub home_dir: PathBuf,
669 pub daemon_running: bool,
670 /// All Local-scope endpoints this session advertises (token redacted).
671 /// Most sessions have exactly one; multiple is permitted for multi-
672 /// relay setups.
673 pub local_endpoints: Vec<LocalEndpointView>,
674}
675
676/// Sessions with no Local endpoint — shown separately so the operator
677/// knows they exist but are federation-only.
678#[derive(Debug, Clone, Serialize)]
679pub struct FederationOnlySessionView {
680 pub name: String,
681 pub handle: Option<String>,
682 pub cwd: Option<String>,
683}
684
685/// Result shape for `wire session list-local`. `local` is grouped by
686/// the local-relay URL so output can render each cluster of mutually-
687/// reachable sister sessions together. `federation_only` lists the rest.
688#[derive(Debug, Clone, Serialize)]
689pub struct LocalSessionListing {
690 pub local: HashMap<String, Vec<LocalSessionView>>,
691 pub federation_only: Vec<FederationOnlySessionView>,
692}
693
694/// Build the listing for `wire session list-local` from current on-disk
695/// state. Read-only; no daemon contact, no relay probe.
696pub fn list_local_sessions() -> Result<LocalSessionListing> {
697 let sessions = list_sessions()?;
698 let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
699 let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
700
701 for s in sessions {
702 let endpoints = read_session_endpoints(&s.home_dir);
703 let local_eps: Vec<Endpoint> = endpoints
704 .into_iter()
705 .filter(|e| {
706 // v0.7.4: include any session whose endpoint URL is a
707 // loopback address even if it's tagged Federation, not
708 // Local. This catches the legitimate-but-misshapen case
709 // where `wire init --relay http://127.0.0.1:8771` was run
710 // without `--with-local`, leaving the session with a
711 // loopback federation slot that's effectively local-mesh-
712 // reachable. Pre-v0.7.4 the strict scope-only filter
713 // silently excluded those sessions from `pair-all-local`,
714 // making nickname-based pairing fail for no operator-
715 // visible reason.
716 matches!(e.scope, EndpointScope::Local)
717 || (matches!(e.scope, EndpointScope::Federation)
718 && url_is_loopback(&e.relay_url))
719 })
720 .collect();
721 if local_eps.is_empty() {
722 federation_only.push(FederationOnlySessionView {
723 name: s.name.clone(),
724 handle: s.handle.clone(),
725 cwd: s.cwd.clone(),
726 });
727 continue;
728 }
729 // Redacted view: drop slot_token before exposing through CLI.
730 let redacted: Vec<LocalEndpointView> = local_eps
731 .iter()
732 .map(|e| LocalEndpointView {
733 relay_url: e.relay_url.clone(),
734 slot_id: e.slot_id.clone(),
735 })
736 .collect();
737 // Group by relay_url. A session with two Local endpoints (rare —
738 // would mean two loopback relays) appears under each.
739 for ep in &local_eps {
740 local
741 .entry(ep.relay_url.clone())
742 .or_default()
743 .push(LocalSessionView {
744 name: s.name.clone(),
745 handle: s.handle.clone(),
746 did: s.did.clone(),
747 cwd: s.cwd.clone(),
748 home_dir: s.home_dir.clone(),
749 daemon_running: s.daemon_running,
750 local_endpoints: redacted.clone(),
751 });
752 }
753 }
754 // Sort each group by session name so output is deterministic.
755 for group in local.values_mut() {
756 group.sort_by(|a, b| a.name.cmp(&b.name));
757 }
758 federation_only.sort_by(|a, b| a.name.cmp(&b.name));
759 Ok(LocalSessionListing {
760 local,
761 federation_only,
762 })
763}
764
765/// v0.6.7: cwd → session WIRE_HOME lookup. Read-only.
766///
767/// When `WIRE_HOME` isn't set in env, look up `cwd` in the session
768/// registry. If a session is registered for this cwd AND its home
769/// directory still exists, return that home dir; otherwise None.
770///
771/// Used by both `wire mcp` (v0.6.1) and the CLI entry point (v0.6.7)
772/// so a `wire whoami` / `wire monitor` invocation from a project cwd
773/// adopts that project's session identity automatically, instead of
774/// silently falling back to the machine default. The CLI parity is
775/// load-bearing: without it, the user-visible identity diverges
776/// between MCP and the terminal, and monitors pull machine-wide
777/// inboxes when the operator expected a per-session view.
778pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option<PathBuf> {
779 let registry = read_registry().ok()?;
780 // v0.7.0-alpha.2: walk up parent dirs. Subdirs of a registered cwd
781 // inherit their parent's wire identity (e.g.
782 // `~/Source/slancha-business/tools/recon` → `slancha-business` session).
783 // Without this, subdirs all fell back to the machine-wide default
784 // identity, which silently collapsed multiple Claude sessions onto the
785 // same DID + character.
786 let mut probe: Option<&std::path::Path> = Some(cwd);
787 while let Some(path) = probe {
788 // Same O(n) normalized scan as derive_name_from_cwd: handles both
789 // consistent-casing and cross-casing upgraders. See the comment
790 // on derive_name_from_cwd for the rationale.
791 let path_str = normalize_cwd_key(path);
792 if let Some(session_name) = registry.by_cwd.get(&path_str).or_else(|| {
793 registry
794 .by_cwd
795 .iter()
796 .find(|(k, _)| normalize_cwd_key(Path::new(k)) == path_str)
797 .map(|(_, v)| v)
798 }) {
799 let session_home = session_dir(session_name).ok()?;
800 if session_home.exists() {
801 return Some(session_home);
802 }
803 }
804 probe = path.parent();
805 }
806 None
807}
808
809/// v0.13: resolve a stable per-session key — host-agnostic, with a Claude
810/// Code adapter and the path left open for other hosts. Order:
811/// 1. `WIRE_SESSION_ID` — explicit universal override (any harness).
812/// 2. `CLAUDE_CODE_SESSION_ID` — Claude Code adapter (stable per
813/// conversation; the same id the auto-memory system keys off).
814/// 3. `CODEX_SESSION_ID` — OpenAI Codex CLI adapter. Stable per Codex
815/// thread (the same UUIDv7 emitted in `thread.started` and used as
816/// the rollout-file suffix under `$CODEX_HOME/sessions/`). Codex
817/// does not yet forward this var to MCP children out of the box —
818/// operators must set it via `[mcp_servers.<name>.env]` in
819/// `~/.codex/config.toml` (or upstream Codex must add it to the
820/// MCP child env). Wiring the name in advance means once Codex
821/// ships the env, wire picks it up with zero further code change.
822/// 4. `COPILOT_AGENT_SESSION_ID` — GitHub Copilot CLI (`gh copilot` /
823/// `copilot`) adapter. Set by the Copilot CLI host for every
824/// session; stable per conversation; UUID-shaped.
825/// 5. `VSCODE_GIT_REPOSITORY_ROOT` — VS Code/GitHub Copilot workspace-based
826/// identity (stable per workspace).
827/// 6. `None` — caller falls back to legacy cwd-detect (bare CLI /
828/// pre-v0.13 hosts). Future host adapters slot in before this.
829///
830/// Returns `(key, source-label)`.
831pub fn resolve_session_key() -> Option<(String, &'static str)> {
832 for (var, source) in [
833 ("WIRE_SESSION_ID", "override"),
834 ("CLAUDE_CODE_SESSION_ID", "claude-code"),
835 ("CODEX_SESSION_ID", "codex-cli"),
836 ("COPILOT_AGENT_SESSION_ID", "copilot-cli"),
837 ("VSCODE_GIT_REPOSITORY_ROOT", "vscode-workspace"),
838 ] {
839 if let Ok(v) = std::env::var(var)
840 && valid_session_key(&v)
841 {
842 return Some((v.trim().to_string(), source));
843 }
844 }
845 // Claude Code adapter (host-agnostic fallback). On some platforms the MCP
846 // server process does not inherit CLAUDE_CODE_SESSION_ID and the MCP
847 // `initialize` handshake carries no session id, so the env checks above
848 // miss. Claude Code, however, writes `~/.claude/sessions/<pid>.json`
849 // ({"sessionId":..., "cwd":...}) for each live session, named by the
850 // owning `claude` process PID. Walk our parent-process chain to that
851 // process and read its sessionId — deterministic, race-free, env-free.
852 if let Some(sid) = claude_code_session_from_pidfile() {
853 return Some((sid, "claude-code-pidfile"));
854 }
855
856 None
857}
858
859/// A session key from the environment is usable only if it is non-empty and is
860/// NOT an unexpanded `${...}` placeholder. A host that writes
861/// `"env": {"WIRE_SESSION_ID": "${CLAUDE_CODE_SESSION_ID}"}` but doesn't expand
862/// it (Windows Claude Code passes the literal when the var is absent) would
863/// otherwise have wire hash the literal — collapsing every session onto one
864/// identity. Treat any `${...}` value as unset so resolution falls through to
865/// the PID-file adapter / per-process mint instead of a shared bogus persona.
866fn valid_session_key(v: &str) -> bool {
867 let v = v.trim();
868 !v.is_empty() && !v.contains("${")
869}
870
871/// Recover the Claude Code session id from the per-session PID-file when it
872/// isn't available via the environment. Claude Code writes
873/// `~/.claude/sessions/<pid>.json` = `{"sessionId": "...", "cwd": "...", ...}`
874/// for each live session, keyed by the owning `claude` process PID. The MCP
875/// server we run inside is a descendant of that process, so we walk our
876/// parent chain and return the `sessionId` of the first ancestor that has a
877/// PID-file. Cross-platform: the file exists on macOS/Linux/Windows alike.
878fn claude_code_session_from_pidfile() -> Option<String> {
879 let dir = dirs::home_dir()?.join(".claude").join("sessions");
880 let mut pid = std::process::id();
881 // Chains are shallow (MCP server -> launcher -> claude); 16 is generous.
882 for _ in 0..16 {
883 let f = dir.join(format!("{pid}.json"));
884 if let Ok(txt) = std::fs::read_to_string(&f)
885 && let Ok(v) = serde_json::from_str::<Value>(&txt)
886 && let Some(s) = v.get("sessionId").and_then(Value::as_str)
887 {
888 let s = s.trim();
889 if !s.is_empty() {
890 return Some(s.to_string());
891 }
892 }
893 pid = parent_pid(pid)?;
894 }
895 None
896}
897
898/// Best-effort parent-PID lookup. Linux: `/proc/<pid>/status`. macOS: `ps`.
899/// Windows: PowerShell CIM (no extra crate). Returns `None` on any failure,
900/// which simply ends the walk.
901#[cfg(target_os = "linux")]
902fn parent_pid(pid: u32) -> Option<u32> {
903 let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
904 for line in status.lines() {
905 if let Some(rest) = line.strip_prefix("PPid:") {
906 return rest.trim().parse().ok();
907 }
908 }
909 None
910}
911
912#[cfg(target_os = "macos")]
913fn parent_pid(pid: u32) -> Option<u32> {
914 let out = std::process::Command::new("ps")
915 .args(["-o", "ppid=", "-p", &pid.to_string()])
916 .output()
917 .ok()?;
918 String::from_utf8_lossy(&out.stdout).trim().parse().ok()
919}
920
921#[cfg(target_os = "windows")]
922fn parent_pid(pid: u32) -> Option<u32> {
923 use std::os::windows::process::CommandExt;
924 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
925 let out = std::process::Command::new("powershell")
926 .args([
927 "-NoProfile",
928 "-NonInteractive",
929 "-Command",
930 &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').ParentProcessId"),
931 ])
932 .creation_flags(CREATE_NO_WINDOW)
933 .output()
934 .ok()?;
935 String::from_utf8_lossy(&out.stdout).trim().parse().ok()
936}
937
938#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
939fn parent_pid(_pid: u32) -> Option<u32> {
940 None
941}
942
943/// v0.13: the WIRE_HOME for a resolved session key —
944/// `<sessions_root>/by-key/<hash>` where `hash` is the first 16 hex of
945/// SHA-256(key). Deterministic and cwd-independent, so two sessions never
946/// collide and there is no path-string to mis-normalize (the Windows bug
947/// cannot occur). 64 bits is collision-safe at this scale.
948pub fn session_home_for_key(key: &str) -> Result<PathBuf> {
949 let mut h = Sha256::new();
950 h.update(key.as_bytes());
951 let digest = h.finalize();
952 let hash = hex::encode(&digest[..8]); // 16 hex chars / 64 bits
953 Ok(sessions_root()?.join("by-key").join(hash))
954}
955
956/// Long-running `wire <subcommand>` invocations that own the inbox
957/// cursor and therefore race each other under a shared `WIRE_HOME`.
958/// Keep this list in sync with [`warn_on_identity_collision`]'s pgrep
959/// predicate and the call-site list in `cli::run` / `mcp::run`.
960///
961/// Note: `pair-host` (and the rest of the SAS code-phrase flow) was removed
962/// in RFC-005 follow-on, so it is naturally absent from this list.
963///
964/// Short-lived commands (`whoami`, `status`, `send`, `peers`, …) are
965/// intentionally absent — they write atomically and don't race, and
966/// warning on every one would spam any operator running scripts.
967pub const INBOX_OWNING_SUBCOMMANDS: &[&str] = &["mcp", "daemon", "monitor", "notify"];
968
969/// v0.6.10: warn at MCP/CLI startup if another long-running `wire`
970/// process is already running with the same effective `WIRE_HOME`.
971/// Closes the "two Claudes in same cwd silently share an identity"
972/// failure mode that wasted hours of operator debugging time: today
973/// the collision is invisible (both Claudes resolve to the same wire
974/// session via v0.6.7 auto-detect, race the inbox cursor, "look
975/// identical" from the operator's view). This surfaces it explicitly
976/// with a clear remediation path.
977///
978/// `role` is the calling subcommand label (`"mcp"`, `"daemon"`,
979/// `"monitor"`, …) — used in the warning's leading tag so operators
980/// can tell which surface is observing the collision. Detection
981/// itself spans every inbox-owning role: a `wire daemon` colliding
982/// with an existing `wire mcp` warns just the same as an mcp/mcp
983/// pair.
984///
985/// Best-effort: any subprocess / env-read failure is silent (the
986/// collision check should never block startup). Cross-platform via
987/// `ps -E -p <pid>` on macOS, `/proc/<pid>/environ` on Linux. Windows
988/// returns empty (no collision detected).
989pub fn warn_on_identity_collision(self_pid: u32, role: &str) {
990 let our_wire_home = match std::env::var("WIRE_HOME") {
991 Ok(h) => h,
992 Err(_) => return,
993 };
994
995 // Single pgrep call with an alternation predicate. `pgrep -f`
996 // matches against the full argv string, so `wire (mcp|daemon|…)`
997 // catches every inbox-owning subcommand in one shot. Falls back to
998 // silent no-op on platforms without pgrep (Windows) — the env-read
999 // path below also returns None there, so detection is end-to-end
1000 // unsupported on Windows. Future: a powershell adapter for
1001 // identity collisions, tracked in #29 / #30.
1002 let predicate = format!("wire ({})", INBOX_OWNING_SUBCOMMANDS.join("|"));
1003 let pgrep_out = match std::process::Command::new("pgrep")
1004 .args(["-f", &predicate])
1005 .output()
1006 {
1007 Ok(o) if o.status.success() => o,
1008 _ => return,
1009 };
1010
1011 let other_pids: Vec<u32> = String::from_utf8_lossy(&pgrep_out.stdout)
1012 .split_whitespace()
1013 .filter_map(|s| s.parse::<u32>().ok())
1014 .filter(|&p| p != self_pid)
1015 .collect();
1016
1017 let other_homes: Vec<(u32, Option<String>)> = other_pids
1018 .iter()
1019 .map(|p| (*p, read_wire_home_from_pid(*p)))
1020 .collect();
1021
1022 let colliders = find_colliders(&our_wire_home, &other_homes);
1023
1024 if colliders.is_empty() {
1025 return;
1026 }
1027
1028 emit_collision_warning(role, &our_wire_home, &colliders);
1029}
1030
1031/// Pure decision: from a snapshot of `(pid, their_wire_home)` for
1032/// every other wire process on the host, return the pids whose
1033/// `WIRE_HOME` exactly matches ours. Missing-home entries (process
1034/// died, env unreadable on this platform) are skipped, never counted.
1035pub(crate) fn find_colliders(
1036 our_wire_home: &str,
1037 other_homes: &[(u32, Option<String>)],
1038) -> Vec<u32> {
1039 other_homes
1040 .iter()
1041 .filter_map(|(pid, their_home)| match their_home {
1042 Some(h) if h == our_wire_home => Some(*pid),
1043 _ => None,
1044 })
1045 .collect()
1046}
1047
1048/// Render the collision warning. Extracted so the format is unit-
1049/// testable without mocking a real pgrep / cross-process env read.
1050pub(crate) fn emit_collision_warning(role: &str, our_wire_home: &str, colliders: &[u32]) {
1051 eprintln!(
1052 "wire {role}: WARNING — {} other wire process(es) already using WIRE_HOME=`{}` (pid {})",
1053 colliders.len(),
1054 our_wire_home,
1055 colliders
1056 .iter()
1057 .map(|p| p.to_string())
1058 .collect::<Vec<_>>()
1059 .join(", ")
1060 );
1061 eprintln!(
1062 " Multiple agents sharing one identity will race the inbox cursor; messages may be lost."
1063 );
1064 eprintln!(" To use a separate identity:");
1065 eprintln!(" 1. Close the other agent(s), OR");
1066 eprintln!(" 2. `wire session new <name> --local-only` to create a fresh identity, then");
1067 eprintln!(
1068 " 3. Restart THIS agent's launcher with `export WIRE_HOME=<path printed by step 2>`"
1069 );
1070}
1071
1072/// Best-effort cross-platform read of another process's `WIRE_HOME`.
1073/// Linux: parses `/proc/<pid>/environ` (NUL-separated KEY=VAL).
1074/// macOS: `ps -E -p <pid>` (whitespace-separated KEY=VAL prefix).
1075/// Windows / other: returns `None` (collision detection no-ops).
1076fn read_wire_home_from_pid(pid: u32) -> Option<String> {
1077 #[cfg(target_os = "linux")]
1078 {
1079 let path = format!("/proc/{pid}/environ");
1080 let bytes = std::fs::read(&path).ok()?;
1081 for entry in bytes.split(|&b| b == 0) {
1082 let s = match std::str::from_utf8(entry) {
1083 Ok(s) => s,
1084 Err(_) => continue,
1085 };
1086 if let Some(val) = s.strip_prefix("WIRE_HOME=") {
1087 return Some(val.to_string());
1088 }
1089 }
1090 None
1091 }
1092
1093 #[cfg(target_os = "macos")]
1094 {
1095 let output = std::process::Command::new("ps")
1096 .args(["-E", "-p", &pid.to_string(), "-o", "command="])
1097 .output()
1098 .ok()?;
1099 let s = String::from_utf8_lossy(&output.stdout);
1100 for tok in s.split_whitespace() {
1101 if let Some(val) = tok.strip_prefix("WIRE_HOME=") {
1102 return Some(val.to_string());
1103 }
1104 }
1105 None
1106 }
1107
1108 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1109 {
1110 let _ = pid;
1111 None
1112 }
1113}
1114
1115/// v0.6.7: apply `detect_session_wire_home` for the current process.
1116///
1117/// If `WIRE_HOME` is unset and the current cwd maps to an existing
1118/// session, set `WIRE_HOME` for the rest of this process and emit a
1119/// one-liner to stderr so the operator knows which identity is in
1120/// use. Noop when `WIRE_HOME` is already set (explicit override wins).
1121///
1122/// `label` distinguishes the caller in the stderr line (`mcp` vs
1123/// `cli`). Output only appears on interactive TTYs; set `WIRE_VERBOSE=1`
1124/// to force it on in non-interactive contexts.
1125///
1126/// MUST be called BEFORE any worker thread or async task spawns —
1127/// `env::set_var` is unsafe in Rust 2024 because of thread-safety
1128/// guarantees, and our use is safe only at process entry.
1129/// Process-global record of WHICH signal won session/home resolution,
1130/// captured at adoption time by [`maybe_adopt_session_wire_home`]. Read by
1131/// `wire whoami --json` (`session_source`) so an operator can see in one
1132/// command whether identity came from an explicit `WIRE_HOME`, a host
1133/// session-id adapter, the Claude-Code pidfile fallback, a minted
1134/// per-process key, or the machine default. Post-hoc re-derivation is
1135/// unreliable — minting sets `WIRE_SESSION_ID` and `WIRE_HOME` is always set
1136/// after adoption — so the winning source MUST be captured here, once.
1137static SESSION_SOURCE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
1138
1139/// The signal that won session/home resolution for this process. One of:
1140/// `env:WIRE_HOME`, `override` (`WIRE_SESSION_ID`), `claude-code`,
1141/// `claude-code-pidfile`, `codex-cli`, `copilot-cli`, `vscode-workspace`,
1142/// `minted`, `machine-default`, or `unknown` if adoption never ran.
1143pub fn session_source() -> &'static str {
1144 SESSION_SOURCE.get().copied().unwrap_or("unknown")
1145}
1146
1147pub fn maybe_adopt_session_wire_home(label: &str) {
1148 if std::env::var("WIRE_HOME").is_ok() {
1149 let _ = SESSION_SOURCE.set("env:WIRE_HOME");
1150 return;
1151 }
1152 // v0.13: prefer the host-agnostic session key (WIRE_SESSION_ID >
1153 // CLAUDE_CODE_SESSION_ID). Each session gets its own WIRE_HOME under
1154 // `by-key/<hash>` — no cwd lookup, no shared default, no Windows path
1155 // collapse. Falls back to legacy cwd-detect only when no session key is
1156 // present (bare CLI / pre-v0.13 hosts).
1157 let (home, why) = if let Some((key, source)) = resolve_session_key() {
1158 match session_home_for_key(&key) {
1159 Ok(h) => {
1160 // v0.13.2 (E8): do NOT create the home here. Creating it
1161 // unconditionally on every resolution — before any identity
1162 // exists — left a permanent empty home for every transient /
1163 // probe session key that never `wire up`d, accumulating
1164 // forever and surfacing as phantom "?" sisters in list-local.
1165 // The home is created lazily by `ensure_dirs` on the first
1166 // real write (init / claim / send), so an uninitialized
1167 // session leaves no trace on disk. (Write paths already
1168 // tolerate a non-existent WIRE_HOME — the test harness runs
1169 // every test against one.)
1170 let _ = SESSION_SOURCE.set(source);
1171 (h, format!("session key ({source})"))
1172 }
1173 Err(_) => return,
1174 }
1175 } else if label == "mcp" {
1176 // v0.13.4 (operator directive: per-session ONLY, never cwd). The MCP
1177 // server must NEVER cwd-resolve — that fallback is what collapsed every
1178 // Claude session sharing a launch dir (`~/Source`, `C:\Users\<user>`)
1179 // onto a single persona. A stdio MCP server is one process per Claude
1180 // session, so when no session id reached us (the
1181 // `${CLAUDE_CODE_SESSION_ID}` env-forward is missing or didn't expand)
1182 // we MINT a per-process key: distinct per session, never a shared cwd
1183 // identity. With the env-forward in place this branch isn't reached —
1184 // the session id resolves above.
1185 let minted = format!(
1186 "mcp-proc-{:016x}{:016x}",
1187 rand::random::<u64>(),
1188 rand::random::<u64>()
1189 );
1190 match session_home_for_key(&minted) {
1191 Ok(h) => {
1192 // Pin it for the process so every later resolve is consistent.
1193 unsafe {
1194 std::env::set_var("WIRE_SESSION_ID", &minted);
1195 }
1196 let _ = SESSION_SOURCE.set("minted");
1197 (
1198 h,
1199 "minted per-process key (no session id; cwd disabled for MCP)".to_string(),
1200 )
1201 }
1202 Err(_) => return,
1203 }
1204 } else {
1205 // CLI with no session id. Per the per-session-only directive we do NOT
1206 // cwd-resolve here either — cwd identity is the collision trap (agents
1207 // shell out to the CLI, and any cwd-derived identity risks the wrong /
1208 // shared persona). Under Claude Code the CLI always carries
1209 // CLAUDE_CODE_SESSION_ID (resolved above), so this only hits a bare
1210 // terminal outside an agent host — which gets the stable machine-default
1211 // identity (set WIRE_SESSION_ID / WIRE_HOME for an explicit one). No cwd.
1212 let _ = SESSION_SOURCE.set("machine-default");
1213 return;
1214 };
1215 // v0.9.1: emit the chatter ONLY when stderr is an interactive TTY.
1216 // When wire is invoked from a non-interactive parent (Claude Code's
1217 // Bash tool, scripts, daemons), the auto-detect line is captured
1218 // alongside command output and pollutes both — wasting agent
1219 // context tokens and breaking JSON parsers that read combined
1220 // streams. WIRE_VERBOSE=1 forces the line on.
1221 use std::io::IsTerminal;
1222 let verbose_env = std::env::var("WIRE_VERBOSE").is_ok();
1223 let interactive = std::io::stderr().is_terminal();
1224 if interactive || verbose_env {
1225 eprintln!(
1226 "wire {label}: adopted {why} → WIRE_HOME=`{}`",
1227 home.display()
1228 );
1229 }
1230 // SAFETY: caller contract is "before any thread spawn." All
1231 // production sites (cli::run, mcp::run) call this as the first
1232 // step in their respective entry points.
1233 unsafe {
1234 std::env::set_var("WIRE_HOME", &home);
1235 }
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240 use super::*;
1241
1242 #[test]
1243 fn valid_session_key_rejects_empty_and_unexpanded_placeholder() {
1244 assert!(valid_session_key("4129275d-cc5c-4d2a"));
1245 assert!(valid_session_key("mcp-proc-deadbeef"));
1246 assert!(!valid_session_key(""));
1247 assert!(!valid_session_key(" "));
1248 // The load-bearing guard: an unexpanded MCP-config placeholder must NOT
1249 // be hashed — that's the all-sessions-collapse (soft-spruce) bug.
1250 assert!(!valid_session_key("${CLAUDE_CODE_SESSION_ID}"));
1251 assert!(!valid_session_key(" ${CLAUDE_CODE_SESSION_ID} "));
1252 }
1253
1254 #[test]
1255 fn resolve_session_key_vscode_adapter_and_placeholder_guard() {
1256 // Per-adapter test for the VS Code / GitHub Copilot path added in #59.
1257 // Holds two invariants the integration depends on:
1258 //
1259 // (a) When VSCODE_GIT_REPOSITORY_ROOT is set to a real workspace
1260 // path, that key wins resolution and two distinct workspace
1261 // paths produce two distinct session homes — proves the
1262 // per-workspace-identity contract documented in
1263 // docs/integrations/GITHUB_COPILOT.md.
1264 //
1265 // (b) When the env entry is the unexpanded literal "${workspaceFolder}"
1266 // (host failed to substitute), the ${} guard rejects it and the
1267 // fn falls through — proves the safe-degradation property
1268 // (no-identity, NOT cross-workspace collision).
1269 //
1270 // Mirrors the WIRE_SESSION_ID / CLAUDE_CODE_SESSION_ID semantics so any
1271 // future adapter added to the env-check loop inherits the same gates.
1272 let _guard = crate::config::test_support::ENV_LOCK
1273 .lock()
1274 .unwrap_or_else(|p| p.into_inner());
1275
1276 // Snapshot + clear every env var resolve_session_key consults so this
1277 // test is hermetic regardless of the harness environment.
1278 let prev_override = std::env::var_os("WIRE_SESSION_ID");
1279 let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
1280 let prev_codex = std::env::var_os("CODEX_SESSION_ID");
1281 let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
1282 let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
1283 // SAFETY: ENV_LOCK is held, serializing all env access.
1284 unsafe {
1285 std::env::remove_var("WIRE_SESSION_ID");
1286 std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1287 std::env::remove_var("CODEX_SESSION_ID");
1288 std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1289 std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1290 }
1291
1292 // (a) Two distinct workspace paths -> two distinct, stable session homes.
1293 unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/frontend") };
1294 let r1 = resolve_session_key();
1295 assert!(
1296 matches!(&r1, Some((k, src)) if k == "/home/dev/frontend" && *src == "vscode-workspace"),
1297 "VSCODE_GIT_REPOSITORY_ROOT must win resolution and be labeled vscode-workspace; got {r1:?}"
1298 );
1299 let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
1300
1301 unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/backend") };
1302 let r2 = resolve_session_key();
1303 let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
1304 assert_ne!(
1305 home_a, home_b,
1306 "distinct workspace roots must map to distinct session homes (no cross-workspace persona collision)"
1307 );
1308
1309 // Same path again -> same home (resume stability).
1310 unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/frontend") };
1311 let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap();
1312 assert_eq!(
1313 home_a, home_a2,
1314 "same workspace root must yield the same home across calls"
1315 );
1316
1317 // (b) Unexpanded ${workspaceFolder} literal MUST NOT be accepted.
1318 // With every other adapter still cleared, resolution must fall
1319 // through to None (or the claude pidfile path, which is absent in
1320 // this test env) — never hash the literal.
1321 unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "${workspaceFolder}") };
1322 let r_guard = resolve_session_key();
1323 assert!(
1324 !matches!(&r_guard, Some((k, _)) if k.contains("${")),
1325 "unexpanded ${{workspaceFolder}} literal must be rejected by the ${{}} guard; got {r_guard:?}"
1326 );
1327 // Same guard for the other adapter slots.
1328 unsafe {
1329 std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1330 std::env::set_var("WIRE_SESSION_ID", "${workspaceFolder}");
1331 }
1332 let r_guard2 = resolve_session_key();
1333 assert!(
1334 !matches!(&r_guard2, Some((k, _)) if k.contains("${")),
1335 "unexpanded ${{workspaceFolder}} in WIRE_SESSION_ID must also be rejected; got {r_guard2:?}"
1336 );
1337
1338 // Restore any env we displaced.
1339 // SAFETY: ENV_LOCK still held.
1340 unsafe {
1341 std::env::remove_var("WIRE_SESSION_ID");
1342 std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1343 std::env::remove_var("CODEX_SESSION_ID");
1344 std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1345 std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1346 if let Some(v) = prev_override {
1347 std::env::set_var("WIRE_SESSION_ID", v);
1348 }
1349 if let Some(v) = prev_claude {
1350 std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
1351 }
1352 if let Some(v) = prev_codex {
1353 std::env::set_var("CODEX_SESSION_ID", v);
1354 }
1355 if let Some(v) = prev_copilot {
1356 std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
1357 }
1358 if let Some(v) = prev_vscode {
1359 std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
1360 }
1361 }
1362 }
1363
1364 #[test]
1365 fn resolve_session_key_copilot_cli_adapter_and_priority() {
1366 // Per-adapter test for the GitHub Copilot CLI path (Phase 2 of #59):
1367 // resolve_session_key reads COPILOT_AGENT_SESSION_ID (set by the
1368 // `gh copilot` / `copilot` CLI host on every session) as a TARGETED
1369 // env adapter — exactly like CLAUDE_CODE_SESSION_ID. Holds three
1370 // invariants:
1371 //
1372 // (a) Set to a real id -> that key wins resolution and two distinct
1373 // conversations map to two distinct session homes (per-
1374 // conversation identity contract).
1375 // (b) WIRE_SESSION_ID overrides COPILOT_AGENT_SESSION_ID (priority
1376 // 1 trumps priority 3).
1377 // (c) Unexpanded ${...} literal is rejected by the ${} guard —
1378 // falls through to the None path, never hashed (mirrors the
1379 // guard inherited from CLAUDE_CODE_SESSION_ID / WIRE_SESSION_ID
1380 // / VSCODE_GIT_REPOSITORY_ROOT).
1381 let _guard = crate::config::test_support::ENV_LOCK
1382 .lock()
1383 .unwrap_or_else(|p| p.into_inner());
1384
1385 // Snapshot every env var resolve_session_key consults so the test is
1386 // hermetic regardless of harness environment (this test literally
1387 // runs under Copilot CLI, where COPILOT_AGENT_SESSION_ID is set).
1388 let prev_override = std::env::var_os("WIRE_SESSION_ID");
1389 let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
1390 let prev_codex = std::env::var_os("CODEX_SESSION_ID");
1391 let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
1392 let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
1393 // SAFETY: ENV_LOCK is held, serializing all env access.
1394 unsafe {
1395 std::env::remove_var("WIRE_SESSION_ID");
1396 std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1397 std::env::remove_var("CODEX_SESSION_ID");
1398 std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1399 std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1400 }
1401
1402 // (a) COPILOT_AGENT_SESSION_ID set -> wins resolution; distinct ids
1403 // map to distinct session homes.
1404 unsafe {
1405 std::env::set_var(
1406 "COPILOT_AGENT_SESSION_ID",
1407 "3869478a-33cc-4c33-82ee-b6403a24d734",
1408 )
1409 };
1410 let r1 = resolve_session_key();
1411 assert!(
1412 matches!(&r1, Some((k, src)) if k == "3869478a-33cc-4c33-82ee-b6403a24d734" && *src == "copilot-cli"),
1413 "COPILOT_AGENT_SESSION_ID must win resolution and be labeled copilot-cli; got {r1:?}"
1414 );
1415 let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
1416
1417 unsafe {
1418 std::env::set_var(
1419 "COPILOT_AGENT_SESSION_ID",
1420 "deadbeef-0000-0000-0000-000000000000",
1421 )
1422 };
1423 let r2 = resolve_session_key();
1424 let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
1425 assert_ne!(
1426 home_a, home_b,
1427 "distinct Copilot CLI session ids must map to distinct session homes"
1428 );
1429
1430 // (b) WIRE_SESSION_ID at priority 1 overrides COPILOT_AGENT_SESSION_ID
1431 // at priority 3. Operator's explicit universal override always wins.
1432 unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") };
1433 let r_override = resolve_session_key();
1434 assert!(
1435 matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"),
1436 "WIRE_SESSION_ID must beat COPILOT_AGENT_SESSION_ID; got {r_override:?}"
1437 );
1438 unsafe { std::env::remove_var("WIRE_SESSION_ID") };
1439
1440 // (c) Unexpanded ${...} literal is rejected by the ${} guard.
1441 // `gh copilot` shouldn't ship literal placeholders in
1442 // COPILOT_AGENT_SESSION_ID, but if some future config-forwarding
1443 // path does, the guard must reject it (same as for the other
1444 // adapters) so we never hash the literal and collapse sessions.
1445 unsafe { std::env::set_var("COPILOT_AGENT_SESSION_ID", "${SOME_PLACEHOLDER}") };
1446 let r_guard = resolve_session_key();
1447 assert!(
1448 !matches!(&r_guard, Some((k, _)) if k.contains("${")),
1449 "unexpanded ${{...}} in COPILOT_AGENT_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}"
1450 );
1451
1452 // Restore any env we displaced.
1453 // SAFETY: ENV_LOCK still held.
1454 unsafe {
1455 std::env::remove_var("WIRE_SESSION_ID");
1456 std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1457 std::env::remove_var("CODEX_SESSION_ID");
1458 std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1459 std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1460 if let Some(v) = prev_override {
1461 std::env::set_var("WIRE_SESSION_ID", v);
1462 }
1463 if let Some(v) = prev_claude {
1464 std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
1465 }
1466 if let Some(v) = prev_codex {
1467 std::env::set_var("CODEX_SESSION_ID", v);
1468 }
1469 if let Some(v) = prev_copilot {
1470 std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
1471 }
1472 if let Some(v) = prev_vscode {
1473 std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
1474 }
1475 }
1476 }
1477
1478 #[test]
1479 fn resolve_session_key_codex_cli_adapter_and_priority() {
1480 // Per-adapter test for the OpenAI Codex CLI path (#__pr_codex__).
1481 // resolve_session_key reads CODEX_SESSION_ID as a TARGETED env adapter
1482 // — exactly like CLAUDE_CODE_SESSION_ID and COPILOT_AGENT_SESSION_ID.
1483 // Until Codex itself forwards the thread id to MCP child env, operators
1484 // wire it via `[mcp_servers.<name>.env]` in `~/.codex/config.toml`;
1485 // landing the adapter now means once Codex ships the env it works
1486 // with zero further code change. Holds three invariants:
1487 //
1488 // (a) Set to a real thread id -> that key wins resolution and two
1489 // distinct threads map to two distinct session homes
1490 // (per-thread identity contract).
1491 // (b) WIRE_SESSION_ID overrides CODEX_SESSION_ID (priority 1
1492 // trumps priority 3); CLAUDE_CODE_SESSION_ID also outranks
1493 // CODEX_SESSION_ID (priority 2 trumps priority 3) — the
1494 // Codex adapter slots between Claude Code and Copilot.
1495 // (c) Unexpanded ${...} literal is rejected by the ${} guard,
1496 // falling through rather than collapsing all sessions
1497 // (mirrors the guard inherited from every other adapter).
1498 let _guard = crate::config::test_support::ENV_LOCK
1499 .lock()
1500 .unwrap_or_else(|p| p.into_inner());
1501
1502 // Snapshot every env var resolve_session_key consults so the test is
1503 // hermetic regardless of harness environment.
1504 let prev_override = std::env::var_os("WIRE_SESSION_ID");
1505 let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
1506 let prev_codex = std::env::var_os("CODEX_SESSION_ID");
1507 let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
1508 let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
1509 // SAFETY: ENV_LOCK is held, serializing all env access.
1510 unsafe {
1511 std::env::remove_var("WIRE_SESSION_ID");
1512 std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1513 std::env::remove_var("CODEX_SESSION_ID");
1514 std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1515 std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1516 }
1517
1518 // (a) CODEX_SESSION_ID set -> wins resolution over the no-id baseline;
1519 // distinct thread ids map to distinct session homes.
1520 unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66ad-277e-7be3-bdd9-b7708e069f3b") };
1521 let r1 = resolve_session_key();
1522 assert!(
1523 matches!(&r1, Some((k, src)) if k == "019e66ad-277e-7be3-bdd9-b7708e069f3b" && *src == "codex-cli"),
1524 "CODEX_SESSION_ID must win resolution and be labeled codex-cli; got {r1:?}"
1525 );
1526 let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
1527
1528 unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66b6-14de-7142-b43a-1861fe59e945") };
1529 let r2 = resolve_session_key();
1530 let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
1531 assert_ne!(
1532 home_a, home_b,
1533 "distinct Codex thread ids must map to distinct session homes"
1534 );
1535
1536 // Same id again -> same home (resume stability — same thread reconnects
1537 // to the same persona).
1538 unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66ad-277e-7be3-bdd9-b7708e069f3b") };
1539 let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap();
1540 assert_eq!(
1541 home_a, home_a2,
1542 "same Codex thread id must yield the same home across calls"
1543 );
1544
1545 // (b) WIRE_SESSION_ID at priority 1 overrides CODEX_SESSION_ID at
1546 // priority 3 (operator explicit override always wins).
1547 unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") };
1548 let r_override = resolve_session_key();
1549 assert!(
1550 matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"),
1551 "WIRE_SESSION_ID must beat CODEX_SESSION_ID; got {r_override:?}"
1552 );
1553 unsafe { std::env::remove_var("WIRE_SESSION_ID") };
1554
1555 // CLAUDE_CODE_SESSION_ID at priority 2 also beats CODEX_SESSION_ID at
1556 // priority 3. (Earlier adapters get to claim the host they were
1557 // designed for; Codex slots in after Claude Code.)
1558 unsafe { std::env::set_var("CLAUDE_CODE_SESSION_ID", "claude-wins-over-codex") };
1559 let r_claude_wins = resolve_session_key();
1560 assert!(
1561 matches!(&r_claude_wins, Some((k, src)) if k == "claude-wins-over-codex" && *src == "claude-code"),
1562 "CLAUDE_CODE_SESSION_ID must beat CODEX_SESSION_ID; got {r_claude_wins:?}"
1563 );
1564 unsafe { std::env::remove_var("CLAUDE_CODE_SESSION_ID") };
1565
1566 // (c) Unexpanded ${...} literal is rejected by the ${} guard.
1567 // If a host's config-forwarding ever ships a literal placeholder,
1568 // the guard rejects it (same as for every other adapter) so we
1569 // never hash the literal and collapse sessions.
1570 unsafe { std::env::set_var("CODEX_SESSION_ID", "${SOME_PLACEHOLDER}") };
1571 let r_guard = resolve_session_key();
1572 assert!(
1573 !matches!(&r_guard, Some((k, _)) if k.contains("${")),
1574 "unexpanded ${{...}} in CODEX_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}"
1575 );
1576
1577 // Restore any env we displaced.
1578 // SAFETY: ENV_LOCK still held.
1579 unsafe {
1580 std::env::remove_var("WIRE_SESSION_ID");
1581 std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1582 std::env::remove_var("CODEX_SESSION_ID");
1583 std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1584 std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1585 if let Some(v) = prev_override {
1586 std::env::set_var("WIRE_SESSION_ID", v);
1587 }
1588 if let Some(v) = prev_claude {
1589 std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
1590 }
1591 if let Some(v) = prev_codex {
1592 std::env::set_var("CODEX_SESSION_ID", v);
1593 }
1594 if let Some(v) = prev_copilot {
1595 std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
1596 }
1597 if let Some(v) = prev_vscode {
1598 std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
1599 }
1600 }
1601 }
1602
1603 #[test]
1604 fn list_sessions_sees_by_key_homes_and_root_resolves_from_inside() {
1605 // Regression (v0.13.2): v0.13 moved session homes under
1606 // `sessions/by-key/<hash>`, but (1) list_sessions only scanned the
1607 // top level so by-key homes were invisible, and (2) sessions_root()'s
1608 // inside-session fallback only walked ONE level up (expecting parent
1609 // `sessions`), so an inside-session WIRE_HOME resolved to a bogus
1610 // nested dir. Together they made same-box discovery (list-local /
1611 // pair-all-local) return zero sisters under v0.13.
1612 let _guard = crate::config::test_support::ENV_LOCK
1613 .lock()
1614 .unwrap_or_else(|p| p.into_inner());
1615 let tmp = std::env::temp_dir().join(format!("wire-bykey-{}", rand::random::<u32>()));
1616 let _ = std::fs::remove_dir_all(&tmp);
1617 let root = tmp.join("sessions");
1618 let home = root.join("by-key").join("abc123def4567890");
1619 let cfg = home.join("config").join("wire");
1620 std::fs::create_dir_all(&cfg).unwrap();
1621 std::fs::write(
1622 cfg.join("agent-card.json"),
1623 r#"{"did":"did:wire:test-persona-6e301ab1","handle":"test-persona","verify_keys":{}}"#,
1624 )
1625 .unwrap();
1626
1627 // (1) sessions_root() must find the real root even when WIRE_HOME
1628 // points INSIDE the by-key home.
1629 // SAFETY: ENV_LOCK is held, serializing all env access.
1630 unsafe { std::env::set_var("WIRE_HOME", &home) };
1631 assert_eq!(
1632 sessions_root().unwrap(),
1633 root,
1634 "sessions_root must resolve the root from inside a by-key home"
1635 );
1636
1637 // (2) list_sessions() must enumerate the by-key home, labeled by handle.
1638 let sessions = list_sessions().unwrap();
1639 let found = sessions
1640 .iter()
1641 .any(|s| s.handle.as_deref() == Some("test-persona"));
1642 unsafe { std::env::remove_var("WIRE_HOME") };
1643 let _ = std::fs::remove_dir_all(&tmp);
1644 assert!(
1645 found,
1646 "by-key home must be enumerated: {:?}",
1647 sessions.iter().map(|s| &s.name).collect::<Vec<_>>()
1648 );
1649 }
1650
1651 #[test]
1652 fn find_session_home_by_name_resolves_both_layouts() {
1653 // #44 / #170 follow-up: v0.6 top-level sessions (dir name ==
1654 // operator-typed name) and v0.13 by-key sessions (dir name is
1655 // a hash, operator types the persona handle from the card)
1656 // must BOTH resolve via `find_session_home_by_name`. Pre-fix
1657 // (`session_dir(name)` only) the v0.13 by-key case bailed
1658 // with "session not found" even though `wire session list`
1659 // showed it.
1660 let _guard = crate::config::test_support::ENV_LOCK
1661 .lock()
1662 .unwrap_or_else(|p| p.into_inner());
1663 let tmp = std::env::temp_dir().join(format!("wire-find-{}", rand::random::<u32>()));
1664 let _ = std::fs::remove_dir_all(&tmp);
1665 let root = tmp.join("sessions");
1666
1667 // Legacy v0.6 top-level: a dir named `legacy-pane` directly
1668 // under sessions_root.
1669 let legacy_home = root.join("legacy-pane");
1670 let legacy_cfg = legacy_home.join("config").join("wire");
1671 std::fs::create_dir_all(&legacy_cfg).unwrap();
1672 std::fs::write(
1673 legacy_cfg.join("agent-card.json"),
1674 r#"{"did":"did:wire:legacy-pane-aaaa1111","handle":"legacy-pane","verify_keys":{}}"#,
1675 )
1676 .unwrap();
1677
1678 // v0.13 by-key: dir name is a hash, card's handle is `coral-weasel`.
1679 let bykey_home = root.join("by-key").join("3049827d92d4fbd5");
1680 let bykey_cfg = bykey_home.join("config").join("wire");
1681 std::fs::create_dir_all(&bykey_cfg).unwrap();
1682 std::fs::write(
1683 bykey_cfg.join("agent-card.json"),
1684 r#"{"did":"did:wire:coral-weasel-0616dc6c","handle":"coral-weasel","verify_keys":{}}"#,
1685 )
1686 .unwrap();
1687
1688 // SAFETY: ENV_LOCK is held.
1689 unsafe { std::env::set_var("WIRE_HOME", &root) };
1690
1691 // Legacy lookup: operator types the literal dir name.
1692 let legacy = super::find_session_home_by_name("legacy-pane").unwrap();
1693 assert_eq!(
1694 legacy.as_deref(),
1695 Some(legacy_home.as_path()),
1696 "v0.6 top-level layout: legacy-pane must resolve to its top-level dir"
1697 );
1698
1699 // by-key lookup: operator types the persona handle, not the hash.
1700 let bykey = super::find_session_home_by_name("coral-weasel").unwrap();
1701 assert_eq!(
1702 bykey.as_deref(),
1703 Some(bykey_home.as_path()),
1704 "v0.13 by-key layout: coral-weasel must resolve to its by-key/<hash> dir"
1705 );
1706
1707 // by-key lookup via the hash itself also works (some tooling
1708 // may pass the raw dir name).
1709 let by_hash = super::find_session_home_by_name("3049827d92d4fbd5").unwrap();
1710 assert_eq!(
1711 by_hash.as_deref(),
1712 Some(bykey_home.as_path()),
1713 "v0.13 by-key layout: hash dir name must also resolve"
1714 );
1715
1716 // Negative: an unknown name returns None, not an error.
1717 let missing = super::find_session_home_by_name("never-existed").unwrap();
1718 assert_eq!(missing, None, "unknown session must return None");
1719
1720 unsafe { std::env::remove_var("WIRE_HOME") };
1721 let _ = std::fs::remove_dir_all(&tmp);
1722 }
1723
1724 #[test]
1725 fn pid_to_session_map_builds_from_session_pidfiles() {
1726 // #173 follow-up (#174 hotfix removed --session arg from
1727 // supervisor children): wire status orphan annotation now
1728 // maps pid → session via per-session pidfiles. Walk should
1729 // find each session whose `<home>/state/wire/daemon.pid`
1730 // contains a valid pid, and IGNORE sessions whose pidfile
1731 // is absent or unreadable.
1732 let _guard = crate::config::test_support::ENV_LOCK
1733 .lock()
1734 .unwrap_or_else(|p| p.into_inner());
1735 let tmp = std::env::temp_dir().join(format!("wire-p2s-{}", rand::random::<u32>()));
1736 let _ = std::fs::remove_dir_all(&tmp);
1737 let root = tmp.join("sessions");
1738 // Three by-key sessions. Two have pidfiles, one doesn't.
1739 let mk_session = |key: &str, handle: &str| -> PathBuf {
1740 let home = root.join("by-key").join(key);
1741 let cfg = home.join("config").join("wire");
1742 std::fs::create_dir_all(&cfg).unwrap();
1743 std::fs::write(
1744 cfg.join("agent-card.json"),
1745 format!(
1746 r#"{{"did":"did:wire:{handle}-6e301ab1","handle":"{handle}","verify_keys":{{}}}}"#
1747 ),
1748 )
1749 .unwrap();
1750 home
1751 };
1752 let h1 = mk_session("abc123def4567890", "alpha-aurora");
1753 let h2 = mk_session("def456abc7890123", "beta-blossom");
1754 let _h3 = mk_session("0000aaaabbbbcccc", "gamma-gorge");
1755 // h1 / h2 get JSON pidfiles; h3 gets none.
1756 let state1 = h1.join("state").join("wire");
1757 let state2 = h2.join("state").join("wire");
1758 std::fs::create_dir_all(&state1).unwrap();
1759 std::fs::create_dir_all(&state2).unwrap();
1760 std::fs::write(state1.join("daemon.pid"), r#"{"pid": 12345}"#).unwrap();
1761 std::fs::write(state2.join("daemon.pid"), r#"{"pid": 67890}"#).unwrap();
1762
1763 // SAFETY: ENV_LOCK is held, serializing all env access.
1764 unsafe { std::env::set_var("WIRE_HOME", &h1) };
1765 let map = super::pid_to_session_map();
1766 unsafe { std::env::remove_var("WIRE_HOME") };
1767 let _ = std::fs::remove_dir_all(&tmp);
1768
1769 // h1 / h2 present, h3 absent. SessionInfo.name is the handle
1770 // derived from the card when the home is initialized
1771 // (list_sessions's mk helper overrides name = handle in that
1772 // case; by-key hash is only the fallback for uninitialized
1773 // homes). That's exactly the production label `wire status`
1774 // already prints for sessions.
1775 assert_eq!(
1776 map.get(&12345).map(String::as_str),
1777 Some("alpha-aurora"),
1778 "pid 12345 should map to the handle for h1"
1779 );
1780 assert_eq!(
1781 map.get(&67890).map(String::as_str),
1782 Some("beta-blossom"),
1783 "pid 67890 should map (JSON pidfile form, handle for h2)"
1784 );
1785 // Sanity: no entry for an unrelated pid.
1786 assert!(
1787 !map.contains_key(&99999),
1788 "synthetic missing pid should not appear in the map"
1789 );
1790 }
1791
1792 #[test]
1793 fn session_home_for_key_is_deterministic_distinct_and_well_formed() {
1794 // session_home_for_key reads WIRE_HOME (via sessions_root); hold the
1795 // shared env lock so a parallel env-mutating test can't change it
1796 // between calls and make a1 != a2 (flaky race).
1797 let _guard = crate::config::test_support::ENV_LOCK
1798 .lock()
1799 .unwrap_or_else(|p| p.into_inner());
1800 let a1 = session_home_for_key("sess-aaa").unwrap();
1801 let a2 = session_home_for_key("sess-aaa").unwrap();
1802 let b = session_home_for_key("sess-bbb").unwrap();
1803 assert_eq!(a1, a2, "same key -> same home (resume stability)");
1804 assert_ne!(a1, b, "distinct keys -> distinct homes (no collision)");
1805 let leaf = a1.file_name().unwrap().to_str().unwrap();
1806 assert_eq!(leaf.len(), 16, "16 hex chars / 64 bits");
1807 assert!(leaf.chars().all(|c| c.is_ascii_hexdigit()));
1808 assert_eq!(
1809 a1.parent().unwrap().file_name().unwrap().to_str().unwrap(),
1810 "by-key"
1811 );
1812 }
1813
1814 #[test]
1815 fn url_is_loopback_recognises_v4_v6_and_localhost_v0_7_4() {
1816 assert!(url_is_loopback("http://127.0.0.1:8771"));
1817 assert!(url_is_loopback("http://127.1.2.3"));
1818 assert!(url_is_loopback("http://localhost:9000"));
1819 assert!(url_is_loopback("https://localhost/v1"));
1820 assert!(url_is_loopback("http://[::1]:8771"));
1821 // Case-insensitive.
1822 assert!(url_is_loopback("HTTP://LOCALHOST:8771"));
1823 // Non-loopback negatives — must NOT be flagged.
1824 assert!(!url_is_loopback("https://wireup.net"));
1825 assert!(!url_is_loopback("http://192.168.1.50:8771"));
1826 assert!(!url_is_loopback("http://10.0.0.5"));
1827 assert!(!url_is_loopback("https://relay.example.com"));
1828 }
1829
1830 #[test]
1831 fn sanitize_handles_unicode_and_long_names() {
1832 assert_eq!(sanitize_name("paul-mac"), "paul-mac");
1833 assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
1834 assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); // ascii-only fallback
1835 assert_eq!(sanitize_name(""), "wire-session");
1836 assert_eq!(sanitize_name("---"), "wire-session");
1837 let long: String = "a".repeat(100);
1838 assert_eq!(sanitize_name(&long).len(), 32);
1839 }
1840
1841 #[test]
1842 fn derive_name_returns_basename_when_no_collision() {
1843 let reg = SessionRegistry::default();
1844 assert_eq!(
1845 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
1846 "wire"
1847 );
1848 assert_eq!(
1849 derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), ®),
1850 "slancha-mesh"
1851 );
1852 }
1853
1854 #[test]
1855 fn derive_name_returns_stored_name_when_cwd_already_registered() {
1856 let mut reg = SessionRegistry::default();
1857 reg.by_cwd.insert(
1858 "/Users/paul/Source/wire".to_string(),
1859 "wire-special".to_string(),
1860 );
1861 assert_eq!(
1862 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
1863 "wire-special"
1864 );
1865 }
1866
1867 #[test]
1868 fn normalize_cwd_key_case_handling_matches_platform_filesystem() {
1869 // Issue #30 Willard repro: on Windows, two terminals in the "same"
1870 // project under different casings of the same path
1871 // (`C:\Foo\Bar` vs `C:\foo\bar`) hashed to DIFFERENT registry keys
1872 // pre-fix → the second terminal missed the registry lookup, fell
1873 // back to the legacy default identity, and both terminals collapsed
1874 // onto a shared DID. Fix: normalize the cwd key case-insensitively
1875 // on Windows, case-sensitively elsewhere (so distinct-on-disk paths
1876 // on case-sensitive filesystems remain distinct).
1877 let upper = Path::new("/Users/paul/Source/WIRE");
1878 let lower = Path::new("/Users/paul/Source/wire");
1879 if cfg!(windows) {
1880 assert_eq!(
1881 normalize_cwd_key(upper),
1882 normalize_cwd_key(lower),
1883 "on Windows, distinct casings of the same path MUST normalize \
1884 to the same key (NTFS is case-insensitive by default)"
1885 );
1886 } else {
1887 assert_ne!(
1888 normalize_cwd_key(upper),
1889 normalize_cwd_key(lower),
1890 "on case-sensitive filesystems, distinct casings ARE distinct \
1891 directories and MUST stay distinct keys"
1892 );
1893 }
1894 // Trivial sanity: same input always produces same output.
1895 assert_eq!(normalize_cwd_key(lower), normalize_cwd_key(lower));
1896 }
1897
1898 #[test]
1899 fn derive_name_no_regression_exact_match_still_resolves() {
1900 // Cross-platform no-regression check for the v0.13.6 lookup
1901 // changes: an exact-match (same casing stored AND looked up)
1902 // entry MUST continue to resolve on the fast path — the new
1903 // O(n) normalized-scan fallback is only reached on the initial
1904 // .get miss.
1905 //
1906 // Honest scope (per coral-weasel's #67 review): this test does
1907 // NOT exercise the case-folding fallback on Linux/macOS — the
1908 // normalizer is a no-op there, so the first `.get` hits and
1909 // the scan never runs. The case-folding behavior is inherently
1910 // Windows-only; that path is covered by
1911 // derive_name_finds_registered_cwd_under_alternate_casing_on_windows
1912 // which executes on Windows CI.
1913 let mut reg = SessionRegistry::default();
1914 let stored = "/Users/Paul/Source/Wire-v0_13_5-Era";
1915 reg.by_cwd
1916 .insert(stored.to_string(), "wire-legacy".to_string());
1917
1918 // Lookup under the EXACT stored path: must resolve on the
1919 // fast `.get` path regardless of platform.
1920 assert_eq!(
1921 derive_name_from_cwd(Path::new(stored), ®),
1922 "wire-legacy",
1923 "exact-match v0.13.5 entry MUST still resolve under v0.13.6+"
1924 );
1925 }
1926
1927 #[test]
1928 fn derive_name_scan_fallback_runs_when_initial_get_misses() {
1929 // Cross-platform proof that the O(n) normalized-scan fallback
1930 // engages on a .get miss. We can't trigger the *case-folding*
1931 // case on Linux/macOS (normalizer is a no-op), but we CAN
1932 // exercise the scan branch by storing under a key the
1933 // normalized lookup definitely won't hit, and verifying that
1934 // the .find()-based fallback resolves it.
1935 //
1936 // Setup: store under a key that's identical to the lookup
1937 // BUT with a trailing slash difference (so `.get` exact-match
1938 // misses, but our normalize_cwd_key — which preserves the
1939 // trailing slash — also misses; then we rely on the .find()
1940 // iterator). This is a contrived setup that proves the scan
1941 // branch is reachable; it does NOT test case-folding (Windows
1942 // only).
1943 //
1944 // A simpler way to exercise the same logic: store under one
1945 // path, look up under a different path that normalizes to the
1946 // SAME key. Without case-folding, the only way to do that is
1947 // to mutate normalize_cwd_key. Since we can't do that in a
1948 // test, this test instead pins the *no-false-positive* side:
1949 // a path with no matching stored entry must NOT resolve.
1950 let mut reg = SessionRegistry::default();
1951 reg.by_cwd.insert(
1952 "/Users/paul/Source/project-a".to_string(),
1953 "project-a".to_string(),
1954 );
1955
1956 // Distinct path → no match → falls through to basename
1957 // derivation. Proves the scan doesn't fabricate matches.
1958 let derived = derive_name_from_cwd(Path::new("/Users/paul/Source/project-b"), ®);
1959 assert_eq!(
1960 derived, "project-b",
1961 "non-matching lookup must fall through to basename derivation, \
1962 NOT fabricate a match via the scan"
1963 );
1964 }
1965
1966 #[cfg(windows)]
1967 #[test]
1968 fn derive_name_finds_registered_cwd_under_alternate_casing_on_windows() {
1969 // Direct integration check for the Willard repro on Windows: an
1970 // existing registry entry written under one casing MUST resolve
1971 // when the lookup arrives under a different casing of the same
1972 // path.
1973 //
1974 // Trace through the v0.13.6 read-side O(n) normalized scan:
1975 // - Stored key: "C:\Users\Willard\ComfyUI\claude-integration"
1976 // - Lookup cwd: "c:\users\willard\comfyui\claude-integration"
1977 // - cwd_key = normalize(lookup) = "c:\users\..." (already lower)
1978 // - .get(&cwd_key) → MISS (stored has mixed casing)
1979 // - .iter().find(normalize(stored) == cwd_key) → HIT
1980 // (normalize("C:\Users\...") == "c:\users\..." == cwd_key)
1981 // - Returns "claude-integration" ← the fix.
1982 //
1983 // Pre-fix this returned the basename → phantom hash-suffix → identity
1984 // collision (the original Willard report).
1985 let mut reg = SessionRegistry::default();
1986 reg.by_cwd.insert(
1987 r"C:\Users\Willard\ComfyUI\claude-integration".to_string(),
1988 "claude-integration".to_string(),
1989 );
1990 let from_lower_cwd = Path::new(r"c:\users\willard\comfyui\claude-integration");
1991 assert_eq!(
1992 derive_name_from_cwd(from_lower_cwd, ®),
1993 "claude-integration",
1994 "Windows lookup MUST find the registered entry regardless of \
1995 how the shell capitalized the cwd, via the normalized scan"
1996 );
1997 }
1998
1999 #[test]
2000 fn read_session_endpoints_handles_missing_relay_state() {
2001 let tmp = tempfile::tempdir().unwrap();
2002 // No relay.json under <home>/config/wire/ — should yield empty.
2003 let endpoints = read_session_endpoints(tmp.path());
2004 assert!(endpoints.is_empty());
2005 }
2006
2007 #[test]
2008 fn read_session_endpoints_parses_dual_slot_form() {
2009 let tmp = tempfile::tempdir().unwrap();
2010 let cfg = tmp.path().join("config").join("wire");
2011 std::fs::create_dir_all(&cfg).unwrap();
2012 let body = serde_json::json!({
2013 "self": {
2014 "relay_url": "https://wireup.net",
2015 "slot_id": "fed-slot",
2016 "slot_token": "fed-tok",
2017 "endpoints": [
2018 {
2019 "relay_url": "https://wireup.net",
2020 "slot_id": "fed-slot",
2021 "slot_token": "fed-tok",
2022 "scope": "federation"
2023 },
2024 {
2025 "relay_url": "http://127.0.0.1:8771",
2026 "slot_id": "loop-slot",
2027 "slot_token": "loop-tok",
2028 "scope": "local"
2029 }
2030 ]
2031 }
2032 });
2033 std::fs::write(cfg.join("relay.json"), serde_json::to_vec(&body).unwrap()).unwrap();
2034 let endpoints = read_session_endpoints(tmp.path());
2035 assert_eq!(endpoints.len(), 2);
2036 let local_count = endpoints
2037 .iter()
2038 .filter(|e| matches!(e.scope, EndpointScope::Local))
2039 .count();
2040 assert_eq!(local_count, 1);
2041 let local = endpoints
2042 .iter()
2043 .find(|e| matches!(e.scope, EndpointScope::Local))
2044 .unwrap();
2045 assert_eq!(local.relay_url, "http://127.0.0.1:8771");
2046 assert_eq!(local.slot_id, "loop-slot");
2047 }
2048
2049 // NOTE: list_local_sessions is integration-tested via tests/cli.rs
2050 // using a subprocess that sets WIRE_HOME per-process. We do not test
2051 // it in-module because env mutation races other parallel unit tests
2052 // (Rust 2024 marks std::env::set_var unsafe for that reason). The
2053 // grouping logic is straightforward enough that the integration
2054 // test plus the read_session_endpoints unit tests above provide
2055 // adequate coverage.
2056
2057 #[test]
2058 fn derive_name_appends_path_hash_when_basename_collides() {
2059 let mut reg = SessionRegistry::default();
2060 reg.by_cwd
2061 .insert("/Users/paul/Source/wire".to_string(), "wire".to_string());
2062 // Different cwd, same basename → must get a hash suffix.
2063 let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), ®);
2064 assert!(name.starts_with("wire-"));
2065 assert_eq!(name.len(), "wire-".len() + 4); // 4 hex chars
2066 assert_ne!(name, "wire");
2067 }
2068
2069 // ---------- identity-collision warning (issue #29/#30 — broaden to
2070 // every inbox-cursor-owning subcommand, not just `wire mcp`). ----------
2071
2072 #[test]
2073 fn inbox_owning_subcommands_covers_each_runtime_role() {
2074 // Lock the role list down — any addition / removal here must
2075 // come with an updated call site (cli::cmd_daemon, cmd_monitor,
2076 // cmd_notify, mcp::run) and an updated rendezvous in the pgrep
2077 // predicate. The pgrep predicate is built from this list at
2078 // call time, so adding "watch" here automatically extends
2079 // detection — but the warning is only fired if a call site
2080 // also invokes warn_on_identity_collision with that role.
2081 assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"mcp"));
2082 assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"daemon"));
2083 assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"monitor"));
2084 assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"notify"));
2085 // pair-host (SAS code-phrase flow) was removed in RFC-005 follow-on
2086 // and must not appear in the list.
2087 assert!(!INBOX_OWNING_SUBCOMMANDS.contains(&"pair-host"));
2088 }
2089
2090 #[test]
2091 fn find_colliders_returns_only_same_home_pids() {
2092 let our_home = "/tmp/wire-home-A";
2093 let others = vec![
2094 (101, Some("/tmp/wire-home-A".to_string())), // collide
2095 (102, Some("/tmp/wire-home-B".to_string())), // distinct home
2096 (103, None), // env-unreadable, skip
2097 (104, Some("/tmp/wire-home-A".to_string())), // collide
2098 ];
2099 let colliders = find_colliders(our_home, &others);
2100 assert_eq!(colliders, vec![101, 104]);
2101 }
2102
2103 #[test]
2104 fn find_colliders_no_match_returns_empty() {
2105 let our_home = "/tmp/wire-home-A";
2106 let others = vec![
2107 (101, Some("/tmp/wire-home-B".to_string())),
2108 (102, Some("/tmp/wire-home-C".to_string())),
2109 (103, None),
2110 ];
2111 assert!(find_colliders(our_home, &others).is_empty());
2112 }
2113
2114 #[test]
2115 fn find_colliders_empty_input_is_empty() {
2116 assert!(find_colliders("/tmp/anywhere", &[]).is_empty());
2117 }
2118
2119 #[test]
2120 fn find_colliders_ignores_substring_matches() {
2121 // `WIRE_HOME=/wire-A` must NOT collide with `WIRE_HOME=/wire-A/sub`.
2122 // Exact-match semantics protect against parent/child confusion.
2123 let our_home = "/tmp/wire-A";
2124 let others = vec![
2125 (201, Some("/tmp/wire-A/sub".to_string())),
2126 (202, Some("/wire-A".to_string())), // distinct path
2127 (203, Some("/tmp/wire-A".to_string())), // real collision
2128 ];
2129 assert_eq!(find_colliders(our_home, &others), vec![203]);
2130 }
2131
2132 #[test]
2133 fn collision_warning_format_includes_role_home_and_pids() {
2134 // Sanity-check the first warning line by reconstructing it
2135 // exactly the way `emit_collision_warning` does. If anyone
2136 // changes the format, this test must change with it — that's
2137 // the point: the format is a documented operator-facing
2138 // surface (Willard's #30 cited the older wording verbatim
2139 // when filing the bug).
2140 let role = "daemon";
2141 let home = "/tmp/by-key/abc123";
2142 let colliders = vec![4242u32, 4243u32];
2143 let expected_head = format!(
2144 "wire {role}: WARNING — {n} other wire process(es) already using WIRE_HOME=`{home}` (pid {pids})",
2145 n = colliders.len(),
2146 pids = colliders
2147 .iter()
2148 .map(u32::to_string)
2149 .collect::<Vec<_>>()
2150 .join(", "),
2151 );
2152 assert_eq!(
2153 expected_head,
2154 "wire daemon: WARNING — 2 other wire process(es) already using WIRE_HOME=`/tmp/by-key/abc123` (pid 4242, 4243)"
2155 );
2156 // Exercise the renderer so it can't bit-rot via dead-code
2157 // pruning. Output goes to stderr; under libtest it's captured.
2158 emit_collision_warning(role, home, &colliders);
2159 }
2160}