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 session_role_pid(session_home, "daemon")
586}
587
588/// Read a session home's `<role>.pid` (path-based, no WIRE_HOME read).
589/// Same JSON shape as `daemon.pid`. None if absent/corrupt.
590///
591/// #247 finding 4: lets the Windows identity-collision adapter walk
592/// `list_sessions()` × every inbox-owning role and reverse-map a
593/// candidate PID back to its serving `WIRE_HOME` without needing to
594/// read the remote process's environment.
595pub fn session_role_pid(session_home: &Path, role: &str) -> Option<u32> {
596 let pidfile = session_home
597 .join("state")
598 .join("wire")
599 .join(format!("{role}.pid"));
600 let bytes = std::fs::read(&pidfile).ok()?;
601 serde_json::from_slice::<serde_json::Value>(&bytes)
602 .ok()
603 .and_then(|v| v.get("pid").and_then(|p| p.as_u64()))
604 .map(|p| p as u32)
605}
606
607fn check_daemon_live(session_home: &Path) -> bool {
608 session_daemon_pid(session_home)
609 .map(is_process_live)
610 .unwrap_or(false)
611}
612
613/// Walk every initialized session and read its `daemon.pid`; return a
614/// map from `pid → session_name`. Used by `wire status`'s orphan-pid
615/// annotation (#173 follow-up) so a supervisor child's pid — which
616/// no longer carries `--session <name>` in its cmdline post-#174 — is
617/// still correctly attributed to the session whose home it serves.
618///
619/// Cost: one filesystem read per session per status invocation. On a
620/// 133-session box that's 133 small reads (a few ms total) — bounded
621/// + acceptable. The map is fresh per call; no caching, no staleness.
622pub fn pid_to_session_map() -> HashMap<u32, String> {
623 let mut out = HashMap::new();
624 let sessions = match list_sessions() {
625 Ok(v) => v,
626 Err(_) => return out,
627 };
628 for info in sessions {
629 if let Some(pid) = session_daemon_pid(&info.home_dir) {
630 out.insert(pid, info.name);
631 }
632 }
633 out
634}
635
636fn is_process_live(pid: u32) -> bool {
637 // v0.7.3: delegate to the shared platform helper. The previous
638 // implementation shelled out to `kill -0` on non-Linux, which
639 // unconditionally failed on Windows (no `kill` binary) and made
640 // `wire session list` report every daemon as `down` regardless of
641 // actual liveness.
642 crate::platform::process_alive(pid)
643}
644
645/// Read a session's `relay.json` and return its `self.endpoints[]`
646/// array (v0.5.17 dual-slot). Empty Vec on any read/parse error — this
647/// is a best-effort discovery helper, not a verification tool. A pre-
648/// v0.5.17 session writes only the legacy flat fields; `self_endpoints`
649/// promotes those to a federation-only Endpoint, so the result is
650/// still meaningful for legacy sessions.
651///
652/// v0.5.20 BUG FIX: this used to join `relay-state.json`, which is
653/// not the canonical filename (`config::relay_state_path` returns
654/// `relay.json`). The mis-named read silently no-op'd and
655/// `list-local` always returned an empty `local` map as a result.
656/// Companion to the `cli.rs::try_allocate_local_slot` filename fix
657/// in the same release — that helper had the symmetric write-side
658/// bug, so the local endpoint never got persisted in the first place.
659pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
660 let path = session_home.join("config").join("wire").join("relay.json");
661 let bytes = match std::fs::read(&path) {
662 Ok(b) => b,
663 Err(_) => return Vec::new(),
664 };
665 let val: Value = match serde_json::from_slice(&bytes) {
666 Ok(v) => v,
667 Err(_) => return Vec::new(),
668 };
669 self_endpoints(&val)
670}
671
672/// Stripped view of a Local endpoint for tooling output. Drops
673/// `slot_token` because it is a bearer credential — exposing it
674/// through `wire session list-local --json` would risk accidental
675/// leak via logs, screenshots, or piped output. Routing code uses
676/// the full `Endpoint` from `relay.json` directly; this type
677/// is for human/JSON observation only.
678#[derive(Debug, Clone, Serialize)]
679pub struct LocalEndpointView {
680 pub relay_url: String,
681 pub slot_id: String,
682}
683
684/// One row of `wire session list-local` output: a session that has a
685/// Local-scope endpoint plus metadata to render it.
686#[derive(Debug, Clone, Serialize)]
687pub struct LocalSessionView {
688 pub name: String,
689 pub handle: Option<String>,
690 pub did: Option<String>,
691 pub cwd: Option<String>,
692 pub home_dir: PathBuf,
693 pub daemon_running: bool,
694 /// All Local-scope endpoints this session advertises (token redacted).
695 /// Most sessions have exactly one; multiple is permitted for multi-
696 /// relay setups.
697 pub local_endpoints: Vec<LocalEndpointView>,
698}
699
700/// Sessions with no Local endpoint — shown separately so the operator
701/// knows they exist but are federation-only.
702#[derive(Debug, Clone, Serialize)]
703pub struct FederationOnlySessionView {
704 pub name: String,
705 pub handle: Option<String>,
706 pub cwd: Option<String>,
707}
708
709/// Result shape for `wire session list-local`. `local` is grouped by
710/// the local-relay URL so output can render each cluster of mutually-
711/// reachable sister sessions together. `federation_only` lists the rest.
712#[derive(Debug, Clone, Serialize)]
713pub struct LocalSessionListing {
714 pub local: HashMap<String, Vec<LocalSessionView>>,
715 pub federation_only: Vec<FederationOnlySessionView>,
716}
717
718/// Build the listing for `wire session list-local` from current on-disk
719/// state. Read-only; no daemon contact, no relay probe.
720pub fn list_local_sessions() -> Result<LocalSessionListing> {
721 let sessions = list_sessions()?;
722 let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
723 let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
724
725 for s in sessions {
726 let endpoints = read_session_endpoints(&s.home_dir);
727 let local_eps: Vec<Endpoint> = endpoints
728 .into_iter()
729 .filter(|e| {
730 // v0.7.4: include any session whose endpoint URL is a
731 // loopback address even if it's tagged Federation, not
732 // Local. This catches the legitimate-but-misshapen case
733 // where `wire init --relay http://127.0.0.1:8771` was run
734 // without `--with-local`, leaving the session with a
735 // loopback federation slot that's effectively local-mesh-
736 // reachable. Pre-v0.7.4 the strict scope-only filter
737 // silently excluded those sessions from `pair-all-local`,
738 // making nickname-based pairing fail for no operator-
739 // visible reason.
740 matches!(e.scope, EndpointScope::Local)
741 || (matches!(e.scope, EndpointScope::Federation)
742 && url_is_loopback(&e.relay_url))
743 })
744 .collect();
745 if local_eps.is_empty() {
746 federation_only.push(FederationOnlySessionView {
747 name: s.name.clone(),
748 handle: s.handle.clone(),
749 cwd: s.cwd.clone(),
750 });
751 continue;
752 }
753 // Redacted view: drop slot_token before exposing through CLI.
754 let redacted: Vec<LocalEndpointView> = local_eps
755 .iter()
756 .map(|e| LocalEndpointView {
757 relay_url: e.relay_url.clone(),
758 slot_id: e.slot_id.clone(),
759 })
760 .collect();
761 // Group by relay_url. A session with two Local endpoints (rare —
762 // would mean two loopback relays) appears under each.
763 for ep in &local_eps {
764 local
765 .entry(ep.relay_url.clone())
766 .or_default()
767 .push(LocalSessionView {
768 name: s.name.clone(),
769 handle: s.handle.clone(),
770 did: s.did.clone(),
771 cwd: s.cwd.clone(),
772 home_dir: s.home_dir.clone(),
773 daemon_running: s.daemon_running,
774 local_endpoints: redacted.clone(),
775 });
776 }
777 }
778 // Sort each group by session name so output is deterministic.
779 for group in local.values_mut() {
780 group.sort_by(|a, b| a.name.cmp(&b.name));
781 }
782 federation_only.sort_by(|a, b| a.name.cmp(&b.name));
783 Ok(LocalSessionListing {
784 local,
785 federation_only,
786 })
787}
788
789/// v0.6.7: cwd → session WIRE_HOME lookup. Read-only.
790///
791/// When `WIRE_HOME` isn't set in env, look up `cwd` in the session
792/// registry. If a session is registered for this cwd AND its home
793/// directory still exists, return that home dir; otherwise None.
794///
795/// Used by both `wire mcp` (v0.6.1) and the CLI entry point (v0.6.7)
796/// so a `wire whoami` / `wire monitor` invocation from a project cwd
797/// adopts that project's session identity automatically, instead of
798/// silently falling back to the machine default. The CLI parity is
799/// load-bearing: without it, the user-visible identity diverges
800/// between MCP and the terminal, and monitors pull machine-wide
801/// inboxes when the operator expected a per-session view.
802pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option<PathBuf> {
803 let registry = read_registry().ok()?;
804 // v0.7.0-alpha.2: walk up parent dirs. Subdirs of a registered cwd
805 // inherit their parent's wire identity (e.g.
806 // `~/Source/slancha-business/tools/recon` → `slancha-business` session).
807 // Without this, subdirs all fell back to the machine-wide default
808 // identity, which silently collapsed multiple Claude sessions onto the
809 // same DID + character.
810 let mut probe: Option<&std::path::Path> = Some(cwd);
811 while let Some(path) = probe {
812 // Same O(n) normalized scan as derive_name_from_cwd: handles both
813 // consistent-casing and cross-casing upgraders. See the comment
814 // on derive_name_from_cwd for the rationale.
815 let path_str = normalize_cwd_key(path);
816 if let Some(session_name) = registry.by_cwd.get(&path_str).or_else(|| {
817 registry
818 .by_cwd
819 .iter()
820 .find(|(k, _)| normalize_cwd_key(Path::new(k)) == path_str)
821 .map(|(_, v)| v)
822 }) {
823 let session_home = session_dir(session_name).ok()?;
824 if session_home.exists() {
825 return Some(session_home);
826 }
827 }
828 probe = path.parent();
829 }
830 None
831}
832
833/// v0.13: resolve a stable per-session key — host-agnostic, with a Claude
834/// Code adapter and the path left open for other hosts. Order:
835/// 1. `WIRE_SESSION_ID` — explicit universal override (any harness).
836/// 2. `CLAUDE_CODE_SESSION_ID` — Claude Code adapter (stable per
837/// conversation; the same id the auto-memory system keys off).
838/// 3. `CODEX_SESSION_ID` — OpenAI Codex CLI adapter. Stable per Codex
839/// thread (the same UUIDv7 emitted in `thread.started` and used as
840/// the rollout-file suffix under `$CODEX_HOME/sessions/`). Codex
841/// does not yet forward this var to MCP children out of the box —
842/// operators must set it via `[mcp_servers.<name>.env]` in
843/// `~/.codex/config.toml` (or upstream Codex must add it to the
844/// MCP child env). Wiring the name in advance means once Codex
845/// ships the env, wire picks it up with zero further code change.
846/// 4. `COPILOT_AGENT_SESSION_ID` — GitHub Copilot CLI (`gh copilot` /
847/// `copilot`) adapter. Set by the Copilot CLI host for every
848/// session; stable per conversation; UUID-shaped.
849/// 5. `VSCODE_GIT_REPOSITORY_ROOT` — VS Code/GitHub Copilot workspace-based
850/// identity (stable per workspace).
851/// 6. `None` — caller falls back to legacy cwd-detect (bare CLI /
852/// pre-v0.13 hosts). Future host adapters slot in before this.
853///
854/// Returns `(key, source-label)`.
855pub fn resolve_session_key() -> Option<(String, &'static str)> {
856 for (var, source) in [
857 ("WIRE_SESSION_ID", "override"),
858 ("CLAUDE_CODE_SESSION_ID", "claude-code"),
859 ("CODEX_SESSION_ID", "codex-cli"),
860 ("COPILOT_AGENT_SESSION_ID", "copilot-cli"),
861 ("VSCODE_GIT_REPOSITORY_ROOT", "vscode-workspace"),
862 ] {
863 if let Ok(v) = std::env::var(var)
864 && valid_session_key(&v)
865 {
866 return Some((v.trim().to_string(), source));
867 }
868 }
869 // Claude Code adapter (host-agnostic fallback). On some platforms the MCP
870 // server process does not inherit CLAUDE_CODE_SESSION_ID and the MCP
871 // `initialize` handshake carries no session id, so the env checks above
872 // miss. Claude Code, however, writes `~/.claude/sessions/<pid>.json`
873 // ({"sessionId":..., "cwd":...}) for each live session, named by the
874 // owning `claude` process PID. Walk our parent-process chain to that
875 // process and read its sessionId — deterministic, race-free, env-free.
876 if let Some(sid) = claude_code_session_from_pidfile() {
877 return Some((sid, "claude-code-pidfile"));
878 }
879
880 None
881}
882
883/// A session key from the environment is usable only if it is non-empty and is
884/// NOT an unexpanded `${...}` placeholder. A host that writes
885/// `"env": {"WIRE_SESSION_ID": "${CLAUDE_CODE_SESSION_ID}"}` but doesn't expand
886/// it (Windows Claude Code passes the literal when the var is absent) would
887/// otherwise have wire hash the literal — collapsing every session onto one
888/// identity. Treat any `${...}` value as unset so resolution falls through to
889/// the PID-file adapter / per-process mint instead of a shared bogus persona.
890fn valid_session_key(v: &str) -> bool {
891 let v = v.trim();
892 !v.is_empty() && !v.contains("${")
893}
894
895/// A session-identity SPLIT: this process's OPERATIONAL identity (the home it is
896/// actually serving, via `config_dir`/`WIRE_HOME`) disagrees with the LIVE
897/// Claude session (resolved fresh from the PID-file). The "displayed name ≠
898/// operational name" bug — a long-lived wire process (usually the MCP server)
899/// frozen to a stale/minted identity while the live Claude session moved on
900/// (e.g. across a resume). Comparing the SERVED identity (not an env key) is
901/// what lets the frozen process itself detect the split: its env key may be
902/// unset (minted) or stale, but the home it serves is concrete — the earlier
903/// env-vs-pidfile check ran in a fresh CLI whose env was already live, so it
904/// never saw the frozen MCP's stale identity at all.
905#[derive(Debug, Clone)]
906pub struct IdentitySplit {
907 /// How this process resolved its session (`session_source()`).
908 pub source: &'static str,
909 /// The handle this process is actually operating (and sending mail) as.
910 pub operational_handle: Option<String>,
911 /// The handle the live Claude session resolves to right now.
912 pub live_handle: Option<String>,
913}
914
915/// Handle served by an initialized by-key home for `key` (DID-derived). `None`
916/// when that home has no agent-card yet (uninitialized session).
917///
918/// Hash the RAW key — homes are created via `session_home_for_key(&key)` on the
919/// raw resolved key (see `maybe_adopt_session_wire_home`). `sanitize_name`
920/// truncates to 32 chars, so sanitizing here would hash a DIFFERENT string than
921/// a 36-char session-id uuid's home and silently miss it (the split then never
922/// surfaces). Keep this in lock-step with home creation: raw key, no sanitize.
923fn handle_for_key(key: &str) -> Option<String> {
924 let home = session_home_for_key(key).ok()?;
925 let card = home.join("config").join("wire").join("agent-card.json");
926 read_card_identity(&card).1
927}
928
929/// The handle this process is CURRENTLY operating as — read from the agent-card
930/// in the home it resolved (`config_dir`, driven by `WIRE_HOME`). `None` when
931/// uninitialized. For a frozen MCP this is the stale identity it froze to,
932/// independent of any env key.
933fn current_operational_handle() -> Option<String> {
934 let card = crate::config::read_agent_card().ok()?;
935 let did = card.get("did").and_then(Value::as_str)?;
936 let handle = card
937 .get("handle")
938 .and_then(Value::as_str)
939 .map(str::to_string)
940 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(did).to_string());
941 Some(handle)
942}
943
944/// Pure split decision: `Some` iff both handles are known and differ. Extracted
945/// so the policy is unit-testable without process env / real homes.
946fn identity_split_between(
947 operational: Option<String>,
948 live: Option<String>,
949 source: &'static str,
950) -> Option<IdentitySplit> {
951 match (&operational, &live) {
952 (Some(op), Some(lv)) if op != lv => Some(IdentitySplit {
953 source,
954 operational_handle: operational,
955 live_handle: live,
956 }),
957 _ => None,
958 }
959}
960
961/// An explicit operator `WIRE_HOME` pin — the RFC-008 §C "deliberate fleet-
962/// share" contract, where several sessions intentionally present as ONE shared
963/// identity. In that mode the served identity legitimately differs from the
964/// per-session one, so a "split" there is BY DESIGN, not the frozen-process bug.
965/// (`SESSION_SOURCE` is set to these exact labels only when `WIRE_HOME` was
966/// already in the env at adopt time — never for wire's own `minted`/`claude-*`
967/// resolution, which is the case the split detector targets.)
968fn is_deliberate_home_pin(source: &str) -> bool {
969 source == "env:WIRE_HOME" || source == "env:WIRE_HOME_FORCE"
970}
971
972/// Detect a session-identity split (see [`IdentitySplit`]). `None` in the
973/// healthy case (served identity == live Claude session), when either side is
974/// unresolvable (bare CLI, uninitialized), or when the operator DELIBERATELY
975/// pinned `WIRE_HOME` to share one identity across sessions (not a bug). Run
976/// INSIDE a long-lived MCP/daemon this catches the frozen-identity case the old
977/// env-vs-pidfile check missed: the served home is stale, the PID-file is live,
978/// they differ. Reads the (cached) parent-chain PID-files — cheap on the
979/// session-start health check, not a per-message hot path.
980pub fn detect_identity_split() -> Option<IdentitySplit> {
981 let source = session_source();
982 // Deliberate fleet-share (operator WIRE_HOME pin) is intentional, not a
983 // split — suppress it, else every such session cries wolf forever.
984 if is_deliberate_home_pin(source) {
985 return None;
986 }
987 let operational = current_operational_handle();
988 let live = claude_code_session_from_pidfile().and_then(|k| handle_for_key(&k));
989 identity_split_between(operational, live, source)
990}
991
992#[cfg(test)]
993mod split_tests {
994 use super::*;
995 use std::io::Write;
996 use std::time::Duration;
997
998 #[test]
999 fn identity_split_only_when_both_known_and_differ() {
1000 // healthy: served identity == live session → no split
1001 assert!(
1002 identity_split_between(
1003 Some("verdant-palm".into()),
1004 Some("verdant-palm".into()),
1005 "claude-code",
1006 )
1007 .is_none()
1008 );
1009 // the real bug: served (frozen) != live
1010 let s = identity_split_between(
1011 Some("merry-spindle".into()),
1012 Some("daydream-gorge".into()),
1013 "minted",
1014 )
1015 .expect("differing handles ⇒ split");
1016 assert_eq!(s.operational_handle.as_deref(), Some("merry-spindle"));
1017 assert_eq!(s.live_handle.as_deref(), Some("daydream-gorge"));
1018 // either side unknown ⇒ never cry wolf
1019 assert!(identity_split_between(None, Some("x".into()), "s").is_none());
1020 assert!(identity_split_between(Some("x".into()), None, "s").is_none());
1021 }
1022
1023 #[test]
1024 fn ancestor_chain_is_bounded_and_stops_at_root() {
1025 let parents = |p: u32| match p {
1026 10 => Some(5),
1027 5 => Some(1),
1028 _ => None,
1029 };
1030 assert_eq!(ancestor_chain(10, 16, parents), vec![10, 5, 1]);
1031 // `max` caps the walk even when the chain continues.
1032 assert_eq!(ancestor_chain(10, 2, parents), vec![10, 5]);
1033 }
1034
1035 #[test]
1036 fn retry_recovers_a_pidfile_that_arrives_late() {
1037 // The startup-race case: the pidfile lands mid-retry, not before.
1038 let dir = tempfile::tempdir().unwrap();
1039 let d = dir.path().to_path_buf();
1040 let writer = std::thread::spawn(move || {
1041 std::thread::sleep(Duration::from_millis(40));
1042 let mut f = std::fs::File::create(d.join("4242.json")).unwrap();
1043 f.write_all(br#"{"sessionId":"late-arriving-id"}"#).unwrap();
1044 });
1045 let got = resolve_session_id_from_chain(dir.path(), &[4242], 30, Duration::from_millis(10));
1046 writer.join().unwrap();
1047 assert_eq!(got.as_deref(), Some("late-arriving-id"));
1048 }
1049
1050 #[test]
1051 fn resolve_returns_none_when_no_pidfile_ever() {
1052 let dir = tempfile::tempdir().unwrap();
1053 assert!(
1054 resolve_session_id_from_chain(dir.path(), &[1, 2, 3], 3, Duration::from_millis(1))
1055 .is_none()
1056 );
1057 }
1058
1059 #[test]
1060 fn read_session_id_parses_first_present_and_skips_empty() {
1061 let dir = tempfile::tempdir().unwrap();
1062 std::fs::write(dir.path().join("7.json"), br#"{"sessionId":" sid-7 "}"#).unwrap();
1063 std::fs::write(dir.path().join("8.json"), br#"{"sessionId":""}"#).unwrap();
1064 assert_eq!(
1065 read_session_id_from_pidfile(dir.path(), 7).as_deref(),
1066 Some("sid-7")
1067 );
1068 assert!(read_session_id_from_pidfile(dir.path(), 8).is_none());
1069 assert!(read_session_id_from_pidfile(dir.path(), 9).is_none());
1070 }
1071
1072 #[test]
1073 fn handle_for_key_home_matches_raw_key_home() {
1074 // Regression (in-situ gate catch): a 36-char session-id uuid is longer
1075 // than sanitize_name's 32-char cap, so hashing the sanitized form yields
1076 // a DIFFERENT by-key home than creation (which hashes the raw key). If
1077 // handle_for_key sanitized, it would look in a home nothing wrote →
1078 // detect_identity_split silently returns None. Lock the invariant: the
1079 // home for a raw key is named by `by_key_dir_name(raw)`, and that differs
1080 // from the sanitized form for a >32-char key.
1081 let uuid = "7122130f-0a1b-431b-93d3-7818585770af";
1082 assert_eq!(uuid.len(), 36);
1083 assert_ne!(
1084 by_key_dir_name(uuid),
1085 by_key_dir_name(&sanitize_name(uuid)),
1086 "sanitize truncates a 36-char key; handle_for_key must hash raw"
1087 );
1088 let home = session_home_for_key(uuid).unwrap();
1089 assert_eq!(
1090 home.file_name().unwrap().to_str().unwrap(),
1091 by_key_dir_name(uuid),
1092 "home name must be by_key_dir_name(raw key)"
1093 );
1094 }
1095
1096 #[test]
1097 fn handle_for_key_reads_raw_key_home_not_truncated() {
1098 // The load-bearing regression guard: drive handle_for_key itself. A
1099 // 36-char session-id uuid exceeds sanitize_name's 32-char cap, so a
1100 // sanitized lookup lands on a DIFFERENT home than creation wrote. If
1101 // handle_for_key re-added sanitize_name, the raw-key assert below returns
1102 // None and this test fails — catching the exact bug the fix closed.
1103 let _guard = crate::config::test_support::ENV_LOCK
1104 .lock()
1105 .unwrap_or_else(|e| e.into_inner());
1106 let tmp = tempfile::tempdir().unwrap();
1107 // SAFETY: ENV_LOCK held — serializes all in-process env access.
1108 unsafe {
1109 std::env::set_var("WIRE_HOME", tmp.path());
1110 }
1111 // Make sessions_root() resolve under the tempdir (never real state).
1112 std::fs::create_dir_all(tmp.path().join("sessions")).unwrap();
1113 let uuid = "7122130f-0a1b-431b-93d3-7818585770af";
1114 let cfg = session_home_for_key(uuid)
1115 .unwrap()
1116 .join("config")
1117 .join("wire");
1118 std::fs::create_dir_all(&cfg).unwrap();
1119 std::fs::write(
1120 cfg.join("agent-card.json"),
1121 br#"{"did":"did:wire:merry-spindle-82f38897","handle":"merry-spindle"}"#,
1122 )
1123 .unwrap();
1124 // Raw-key lookup finds the card…
1125 assert_eq!(handle_for_key(uuid).as_deref(), Some("merry-spindle"));
1126 // …a sanitized (truncated) key lands on a different, empty home → None.
1127 assert_ne!(sanitize_name(uuid), uuid);
1128 assert!(handle_for_key(&sanitize_name(uuid)).is_none());
1129 // SAFETY: ENV_LOCK still held.
1130 unsafe {
1131 std::env::remove_var("WIRE_HOME");
1132 }
1133 }
1134
1135 #[test]
1136 fn deliberate_home_pin_suppresses_split() {
1137 // RFC-008 fleet-share (operator WIRE_HOME pin) is intentional, not a bug.
1138 assert!(is_deliberate_home_pin("env:WIRE_HOME"));
1139 assert!(is_deliberate_home_pin("env:WIRE_HOME_FORCE"));
1140 // wire's own resolution labels are NOT suppressed — that's the real bug.
1141 assert!(!is_deliberate_home_pin("minted"));
1142 assert!(!is_deliberate_home_pin("claude-code"));
1143 assert!(!is_deliberate_home_pin("claude-code-pidfile"));
1144 }
1145
1146 #[test]
1147 fn unexpanded_template_key_is_invalid() {
1148 // The ${...} literal that caused the historical collision stays rejected.
1149 assert!(!valid_session_key("${CLAUDE_CODE_SESSION_ID}"));
1150 assert!(valid_session_key("real-session-id"));
1151 }
1152}
1153
1154/// The by-key session-id read from a single `<dir>/<pid>.json` Claude PID-file.
1155/// `None` if absent / unparseable / empty. The shared atom of the one-shot and
1156/// retry readers below.
1157fn read_session_id_from_pidfile(dir: &Path, pid: u32) -> Option<String> {
1158 let txt = std::fs::read_to_string(dir.join(format!("{pid}.json"))).ok()?;
1159 let v: Value = serde_json::from_str(&txt).ok()?;
1160 let s = v.get("sessionId").and_then(Value::as_str)?.trim();
1161 (!s.is_empty()).then(|| s.to_string())
1162}
1163
1164/// The process ancestry `[start, parent, grandparent, …]`, up to `max` deep,
1165/// walked via the injected `parent_of` (so it is unit-testable without `ps`).
1166/// Stops early when `parent_of` returns `None`. Bounded — no `ps` re-fork after
1167/// this single walk (the retry reader below re-reads the cached pids only).
1168fn ancestor_chain(start_pid: u32, max: usize, parent_of: impl Fn(u32) -> Option<u32>) -> Vec<u32> {
1169 let mut chain = Vec::with_capacity(max.min(16));
1170 let mut pid = start_pid;
1171 for _ in 0..max {
1172 chain.push(pid);
1173 match parent_of(pid) {
1174 Some(p) => pid = p,
1175 None => break,
1176 }
1177 }
1178 chain
1179}
1180
1181/// Re-read the ancestor `chain`'s PID-files across a short backoff, returning
1182/// the first session id found. Pure over `dir`/`chain` so it is unit-testable
1183/// (tempdir + a late-arriving file). No `ps` calls here — the chain is walked
1184/// once by the caller; only the leaf file reads repeat (avoids the #353 Windows
1185/// per-hop perf trap).
1186fn resolve_session_id_from_chain(
1187 dir: &Path,
1188 chain: &[u32],
1189 attempts: u32,
1190 backoff: std::time::Duration,
1191) -> Option<String> {
1192 for i in 0..attempts.max(1) {
1193 if let Some(sid) = chain
1194 .iter()
1195 .find_map(|&pid| read_session_id_from_pidfile(dir, pid))
1196 {
1197 return Some(sid);
1198 }
1199 if i + 1 < attempts {
1200 std::thread::sleep(backoff);
1201 }
1202 }
1203 None
1204}
1205
1206/// The ancestor PID chain, walked ONCE per process and cached. A process's
1207/// parentage is immutable, so repeat identity checks (`detect_identity_split`
1208/// fires on every `wire_status`/`wire_whoami`) must re-read only the tiny
1209/// PID-files, never re-fork `ps`. This keeps the session-start hot path off the
1210/// per-call subprocess walk — the single bounded walk happens on first use
1211/// (typically already paid by startup resolution).
1212fn ancestor_chain_cached() -> &'static [u32] {
1213 static ANCESTOR_CHAIN: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
1214 ANCESTOR_CHAIN.get_or_init(|| ancestor_chain(std::process::id(), 16, parent_pid))
1215}
1216
1217/// Recover the Claude Code session id from the per-session PID-file when it
1218/// isn't available via the environment. Claude Code writes
1219/// `~/.claude/sessions/<pid>.json` = `{"sessionId": "...", "cwd": "...", ...}`
1220/// for each live session, keyed by the owning `claude` process PID. The MCP
1221/// server we run inside is a descendant of that process, so we walk our
1222/// parent chain (cached — no per-call `ps` re-fork) and return the `sessionId`
1223/// of the first ancestor that has a PID-file. Cross-platform.
1224fn claude_code_session_from_pidfile() -> Option<String> {
1225 let dir = dirs::home_dir()?.join(".claude").join("sessions");
1226 ancestor_chain_cached()
1227 .iter()
1228 .find_map(|&pid| read_session_id_from_pidfile(&dir, pid))
1229}
1230
1231/// Retry variant for the MCP-startup RACE: Claude may not have written its
1232/// session PID-file yet when the stdio MCP server boots, so a single read
1233/// misses and the caller would mint a throwaway identity (the "two names" bug).
1234/// Uses the cached ancestor chain (walked once), re-reading only those PID-files
1235/// across `attempts × backoff`. `None` if the live session is still
1236/// unrecoverable after the window.
1237fn claude_code_session_from_pidfile_retry(
1238 attempts: u32,
1239 backoff: std::time::Duration,
1240) -> Option<String> {
1241 let dir = dirs::home_dir()?.join(".claude").join("sessions");
1242 resolve_session_id_from_chain(&dir, ancestor_chain_cached(), attempts, backoff)
1243}
1244
1245/// MCP-startup race window: how many times / how long to re-poll the ancestor
1246/// PID-files before falling back to a minted per-process identity. Small: the
1247/// pidfile lands within a few ms in practice; ~100 ms worst case at boot only.
1248const MINT_PIDFILE_ATTEMPTS: u32 = 5;
1249const MINT_PIDFILE_BACKOFF: std::time::Duration = std::time::Duration::from_millis(20);
1250
1251/// Best-effort parent-PID lookup. Linux: `/proc/<pid>/status`. macOS: `ps`.
1252/// Windows: PowerShell CIM (no extra crate). Returns `None` on any failure,
1253/// which simply ends the walk.
1254#[cfg(target_os = "linux")]
1255fn parent_pid(pid: u32) -> Option<u32> {
1256 let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
1257 for line in status.lines() {
1258 if let Some(rest) = line.strip_prefix("PPid:") {
1259 return rest.trim().parse().ok();
1260 }
1261 }
1262 None
1263}
1264
1265#[cfg(target_os = "macos")]
1266fn parent_pid(pid: u32) -> Option<u32> {
1267 let out = std::process::Command::new("ps")
1268 .args(["-o", "ppid=", "-p", &pid.to_string()])
1269 .output()
1270 .ok()?;
1271 String::from_utf8_lossy(&out.stdout).trim().parse().ok()
1272}
1273
1274#[cfg(target_os = "windows")]
1275fn parent_pid(pid: u32) -> Option<u32> {
1276 use std::os::windows::process::CommandExt;
1277 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
1278 let out = std::process::Command::new("powershell")
1279 .args([
1280 "-NoProfile",
1281 "-NonInteractive",
1282 "-Command",
1283 &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').ParentProcessId"),
1284 ])
1285 .creation_flags(CREATE_NO_WINDOW)
1286 .output()
1287 .ok()?;
1288 String::from_utf8_lossy(&out.stdout).trim().parse().ok()
1289}
1290
1291#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1292fn parent_pid(_pid: u32) -> Option<u32> {
1293 None
1294}
1295
1296/// v0.13: the WIRE_HOME for a resolved session key —
1297/// `<sessions_root>/by-key/<hash>` where `hash` is the first 16 hex of
1298/// SHA-256(key). Deterministic and cwd-independent, so two sessions never
1299/// collide and there is no path-string to mis-normalize (the Windows bug
1300/// cannot occur). 64 bits is collision-safe at this scale.
1301pub fn session_home_for_key(key: &str) -> Result<PathBuf> {
1302 Ok(sessions_root()?.join("by-key").join(by_key_dir_name(key)))
1303}
1304
1305/// The by-key directory name (16 hex chars / 64 bits) for a session key —
1306/// the first 8 bytes of SHA-256(key). Public so test fixtures and external
1307/// tooling can locate a session home without replicating the hash:
1308/// `session_home_for_key(key) == sessions_root()/by-key/<by_key_dir_name(key)>`.
1309pub fn by_key_dir_name(key: &str) -> String {
1310 let mut h = Sha256::new();
1311 h.update(key.as_bytes());
1312 hex::encode(&h.finalize()[..8])
1313}
1314
1315/// Long-running `wire <subcommand>` invocations that own the inbox
1316/// cursor and therefore race each other under a shared `WIRE_HOME`.
1317/// Keep this list in sync with [`warn_on_identity_collision`]'s pgrep
1318/// predicate and the call-site list in `cli::run` / `mcp::run`.
1319///
1320/// Note: `pair-host` (and the rest of the SAS code-phrase flow) was removed
1321/// in RFC-005 follow-on, so it is naturally absent from this list.
1322///
1323/// Short-lived commands (`whoami`, `status`, `send`, `peers`, …) are
1324/// intentionally absent — they write atomically and don't race, and
1325/// warning on every one would spam any operator running scripts.
1326pub const INBOX_OWNING_SUBCOMMANDS: &[&str] = &["mcp", "daemon", "monitor", "notify"];
1327
1328/// v0.6.10: warn at MCP/CLI startup if another long-running `wire`
1329/// process is already running with the same effective `WIRE_HOME`.
1330/// Closes the "two Claudes in same cwd silently share an identity"
1331/// failure mode that wasted hours of operator debugging time: today
1332/// the collision is invisible (both Claudes resolve to the same wire
1333/// session via v0.6.7 auto-detect, race the inbox cursor, "look
1334/// identical" from the operator's view). This surfaces it explicitly
1335/// with a clear remediation path.
1336///
1337/// `role` is the calling subcommand label (`"mcp"`, `"daemon"`,
1338/// `"monitor"`, …) — used in the warning's leading tag so operators
1339/// can tell which surface is observing the collision. Detection
1340/// itself spans every inbox-owning role: a `wire daemon` colliding
1341/// with an existing `wire mcp` warns just the same as an mcp/mcp
1342/// pair.
1343///
1344/// Best-effort: any subprocess / env-read failure is silent (the
1345/// collision check should never block startup).
1346///
1347/// Cross-platform process enumeration via
1348/// [`crate::platform::find_processes_by_cmdline`] — the existing
1349/// PowerShell + CIM `Get-CimInstance Win32_Process` adapter for
1350/// Windows, `pgrep -f` for POSIX. The per-role mapping back to a
1351/// `WIRE_HOME` then uses [`read_wire_home_from_pid`], which (POSIX)
1352/// reads `/proc/<pid>/environ` or `ps -E` directly, and (Windows)
1353/// walks `list_sessions()` × `<role>.pid` files since Windows has no
1354/// portable cross-process env read. #247 finding 4: closes the
1355/// "Windows returns empty" gap that left two `wire mcp` servers
1356/// sharing one `WIRE_HOME` silently racing the inbox cursor on a
1357/// Windows host.
1358pub fn warn_on_identity_collision(self_pid: u32, role: &str) {
1359 let our_wire_home = match std::env::var("WIRE_HOME") {
1360 Ok(h) => h,
1361 Err(_) => return,
1362 };
1363
1364 // One enumeration per inbox-owning role; merge the results. Going
1365 // role-by-role rather than a single alternation predicate lets us
1366 // reuse `crate::platform::find_processes_by_cmdline` unchanged —
1367 // the Windows adapter there already matches the `wire.exe` image +
1368 // a single CommandLine substring, and chaining N small queries is
1369 // cheap (one PowerShell launch per role, ≤4 launches total).
1370 let mut other_pids: Vec<u32> = Vec::new();
1371 for sub in INBOX_OWNING_SUBCOMMANDS {
1372 for pid in crate::platform::find_processes_by_cmdline(&format!("wire {sub}")) {
1373 if pid != self_pid && !other_pids.contains(&pid) {
1374 other_pids.push(pid);
1375 }
1376 }
1377 }
1378
1379 let other_homes: Vec<(u32, Option<String>)> = other_pids
1380 .iter()
1381 .map(|p| (*p, read_wire_home_from_pid(*p)))
1382 .collect();
1383
1384 let colliders = find_colliders(&our_wire_home, &other_homes);
1385
1386 if colliders.is_empty() {
1387 return;
1388 }
1389
1390 emit_collision_warning(role, &our_wire_home, &colliders);
1391}
1392
1393/// Pure decision: from a snapshot of `(pid, their_wire_home)` for
1394/// every other wire process on the host, return the pids whose
1395/// `WIRE_HOME` exactly matches ours. Missing-home entries (process
1396/// died, env unreadable on this platform) are skipped, never counted.
1397pub(crate) fn find_colliders(
1398 our_wire_home: &str,
1399 other_homes: &[(u32, Option<String>)],
1400) -> Vec<u32> {
1401 other_homes
1402 .iter()
1403 .filter_map(|(pid, their_home)| match their_home {
1404 Some(h) if h == our_wire_home => Some(*pid),
1405 _ => None,
1406 })
1407 .collect()
1408}
1409
1410/// Render the collision warning. Extracted so the format is unit-
1411/// testable without mocking a real pgrep / cross-process env read.
1412pub(crate) fn emit_collision_warning(role: &str, our_wire_home: &str, colliders: &[u32]) {
1413 eprintln!(
1414 "wire {role}: WARNING — {} other wire process(es) already using WIRE_HOME=`{}` (pid {})",
1415 colliders.len(),
1416 our_wire_home,
1417 colliders
1418 .iter()
1419 .map(|p| p.to_string())
1420 .collect::<Vec<_>>()
1421 .join(", ")
1422 );
1423 eprintln!(
1424 " Multiple agents sharing one identity will race the inbox cursor; messages may be lost."
1425 );
1426 eprintln!(" To use a separate identity:");
1427 eprintln!(" 1. Close the other agent(s), OR");
1428 eprintln!(" 2. `wire session new <name> --local-only` to create a fresh identity, then");
1429 eprintln!(
1430 " 3. Restart THIS agent's launcher with `export WIRE_HOME=<path printed by step 2>`"
1431 );
1432}
1433
1434/// Best-effort cross-platform read of another process's `WIRE_HOME`.
1435///
1436/// Linux: parses `/proc/<pid>/environ` (NUL-separated KEY=VAL).
1437/// macOS: `ps -E -p <pid>` (whitespace-separated KEY=VAL prefix).
1438/// Windows: walks every known session (`list_sessions()`) × every
1439/// inbox-owning role (`INBOX_OWNING_SUBCOMMANDS`) and reads the
1440/// per-role pidfile (`<session_home>/state/wire/<role>.pid`) — if
1441/// any pidfile records `pid`, the session's `home_dir` is the
1442/// candidate's `WIRE_HOME`. Returns None if no pidfile matches.
1443///
1444/// The Windows path requires every inbox-owning role to actually
1445/// write its pidfile on startup — daemon has always done so via
1446/// `write_self_daemon_pid`; #247 finding 4 adds the equivalent for
1447/// `mcp` / `monitor` / `notify` via [`crate::ensure_up::write_self_role_pid`].
1448///
1449/// Why not read the remote process environment on Windows? `NtQuery
1450/// InformationProcess` + `ReadProcessMemory` would give 100% coverage
1451/// but adds an `ntdll` FFI dep + brittle PEB-layout assumptions across
1452/// Windows builds. The pidfile-lookup gives equivalent coverage for
1453/// every long-running wire role we care about, with the same on-disk
1454/// data structure POSIX uses today, and zero new platform deps.
1455///
1456/// Also used by `ensure_up::daemon_liveness` to scope the orphan-daemon
1457/// check to processes serving the same WIRE_HOME.
1458pub(crate) fn read_wire_home_from_pid(pid: u32) -> Option<String> {
1459 #[cfg(target_os = "linux")]
1460 {
1461 let path = format!("/proc/{pid}/environ");
1462 let bytes = std::fs::read(&path).ok()?;
1463 for entry in bytes.split(|&b| b == 0) {
1464 let s = match std::str::from_utf8(entry) {
1465 Ok(s) => s,
1466 Err(_) => continue,
1467 };
1468 if let Some(val) = s.strip_prefix("WIRE_HOME=") {
1469 return Some(val.to_string());
1470 }
1471 }
1472 None
1473 }
1474
1475 #[cfg(target_os = "macos")]
1476 {
1477 let output = std::process::Command::new("ps")
1478 .args(["-E", "-p", &pid.to_string(), "-o", "command="])
1479 .output()
1480 .ok()?;
1481 let s = String::from_utf8_lossy(&output.stdout);
1482 for tok in s.split_whitespace() {
1483 if let Some(val) = tok.strip_prefix("WIRE_HOME=") {
1484 return Some(val.to_string());
1485 }
1486 }
1487 None
1488 }
1489
1490 #[cfg(windows)]
1491 {
1492 wire_home_from_pid_via_pidfile_scan(pid, &list_sessions().unwrap_or_default())
1493 }
1494
1495 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
1496 {
1497 let _ = pid;
1498 None
1499 }
1500}
1501
1502/// Pure-logic helper for the Windows pidfile-scan path: given a
1503/// candidate PID and a snapshot of `(home_dir, role-pid)` pairs from
1504/// every known session × inbox-owning role, return the home whose
1505/// pidfile records this PID. Extracted so the policy is unit-testable
1506/// without a real `list_sessions` filesystem snapshot.
1507#[cfg_attr(not(windows), allow(dead_code))]
1508pub(crate) fn find_home_for_pid(pid: u32, sessions_role_pids: &[(String, u32)]) -> Option<String> {
1509 sessions_role_pids
1510 .iter()
1511 .find_map(|(home, recorded)| (*recorded == pid).then(|| home.clone()))
1512}
1513
1514/// Windows-side bridge: from the SessionInfo list, expand to
1515/// `(home_str, pid)` for every inbox-owning role's pidfile, then ask
1516/// [`find_home_for_pid`].
1517#[cfg(windows)]
1518fn wire_home_from_pid_via_pidfile_scan(pid: u32, sessions: &[SessionInfo]) -> Option<String> {
1519 let mut pairs: Vec<(String, u32)> = Vec::new();
1520 for s in sessions {
1521 for role in INBOX_OWNING_SUBCOMMANDS {
1522 if let Some(p) = session_role_pid(&s.home_dir, role) {
1523 pairs.push((s.home_dir.to_string_lossy().to_string(), p));
1524 }
1525 }
1526 }
1527 find_home_for_pid(pid, &pairs)
1528}
1529
1530/// v0.6.7: apply `detect_session_wire_home` for the current process.
1531///
1532/// If `WIRE_HOME` is unset and the current cwd maps to an existing
1533/// session, set `WIRE_HOME` for the rest of this process and emit a
1534/// one-liner to stderr so the operator knows which identity is in
1535/// use. Noop when `WIRE_HOME` is already set (explicit override wins).
1536///
1537/// `label` distinguishes the caller in the stderr line (`mcp` vs
1538/// `cli`). Output only appears on interactive TTYs; set `WIRE_VERBOSE=1`
1539/// to force it on in non-interactive contexts.
1540///
1541/// MUST be called BEFORE any worker thread or async task spawns —
1542/// `env::set_var` is unsafe in Rust 2024 because of thread-safety
1543/// guarantees, and our use is safe only at process entry.
1544/// Process-global record of WHICH signal won session/home resolution,
1545/// captured at adoption time by [`maybe_adopt_session_wire_home`]. Read by
1546/// `wire whoami --json` (`session_source`) so an operator can see in one
1547/// command whether identity came from an explicit `WIRE_HOME`, a host
1548/// session-id adapter, the Claude-Code pidfile fallback, a minted
1549/// per-process key, or the machine default. Post-hoc re-derivation is
1550/// unreliable — minting sets `WIRE_SESSION_ID` and `WIRE_HOME` is always set
1551/// after adoption — so the winning source MUST be captured here, once.
1552static SESSION_SOURCE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
1553
1554/// The signal that won session/home resolution for this process. One of:
1555/// `env:WIRE_HOME`, `env:WIRE_HOME_FORCE` (RFC-008 §C legacy-shape force),
1556/// `override` (`WIRE_SESSION_ID`), `claude-code`, `claude-code-pidfile`,
1557/// `codex-cli`, `copilot-cli`, `vscode-workspace`, `minted`,
1558/// `machine-default`, or `unknown` if adoption never ran.
1559pub fn session_source() -> &'static str {
1560 SESSION_SOURCE.get().copied().unwrap_or("unknown")
1561}
1562
1563/// Sources that indicate the process did NOT inherit an explicit identity
1564/// signal from its launcher. `machine-default` means a bare CLI / no
1565/// session id at all; `minted` means an MCP server fell through to a
1566/// fresh per-process key. Either way, a long-running inbox-owning role
1567/// running under one of these sources is almost certainly NOT the
1568/// identity its launcher intended (#284.4: the operator-facing symptom
1569/// is a spawn that should have inherited `WIRE_HOME` but didn't, and
1570/// then silently writes to / blocks on the cwd-default home alongside a
1571/// sibling process serving the real session-key home).
1572pub fn is_unexpected_session_source(source: &str) -> bool {
1573 matches!(source, "machine-default" | "minted")
1574}
1575
1576/// #284.4: at startup, a long-running inbox-owning role (`daemon`, `mcp`,
1577/// `monitor`, `notify`) calls this to surface the "the launcher meant
1578/// to hand us an explicit identity but didn't" failure mode. Default
1579/// behavior is a loud, force-rendered stderr warning naming the
1580/// resolved source so operators see the silent-collision risk; setting
1581/// `WIRE_STRICT_SESSION=1` upgrades it to a hard exit (code 2) so a
1582/// script wrapper can fail-fast instead of waiting for the downstream
1583/// `init` / `bind` / `daemon` call to block on a shared relay lock.
1584///
1585/// Roles that aren't in the inbox-owning long-running set (`whoami`,
1586/// `send`, `peers`, …) deliberately skip this check — they're meant to
1587/// run from a bare CLI on the machine-default identity, and warning
1588/// every one of them would drown legitimate ad-hoc usage in noise.
1589pub fn warn_if_unexpected_session_source(role: &str) {
1590 let source = session_source();
1591 if !is_unexpected_session_source(source) {
1592 return;
1593 }
1594 let strict = std::env::var("WIRE_STRICT_SESSION").is_ok_and(|v| !v.is_empty() && v != "0");
1595 let message = format!(
1596 "wire {role}: session-source=`{source}` — the launcher did not pass a session-key \
1597 (WIRE_HOME / WIRE_SESSION_ID / CLAUDE_CODE_SESSION_ID), so this process is running \
1598 against the {kind} identity. If a sibling agent is serving the real session-key \
1599 home, they will race the inbox cursor. Pass an explicit `WIRE_SESSION_ID=<key>` or \
1600 `WIRE_HOME=<path>` to fix.",
1601 kind = match source {
1602 "machine-default" => "machine-default (bare CLI)",
1603 "minted" => "freshly minted per-process",
1604 _ => "unexpected fallback",
1605 },
1606 );
1607 if strict {
1608 eprintln!("wire {role}: ERROR (WIRE_STRICT_SESSION=1) — {message}");
1609 std::process::exit(2);
1610 }
1611 eprintln!("{message}");
1612}
1613
1614/// RFC-008 §C — does this `WIRE_HOME` path point at the modern operator-
1615/// explicit `sessions/by-key/<16-hex-hash>` shape, or at an older/foreign
1616/// path?
1617///
1618/// The precedence flip in `maybe_adopt_session_wire_home` uses this to keep
1619/// **explicit modern pins** winning (operator deliberately joining two CC
1620/// tabs to one fleet-shared home, IDE config pinning a by-key path on
1621/// purpose) while letting the **session-key chain** beat a stale pre-v0.13.5
1622/// shell-profile `WIRE_HOME` pointing at the cwd-derived legacy layout
1623/// (paul's RFC-005 Phase 4 deletes the LAYOUT reader, but a shell-set env
1624/// var pointing at the path lingers across upgrades — that's the #210
1625/// regression). A non-by-key-shape `WIRE_HOME` loses to a present
1626/// session-key env var unless the operator opts back into legacy
1627/// ordering via `WIRE_HOME_FORCE=1`.
1628///
1629/// Match rule: `/by-key/<16-hex>` substring anywhere in the path (anchored
1630/// to a `by-key` segment with a `/` or `\` separator). Cross-platform: works
1631/// for both `/` and `\` separators by checking both.
1632fn is_by_key_shape(path: &str) -> bool {
1633 for needle in ["/by-key/", "\\by-key\\"] {
1634 if let Some(pos) = path.find(needle) {
1635 let after = &path[pos + needle.len()..];
1636 // Hash is the first path segment after `by-key/`. Take up to
1637 // the next separator (or end of string).
1638 let hash = after.split(['/', '\\']).next().unwrap_or("");
1639 // Wire writes exactly 16 lowercase hex chars
1640 // (`session_home_for_key`); reject anything else as malformed
1641 // → treat as legacy for safety.
1642 // Lowercase-only: wire emits `hex::encode(...)` which is
1643 // lowercase. `is_ascii_hexdigit` accepts both cases, so guard
1644 // explicitly against uppercase to keep the test pin from
1645 // `is_by_key_shape_rejects_legacy_and_malformed` honest.
1646 if hash.len() == 16
1647 && hash
1648 .chars()
1649 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
1650 {
1651 return true;
1652 }
1653 }
1654 }
1655 false
1656}
1657
1658pub fn maybe_adopt_session_wire_home(label: &str) {
1659 // RFC-008 §C — precedence flip for the agent-host case. Before §C:
1660 // presence of `WIRE_HOME` in env unconditionally short-circuited the
1661 // session-key chain, so a stale pre-v0.13.5 shell-profile pin (#210
1662 // reproducer) silently overrode CLAUDE_CODE_SESSION_ID. After §C:
1663 // WIRE_HOME still wins for the OPERATOR-EXPLICIT cases (by-key-shape
1664 // modern pin OR WIRE_HOME_FORCE=1 legacy-shape override). A
1665 // non-by-key-shape WIRE_HOME without WIRE_HOME_FORCE=1 LOSES to a
1666 // present session-key env var. Closes the silent-override path
1667 // without breaking the deliberate-fleet-share contract.
1668 if let Ok(pin) = std::env::var("WIRE_HOME") {
1669 let force = std::env::var("WIRE_HOME_FORCE").is_ok();
1670 let by_key = is_by_key_shape(&pin);
1671 if by_key {
1672 // Modern operator-explicit pin. Always wins.
1673 let _ = SESSION_SOURCE.set("env:WIRE_HOME");
1674 return;
1675 }
1676 if force {
1677 // Legacy-shape pin + explicit operator opt-back-in. Surface the
1678 // force so operators reading whoami can tell the override is
1679 // active (not silent).
1680 let _ = SESSION_SOURCE.set("env:WIRE_HOME_FORCE");
1681 return;
1682 }
1683 // Legacy-shape pin without WIRE_HOME_FORCE. Check if a session-key
1684 // env var is present; if so, it WINS (the §C flip). If no
1685 // session-key resolves either, fall through to honor the pin
1686 // (preserves the bare-pin path).
1687 if resolve_session_key().is_some() {
1688 // Session-key chain takes over. Clear WIRE_HOME from env so
1689 // downstream resolution writes the session-key by-key home
1690 // instead of layering on top of the stale pin.
1691 //
1692 // SAFETY: caller contract is "before any thread spawn." All
1693 // production sites (cli::run, mcp::run) call this fn as the
1694 // first step in their respective entry points.
1695 unsafe {
1696 std::env::remove_var("WIRE_HOME");
1697 }
1698 // Audible warning to stderr (gated on interactive TTY +
1699 // WIRE_VERBOSE, matching the existing autosession line below).
1700 // Suppress with WIRE_QUIET_AUTOSESSION=1 (same gate as the
1701 // autosession chatter).
1702 use std::io::IsTerminal;
1703 let quiet = std::env::var("WIRE_QUIET_AUTOSESSION").is_ok();
1704 let verbose = std::env::var("WIRE_VERBOSE").is_ok();
1705 let interactive = std::io::stderr().is_terminal();
1706 if !quiet && (interactive || verbose) {
1707 eprintln!(
1708 "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."
1709 );
1710 }
1711 // Fall through to the session-key resolution block below; it
1712 // will set SESSION_SOURCE to its own adapter label via the
1713 // existing `let _ = SESSION_SOURCE.set(source);` call.
1714 } else {
1715 // No session-key. Fall back to honoring the pin (legacy shape
1716 // but only signal present). Same as pre-§C behavior for this
1717 // case.
1718 let _ = SESSION_SOURCE.set("env:WIRE_HOME");
1719 return;
1720 }
1721 }
1722 // v0.13: prefer the host-agnostic session key (WIRE_SESSION_ID >
1723 // CLAUDE_CODE_SESSION_ID). Each session gets its own WIRE_HOME under
1724 // `by-key/<hash>` — no cwd lookup, no shared default, no Windows path
1725 // collapse. Falls back to legacy cwd-detect only when no session key is
1726 // present (bare CLI / pre-v0.13 hosts).
1727 let (home, why) = if let Some((key, source)) = resolve_session_key() {
1728 match session_home_for_key(&key) {
1729 Ok(h) => {
1730 // v0.13.2 (E8): do NOT create the home here. Creating it
1731 // unconditionally on every resolution — before any identity
1732 // exists — left a permanent empty home for every transient /
1733 // probe session key that never `wire up`d, accumulating
1734 // forever and surfacing as phantom "?" sisters in list-local.
1735 // The home is created lazily by `ensure_dirs` on the first
1736 // real write (init / claim / send), so an uninitialized
1737 // session leaves no trace on disk. (Write paths already
1738 // tolerate a non-existent WIRE_HOME — the test harness runs
1739 // every test against one.)
1740 let _ = SESSION_SOURCE.set(source);
1741 (h, format!("session key ({source})"))
1742 }
1743 Err(_) => return,
1744 }
1745 } else if label == "mcp" {
1746 // v0.13.4 (operator directive: per-session ONLY, never cwd). The MCP
1747 // server must NEVER cwd-resolve — that fallback is what collapsed every
1748 // Claude session sharing a launch dir onto a single persona.
1749 //
1750 // We reach here only when `resolve_session_key()` above returned None —
1751 // no session-id env var AND a single PID-file read missed. The dominant
1752 // cause is a STARTUP RACE: Claude hadn't written its session PID-file yet
1753 // when this stdio MCP booted. So retry the PID-file (cached-chain, no
1754 // per-hop `ps` re-fork) before giving up — this recovers the LIVE
1755 // identity instead of freezing a throwaway one (the "two names" bug).
1756 if let Some(sid) =
1757 claude_code_session_from_pidfile_retry(MINT_PIDFILE_ATTEMPTS, MINT_PIDFILE_BACKOFF)
1758 {
1759 match session_home_for_key(&sid) {
1760 Ok(h) => {
1761 let _ = SESSION_SOURCE.set("claude-code-pidfile");
1762 (
1763 h,
1764 "session key (claude-code-pidfile, post-race-retry)".to_string(),
1765 )
1766 }
1767 Err(_) => return,
1768 }
1769 } else {
1770 // Still no live session after the race window: mint a per-process
1771 // key — distinct per session, never a shared cwd identity.
1772 let minted = format!(
1773 "mcp-proc-{:016x}{:016x}",
1774 rand::random::<u64>(),
1775 rand::random::<u64>()
1776 );
1777 match session_home_for_key(&minted) {
1778 Ok(h) => {
1779 // Do NOT pin the minted key into WIRE_SESSION_ID. That slot
1780 // is the operator-override / live-session channel; a minted
1781 // value parked there masqueraded as an operator override and
1782 // beat the live session on any re-resolve — the root of the
1783 // "two names" split. WIRE_HOME (set below) already pins the
1784 // home for this process and is inherited by children, so
1785 // consistency holds without overloading the public slot.
1786 let _ = SESSION_SOURCE.set("minted");
1787 (
1788 h,
1789 "minted per-process key (no live session after race retry; cwd disabled for MCP)"
1790 .to_string(),
1791 )
1792 }
1793 Err(_) => return,
1794 }
1795 }
1796 } else {
1797 // CLI with no session id. Per the per-session-only directive we do NOT
1798 // cwd-resolve here either — cwd identity is the collision trap (agents
1799 // shell out to the CLI, and any cwd-derived identity risks the wrong /
1800 // shared persona). Under Claude Code the CLI always carries
1801 // CLAUDE_CODE_SESSION_ID (resolved above), so this only hits a bare
1802 // terminal outside an agent host — which gets the stable machine-default
1803 // identity (set WIRE_SESSION_ID / WIRE_HOME for an explicit one). No cwd.
1804 let _ = SESSION_SOURCE.set("machine-default");
1805 return;
1806 };
1807 // v0.9.1: emit the chatter ONLY when stderr is an interactive TTY.
1808 // When wire is invoked from a non-interactive parent (Claude Code's
1809 // Bash tool, scripts, daemons), the auto-detect line is captured
1810 // alongside command output and pollutes both — wasting agent
1811 // context tokens and breaking JSON parsers that read combined
1812 // streams. WIRE_VERBOSE=1 forces the line on.
1813 use std::io::IsTerminal;
1814 let verbose_env = std::env::var("WIRE_VERBOSE").is_ok();
1815 let interactive = std::io::stderr().is_terminal();
1816 if interactive || verbose_env {
1817 eprintln!(
1818 "wire {label}: adopted {why} → WIRE_HOME=`{}`",
1819 home.display()
1820 );
1821 }
1822 // SAFETY: caller contract is "before any thread spawn." All
1823 // production sites (cli::run, mcp::run) call this as the first
1824 // step in their respective entry points.
1825 unsafe {
1826 std::env::set_var("WIRE_HOME", &home);
1827 }
1828}
1829
1830#[cfg(test)]
1831mod tests {
1832 use super::*;
1833
1834 #[test]
1835 fn valid_session_key_rejects_empty_and_unexpanded_placeholder() {
1836 assert!(valid_session_key("4129275d-cc5c-4d2a"));
1837 assert!(valid_session_key("mcp-proc-deadbeef"));
1838 assert!(!valid_session_key(""));
1839 assert!(!valid_session_key(" "));
1840 // The load-bearing guard: an unexpanded MCP-config placeholder must NOT
1841 // be hashed — that's the all-sessions-collapse (soft-spruce) bug.
1842 assert!(!valid_session_key("${CLAUDE_CODE_SESSION_ID}"));
1843 assert!(!valid_session_key(" ${CLAUDE_CODE_SESSION_ID} "));
1844 }
1845
1846 #[test]
1847 fn resolve_session_key_vscode_adapter_and_placeholder_guard() {
1848 // Per-adapter test for the VS Code / GitHub Copilot path added in #59.
1849 // Holds two invariants the integration depends on:
1850 //
1851 // (a) When VSCODE_GIT_REPOSITORY_ROOT is set to a real workspace
1852 // path, that key wins resolution and two distinct workspace
1853 // paths produce two distinct session homes — proves the
1854 // per-workspace-identity contract documented in
1855 // docs/integrations/GITHUB_COPILOT.md.
1856 //
1857 // (b) When the env entry is the unexpanded literal "${workspaceFolder}"
1858 // (host failed to substitute), the ${} guard rejects it and the
1859 // fn falls through — proves the safe-degradation property
1860 // (no-identity, NOT cross-workspace collision).
1861 //
1862 // Mirrors the WIRE_SESSION_ID / CLAUDE_CODE_SESSION_ID semantics so any
1863 // future adapter added to the env-check loop inherits the same gates.
1864 let _guard = crate::config::test_support::ENV_LOCK
1865 .lock()
1866 .unwrap_or_else(|p| p.into_inner());
1867
1868 // Snapshot + clear every env var resolve_session_key consults so this
1869 // test is hermetic regardless of the harness environment.
1870 let prev_override = std::env::var_os("WIRE_SESSION_ID");
1871 let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
1872 let prev_codex = std::env::var_os("CODEX_SESSION_ID");
1873 let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
1874 let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
1875 // SAFETY: ENV_LOCK is held, serializing all env access.
1876 unsafe {
1877 std::env::remove_var("WIRE_SESSION_ID");
1878 std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1879 std::env::remove_var("CODEX_SESSION_ID");
1880 std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1881 std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1882 }
1883
1884 // (a) Two distinct workspace paths -> two distinct, stable session homes.
1885 unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/frontend") };
1886 let r1 = resolve_session_key();
1887 assert!(
1888 matches!(&r1, Some((k, src)) if k == "/home/dev/frontend" && *src == "vscode-workspace"),
1889 "VSCODE_GIT_REPOSITORY_ROOT must win resolution and be labeled vscode-workspace; got {r1:?}"
1890 );
1891 let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
1892
1893 unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/backend") };
1894 let r2 = resolve_session_key();
1895 let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
1896 assert_ne!(
1897 home_a, home_b,
1898 "distinct workspace roots must map to distinct session homes (no cross-workspace persona collision)"
1899 );
1900
1901 // Same path again -> same home (resume stability).
1902 unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/frontend") };
1903 let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap();
1904 assert_eq!(
1905 home_a, home_a2,
1906 "same workspace root must yield the same home across calls"
1907 );
1908
1909 // (b) Unexpanded ${workspaceFolder} literal MUST NOT be accepted.
1910 // With every other adapter still cleared, resolution must fall
1911 // through to None (or the claude pidfile path, which is absent in
1912 // this test env) — never hash the literal.
1913 unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "${workspaceFolder}") };
1914 let r_guard = resolve_session_key();
1915 assert!(
1916 !matches!(&r_guard, Some((k, _)) if k.contains("${")),
1917 "unexpanded ${{workspaceFolder}} literal must be rejected by the ${{}} guard; got {r_guard:?}"
1918 );
1919 // Same guard for the other adapter slots.
1920 unsafe {
1921 std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1922 std::env::set_var("WIRE_SESSION_ID", "${workspaceFolder}");
1923 }
1924 let r_guard2 = resolve_session_key();
1925 assert!(
1926 !matches!(&r_guard2, Some((k, _)) if k.contains("${")),
1927 "unexpanded ${{workspaceFolder}} in WIRE_SESSION_ID must also be rejected; got {r_guard2:?}"
1928 );
1929
1930 // Restore any env we displaced.
1931 // SAFETY: ENV_LOCK still held.
1932 unsafe {
1933 std::env::remove_var("WIRE_SESSION_ID");
1934 std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1935 std::env::remove_var("CODEX_SESSION_ID");
1936 std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1937 std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1938 if let Some(v) = prev_override {
1939 std::env::set_var("WIRE_SESSION_ID", v);
1940 }
1941 if let Some(v) = prev_claude {
1942 std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
1943 }
1944 if let Some(v) = prev_codex {
1945 std::env::set_var("CODEX_SESSION_ID", v);
1946 }
1947 if let Some(v) = prev_copilot {
1948 std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
1949 }
1950 if let Some(v) = prev_vscode {
1951 std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
1952 }
1953 }
1954 }
1955
1956 #[test]
1957 fn resolve_session_key_copilot_cli_adapter_and_priority() {
1958 // Per-adapter test for the GitHub Copilot CLI path (Phase 2 of #59):
1959 // resolve_session_key reads COPILOT_AGENT_SESSION_ID (set by the
1960 // `gh copilot` / `copilot` CLI host on every session) as a TARGETED
1961 // env adapter — exactly like CLAUDE_CODE_SESSION_ID. Holds three
1962 // invariants:
1963 //
1964 // (a) Set to a real id -> that key wins resolution and two distinct
1965 // conversations map to two distinct session homes (per-
1966 // conversation identity contract).
1967 // (b) WIRE_SESSION_ID overrides COPILOT_AGENT_SESSION_ID (priority
1968 // 1 trumps priority 3).
1969 // (c) Unexpanded ${...} literal is rejected by the ${} guard —
1970 // falls through to the None path, never hashed (mirrors the
1971 // guard inherited from CLAUDE_CODE_SESSION_ID / WIRE_SESSION_ID
1972 // / VSCODE_GIT_REPOSITORY_ROOT).
1973 let _guard = crate::config::test_support::ENV_LOCK
1974 .lock()
1975 .unwrap_or_else(|p| p.into_inner());
1976
1977 // Snapshot every env var resolve_session_key consults so the test is
1978 // hermetic regardless of harness environment (this test literally
1979 // runs under Copilot CLI, where COPILOT_AGENT_SESSION_ID is set).
1980 let prev_override = std::env::var_os("WIRE_SESSION_ID");
1981 let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
1982 let prev_codex = std::env::var_os("CODEX_SESSION_ID");
1983 let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
1984 let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
1985 // SAFETY: ENV_LOCK is held, serializing all env access.
1986 unsafe {
1987 std::env::remove_var("WIRE_SESSION_ID");
1988 std::env::remove_var("CLAUDE_CODE_SESSION_ID");
1989 std::env::remove_var("CODEX_SESSION_ID");
1990 std::env::remove_var("COPILOT_AGENT_SESSION_ID");
1991 std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
1992 }
1993
1994 // (a) COPILOT_AGENT_SESSION_ID set -> wins resolution; distinct ids
1995 // map to distinct session homes.
1996 unsafe {
1997 std::env::set_var(
1998 "COPILOT_AGENT_SESSION_ID",
1999 "3869478a-33cc-4c33-82ee-b6403a24d734",
2000 )
2001 };
2002 let r1 = resolve_session_key();
2003 assert!(
2004 matches!(&r1, Some((k, src)) if k == "3869478a-33cc-4c33-82ee-b6403a24d734" && *src == "copilot-cli"),
2005 "COPILOT_AGENT_SESSION_ID must win resolution and be labeled copilot-cli; got {r1:?}"
2006 );
2007 let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
2008
2009 unsafe {
2010 std::env::set_var(
2011 "COPILOT_AGENT_SESSION_ID",
2012 "deadbeef-0000-0000-0000-000000000000",
2013 )
2014 };
2015 let r2 = resolve_session_key();
2016 let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
2017 assert_ne!(
2018 home_a, home_b,
2019 "distinct Copilot CLI session ids must map to distinct session homes"
2020 );
2021
2022 // (b) WIRE_SESSION_ID at priority 1 overrides COPILOT_AGENT_SESSION_ID
2023 // at priority 3. Operator's explicit universal override always wins.
2024 unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") };
2025 let r_override = resolve_session_key();
2026 assert!(
2027 matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"),
2028 "WIRE_SESSION_ID must beat COPILOT_AGENT_SESSION_ID; got {r_override:?}"
2029 );
2030 unsafe { std::env::remove_var("WIRE_SESSION_ID") };
2031
2032 // (c) Unexpanded ${...} literal is rejected by the ${} guard.
2033 // `gh copilot` shouldn't ship literal placeholders in
2034 // COPILOT_AGENT_SESSION_ID, but if some future config-forwarding
2035 // path does, the guard must reject it (same as for the other
2036 // adapters) so we never hash the literal and collapse sessions.
2037 unsafe { std::env::set_var("COPILOT_AGENT_SESSION_ID", "${SOME_PLACEHOLDER}") };
2038 let r_guard = resolve_session_key();
2039 assert!(
2040 !matches!(&r_guard, Some((k, _)) if k.contains("${")),
2041 "unexpanded ${{...}} in COPILOT_AGENT_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}"
2042 );
2043
2044 // Restore any env we displaced.
2045 // SAFETY: ENV_LOCK still held.
2046 unsafe {
2047 std::env::remove_var("WIRE_SESSION_ID");
2048 std::env::remove_var("CLAUDE_CODE_SESSION_ID");
2049 std::env::remove_var("CODEX_SESSION_ID");
2050 std::env::remove_var("COPILOT_AGENT_SESSION_ID");
2051 std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
2052 if let Some(v) = prev_override {
2053 std::env::set_var("WIRE_SESSION_ID", v);
2054 }
2055 if let Some(v) = prev_claude {
2056 std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
2057 }
2058 if let Some(v) = prev_codex {
2059 std::env::set_var("CODEX_SESSION_ID", v);
2060 }
2061 if let Some(v) = prev_copilot {
2062 std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
2063 }
2064 if let Some(v) = prev_vscode {
2065 std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
2066 }
2067 }
2068 }
2069
2070 #[test]
2071 fn resolve_session_key_codex_cli_adapter_and_priority() {
2072 // Per-adapter test for the OpenAI Codex CLI path (#__pr_codex__).
2073 // resolve_session_key reads CODEX_SESSION_ID as a TARGETED env adapter
2074 // — exactly like CLAUDE_CODE_SESSION_ID and COPILOT_AGENT_SESSION_ID.
2075 // Until Codex itself forwards the thread id to MCP child env, operators
2076 // wire it via `[mcp_servers.<name>.env]` in `~/.codex/config.toml`;
2077 // landing the adapter now means once Codex ships the env it works
2078 // with zero further code change. Holds three invariants:
2079 //
2080 // (a) Set to a real thread id -> that key wins resolution and two
2081 // distinct threads map to two distinct session homes
2082 // (per-thread identity contract).
2083 // (b) WIRE_SESSION_ID overrides CODEX_SESSION_ID (priority 1
2084 // trumps priority 3); CLAUDE_CODE_SESSION_ID also outranks
2085 // CODEX_SESSION_ID (priority 2 trumps priority 3) — the
2086 // Codex adapter slots between Claude Code and Copilot.
2087 // (c) Unexpanded ${...} literal is rejected by the ${} guard,
2088 // falling through rather than collapsing all sessions
2089 // (mirrors the guard inherited from every other adapter).
2090 let _guard = crate::config::test_support::ENV_LOCK
2091 .lock()
2092 .unwrap_or_else(|p| p.into_inner());
2093
2094 // Snapshot every env var resolve_session_key consults so the test is
2095 // hermetic regardless of harness environment.
2096 let prev_override = std::env::var_os("WIRE_SESSION_ID");
2097 let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
2098 let prev_codex = std::env::var_os("CODEX_SESSION_ID");
2099 let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
2100 let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
2101 // SAFETY: ENV_LOCK is held, serializing all env access.
2102 unsafe {
2103 std::env::remove_var("WIRE_SESSION_ID");
2104 std::env::remove_var("CLAUDE_CODE_SESSION_ID");
2105 std::env::remove_var("CODEX_SESSION_ID");
2106 std::env::remove_var("COPILOT_AGENT_SESSION_ID");
2107 std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
2108 }
2109
2110 // (a) CODEX_SESSION_ID set -> wins resolution over the no-id baseline;
2111 // distinct thread ids map to distinct session homes.
2112 unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66ad-277e-7be3-bdd9-b7708e069f3b") };
2113 let r1 = resolve_session_key();
2114 assert!(
2115 matches!(&r1, Some((k, src)) if k == "019e66ad-277e-7be3-bdd9-b7708e069f3b" && *src == "codex-cli"),
2116 "CODEX_SESSION_ID must win resolution and be labeled codex-cli; got {r1:?}"
2117 );
2118 let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
2119
2120 unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66b6-14de-7142-b43a-1861fe59e945") };
2121 let r2 = resolve_session_key();
2122 let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
2123 assert_ne!(
2124 home_a, home_b,
2125 "distinct Codex thread ids must map to distinct session homes"
2126 );
2127
2128 // Same id again -> same home (resume stability — same thread reconnects
2129 // to the same persona).
2130 unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66ad-277e-7be3-bdd9-b7708e069f3b") };
2131 let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap();
2132 assert_eq!(
2133 home_a, home_a2,
2134 "same Codex thread id must yield the same home across calls"
2135 );
2136
2137 // (b) WIRE_SESSION_ID at priority 1 overrides CODEX_SESSION_ID at
2138 // priority 3 (operator explicit override always wins).
2139 unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") };
2140 let r_override = resolve_session_key();
2141 assert!(
2142 matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"),
2143 "WIRE_SESSION_ID must beat CODEX_SESSION_ID; got {r_override:?}"
2144 );
2145 unsafe { std::env::remove_var("WIRE_SESSION_ID") };
2146
2147 // CLAUDE_CODE_SESSION_ID at priority 2 also beats CODEX_SESSION_ID at
2148 // priority 3. (Earlier adapters get to claim the host they were
2149 // designed for; Codex slots in after Claude Code.)
2150 unsafe { std::env::set_var("CLAUDE_CODE_SESSION_ID", "claude-wins-over-codex") };
2151 let r_claude_wins = resolve_session_key();
2152 assert!(
2153 matches!(&r_claude_wins, Some((k, src)) if k == "claude-wins-over-codex" && *src == "claude-code"),
2154 "CLAUDE_CODE_SESSION_ID must beat CODEX_SESSION_ID; got {r_claude_wins:?}"
2155 );
2156 unsafe { std::env::remove_var("CLAUDE_CODE_SESSION_ID") };
2157
2158 // (c) Unexpanded ${...} literal is rejected by the ${} guard.
2159 // If a host's config-forwarding ever ships a literal placeholder,
2160 // the guard rejects it (same as for every other adapter) so we
2161 // never hash the literal and collapse sessions.
2162 unsafe { std::env::set_var("CODEX_SESSION_ID", "${SOME_PLACEHOLDER}") };
2163 let r_guard = resolve_session_key();
2164 assert!(
2165 !matches!(&r_guard, Some((k, _)) if k.contains("${")),
2166 "unexpanded ${{...}} in CODEX_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}"
2167 );
2168
2169 // Restore any env we displaced.
2170 // SAFETY: ENV_LOCK still held.
2171 unsafe {
2172 std::env::remove_var("WIRE_SESSION_ID");
2173 std::env::remove_var("CLAUDE_CODE_SESSION_ID");
2174 std::env::remove_var("CODEX_SESSION_ID");
2175 std::env::remove_var("COPILOT_AGENT_SESSION_ID");
2176 std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
2177 if let Some(v) = prev_override {
2178 std::env::set_var("WIRE_SESSION_ID", v);
2179 }
2180 if let Some(v) = prev_claude {
2181 std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
2182 }
2183 if let Some(v) = prev_codex {
2184 std::env::set_var("CODEX_SESSION_ID", v);
2185 }
2186 if let Some(v) = prev_copilot {
2187 std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
2188 }
2189 if let Some(v) = prev_vscode {
2190 std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
2191 }
2192 }
2193 }
2194
2195 #[test]
2196 fn list_sessions_sees_by_key_homes_and_root_resolves_from_inside() {
2197 // Regression (v0.13.2): v0.13 moved session homes under
2198 // `sessions/by-key/<hash>`, but (1) list_sessions only scanned the
2199 // top level so by-key homes were invisible, and (2) sessions_root()'s
2200 // inside-session fallback only walked ONE level up (expecting parent
2201 // `sessions`), so an inside-session WIRE_HOME resolved to a bogus
2202 // nested dir. Together they made same-box discovery (list-local /
2203 // pair-all-local) return zero sisters under v0.13.
2204 let _guard = crate::config::test_support::ENV_LOCK
2205 .lock()
2206 .unwrap_or_else(|p| p.into_inner());
2207 let tmp = std::env::temp_dir().join(format!("wire-bykey-{}", rand::random::<u32>()));
2208 let _ = std::fs::remove_dir_all(&tmp);
2209 let root = tmp.join("sessions");
2210 let home = root.join("by-key").join("abc123def4567890");
2211 let cfg = home.join("config").join("wire");
2212 std::fs::create_dir_all(&cfg).unwrap();
2213 std::fs::write(
2214 cfg.join("agent-card.json"),
2215 r#"{"did":"did:wire:test-persona-6e301ab1","handle":"test-persona","verify_keys":{}}"#,
2216 )
2217 .unwrap();
2218
2219 // (1) sessions_root() must find the real root even when WIRE_HOME
2220 // points INSIDE the by-key home.
2221 // SAFETY: ENV_LOCK is held, serializing all env access.
2222 unsafe { std::env::set_var("WIRE_HOME", &home) };
2223 assert_eq!(
2224 sessions_root().unwrap(),
2225 root,
2226 "sessions_root must resolve the root from inside a by-key home"
2227 );
2228
2229 // (2) list_sessions() must enumerate the by-key home, labeled by handle.
2230 let sessions = list_sessions().unwrap();
2231 let found = sessions
2232 .iter()
2233 .any(|s| s.handle.as_deref() == Some("test-persona"));
2234 unsafe { std::env::remove_var("WIRE_HOME") };
2235 let _ = std::fs::remove_dir_all(&tmp);
2236 assert!(
2237 found,
2238 "by-key home must be enumerated: {:?}",
2239 sessions.iter().map(|s| &s.name).collect::<Vec<_>>()
2240 );
2241 }
2242
2243 #[test]
2244 fn find_session_home_by_name_resolves_named_and_persona() {
2245 // RFC-006 Part A: a single by-key store, two naming conventions.
2246 // (1) A NAMED session's key is its name, so its home is
2247 // `session_dir(name)` directly. (2) An AGENT session's key is
2248 // its session id; the operator types the DID-derived persona
2249 // HANDLE, which does not hash to the home, so the handle walk
2250 // resolves it. Both must resolve via `find_session_home_by_name`.
2251 let _guard = crate::config::test_support::ENV_LOCK
2252 .lock()
2253 .unwrap_or_else(|p| p.into_inner());
2254 let tmp = std::env::temp_dir().join(format!("wire-find-{}", rand::random::<u32>()));
2255 let _ = std::fs::remove_dir_all(&tmp);
2256 let root = tmp.join("sessions");
2257 std::fs::create_dir_all(&root).unwrap();
2258
2259 // SAFETY: ENV_LOCK is held. Set early so `session_dir` resolves
2260 // the canonical by-key home under this test root.
2261 unsafe { std::env::set_var("WIRE_HOME", &root) };
2262
2263 // (1) Named session: key == name → `by-key/<hash(name)>`. The card
2264 // handle equals the name (named sessions claim their own name).
2265 let named_home = super::session_dir("named-pane").unwrap();
2266 let named_cfg = named_home.join("config").join("wire");
2267 std::fs::create_dir_all(&named_cfg).unwrap();
2268 std::fs::write(
2269 named_cfg.join("agent-card.json"),
2270 r#"{"did":"did:wire:named-pane-aaaa1111","handle":"named-pane","verify_keys":{}}"#,
2271 )
2272 .unwrap();
2273
2274 // (2) Agent session: dir is a session-key hash, card handle is the
2275 // DID-derived persona `coral-weasel` (≠ the hash).
2276 let bykey_home = root.join("by-key").join("3049827d92d4fbd5");
2277 let bykey_cfg = bykey_home.join("config").join("wire");
2278 std::fs::create_dir_all(&bykey_cfg).unwrap();
2279 std::fs::write(
2280 bykey_cfg.join("agent-card.json"),
2281 r#"{"did":"did:wire:coral-weasel-0616dc6c","handle":"coral-weasel","verify_keys":{}}"#,
2282 )
2283 .unwrap();
2284
2285 // Named lookup: operator types the session name; resolves via the
2286 // deterministic by-key home, no enumeration.
2287 let named = super::find_session_home_by_name("named-pane").unwrap();
2288 assert_eq!(
2289 named.as_deref(),
2290 Some(named_home.as_path()),
2291 "named session must resolve to its by-key/<hash(name)> home"
2292 );
2293
2294 // Persona lookup: operator types the persona handle, not the hash.
2295 let bykey = super::find_session_home_by_name("coral-weasel").unwrap();
2296 assert_eq!(
2297 bykey.as_deref(),
2298 Some(bykey_home.as_path()),
2299 "agent persona handle must resolve to its by-key/<hash> dir"
2300 );
2301
2302 // by-key lookup via the hash itself also works (some tooling
2303 // may pass the raw dir name).
2304 let by_hash = super::find_session_home_by_name("3049827d92d4fbd5").unwrap();
2305 assert_eq!(
2306 by_hash.as_deref(),
2307 Some(bykey_home.as_path()),
2308 "raw by-key hash dir name must also resolve"
2309 );
2310
2311 // Negative: an unknown name returns None, not an error.
2312 let missing = super::find_session_home_by_name("never-existed").unwrap();
2313 assert_eq!(missing, None, "unknown session must return None");
2314
2315 unsafe { std::env::remove_var("WIRE_HOME") };
2316 let _ = std::fs::remove_dir_all(&tmp);
2317 }
2318
2319 #[test]
2320 fn pid_to_session_map_builds_from_session_pidfiles() {
2321 // #173 follow-up (#174 hotfix removed --session arg from
2322 // supervisor children): wire status orphan annotation now
2323 // maps pid → session via per-session pidfiles. Walk should
2324 // find each session whose `<home>/state/wire/daemon.pid`
2325 // contains a valid pid, and IGNORE sessions whose pidfile
2326 // is absent or unreadable.
2327 let _guard = crate::config::test_support::ENV_LOCK
2328 .lock()
2329 .unwrap_or_else(|p| p.into_inner());
2330 let tmp = std::env::temp_dir().join(format!("wire-p2s-{}", rand::random::<u32>()));
2331 let _ = std::fs::remove_dir_all(&tmp);
2332 let root = tmp.join("sessions");
2333 // Three by-key sessions. Two have pidfiles, one doesn't.
2334 let mk_session = |key: &str, handle: &str| -> PathBuf {
2335 let home = root.join("by-key").join(key);
2336 let cfg = home.join("config").join("wire");
2337 std::fs::create_dir_all(&cfg).unwrap();
2338 std::fs::write(
2339 cfg.join("agent-card.json"),
2340 format!(
2341 r#"{{"did":"did:wire:{handle}-6e301ab1","handle":"{handle}","verify_keys":{{}}}}"#
2342 ),
2343 )
2344 .unwrap();
2345 home
2346 };
2347 let h1 = mk_session("abc123def4567890", "alpha-aurora");
2348 let h2 = mk_session("def456abc7890123", "beta-blossom");
2349 let _h3 = mk_session("0000aaaabbbbcccc", "gamma-gorge");
2350 // h1 / h2 get JSON pidfiles; h3 gets none.
2351 let state1 = h1.join("state").join("wire");
2352 let state2 = h2.join("state").join("wire");
2353 std::fs::create_dir_all(&state1).unwrap();
2354 std::fs::create_dir_all(&state2).unwrap();
2355 std::fs::write(state1.join("daemon.pid"), r#"{"pid": 12345}"#).unwrap();
2356 std::fs::write(state2.join("daemon.pid"), r#"{"pid": 67890}"#).unwrap();
2357
2358 // SAFETY: ENV_LOCK is held, serializing all env access.
2359 unsafe { std::env::set_var("WIRE_HOME", &h1) };
2360 let map = super::pid_to_session_map();
2361 unsafe { std::env::remove_var("WIRE_HOME") };
2362 let _ = std::fs::remove_dir_all(&tmp);
2363
2364 // h1 / h2 present, h3 absent. SessionInfo.name is the handle
2365 // derived from the card when the home is initialized
2366 // (list_sessions's mk helper overrides name = handle in that
2367 // case; by-key hash is only the fallback for uninitialized
2368 // homes). That's exactly the production label `wire status`
2369 // already prints for sessions.
2370 assert_eq!(
2371 map.get(&12345).map(String::as_str),
2372 Some("alpha-aurora"),
2373 "pid 12345 should map to the handle for h1"
2374 );
2375 assert_eq!(
2376 map.get(&67890).map(String::as_str),
2377 Some("beta-blossom"),
2378 "pid 67890 should map (JSON pidfile form, handle for h2)"
2379 );
2380 // Sanity: no entry for an unrelated pid.
2381 assert!(
2382 !map.contains_key(&99999),
2383 "synthetic missing pid should not appear in the map"
2384 );
2385 }
2386
2387 #[test]
2388 fn session_home_for_key_is_deterministic_distinct_and_well_formed() {
2389 // session_home_for_key reads WIRE_HOME (via sessions_root); hold the
2390 // shared env lock so a parallel env-mutating test can't change it
2391 // between calls and make a1 != a2 (flaky race).
2392 let _guard = crate::config::test_support::ENV_LOCK
2393 .lock()
2394 .unwrap_or_else(|p| p.into_inner());
2395 let a1 = session_home_for_key("sess-aaa").unwrap();
2396 let a2 = session_home_for_key("sess-aaa").unwrap();
2397 let b = session_home_for_key("sess-bbb").unwrap();
2398 assert_eq!(a1, a2, "same key -> same home (resume stability)");
2399 assert_ne!(a1, b, "distinct keys -> distinct homes (no collision)");
2400 let leaf = a1.file_name().unwrap().to_str().unwrap();
2401 assert_eq!(leaf.len(), 16, "16 hex chars / 64 bits");
2402 assert!(leaf.chars().all(|c| c.is_ascii_hexdigit()));
2403 assert_eq!(
2404 a1.parent().unwrap().file_name().unwrap().to_str().unwrap(),
2405 "by-key"
2406 );
2407 }
2408
2409 #[test]
2410 fn url_is_loopback_recognises_v4_v6_and_localhost_v0_7_4() {
2411 assert!(url_is_loopback("http://127.0.0.1:8771"));
2412 assert!(url_is_loopback("http://127.1.2.3"));
2413 assert!(url_is_loopback("http://localhost:9000"));
2414 assert!(url_is_loopback("https://localhost/v1"));
2415 assert!(url_is_loopback("http://[::1]:8771"));
2416 // Case-insensitive.
2417 assert!(url_is_loopback("HTTP://LOCALHOST:8771"));
2418 // Non-loopback negatives — must NOT be flagged.
2419 assert!(!url_is_loopback("https://wireup.net"));
2420 assert!(!url_is_loopback("http://192.168.1.50:8771"));
2421 assert!(!url_is_loopback("http://10.0.0.5"));
2422 assert!(!url_is_loopback("https://relay.example.com"));
2423 }
2424
2425 #[test]
2426 fn sanitize_handles_unicode_and_long_names() {
2427 assert_eq!(sanitize_name("paul-mac"), "paul-mac");
2428 assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
2429 assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); // ascii-only fallback
2430 assert_eq!(sanitize_name(""), "wire-session");
2431 assert_eq!(sanitize_name("---"), "wire-session");
2432 let long: String = "a".repeat(100);
2433 assert_eq!(sanitize_name(&long).len(), 32);
2434 }
2435
2436 #[test]
2437 fn derive_name_returns_basename_when_no_collision() {
2438 let reg = SessionRegistry::default();
2439 assert_eq!(
2440 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
2441 "wire"
2442 );
2443 assert_eq!(
2444 derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), ®),
2445 "slancha-mesh"
2446 );
2447 }
2448
2449 #[test]
2450 fn derive_name_returns_stored_name_when_cwd_already_registered() {
2451 let mut reg = SessionRegistry::default();
2452 reg.by_cwd.insert(
2453 "/Users/paul/Source/wire".to_string(),
2454 "wire-special".to_string(),
2455 );
2456 assert_eq!(
2457 derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
2458 "wire-special"
2459 );
2460 }
2461
2462 #[test]
2463 fn normalize_cwd_key_case_handling_matches_platform_filesystem() {
2464 // Issue #30 Willard repro: on Windows, two terminals in the "same"
2465 // project under different casings of the same path
2466 // (`C:\Foo\Bar` vs `C:\foo\bar`) hashed to DIFFERENT registry keys
2467 // pre-fix → the second terminal missed the registry lookup, fell
2468 // back to the legacy default identity, and both terminals collapsed
2469 // onto a shared DID. Fix: normalize the cwd key case-insensitively
2470 // on Windows, case-sensitively elsewhere (so distinct-on-disk paths
2471 // on case-sensitive filesystems remain distinct).
2472 let upper = Path::new("/Users/paul/Source/WIRE");
2473 let lower = Path::new("/Users/paul/Source/wire");
2474 if cfg!(windows) {
2475 assert_eq!(
2476 normalize_cwd_key(upper),
2477 normalize_cwd_key(lower),
2478 "on Windows, distinct casings of the same path MUST normalize \
2479 to the same key (NTFS is case-insensitive by default)"
2480 );
2481 } else {
2482 assert_ne!(
2483 normalize_cwd_key(upper),
2484 normalize_cwd_key(lower),
2485 "on case-sensitive filesystems, distinct casings ARE distinct \
2486 directories and MUST stay distinct keys"
2487 );
2488 }
2489 // Trivial sanity: same input always produces same output.
2490 assert_eq!(normalize_cwd_key(lower), normalize_cwd_key(lower));
2491 }
2492
2493 #[test]
2494 fn derive_name_no_regression_exact_match_still_resolves() {
2495 // Cross-platform no-regression check for the v0.13.6 lookup
2496 // changes: an exact-match (same casing stored AND looked up)
2497 // entry MUST continue to resolve on the fast path — the new
2498 // O(n) normalized-scan fallback is only reached on the initial
2499 // .get miss.
2500 //
2501 // Honest scope (per coral-weasel's #67 review): this test does
2502 // NOT exercise the case-folding fallback on Linux/macOS — the
2503 // normalizer is a no-op there, so the first `.get` hits and
2504 // the scan never runs. The case-folding behavior is inherently
2505 // Windows-only; that path is covered by
2506 // derive_name_finds_registered_cwd_under_alternate_casing_on_windows
2507 // which executes on Windows CI.
2508 let mut reg = SessionRegistry::default();
2509 let stored = "/Users/Paul/Source/Wire-v0_13_5-Era";
2510 reg.by_cwd
2511 .insert(stored.to_string(), "wire-legacy".to_string());
2512
2513 // Lookup under the EXACT stored path: must resolve on the
2514 // fast `.get` path regardless of platform.
2515 assert_eq!(
2516 derive_name_from_cwd(Path::new(stored), ®),
2517 "wire-legacy",
2518 "exact-match v0.13.5 entry MUST still resolve under v0.13.6+"
2519 );
2520 }
2521
2522 #[test]
2523 fn derive_name_scan_fallback_runs_when_initial_get_misses() {
2524 // Cross-platform proof that the O(n) normalized-scan fallback
2525 // engages on a .get miss. We can't trigger the *case-folding*
2526 // case on Linux/macOS (normalizer is a no-op), but we CAN
2527 // exercise the scan branch by storing under a key the
2528 // normalized lookup definitely won't hit, and verifying that
2529 // the .find()-based fallback resolves it.
2530 //
2531 // Setup: store under a key that's identical to the lookup
2532 // BUT with a trailing slash difference (so `.get` exact-match
2533 // misses, but our normalize_cwd_key — which preserves the
2534 // trailing slash — also misses; then we rely on the .find()
2535 // iterator). This is a contrived setup that proves the scan
2536 // branch is reachable; it does NOT test case-folding (Windows
2537 // only).
2538 //
2539 // A simpler way to exercise the same logic: store under one
2540 // path, look up under a different path that normalizes to the
2541 // SAME key. Without case-folding, the only way to do that is
2542 // to mutate normalize_cwd_key. Since we can't do that in a
2543 // test, this test instead pins the *no-false-positive* side:
2544 // a path with no matching stored entry must NOT resolve.
2545 let mut reg = SessionRegistry::default();
2546 reg.by_cwd.insert(
2547 "/Users/paul/Source/project-a".to_string(),
2548 "project-a".to_string(),
2549 );
2550
2551 // Distinct path → no match → falls through to basename
2552 // derivation. Proves the scan doesn't fabricate matches.
2553 let derived = derive_name_from_cwd(Path::new("/Users/paul/Source/project-b"), ®);
2554 assert_eq!(
2555 derived, "project-b",
2556 "non-matching lookup must fall through to basename derivation, \
2557 NOT fabricate a match via the scan"
2558 );
2559 }
2560
2561 #[cfg(windows)]
2562 #[test]
2563 fn derive_name_finds_registered_cwd_under_alternate_casing_on_windows() {
2564 // Direct integration check for the Willard repro on Windows: an
2565 // existing registry entry written under one casing MUST resolve
2566 // when the lookup arrives under a different casing of the same
2567 // path.
2568 //
2569 // Trace through the v0.13.6 read-side O(n) normalized scan:
2570 // - Stored key: "C:\Users\Willard\ComfyUI\claude-integration"
2571 // - Lookup cwd: "c:\users\willard\comfyui\claude-integration"
2572 // - cwd_key = normalize(lookup) = "c:\users\..." (already lower)
2573 // - .get(&cwd_key) → MISS (stored has mixed casing)
2574 // - .iter().find(normalize(stored) == cwd_key) → HIT
2575 // (normalize("C:\Users\...") == "c:\users\..." == cwd_key)
2576 // - Returns "claude-integration" ← the fix.
2577 //
2578 // Pre-fix this returned the basename → phantom hash-suffix → identity
2579 // collision (the original Willard report).
2580 let mut reg = SessionRegistry::default();
2581 reg.by_cwd.insert(
2582 r"C:\Users\Willard\ComfyUI\claude-integration".to_string(),
2583 "claude-integration".to_string(),
2584 );
2585 let from_lower_cwd = Path::new(r"c:\users\willard\comfyui\claude-integration");
2586 assert_eq!(
2587 derive_name_from_cwd(from_lower_cwd, ®),
2588 "claude-integration",
2589 "Windows lookup MUST find the registered entry regardless of \
2590 how the shell capitalized the cwd, via the normalized scan"
2591 );
2592 }
2593
2594 #[test]
2595 fn read_session_endpoints_handles_missing_relay_state() {
2596 let tmp = tempfile::tempdir().unwrap();
2597 // No relay.json under <home>/config/wire/ — should yield empty.
2598 let endpoints = read_session_endpoints(tmp.path());
2599 assert!(endpoints.is_empty());
2600 }
2601
2602 #[test]
2603 fn read_session_endpoints_parses_dual_slot_form() {
2604 let tmp = tempfile::tempdir().unwrap();
2605 let cfg = tmp.path().join("config").join("wire");
2606 std::fs::create_dir_all(&cfg).unwrap();
2607 let body = serde_json::json!({
2608 "self": {
2609 "relay_url": "https://wireup.net",
2610 "slot_id": "fed-slot",
2611 "slot_token": "fed-tok",
2612 "endpoints": [
2613 {
2614 "relay_url": "https://wireup.net",
2615 "slot_id": "fed-slot",
2616 "slot_token": "fed-tok",
2617 "scope": "federation"
2618 },
2619 {
2620 "relay_url": "http://127.0.0.1:8771",
2621 "slot_id": "loop-slot",
2622 "slot_token": "loop-tok",
2623 "scope": "local"
2624 }
2625 ]
2626 }
2627 });
2628 std::fs::write(cfg.join("relay.json"), serde_json::to_vec(&body).unwrap()).unwrap();
2629 let endpoints = read_session_endpoints(tmp.path());
2630 assert_eq!(endpoints.len(), 2);
2631 let local_count = endpoints
2632 .iter()
2633 .filter(|e| matches!(e.scope, EndpointScope::Local))
2634 .count();
2635 assert_eq!(local_count, 1);
2636 let local = endpoints
2637 .iter()
2638 .find(|e| matches!(e.scope, EndpointScope::Local))
2639 .unwrap();
2640 assert_eq!(local.relay_url, "http://127.0.0.1:8771");
2641 assert_eq!(local.slot_id, "loop-slot");
2642 }
2643
2644 // NOTE: list_local_sessions is integration-tested via tests/cli.rs
2645 // using a subprocess that sets WIRE_HOME per-process. We do not test
2646 // it in-module because env mutation races other parallel unit tests
2647 // (Rust 2024 marks std::env::set_var unsafe for that reason). The
2648 // grouping logic is straightforward enough that the integration
2649 // test plus the read_session_endpoints unit tests above provide
2650 // adequate coverage.
2651
2652 #[test]
2653 fn derive_name_appends_path_hash_when_basename_collides() {
2654 let mut reg = SessionRegistry::default();
2655 reg.by_cwd
2656 .insert("/Users/paul/Source/wire".to_string(), "wire".to_string());
2657 // Different cwd, same basename → must get a hash suffix.
2658 let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), ®);
2659 assert!(name.starts_with("wire-"));
2660 assert_eq!(name.len(), "wire-".len() + 4); // 4 hex chars
2661 assert_ne!(name, "wire");
2662 }
2663
2664 // ---------- identity-collision warning (issue #29/#30 — broaden to
2665 // every inbox-cursor-owning subcommand, not just `wire mcp`). ----------
2666
2667 #[test]
2668 fn inbox_owning_subcommands_covers_each_runtime_role() {
2669 // Lock the role list down — any addition / removal here must
2670 // come with an updated call site (cli::cmd_daemon, cmd_monitor,
2671 // cmd_notify, mcp::run) and an updated rendezvous in the pgrep
2672 // predicate. The pgrep predicate is built from this list at
2673 // call time, so adding "watch" here automatically extends
2674 // detection — but the warning is only fired if a call site
2675 // also invokes warn_on_identity_collision with that role.
2676 assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"mcp"));
2677 assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"daemon"));
2678 assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"monitor"));
2679 assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"notify"));
2680 // pair-host (SAS code-phrase flow) was removed in RFC-005 follow-on
2681 // and must not appear in the list.
2682 assert!(!INBOX_OWNING_SUBCOMMANDS.contains(&"pair-host"));
2683 }
2684
2685 #[test]
2686 fn find_colliders_returns_only_same_home_pids() {
2687 let our_home = "/tmp/wire-home-A";
2688 let others = vec![
2689 (101, Some("/tmp/wire-home-A".to_string())), // collide
2690 (102, Some("/tmp/wire-home-B".to_string())), // distinct home
2691 (103, None), // env-unreadable, skip
2692 (104, Some("/tmp/wire-home-A".to_string())), // collide
2693 ];
2694 let colliders = find_colliders(our_home, &others);
2695 assert_eq!(colliders, vec![101, 104]);
2696 }
2697
2698 #[test]
2699 fn find_colliders_no_match_returns_empty() {
2700 let our_home = "/tmp/wire-home-A";
2701 let others = vec![
2702 (101, Some("/tmp/wire-home-B".to_string())),
2703 (102, Some("/tmp/wire-home-C".to_string())),
2704 (103, None),
2705 ];
2706 assert!(find_colliders(our_home, &others).is_empty());
2707 }
2708
2709 #[test]
2710 fn find_colliders_empty_input_is_empty() {
2711 assert!(find_colliders("/tmp/anywhere", &[]).is_empty());
2712 }
2713
2714 #[test]
2715 fn find_colliders_ignores_substring_matches() {
2716 // `WIRE_HOME=/wire-A` must NOT collide with `WIRE_HOME=/wire-A/sub`.
2717 // Exact-match semantics protect against parent/child confusion.
2718 let our_home = "/tmp/wire-A";
2719 let others = vec![
2720 (201, Some("/tmp/wire-A/sub".to_string())),
2721 (202, Some("/wire-A".to_string())), // distinct path
2722 (203, Some("/tmp/wire-A".to_string())), // real collision
2723 ];
2724 assert_eq!(find_colliders(our_home, &others), vec![203]);
2725 }
2726
2727 #[test]
2728 fn collision_warning_format_includes_role_home_and_pids() {
2729 // Sanity-check the first warning line by reconstructing it
2730 // exactly the way `emit_collision_warning` does. If anyone
2731 // changes the format, this test must change with it — that's
2732 // the point: the format is a documented operator-facing
2733 // surface (Willard's #30 cited the older wording verbatim
2734 // when filing the bug).
2735 let role = "daemon";
2736 let home = "/tmp/by-key/abc123";
2737 let colliders = vec![4242u32, 4243u32];
2738 let expected_head = format!(
2739 "wire {role}: WARNING — {n} other wire process(es) already using WIRE_HOME=`{home}` (pid {pids})",
2740 n = colliders.len(),
2741 pids = colliders
2742 .iter()
2743 .map(u32::to_string)
2744 .collect::<Vec<_>>()
2745 .join(", "),
2746 );
2747 assert_eq!(
2748 expected_head,
2749 "wire daemon: WARNING — 2 other wire process(es) already using WIRE_HOME=`/tmp/by-key/abc123` (pid 4242, 4243)"
2750 );
2751 // Exercise the renderer so it can't bit-rot via dead-code
2752 // pruning. Output goes to stderr; under libtest it's captured.
2753 emit_collision_warning(role, home, &colliders);
2754 }
2755
2756 /// RFC-008 §C — `is_by_key_shape` must distinguish modern operator-
2757 /// explicit `sessions/by-key/<16-hex>` pins from every other path
2758 /// shape (legacy cwd-derived `sessions/<name>/config/wire`, foreign
2759 /// paths, malformed by-key dirs). Fast — used at every wire startup
2760 /// when WIRE_HOME is set — so this asserts cross-platform path-sep
2761 /// handling without allocating.
2762 #[test]
2763 fn is_by_key_shape_recognizes_modern_pin() {
2764 // Linux/macOS modern shape.
2765 assert!(is_by_key_shape(
2766 "/home/dev/.local/state/wire/sessions/by-key/0c38ce498aa9d955"
2767 ));
2768 // Windows modern shape (escaped backslashes).
2769 assert!(is_by_key_shape(
2770 "C:\\Users\\Willard\\AppData\\Local\\wire\\sessions\\by-key\\0c38ce498aa9d955"
2771 ));
2772 // With trailing path segments (e.g. /config/wire suffix).
2773 assert!(is_by_key_shape(
2774 "/home/dev/.local/state/wire/sessions/by-key/abcdef0123456789/config/wire"
2775 ));
2776 assert!(is_by_key_shape(
2777 "C:\\wire\\sessions\\by-key\\abcdef0123456789\\config\\wire"
2778 ));
2779 }
2780
2781 #[test]
2782 fn is_by_key_shape_rejects_legacy_and_malformed() {
2783 // Legacy cwd-derived shape (the #210 case).
2784 assert!(!is_by_key_shape(
2785 "C:\\Users\\Willard\\AppData\\Local\\wire\\sessions\\willard\\config\\wire"
2786 ));
2787 assert!(!is_by_key_shape(
2788 "/home/dev/.local/state/wire/sessions/projx/config/wire"
2789 ));
2790 // Foreign path, no `by-key/` at all.
2791 assert!(!is_by_key_shape("/tmp/some-fleet-shared-dir"));
2792 // by-key with wrong hash length (8 hex — too short).
2793 assert!(!is_by_key_shape("/wire/sessions/by-key/abcdef01"));
2794 // by-key with non-hex chars in hash.
2795 assert!(!is_by_key_shape("/wire/sessions/by-key/not-a-hex-hash"));
2796 // by-key with uppercase hex (wire writes lowercase only).
2797 assert!(!is_by_key_shape("/wire/sessions/by-key/0C38CE498AA9D955"));
2798 // by-key with hash too long (32 hex).
2799 assert!(!is_by_key_shape(
2800 "/wire/sessions/by-key/0c38ce498aa9d9550c38ce498aa9d955"
2801 ));
2802 // Empty path.
2803 assert!(!is_by_key_shape(""));
2804 }
2805
2806 /// RFC-008 §C — verify a path containing `by-key` as a partial
2807 /// substring (NOT as a path segment) is correctly rejected. Catches a
2808 /// regex-style regression where the matcher splits on the wrong
2809 /// delimiter.
2810 #[test]
2811 fn is_by_key_shape_substring_not_segment_rejected() {
2812 // `by-key` appears, but NOT as a path segment — should reject.
2813 assert!(!is_by_key_shape(
2814 "/wire/sessions/foo-by-key-bar/0c38ce498aa9d955"
2815 ));
2816 // by-key/ but no hash after (just the bare dir).
2817 assert!(!is_by_key_shape("/wire/sessions/by-key/"));
2818 assert!(!is_by_key_shape("/wire/sessions/by-key"));
2819 }
2820
2821 // ---------- #247 finding 4: Windows pidfile-scan path ----------
2822
2823 #[test]
2824 fn find_home_for_pid_matches_first_recorded_session() {
2825 let pairs = vec![
2826 ("/wire/sessions/by-key/aaaaaaaaaaaaaaaa".to_string(), 1001),
2827 ("/wire/sessions/by-key/bbbbbbbbbbbbbbbb".to_string(), 1002),
2828 ("/wire/sessions/by-key/cccccccccccccccc".to_string(), 1003),
2829 ];
2830 assert_eq!(
2831 find_home_for_pid(1002, &pairs).as_deref(),
2832 Some("/wire/sessions/by-key/bbbbbbbbbbbbbbbb")
2833 );
2834 }
2835
2836 #[test]
2837 fn find_home_for_pid_returns_none_when_no_session_owns_pid() {
2838 let pairs = vec![("/home-A".to_string(), 1001), ("/home-B".to_string(), 1002)];
2839 assert_eq!(find_home_for_pid(9999, &pairs), None);
2840 }
2841
2842 #[test]
2843 fn find_home_for_pid_empty_input_is_none() {
2844 assert_eq!(find_home_for_pid(1234, &[]), None);
2845 }
2846
2847 #[test]
2848 fn find_home_for_pid_handles_one_pid_in_multiple_roles() {
2849 // Realistic shape on Windows: a single wire process serves
2850 // multiple inbox-owning roles for one session (e.g. the same
2851 // pid in daemon.pid + mcp.pid, hypothetical). Pidfile-scan
2852 // emits one entry per (home, role) pair, so the same pid
2853 // appears multiple times under the same home — first match
2854 // wins, which is correct.
2855 let pairs = vec![("/home-A".to_string(), 4242), ("/home-A".to_string(), 4242)];
2856 assert_eq!(find_home_for_pid(4242, &pairs).as_deref(), Some("/home-A"));
2857 }
2858
2859 #[test]
2860 fn session_role_pid_reads_json_pid_from_role_pidfile() {
2861 use std::io::Write as _;
2862 let tmp = tempfile::tempdir().unwrap();
2863 let pid_dir = tmp.path().join("state").join("wire");
2864 std::fs::create_dir_all(&pid_dir).unwrap();
2865
2866 // Mimic the JSON shape ensure_up::write_pid_record produces.
2867 let pidfile = pid_dir.join("mcp.pid");
2868 let mut f = std::fs::File::create(&pidfile).unwrap();
2869 write!(&mut f, r#"{{"pid":7777,"version":"0.16.0"}}"#).unwrap();
2870
2871 assert_eq!(session_role_pid(tmp.path(), "mcp"), Some(7777));
2872 // A role with no pidfile reads as None.
2873 assert_eq!(session_role_pid(tmp.path(), "monitor"), None);
2874 // Corrupt body reads as None (no panic).
2875 std::fs::write(pid_dir.join("notify.pid"), b"this is not json").unwrap();
2876 assert_eq!(session_role_pid(tmp.path(), "notify"), None);
2877 }
2878
2879 // ---------- #284.4: is_unexpected_session_source predicate ----------
2880
2881 #[test]
2882 fn is_unexpected_session_source_flags_machine_default_and_minted() {
2883 assert!(is_unexpected_session_source("machine-default"));
2884 assert!(is_unexpected_session_source("minted"));
2885 }
2886
2887 #[test]
2888 fn is_unexpected_session_source_passes_explicit_sources() {
2889 // Every adapter that names an explicit launcher signal counts
2890 // as "the operator actually meant this identity."
2891 for ok in [
2892 "env:WIRE_HOME",
2893 "env:WIRE_HOME_FORCE",
2894 "override",
2895 "claude-code",
2896 "claude-code-pidfile",
2897 "codex-cli",
2898 "copilot-cli",
2899 "vscode-workspace",
2900 ] {
2901 assert!(
2902 !is_unexpected_session_source(ok),
2903 "explicit source `{ok}` must NOT be flagged"
2904 );
2905 }
2906 }
2907
2908 #[test]
2909 fn is_unexpected_session_source_handles_unknown_conservatively() {
2910 // `unknown` means adoption never ran — treat as a separate
2911 // failure mode, NOT as the same class as machine-default /
2912 // minted. Callers (warn_if_unexpected_session_source) can
2913 // decide what to do with it; this predicate stays narrow.
2914 assert!(!is_unexpected_session_source("unknown"));
2915 // Future adapter labels likewise default to "explicit"
2916 // until a maintainer opts them in.
2917 assert!(!is_unexpected_session_source("future-adapter-X"));
2918 }
2919}