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