use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::endpoints::{Endpoint, EndpointScope, self_endpoints};
pub fn sessions_root() -> Result<PathBuf> {
if let Ok(home_str) = std::env::var("WIRE_HOME") {
let home = PathBuf::from(&home_str);
let direct = home.join("sessions");
if direct.exists() {
return Ok(direct);
}
let mut anc = Some(home.as_path());
while let Some(p) = anc {
if p.file_name().and_then(|s| s.to_str()) == Some("sessions") {
return Ok(p.to_path_buf());
}
anc = p.parent();
}
return Ok(direct);
}
let state = dirs::state_dir()
.or_else(dirs::data_local_dir)
.ok_or_else(|| {
anyhow!(
"could not resolve XDG_STATE_HOME (or platform-equivalent local data dir) — \
set WIRE_HOME or run on a platform with `dirs` support"
)
})?;
Ok(state.join("wire").join("sessions"))
}
pub fn session_dir(name: &str) -> Result<PathBuf> {
Ok(sessions_root()?.join(sanitize_name(name)))
}
pub fn find_session_home_by_name(name: &str) -> Result<Option<PathBuf>> {
let direct = session_dir(name)?;
if direct.exists() {
return Ok(Some(direct));
}
let sanitized = sanitize_name(name);
for info in list_sessions().unwrap_or_default() {
if info.name == name
|| info.name == sanitized
|| info
.home_dir
.file_name()
.and_then(|s| s.to_str())
.map(|f| f == name)
.unwrap_or(false)
{
return Ok(Some(info.home_dir));
}
}
Ok(None)
}
pub fn registry_path() -> Result<PathBuf> {
Ok(sessions_root()?.join("registry.json"))
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionRegistry {
#[serde(default)]
pub by_cwd: HashMap<String, String>,
}
pub fn read_registry() -> Result<SessionRegistry> {
let path = registry_path()?;
if !path.exists() {
return Ok(SessionRegistry::default());
}
let bytes =
std::fs::read(&path).with_context(|| format!("reading session registry {path:?}"))?;
serde_json::from_slice(&bytes).with_context(|| format!("parsing session registry {path:?}"))
}
pub fn write_registry(reg: &SessionRegistry) -> Result<()> {
let path = registry_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
}
let body = serde_json::to_vec_pretty(reg)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, body).with_context(|| format!("writing tmp session registry {tmp:?}"))?;
std::fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
Ok(())
}
pub fn update_registry<F>(modifier: F) -> Result<()>
where
F: FnOnce(&mut SessionRegistry) -> Result<()>,
{
use fs2::FileExt;
let path = registry_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
}
let lock_path = path.with_extension("lock");
let lock_file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.with_context(|| format!("opening {lock_path:?}"))?;
lock_file
.lock_exclusive()
.with_context(|| format!("flock {lock_path:?}"))?;
let mut reg = read_registry().unwrap_or_default();
let result = modifier(&mut reg);
let write_result = if result.is_ok() {
write_registry(®)
} else {
Ok(())
};
let _ = fs2::FileExt::unlock(&lock_file);
result?;
write_result?;
Ok(())
}
pub fn sanitize_name(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut prev_dash = false;
for c in raw.chars() {
let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
let ch = if ok { c.to_ascii_lowercase() } else { '-' };
if ch == '-' {
if !prev_dash && !out.is_empty() {
out.push('-');
}
prev_dash = true;
} else {
out.push(ch);
prev_dash = false;
}
}
let trimmed = out.trim_matches('-').to_string();
if trimmed.is_empty() {
return "wire-session".to_string();
}
if trimmed.len() > 32 {
return trimmed[..32].trim_end_matches('-').to_string();
}
trimmed
}
fn path_hash_suffix(cwd: &Path) -> String {
let bytes = cwd.as_os_str().to_string_lossy().into_owned();
let mut h = Sha256::new();
h.update(bytes.as_bytes());
let digest = h.finalize();
hex::encode(&digest[..2]) }
pub fn normalize_cwd_key(path: &Path) -> String {
let s = path.to_string_lossy().into_owned();
if cfg!(windows) { s.to_lowercase() } else { s }
}
pub fn derive_name_from_cwd(cwd: &Path, registry: &SessionRegistry) -> String {
let cwd_key = normalize_cwd_key(cwd);
if let Some(existing) = registry.by_cwd.get(&cwd_key).or_else(|| {
registry
.by_cwd
.iter()
.find(|(k, _)| normalize_cwd_key(Path::new(k)) == cwd_key)
.map(|(_, v)| v)
}) {
return existing.clone();
}
let base = cwd
.file_name()
.and_then(|s| s.to_str())
.map(sanitize_name)
.unwrap_or_else(|| "wire-session".to_string());
let occupied: std::collections::HashSet<String> = registry.by_cwd.values().cloned().collect();
if !occupied.contains(&base) {
return base;
}
let with_hash = format!("{}-{}", base, path_hash_suffix(cwd));
if !occupied.contains(&with_hash) {
return with_hash;
}
for n in 2..1000 {
let candidate = format!("{base}-{n}");
if !occupied.contains(&candidate) {
return candidate;
}
}
format!("{base}-{}-overflow", path_hash_suffix(cwd))
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionInfo {
pub name: String,
pub cwd: Option<String>,
pub home_dir: PathBuf,
pub did: Option<String>,
pub handle: Option<String>,
pub daemon_running: bool,
pub character: Option<crate::character::Character>,
}
fn url_is_loopback(url: &str) -> bool {
let lower = url.to_ascii_lowercase();
let after_scheme = match lower.split_once("://") {
Some((_, rest)) => rest,
None => lower.as_str(),
};
if let Some(rest) = after_scheme.strip_prefix('[') {
return rest
.split_once(']')
.map(|(host, _)| host == "::1")
.unwrap_or(false);
}
let host = after_scheme.split(['/', ':']).next().unwrap_or("");
host == "localhost" || host == "127.0.0.1" || host.starts_with("127.")
}
pub fn resolve_local_sister(input: &str) -> Option<String> {
let needle = input.trim();
if needle.is_empty() {
return None;
}
let sessions = list_sessions().ok()?;
for s in &sessions {
if s.name.eq_ignore_ascii_case(needle) {
return Some(s.name.clone());
}
if let Some(h) = &s.handle
&& h.eq_ignore_ascii_case(needle)
{
return Some(s.name.clone());
}
if let Some(ch) = &s.character
&& ch.nickname.eq_ignore_ascii_case(needle)
{
return Some(s.name.clone());
}
}
None
}
pub fn list_sessions() -> Result<Vec<SessionInfo>> {
let root = sessions_root()?;
if !root.exists() {
return Ok(Vec::new());
}
let registry = read_registry().unwrap_or_default();
let mut name_to_cwd: HashMap<String, String> = HashMap::new();
for (cwd, name) in ®istry.by_cwd {
name_to_cwd.insert(name.clone(), cwd.clone());
}
let mk = |path: PathBuf, name: String| -> SessionInfo {
let card_path = path.join("config").join("wire").join("agent-card.json");
let (did, handle) = read_card_identity(&card_path);
let daemon_running = check_daemon_live(&path);
let character = did.as_deref().map(crate::character::Character::from_did);
SessionInfo {
cwd: name_to_cwd.get(&name).cloned(),
name,
home_dir: path,
did,
handle,
daemon_running,
character,
}
};
let mut out = Vec::new();
for entry in std::fs::read_dir(&root)?.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = match path.file_name().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
if name == "registry.json" {
continue;
}
if name == "by-key" {
for sub in std::fs::read_dir(&path)?.flatten() {
let sub_path = sub.path();
if !sub_path.is_dir() {
continue;
}
let hash = sub_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("?")
.to_string();
let mut info = mk(sub_path, hash);
if info.did.is_none() {
continue;
}
if let Some(h) = info.handle.clone() {
info.name = h;
}
out.push(info);
}
continue;
}
out.push(mk(path, name));
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
fn read_card_identity(card_path: &Path) -> (Option<String>, Option<String>) {
let bytes = match std::fs::read(card_path) {
Ok(b) => b,
Err(_) => return (None, None),
};
let v: serde_json::Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(_) => return (None, None),
};
let did = v.get("did").and_then(|x| x.as_str()).map(str::to_string);
let handle = v
.get("handle")
.and_then(|x| x.as_str())
.map(str::to_string)
.or_else(|| {
did.as_ref()
.map(|d| crate::agent_card::display_handle_from_did(d).to_string())
});
(did, handle)
}
pub fn session_daemon_pid(session_home: &Path) -> Option<u32> {
let pidfile = session_home.join("state").join("wire").join("daemon.pid");
let bytes = std::fs::read(&pidfile).ok()?;
serde_json::from_slice::<serde_json::Value>(&bytes)
.ok()
.and_then(|v| v.get("pid").and_then(|p| p.as_u64()))
.or_else(|| String::from_utf8_lossy(&bytes).trim().parse::<u64>().ok())
.map(|p| p as u32)
}
fn check_daemon_live(session_home: &Path) -> bool {
session_daemon_pid(session_home)
.map(is_process_live)
.unwrap_or(false)
}
pub fn pid_to_session_map() -> HashMap<u32, String> {
let mut out = HashMap::new();
let sessions = match list_sessions() {
Ok(v) => v,
Err(_) => return out,
};
for info in sessions {
if let Some(pid) = session_daemon_pid(&info.home_dir) {
out.insert(pid, info.name);
}
}
out
}
fn is_process_live(pid: u32) -> bool {
crate::platform::process_alive(pid)
}
pub fn read_session_endpoints(session_home: &Path) -> Vec<Endpoint> {
let path = session_home.join("config").join("wire").join("relay.json");
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => return Vec::new(),
};
let val: Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
self_endpoints(&val)
}
#[derive(Debug, Clone, Serialize)]
pub struct LocalEndpointView {
pub relay_url: String,
pub slot_id: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct LocalSessionView {
pub name: String,
pub handle: Option<String>,
pub did: Option<String>,
pub cwd: Option<String>,
pub home_dir: PathBuf,
pub daemon_running: bool,
pub local_endpoints: Vec<LocalEndpointView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FederationOnlySessionView {
pub name: String,
pub handle: Option<String>,
pub cwd: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LocalSessionListing {
pub local: HashMap<String, Vec<LocalSessionView>>,
pub federation_only: Vec<FederationOnlySessionView>,
}
pub fn list_local_sessions() -> Result<LocalSessionListing> {
let sessions = list_sessions()?;
let mut local: HashMap<String, Vec<LocalSessionView>> = HashMap::new();
let mut federation_only: Vec<FederationOnlySessionView> = Vec::new();
for s in sessions {
let endpoints = read_session_endpoints(&s.home_dir);
let local_eps: Vec<Endpoint> = endpoints
.into_iter()
.filter(|e| {
matches!(e.scope, EndpointScope::Local)
|| (matches!(e.scope, EndpointScope::Federation)
&& url_is_loopback(&e.relay_url))
})
.collect();
if local_eps.is_empty() {
federation_only.push(FederationOnlySessionView {
name: s.name.clone(),
handle: s.handle.clone(),
cwd: s.cwd.clone(),
});
continue;
}
let redacted: Vec<LocalEndpointView> = local_eps
.iter()
.map(|e| LocalEndpointView {
relay_url: e.relay_url.clone(),
slot_id: e.slot_id.clone(),
})
.collect();
for ep in &local_eps {
local
.entry(ep.relay_url.clone())
.or_default()
.push(LocalSessionView {
name: s.name.clone(),
handle: s.handle.clone(),
did: s.did.clone(),
cwd: s.cwd.clone(),
home_dir: s.home_dir.clone(),
daemon_running: s.daemon_running,
local_endpoints: redacted.clone(),
});
}
}
for group in local.values_mut() {
group.sort_by(|a, b| a.name.cmp(&b.name));
}
federation_only.sort_by(|a, b| a.name.cmp(&b.name));
Ok(LocalSessionListing {
local,
federation_only,
})
}
pub fn detect_session_wire_home(cwd: &std::path::Path) -> Option<PathBuf> {
let registry = read_registry().ok()?;
let mut probe: Option<&std::path::Path> = Some(cwd);
while let Some(path) = probe {
let path_str = normalize_cwd_key(path);
if let Some(session_name) = registry.by_cwd.get(&path_str).or_else(|| {
registry
.by_cwd
.iter()
.find(|(k, _)| normalize_cwd_key(Path::new(k)) == path_str)
.map(|(_, v)| v)
}) {
let session_home = session_dir(session_name).ok()?;
if session_home.exists() {
return Some(session_home);
}
}
probe = path.parent();
}
None
}
pub fn resolve_session_key() -> Option<(String, &'static str)> {
for (var, source) in [
("WIRE_SESSION_ID", "override"),
("CLAUDE_CODE_SESSION_ID", "claude-code"),
("CODEX_SESSION_ID", "codex-cli"),
("COPILOT_AGENT_SESSION_ID", "copilot-cli"),
("VSCODE_GIT_REPOSITORY_ROOT", "vscode-workspace"),
] {
if let Ok(v) = std::env::var(var)
&& valid_session_key(&v)
{
return Some((v.trim().to_string(), source));
}
}
if let Some(sid) = claude_code_session_from_pidfile() {
return Some((sid, "claude-code-pidfile"));
}
None
}
fn valid_session_key(v: &str) -> bool {
let v = v.trim();
!v.is_empty() && !v.contains("${")
}
fn claude_code_session_from_pidfile() -> Option<String> {
let dir = dirs::home_dir()?.join(".claude").join("sessions");
let mut pid = std::process::id();
for _ in 0..16 {
let f = dir.join(format!("{pid}.json"));
if let Ok(txt) = std::fs::read_to_string(&f)
&& let Ok(v) = serde_json::from_str::<Value>(&txt)
&& let Some(s) = v.get("sessionId").and_then(Value::as_str)
{
let s = s.trim();
if !s.is_empty() {
return Some(s.to_string());
}
}
pid = parent_pid(pid)?;
}
None
}
#[cfg(target_os = "linux")]
fn parent_pid(pid: u32) -> Option<u32> {
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
for line in status.lines() {
if let Some(rest) = line.strip_prefix("PPid:") {
return rest.trim().parse().ok();
}
}
None
}
#[cfg(target_os = "macos")]
fn parent_pid(pid: u32) -> Option<u32> {
let out = std::process::Command::new("ps")
.args(["-o", "ppid=", "-p", &pid.to_string()])
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
}
#[cfg(target_os = "windows")]
fn parent_pid(pid: u32) -> Option<u32> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let out = std::process::Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
&format!("(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').ParentProcessId"),
])
.creation_flags(CREATE_NO_WINDOW)
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn parent_pid(_pid: u32) -> Option<u32> {
None
}
pub fn session_home_for_key(key: &str) -> Result<PathBuf> {
let mut h = Sha256::new();
h.update(key.as_bytes());
let digest = h.finalize();
let hash = hex::encode(&digest[..8]); Ok(sessions_root()?.join("by-key").join(hash))
}
pub const INBOX_OWNING_SUBCOMMANDS: &[&str] = &["mcp", "daemon", "monitor", "notify"];
pub fn warn_on_identity_collision(self_pid: u32, role: &str) {
let our_wire_home = match std::env::var("WIRE_HOME") {
Ok(h) => h,
Err(_) => return,
};
let predicate = format!("wire ({})", INBOX_OWNING_SUBCOMMANDS.join("|"));
let pgrep_out = match std::process::Command::new("pgrep")
.args(["-f", &predicate])
.output()
{
Ok(o) if o.status.success() => o,
_ => return,
};
let other_pids: Vec<u32> = String::from_utf8_lossy(&pgrep_out.stdout)
.split_whitespace()
.filter_map(|s| s.parse::<u32>().ok())
.filter(|&p| p != self_pid)
.collect();
let other_homes: Vec<(u32, Option<String>)> = other_pids
.iter()
.map(|p| (*p, read_wire_home_from_pid(*p)))
.collect();
let colliders = find_colliders(&our_wire_home, &other_homes);
if colliders.is_empty() {
return;
}
emit_collision_warning(role, &our_wire_home, &colliders);
}
pub(crate) fn find_colliders(
our_wire_home: &str,
other_homes: &[(u32, Option<String>)],
) -> Vec<u32> {
other_homes
.iter()
.filter_map(|(pid, their_home)| match their_home {
Some(h) if h == our_wire_home => Some(*pid),
_ => None,
})
.collect()
}
pub(crate) fn emit_collision_warning(role: &str, our_wire_home: &str, colliders: &[u32]) {
eprintln!(
"wire {role}: WARNING — {} other wire process(es) already using WIRE_HOME=`{}` (pid {})",
colliders.len(),
our_wire_home,
colliders
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(", ")
);
eprintln!(
" Multiple agents sharing one identity will race the inbox cursor; messages may be lost."
);
eprintln!(" To use a separate identity:");
eprintln!(" 1. Close the other agent(s), OR");
eprintln!(" 2. `wire session new <name> --local-only` to create a fresh identity, then");
eprintln!(
" 3. Restart THIS agent's launcher with `export WIRE_HOME=<path printed by step 2>`"
);
}
fn read_wire_home_from_pid(pid: u32) -> Option<String> {
#[cfg(target_os = "linux")]
{
let path = format!("/proc/{pid}/environ");
let bytes = std::fs::read(&path).ok()?;
for entry in bytes.split(|&b| b == 0) {
let s = match std::str::from_utf8(entry) {
Ok(s) => s,
Err(_) => continue,
};
if let Some(val) = s.strip_prefix("WIRE_HOME=") {
return Some(val.to_string());
}
}
None
}
#[cfg(target_os = "macos")]
{
let output = std::process::Command::new("ps")
.args(["-E", "-p", &pid.to_string(), "-o", "command="])
.output()
.ok()?;
let s = String::from_utf8_lossy(&output.stdout);
for tok in s.split_whitespace() {
if let Some(val) = tok.strip_prefix("WIRE_HOME=") {
return Some(val.to_string());
}
}
None
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = pid;
None
}
}
pub fn maybe_adopt_session_wire_home(label: &str) {
if std::env::var("WIRE_HOME").is_ok() {
return;
}
let (home, why) = if let Some((key, source)) = resolve_session_key() {
match session_home_for_key(&key) {
Ok(h) => {
(h, format!("session key ({source})"))
}
Err(_) => return,
}
} else if label == "mcp" {
let minted = format!(
"mcp-proc-{:016x}{:016x}",
rand::random::<u64>(),
rand::random::<u64>()
);
match session_home_for_key(&minted) {
Ok(h) => {
unsafe {
std::env::set_var("WIRE_SESSION_ID", &minted);
}
(
h,
"minted per-process key (no session id; cwd disabled for MCP)".to_string(),
)
}
Err(_) => return,
}
} else {
return;
};
use std::io::IsTerminal;
let quiet_env = std::env::var("WIRE_QUIET_AUTOSESSION").is_ok();
let verbose_env = std::env::var("WIRE_VERBOSE").is_ok();
let interactive = std::io::stderr().is_terminal();
if !quiet_env && (interactive || verbose_env) {
eprintln!(
"wire {label}: adopted {why} → WIRE_HOME=`{}`",
home.display()
);
}
unsafe {
std::env::set_var("WIRE_HOME", &home);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_session_key_rejects_empty_and_unexpanded_placeholder() {
assert!(valid_session_key("4129275d-cc5c-4d2a"));
assert!(valid_session_key("mcp-proc-deadbeef"));
assert!(!valid_session_key(""));
assert!(!valid_session_key(" "));
assert!(!valid_session_key("${CLAUDE_CODE_SESSION_ID}"));
assert!(!valid_session_key(" ${CLAUDE_CODE_SESSION_ID} "));
}
#[test]
fn resolve_session_key_vscode_adapter_and_placeholder_guard() {
let _guard = crate::config::test_support::ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let prev_override = std::env::var_os("WIRE_SESSION_ID");
let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
let prev_codex = std::env::var_os("CODEX_SESSION_ID");
let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
unsafe {
std::env::remove_var("WIRE_SESSION_ID");
std::env::remove_var("CLAUDE_CODE_SESSION_ID");
std::env::remove_var("CODEX_SESSION_ID");
std::env::remove_var("COPILOT_AGENT_SESSION_ID");
std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
}
unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/frontend") };
let r1 = resolve_session_key();
assert!(
matches!(&r1, Some((k, src)) if k == "/home/dev/frontend" && *src == "vscode-workspace"),
"VSCODE_GIT_REPOSITORY_ROOT must win resolution and be labeled vscode-workspace; got {r1:?}"
);
let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/backend") };
let r2 = resolve_session_key();
let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
assert_ne!(
home_a, home_b,
"distinct workspace roots must map to distinct session homes (no cross-workspace persona collision)"
);
unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "/home/dev/frontend") };
let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap();
assert_eq!(
home_a, home_a2,
"same workspace root must yield the same home across calls"
);
unsafe { std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", "${workspaceFolder}") };
let r_guard = resolve_session_key();
assert!(
!matches!(&r_guard, Some((k, _)) if k.contains("${")),
"unexpanded ${{workspaceFolder}} literal must be rejected by the ${{}} guard; got {r_guard:?}"
);
unsafe {
std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
std::env::set_var("WIRE_SESSION_ID", "${workspaceFolder}");
}
let r_guard2 = resolve_session_key();
assert!(
!matches!(&r_guard2, Some((k, _)) if k.contains("${")),
"unexpanded ${{workspaceFolder}} in WIRE_SESSION_ID must also be rejected; got {r_guard2:?}"
);
unsafe {
std::env::remove_var("WIRE_SESSION_ID");
std::env::remove_var("CLAUDE_CODE_SESSION_ID");
std::env::remove_var("CODEX_SESSION_ID");
std::env::remove_var("COPILOT_AGENT_SESSION_ID");
std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
if let Some(v) = prev_override {
std::env::set_var("WIRE_SESSION_ID", v);
}
if let Some(v) = prev_claude {
std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
}
if let Some(v) = prev_codex {
std::env::set_var("CODEX_SESSION_ID", v);
}
if let Some(v) = prev_copilot {
std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
}
if let Some(v) = prev_vscode {
std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
}
}
}
#[test]
fn resolve_session_key_copilot_cli_adapter_and_priority() {
let _guard = crate::config::test_support::ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let prev_override = std::env::var_os("WIRE_SESSION_ID");
let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
let prev_codex = std::env::var_os("CODEX_SESSION_ID");
let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
unsafe {
std::env::remove_var("WIRE_SESSION_ID");
std::env::remove_var("CLAUDE_CODE_SESSION_ID");
std::env::remove_var("CODEX_SESSION_ID");
std::env::remove_var("COPILOT_AGENT_SESSION_ID");
std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
}
unsafe {
std::env::set_var(
"COPILOT_AGENT_SESSION_ID",
"3869478a-33cc-4c33-82ee-b6403a24d734",
)
};
let r1 = resolve_session_key();
assert!(
matches!(&r1, Some((k, src)) if k == "3869478a-33cc-4c33-82ee-b6403a24d734" && *src == "copilot-cli"),
"COPILOT_AGENT_SESSION_ID must win resolution and be labeled copilot-cli; got {r1:?}"
);
let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
unsafe {
std::env::set_var(
"COPILOT_AGENT_SESSION_ID",
"deadbeef-0000-0000-0000-000000000000",
)
};
let r2 = resolve_session_key();
let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
assert_ne!(
home_a, home_b,
"distinct Copilot CLI session ids must map to distinct session homes"
);
unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") };
let r_override = resolve_session_key();
assert!(
matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"),
"WIRE_SESSION_ID must beat COPILOT_AGENT_SESSION_ID; got {r_override:?}"
);
unsafe { std::env::remove_var("WIRE_SESSION_ID") };
unsafe { std::env::set_var("COPILOT_AGENT_SESSION_ID", "${SOME_PLACEHOLDER}") };
let r_guard = resolve_session_key();
assert!(
!matches!(&r_guard, Some((k, _)) if k.contains("${")),
"unexpanded ${{...}} in COPILOT_AGENT_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}"
);
unsafe {
std::env::remove_var("WIRE_SESSION_ID");
std::env::remove_var("CLAUDE_CODE_SESSION_ID");
std::env::remove_var("CODEX_SESSION_ID");
std::env::remove_var("COPILOT_AGENT_SESSION_ID");
std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
if let Some(v) = prev_override {
std::env::set_var("WIRE_SESSION_ID", v);
}
if let Some(v) = prev_claude {
std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
}
if let Some(v) = prev_codex {
std::env::set_var("CODEX_SESSION_ID", v);
}
if let Some(v) = prev_copilot {
std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
}
if let Some(v) = prev_vscode {
std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
}
}
}
#[test]
fn resolve_session_key_codex_cli_adapter_and_priority() {
let _guard = crate::config::test_support::ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let prev_override = std::env::var_os("WIRE_SESSION_ID");
let prev_claude = std::env::var_os("CLAUDE_CODE_SESSION_ID");
let prev_codex = std::env::var_os("CODEX_SESSION_ID");
let prev_copilot = std::env::var_os("COPILOT_AGENT_SESSION_ID");
let prev_vscode = std::env::var_os("VSCODE_GIT_REPOSITORY_ROOT");
unsafe {
std::env::remove_var("WIRE_SESSION_ID");
std::env::remove_var("CLAUDE_CODE_SESSION_ID");
std::env::remove_var("CODEX_SESSION_ID");
std::env::remove_var("COPILOT_AGENT_SESSION_ID");
std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
}
unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66ad-277e-7be3-bdd9-b7708e069f3b") };
let r1 = resolve_session_key();
assert!(
matches!(&r1, Some((k, src)) if k == "019e66ad-277e-7be3-bdd9-b7708e069f3b" && *src == "codex-cli"),
"CODEX_SESSION_ID must win resolution and be labeled codex-cli; got {r1:?}"
);
let home_a = session_home_for_key(&r1.as_ref().unwrap().0).unwrap();
unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66b6-14de-7142-b43a-1861fe59e945") };
let r2 = resolve_session_key();
let home_b = session_home_for_key(&r2.as_ref().unwrap().0).unwrap();
assert_ne!(
home_a, home_b,
"distinct Codex thread ids must map to distinct session homes"
);
unsafe { std::env::set_var("CODEX_SESSION_ID", "019e66ad-277e-7be3-bdd9-b7708e069f3b") };
let home_a2 = session_home_for_key(&resolve_session_key().unwrap().0).unwrap();
assert_eq!(
home_a, home_a2,
"same Codex thread id must yield the same home across calls"
);
unsafe { std::env::set_var("WIRE_SESSION_ID", "operator-override") };
let r_override = resolve_session_key();
assert!(
matches!(&r_override, Some((k, src)) if k == "operator-override" && *src == "override"),
"WIRE_SESSION_ID must beat CODEX_SESSION_ID; got {r_override:?}"
);
unsafe { std::env::remove_var("WIRE_SESSION_ID") };
unsafe { std::env::set_var("CLAUDE_CODE_SESSION_ID", "claude-wins-over-codex") };
let r_claude_wins = resolve_session_key();
assert!(
matches!(&r_claude_wins, Some((k, src)) if k == "claude-wins-over-codex" && *src == "claude-code"),
"CLAUDE_CODE_SESSION_ID must beat CODEX_SESSION_ID; got {r_claude_wins:?}"
);
unsafe { std::env::remove_var("CLAUDE_CODE_SESSION_ID") };
unsafe { std::env::set_var("CODEX_SESSION_ID", "${SOME_PLACEHOLDER}") };
let r_guard = resolve_session_key();
assert!(
!matches!(&r_guard, Some((k, _)) if k.contains("${")),
"unexpanded ${{...}} in CODEX_SESSION_ID must be rejected by the ${{}} guard; got {r_guard:?}"
);
unsafe {
std::env::remove_var("WIRE_SESSION_ID");
std::env::remove_var("CLAUDE_CODE_SESSION_ID");
std::env::remove_var("CODEX_SESSION_ID");
std::env::remove_var("COPILOT_AGENT_SESSION_ID");
std::env::remove_var("VSCODE_GIT_REPOSITORY_ROOT");
if let Some(v) = prev_override {
std::env::set_var("WIRE_SESSION_ID", v);
}
if let Some(v) = prev_claude {
std::env::set_var("CLAUDE_CODE_SESSION_ID", v);
}
if let Some(v) = prev_codex {
std::env::set_var("CODEX_SESSION_ID", v);
}
if let Some(v) = prev_copilot {
std::env::set_var("COPILOT_AGENT_SESSION_ID", v);
}
if let Some(v) = prev_vscode {
std::env::set_var("VSCODE_GIT_REPOSITORY_ROOT", v);
}
}
}
#[test]
fn list_sessions_sees_by_key_homes_and_root_resolves_from_inside() {
let _guard = crate::config::test_support::ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let tmp = std::env::temp_dir().join(format!("wire-bykey-{}", rand::random::<u32>()));
let _ = std::fs::remove_dir_all(&tmp);
let root = tmp.join("sessions");
let home = root.join("by-key").join("abc123def4567890");
let cfg = home.join("config").join("wire");
std::fs::create_dir_all(&cfg).unwrap();
std::fs::write(
cfg.join("agent-card.json"),
r#"{"did":"did:wire:test-persona-6e301ab1","handle":"test-persona","verify_keys":{}}"#,
)
.unwrap();
unsafe { std::env::set_var("WIRE_HOME", &home) };
assert_eq!(
sessions_root().unwrap(),
root,
"sessions_root must resolve the root from inside a by-key home"
);
let sessions = list_sessions().unwrap();
let found = sessions
.iter()
.any(|s| s.handle.as_deref() == Some("test-persona"));
unsafe { std::env::remove_var("WIRE_HOME") };
let _ = std::fs::remove_dir_all(&tmp);
assert!(
found,
"by-key home must be enumerated: {:?}",
sessions.iter().map(|s| &s.name).collect::<Vec<_>>()
);
}
#[test]
fn find_session_home_by_name_resolves_both_layouts() {
let _guard = crate::config::test_support::ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let tmp = std::env::temp_dir().join(format!("wire-find-{}", rand::random::<u32>()));
let _ = std::fs::remove_dir_all(&tmp);
let root = tmp.join("sessions");
let legacy_home = root.join("legacy-pane");
let legacy_cfg = legacy_home.join("config").join("wire");
std::fs::create_dir_all(&legacy_cfg).unwrap();
std::fs::write(
legacy_cfg.join("agent-card.json"),
r#"{"did":"did:wire:legacy-pane-aaaa1111","handle":"legacy-pane","verify_keys":{}}"#,
)
.unwrap();
let bykey_home = root.join("by-key").join("3049827d92d4fbd5");
let bykey_cfg = bykey_home.join("config").join("wire");
std::fs::create_dir_all(&bykey_cfg).unwrap();
std::fs::write(
bykey_cfg.join("agent-card.json"),
r#"{"did":"did:wire:coral-weasel-0616dc6c","handle":"coral-weasel","verify_keys":{}}"#,
)
.unwrap();
unsafe { std::env::set_var("WIRE_HOME", &root) };
let legacy = super::find_session_home_by_name("legacy-pane").unwrap();
assert_eq!(
legacy.as_deref(),
Some(legacy_home.as_path()),
"v0.6 top-level layout: legacy-pane must resolve to its top-level dir"
);
let bykey = super::find_session_home_by_name("coral-weasel").unwrap();
assert_eq!(
bykey.as_deref(),
Some(bykey_home.as_path()),
"v0.13 by-key layout: coral-weasel must resolve to its by-key/<hash> dir"
);
let by_hash = super::find_session_home_by_name("3049827d92d4fbd5").unwrap();
assert_eq!(
by_hash.as_deref(),
Some(bykey_home.as_path()),
"v0.13 by-key layout: hash dir name must also resolve"
);
let missing = super::find_session_home_by_name("never-existed").unwrap();
assert_eq!(missing, None, "unknown session must return None");
unsafe { std::env::remove_var("WIRE_HOME") };
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn pid_to_session_map_builds_from_session_pidfiles() {
let _guard = crate::config::test_support::ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let tmp = std::env::temp_dir().join(format!("wire-p2s-{}", rand::random::<u32>()));
let _ = std::fs::remove_dir_all(&tmp);
let root = tmp.join("sessions");
let mk_session = |key: &str, handle: &str| -> PathBuf {
let home = root.join("by-key").join(key);
let cfg = home.join("config").join("wire");
std::fs::create_dir_all(&cfg).unwrap();
std::fs::write(
cfg.join("agent-card.json"),
format!(
r#"{{"did":"did:wire:{handle}-6e301ab1","handle":"{handle}","verify_keys":{{}}}}"#
),
)
.unwrap();
home
};
let h1 = mk_session("abc123def4567890", "alpha-aurora");
let h2 = mk_session("def456abc7890123", "beta-blossom");
let _h3 = mk_session("0000aaaabbbbcccc", "gamma-gorge");
let state1 = h1.join("state").join("wire");
let state2 = h2.join("state").join("wire");
std::fs::create_dir_all(&state1).unwrap();
std::fs::create_dir_all(&state2).unwrap();
std::fs::write(state1.join("daemon.pid"), r#"{"pid": 12345}"#).unwrap();
std::fs::write(state2.join("daemon.pid"), "67890").unwrap();
unsafe { std::env::set_var("WIRE_HOME", &h1) };
let map = super::pid_to_session_map();
unsafe { std::env::remove_var("WIRE_HOME") };
let _ = std::fs::remove_dir_all(&tmp);
assert_eq!(
map.get(&12345).map(String::as_str),
Some("alpha-aurora"),
"pid 12345 should map to the handle for h1"
);
assert_eq!(
map.get(&67890).map(String::as_str),
Some("beta-blossom"),
"pid 67890 should map (legacy-int pidfile form, handle for h2)"
);
assert!(
!map.contains_key(&99999),
"synthetic missing pid should not appear in the map"
);
}
#[test]
fn session_home_for_key_is_deterministic_distinct_and_well_formed() {
let _guard = crate::config::test_support::ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let a1 = session_home_for_key("sess-aaa").unwrap();
let a2 = session_home_for_key("sess-aaa").unwrap();
let b = session_home_for_key("sess-bbb").unwrap();
assert_eq!(a1, a2, "same key -> same home (resume stability)");
assert_ne!(a1, b, "distinct keys -> distinct homes (no collision)");
let leaf = a1.file_name().unwrap().to_str().unwrap();
assert_eq!(leaf.len(), 16, "16 hex chars / 64 bits");
assert!(leaf.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(
a1.parent().unwrap().file_name().unwrap().to_str().unwrap(),
"by-key"
);
}
#[test]
fn url_is_loopback_recognises_v4_v6_and_localhost_v0_7_4() {
assert!(url_is_loopback("http://127.0.0.1:8771"));
assert!(url_is_loopback("http://127.1.2.3"));
assert!(url_is_loopback("http://localhost:9000"));
assert!(url_is_loopback("https://localhost/v1"));
assert!(url_is_loopback("http://[::1]:8771"));
assert!(url_is_loopback("HTTP://LOCALHOST:8771"));
assert!(!url_is_loopback("https://wireup.net"));
assert!(!url_is_loopback("http://192.168.1.50:8771"));
assert!(!url_is_loopback("http://10.0.0.5"));
assert!(!url_is_loopback("https://relay.example.com"));
}
#[test]
fn sanitize_handles_unicode_and_long_names() {
assert_eq!(sanitize_name("paul-mac"), "paul-mac");
assert_eq!(sanitize_name("Paul Mac!"), "paul-mac");
assert_eq!(sanitize_name("ünìcødë"), "n-c-d"); assert_eq!(sanitize_name(""), "wire-session");
assert_eq!(sanitize_name("---"), "wire-session");
let long: String = "a".repeat(100);
assert_eq!(sanitize_name(&long).len(), 32);
}
#[test]
fn derive_name_returns_basename_when_no_collision() {
let reg = SessionRegistry::default();
assert_eq!(
derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
"wire"
);
assert_eq!(
derive_name_from_cwd(Path::new("/Users/paul/Source/slancha-mesh"), ®),
"slancha-mesh"
);
}
#[test]
fn derive_name_returns_stored_name_when_cwd_already_registered() {
let mut reg = SessionRegistry::default();
reg.by_cwd.insert(
"/Users/paul/Source/wire".to_string(),
"wire-special".to_string(),
);
assert_eq!(
derive_name_from_cwd(Path::new("/Users/paul/Source/wire"), ®),
"wire-special"
);
}
#[test]
fn normalize_cwd_key_case_handling_matches_platform_filesystem() {
let upper = Path::new("/Users/paul/Source/WIRE");
let lower = Path::new("/Users/paul/Source/wire");
if cfg!(windows) {
assert_eq!(
normalize_cwd_key(upper),
normalize_cwd_key(lower),
"on Windows, distinct casings of the same path MUST normalize \
to the same key (NTFS is case-insensitive by default)"
);
} else {
assert_ne!(
normalize_cwd_key(upper),
normalize_cwd_key(lower),
"on case-sensitive filesystems, distinct casings ARE distinct \
directories and MUST stay distinct keys"
);
}
assert_eq!(normalize_cwd_key(lower), normalize_cwd_key(lower));
}
#[test]
fn derive_name_no_regression_exact_match_still_resolves() {
let mut reg = SessionRegistry::default();
let stored = "/Users/Paul/Source/Wire-v0_13_5-Era";
reg.by_cwd
.insert(stored.to_string(), "wire-legacy".to_string());
assert_eq!(
derive_name_from_cwd(Path::new(stored), ®),
"wire-legacy",
"exact-match v0.13.5 entry MUST still resolve under v0.13.6+"
);
}
#[test]
fn derive_name_scan_fallback_runs_when_initial_get_misses() {
let mut reg = SessionRegistry::default();
reg.by_cwd.insert(
"/Users/paul/Source/project-a".to_string(),
"project-a".to_string(),
);
let derived = derive_name_from_cwd(Path::new("/Users/paul/Source/project-b"), ®);
assert_eq!(
derived, "project-b",
"non-matching lookup must fall through to basename derivation, \
NOT fabricate a match via the scan"
);
}
#[cfg(windows)]
#[test]
fn derive_name_finds_registered_cwd_under_alternate_casing_on_windows() {
let mut reg = SessionRegistry::default();
reg.by_cwd.insert(
r"C:\Users\Willard\ComfyUI\claude-integration".to_string(),
"claude-integration".to_string(),
);
let from_lower_cwd = Path::new(r"c:\users\willard\comfyui\claude-integration");
assert_eq!(
derive_name_from_cwd(from_lower_cwd, ®),
"claude-integration",
"Windows lookup MUST find the registered entry regardless of \
how the shell capitalized the cwd, via the normalized scan"
);
}
#[test]
fn read_session_endpoints_handles_missing_relay_state() {
let tmp = tempfile::tempdir().unwrap();
let endpoints = read_session_endpoints(tmp.path());
assert!(endpoints.is_empty());
}
#[test]
fn read_session_endpoints_parses_dual_slot_form() {
let tmp = tempfile::tempdir().unwrap();
let cfg = tmp.path().join("config").join("wire");
std::fs::create_dir_all(&cfg).unwrap();
let body = serde_json::json!({
"self": {
"relay_url": "https://wireup.net",
"slot_id": "fed-slot",
"slot_token": "fed-tok",
"endpoints": [
{
"relay_url": "https://wireup.net",
"slot_id": "fed-slot",
"slot_token": "fed-tok",
"scope": "federation"
},
{
"relay_url": "http://127.0.0.1:8771",
"slot_id": "loop-slot",
"slot_token": "loop-tok",
"scope": "local"
}
]
}
});
std::fs::write(cfg.join("relay.json"), serde_json::to_vec(&body).unwrap()).unwrap();
let endpoints = read_session_endpoints(tmp.path());
assert_eq!(endpoints.len(), 2);
let local_count = endpoints
.iter()
.filter(|e| matches!(e.scope, EndpointScope::Local))
.count();
assert_eq!(local_count, 1);
let local = endpoints
.iter()
.find(|e| matches!(e.scope, EndpointScope::Local))
.unwrap();
assert_eq!(local.relay_url, "http://127.0.0.1:8771");
assert_eq!(local.slot_id, "loop-slot");
}
#[test]
fn derive_name_appends_path_hash_when_basename_collides() {
let mut reg = SessionRegistry::default();
reg.by_cwd
.insert("/Users/paul/Source/wire".to_string(), "wire".to_string());
let name = derive_name_from_cwd(Path::new("/Users/paul/Archive/wire"), ®);
assert!(name.starts_with("wire-"));
assert_eq!(name.len(), "wire-".len() + 4); assert_ne!(name, "wire");
}
#[test]
fn inbox_owning_subcommands_covers_each_runtime_role() {
assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"mcp"));
assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"daemon"));
assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"monitor"));
assert!(INBOX_OWNING_SUBCOMMANDS.contains(&"notify"));
assert!(!INBOX_OWNING_SUBCOMMANDS.contains(&"pair-host"));
}
#[test]
fn find_colliders_returns_only_same_home_pids() {
let our_home = "/tmp/wire-home-A";
let others = vec![
(101, Some("/tmp/wire-home-A".to_string())), (102, Some("/tmp/wire-home-B".to_string())), (103, None), (104, Some("/tmp/wire-home-A".to_string())), ];
let colliders = find_colliders(our_home, &others);
assert_eq!(colliders, vec![101, 104]);
}
#[test]
fn find_colliders_no_match_returns_empty() {
let our_home = "/tmp/wire-home-A";
let others = vec![
(101, Some("/tmp/wire-home-B".to_string())),
(102, Some("/tmp/wire-home-C".to_string())),
(103, None),
];
assert!(find_colliders(our_home, &others).is_empty());
}
#[test]
fn find_colliders_empty_input_is_empty() {
assert!(find_colliders("/tmp/anywhere", &[]).is_empty());
}
#[test]
fn find_colliders_ignores_substring_matches() {
let our_home = "/tmp/wire-A";
let others = vec![
(201, Some("/tmp/wire-A/sub".to_string())),
(202, Some("/wire-A".to_string())), (203, Some("/tmp/wire-A".to_string())), ];
assert_eq!(find_colliders(our_home, &others), vec![203]);
}
#[test]
fn collision_warning_format_includes_role_home_and_pids() {
let role = "daemon";
let home = "/tmp/by-key/abc123";
let colliders = vec![4242u32, 4243u32];
let expected_head = format!(
"wire {role}: WARNING — {n} other wire process(es) already using WIRE_HOME=`{home}` (pid {pids})",
n = colliders.len(),
pids = colliders
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", "),
);
assert_eq!(
expected_head,
"wire daemon: WARNING — 2 other wire process(es) already using WIRE_HOME=`/tmp/by-key/abc123` (pid 4242, 4243)"
);
emit_collision_warning(role, home, &colliders);
}
}