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