1use anyhow::{Context, Result, anyhow, bail};
2use serde_json::{Value, json};
3
4fn resolve_session_name(name: Option<&str>) -> Result<String> {
5 if let Some(n) = name {
6 return Ok(crate::session::sanitize_name(n));
7 }
8 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9 let registry = crate::session::read_registry().unwrap_or_default();
10 Ok(crate::session::derive_name_from_cwd(&cwd, ®istry))
11}
12
13#[allow(clippy::too_many_arguments)] pub(super) fn cmd_session_new(
17 name_arg: Option<&str>,
18 relay: &str,
19 with_local: bool,
20 local_relay: &str,
21 with_lan: bool,
22 lan_relay: Option<&str>,
23 with_uds: bool,
24 uds_socket: Option<&std::path::Path>,
25 no_daemon: bool,
26 local_only: bool,
27 as_json: bool,
28) -> Result<()> {
29 let with_local = with_local || local_only;
32 if with_lan && lan_relay.is_none() {
34 bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
35 }
36 if with_uds && uds_socket.is_none() {
38 bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
39 }
40 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
41 let mut registry = crate::session::read_registry().unwrap_or_default();
42 let name = match name_arg {
43 Some(n) => crate::session::sanitize_name(n),
44 None => crate::session::derive_name_from_cwd(&cwd, ®istry),
45 };
46 let session_home = crate::session::session_dir(&name)?;
47
48 let already_exists = session_home.exists()
49 && session_home
50 .join("config")
51 .join("wire")
52 .join("agent-card.json")
53 .exists();
54 if already_exists {
55 registry
59 .by_cwd
60 .insert(cwd.to_string_lossy().into_owned(), name.clone());
61 crate::session::write_registry(®istry)?;
62 let info = render_session_info(&name, &session_home, &cwd)?;
63 emit_session_new_result(&info, "already_exists", as_json)?;
64 if !no_daemon {
65 ensure_session_daemon(&session_home)?;
66 }
67 return Ok(());
68 }
69
70 std::fs::create_dir_all(&session_home)
71 .with_context(|| format!("creating session dir {session_home:?}"))?;
72
73 let init_args: Vec<&str> = if local_only {
82 vec!["init", "--offline"]
83 } else {
84 vec!["init", "--relay", relay]
85 };
86 let init_status = super::run_wire_with_home(&session_home, &init_args)?;
87 if !init_status.success() {
88 let how = if local_only {
89 format!("`wire init {name}` (local-only)")
90 } else {
91 format!("`wire init {name} --relay {relay}`")
92 };
93 bail!("{how} failed inside session dir {session_home:?}");
94 }
95
96 let effective_handle = if local_only {
101 name.clone()
102 } else {
103 let mut claim_attempt = 0u32;
104 let mut effective = name.clone();
105 loop {
106 claim_attempt += 1;
107 let status =
108 super::run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
109 if status.success() {
110 break;
111 }
112 if claim_attempt >= 5 {
113 bail!(
114 "5 failed attempts to claim a handle on {relay} for session {name}. \
115 Try `wire session destroy {name} --force` and re-run with a different name, \
116 or use `--local-only` if you don't need a federation address."
117 );
118 }
119 let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
120 let suffix = crate::session::derive_name_from_cwd(&attempt_path, ®istry);
121 let token = suffix
122 .rsplit('-')
123 .next()
124 .filter(|t| t.len() == 4)
125 .map(str::to_string)
126 .unwrap_or_else(|| format!("{claim_attempt}"));
127 effective = format!("{name}-{token}");
128 }
129 effective
130 };
131
132 registry
135 .by_cwd
136 .insert(cwd.to_string_lossy().into_owned(), name.clone());
137 crate::session::write_registry(®istry)?;
138
139 if with_local {
150 try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
151 if local_only {
152 let relay_state_path = session_home.join("config").join("wire").join("relay.json");
157 let state: Value = std::fs::read(&relay_state_path)
158 .ok()
159 .and_then(|b| serde_json::from_slice(&b).ok())
160 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
161 let endpoints = crate::endpoints::self_endpoints(&state);
162 let has_local = endpoints
163 .iter()
164 .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
165 if !has_local {
166 bail!(
167 "--local-only requested but local-relay probe at {local_relay} failed — \
168 ensure the local relay is running (`wire service install --local-relay`), \
169 then re-run `wire session new {name} --local-only`."
170 );
171 }
172 }
173 }
174
175 if with_lan && let Some(lan_url) = lan_relay {
179 try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
180 }
181 if with_uds && let Some(socket_path) = uds_socket {
183 try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
184 }
185
186 if !no_daemon {
187 ensure_session_daemon(&session_home)?;
188 }
189
190 let info = render_session_info(&name, &session_home, &cwd)?;
191 emit_session_new_result(&info, "created", as_json)
192}
193
194fn coerce_object_root(v: &mut serde_json::Value) {
201 if !v.is_object() {
202 *v = serde_json::json!({});
203 }
204}
205
206#[cfg(unix)]
216fn try_allocate_uds_slot(
217 session_home: &std::path::Path,
218 handle: &str,
219 uds_socket: &std::path::Path,
220) {
221 let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
224 Ok((200, _)) => true,
225 Ok((status, body)) => {
226 eprintln!(
227 "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
228 String::from_utf8_lossy(&body)
229 );
230 return;
231 }
232 Err(e) => {
233 eprintln!(
234 "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
235 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
236 );
237 return;
238 }
239 };
240 if !healthz {
241 return;
242 }
243
244 let alloc_body = serde_json::json!({"handle": handle}).to_string();
246 let (status, body) = match crate::relay_client::uds_request(
247 uds_socket,
248 "POST",
249 "/v1/slot/allocate",
250 &[("Content-Type", "application/json")],
251 alloc_body.as_bytes(),
252 ) {
253 Ok(r) => r,
254 Err(e) => {
255 eprintln!(
256 "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
257 );
258 return;
259 }
260 };
261 if status >= 300 {
262 eprintln!(
263 "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
264 String::from_utf8_lossy(&body)
265 );
266 return;
267 }
268 let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
269 Ok(a) => a,
270 Err(e) => {
271 eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
272 return;
273 }
274 };
275
276 let state_path = session_home.join("config").join("wire").join("relay.json");
277 let mut state: serde_json::Value = std::fs::read(&state_path)
278 .ok()
279 .and_then(|b| serde_json::from_slice(&b).ok())
280 .unwrap_or_else(|| serde_json::json!({}));
281
282 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
283 .get("self")
284 .and_then(|s| s.get("endpoints"))
285 .and_then(|e| e.as_array())
286 .map(|arr| {
287 arr.iter()
288 .filter_map(|v| {
289 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
290 })
291 .collect()
292 })
293 .unwrap_or_default();
294 endpoints.push(crate::endpoints::Endpoint::uds(
295 format!("unix://{}", uds_socket.display()),
296 alloc.slot_id.clone(),
297 alloc.slot_token.clone(),
298 ));
299
300 coerce_object_root(&mut state);
301 let self_obj = state
302 .as_object_mut()
303 .expect("relay_state root coerced to object above")
304 .entry("self")
305 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
306 if !self_obj.is_object() {
307 *self_obj = serde_json::Value::Object(serde_json::Map::new());
308 }
309 if let Some(obj) = self_obj.as_object_mut() {
310 obj.insert(
311 "endpoints".into(),
312 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
313 );
314 }
315 if let Err(e) = std::fs::write(
316 &state_path,
317 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
318 ) {
319 eprintln!("wire session new: failed to write {state_path:?}: {e}");
320 return;
321 }
322 eprintln!(
323 "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
324 uds_socket.display(),
325 alloc.slot_id
326 );
327}
328
329#[cfg(not(unix))]
330fn try_allocate_uds_slot(
331 _session_home: &std::path::Path,
332 _handle: &str,
333 _uds_socket: &std::path::Path,
334) {
335 eprintln!(
336 "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
337 );
338}
339
340fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
350 let probe = match crate::relay_client::build_blocking_client(Some(
351 std::time::Duration::from_millis(500),
352 )) {
353 Ok(c) => c,
354 Err(e) => {
355 eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
356 return;
357 }
358 };
359 let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
360 match probe.get(&healthz_url).send() {
361 Ok(resp) if resp.status().is_success() => {}
362 Ok(resp) => {
363 eprintln!(
364 "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
365 resp.status()
366 );
367 return;
368 }
369 Err(e) => {
370 eprintln!(
371 "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
372 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
373 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
374 );
375 return;
376 }
377 };
378
379 let lan_client = crate::relay_client::RelayClient::new(lan_relay);
380 let alloc = match lan_client.allocate_slot(Some(handle)) {
381 Ok(a) => a,
382 Err(e) => {
383 eprintln!(
384 "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
385 );
386 return;
387 }
388 };
389
390 let state_path = session_home.join("config").join("wire").join("relay.json");
391 let mut state: serde_json::Value = std::fs::read(&state_path)
392 .ok()
393 .and_then(|b| serde_json::from_slice(&b).ok())
394 .unwrap_or_else(|| serde_json::json!({}));
395
396 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
399 .get("self")
400 .and_then(|s| s.get("endpoints"))
401 .and_then(|e| e.as_array())
402 .map(|arr| {
403 arr.iter()
404 .filter_map(|v| {
405 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
406 })
407 .collect()
408 })
409 .unwrap_or_default();
410 endpoints.push(crate::endpoints::Endpoint::lan(
411 lan_relay.trim_end_matches('/').to_string(),
412 alloc.slot_id.clone(),
413 alloc.slot_token.clone(),
414 ));
415
416 coerce_object_root(&mut state);
417 let self_obj = state
418 .as_object_mut()
419 .expect("relay_state root coerced to object above")
420 .entry("self")
421 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
422 if !self_obj.is_object() {
423 *self_obj = serde_json::Value::Object(serde_json::Map::new());
424 }
425 if let Some(obj) = self_obj.as_object_mut() {
426 obj.insert(
427 "endpoints".into(),
428 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
429 );
430 }
431 if let Err(e) = std::fs::write(
432 &state_path,
433 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
434 ) {
435 eprintln!("wire session new: failed to write {state_path:?}: {e}");
436 return;
437 }
438 eprintln!(
439 "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
440 alloc.slot_id
441 );
442}
443
444fn try_allocate_local_slot(
452 session_home: &std::path::Path,
453 handle: &str,
454 _federation_relay: &str,
455 local_relay: &str,
456) {
457 let probe = match crate::relay_client::build_blocking_client(Some(
460 std::time::Duration::from_millis(500),
461 )) {
462 Ok(c) => c,
463 Err(e) => {
464 eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
465 return;
466 }
467 };
468 let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
469 match probe.get(&healthz_url).send() {
470 Ok(resp) if resp.status().is_success() => {}
471 Ok(resp) => {
472 eprintln!(
473 "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
474 resp.status()
475 );
476 return;
477 }
478 Err(e) => {
479 eprintln!(
480 "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
481 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
482 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
483 );
484 return;
485 }
486 };
487
488 let local_client = crate::relay_client::RelayClient::new(local_relay);
490 let alloc = match local_client.allocate_slot(Some(handle)) {
491 Ok(a) => a,
492 Err(e) => {
493 eprintln!(
494 "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
495 );
496 return;
497 }
498 };
499
500 let state_path = session_home.join("config").join("wire").join("relay.json");
515 let mut state: serde_json::Value = std::fs::read(&state_path)
516 .ok()
517 .and_then(|b| serde_json::from_slice(&b).ok())
518 .unwrap_or_else(|| serde_json::json!({}));
519 let fed_endpoint = state.get("self").and_then(|s| {
522 let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
523 let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
524 let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
525 Some(crate::endpoints::Endpoint::federation(
526 url.to_string(),
527 slot_id.to_string(),
528 slot_token.to_string(),
529 ))
530 });
531
532 let local_endpoint = crate::endpoints::Endpoint::local(
533 local_relay.trim_end_matches('/').to_string(),
534 alloc.slot_id.clone(),
535 alloc.slot_token.clone(),
536 );
537
538 let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
539 if let Some(f) = fed_endpoint.clone() {
540 endpoints.push(f);
541 }
542 endpoints.push(local_endpoint);
543
544 let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
554 Some(f) => (f.relay_url, f.slot_id, f.slot_token),
555 None => (
556 local_relay.trim_end_matches('/').to_string(),
557 alloc.slot_id.clone(),
558 alloc.slot_token.clone(),
559 ),
560 };
561 coerce_object_root(&mut state);
562 let self_obj = state
563 .as_object_mut()
564 .expect("relay_state root coerced to object above")
565 .entry("self")
566 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
567 if !self_obj.is_object() {
570 *self_obj = serde_json::Value::Object(serde_json::Map::new());
571 }
572 if let Some(obj) = self_obj.as_object_mut() {
573 obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
574 obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
575 obj.insert(
576 "slot_token".into(),
577 serde_json::Value::String(legacy_slot_token),
578 );
579 obj.insert(
580 "endpoints".into(),
581 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
582 );
583 }
584
585 if let Err(e) = std::fs::write(
586 &state_path,
587 serde_json::to_vec_pretty(&state).unwrap_or_default(),
588 ) {
589 eprintln!(
590 "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
591 );
592 return;
593 }
594 eprintln!(
595 "wire session new: local slot allocated on {local_relay} (slot_id={})",
596 alloc.slot_id
597 );
598}
599
600fn render_session_info(
601 name: &str,
602 session_home: &std::path::Path,
603 cwd: &std::path::Path,
604) -> Result<serde_json::Value> {
605 let card_path = session_home
606 .join("config")
607 .join("wire")
608 .join("agent-card.json");
609 let (did, handle) = if card_path.exists() {
610 let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
611 let did = card
612 .get("did")
613 .and_then(Value::as_str)
614 .unwrap_or("")
615 .to_string();
616 let handle = card
617 .get("handle")
618 .and_then(Value::as_str)
619 .map(str::to_string)
620 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
621 (did, handle)
622 } else {
623 (String::new(), String::new())
624 };
625 Ok(json!({
626 "name": name,
627 "home_dir": session_home.to_string_lossy(),
628 "cwd": cwd.to_string_lossy(),
629 "did": did,
630 "handle": handle,
631 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
632 }))
633}
634
635fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
636 if as_json {
637 let mut obj = info.clone();
638 obj["status"] = json!(status);
639 println!("{}", serde_json::to_string(&obj)?);
640 } else {
641 let name = info["name"].as_str().unwrap_or("?");
642 let handle = info["handle"].as_str().unwrap_or("?");
643 let home = info["home_dir"].as_str().unwrap_or("?");
644 let did = info["did"].as_str().unwrap_or("?");
645 let export = info["export"].as_str().unwrap_or("?");
646 let prefix = if status == "already_exists" {
647 "session already exists (re-registered cwd)"
648 } else {
649 "session created"
650 };
651 println!(
652 "{prefix}\n name: {name}\n handle: {handle}\n did: {did}\n home: {home}\n\nactivate with:\n {export}"
653 );
654 }
655 Ok(())
656}
657
658pub fn maybe_auto_init_cwd_session(label: &str) {
677 if std::env::var("WIRE_HOME").is_ok() {
678 return; }
680 if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
681 return; }
683 let cwd = match std::env::current_dir() {
684 Ok(c) => c,
685 Err(_) => return,
686 };
687 if crate::session::detect_session_wire_home(&cwd).is_some() {
690 return;
691 }
692
693 use fs2::FileExt;
710 let sessions_root = match crate::session::sessions_root() {
711 Ok(r) => r,
712 Err(_) => return,
713 };
714 if let Err(e) = std::fs::create_dir_all(&sessions_root) {
715 eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
716 return;
717 }
718 let lock_path = sessions_root.join(".auto-init.lock");
719 let lock_file = match std::fs::OpenOptions::new()
720 .create(true)
721 .truncate(false)
722 .read(true)
723 .write(true)
724 .open(&lock_path)
725 {
726 Ok(f) => f,
727 Err(e) => {
728 eprintln!(
729 "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
730 );
731 return;
732 }
733 };
734 if let Err(e) = lock_file.lock_exclusive() {
735 eprintln!(
736 "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
737 );
738 return;
739 }
740 let registry = crate::session::read_registry().unwrap_or_default();
745 let name = crate::session::derive_name_from_cwd(&cwd, ®istry);
746 let session_home = match crate::session::session_dir(&name) {
747 Ok(h) => h,
748 Err(_) => {
749 let _ = fs2::FileExt::unlock(&lock_file);
750 return;
751 }
752 };
753 let agent_card_path = session_home
754 .join("config")
755 .join("wire")
756 .join("agent-card.json");
757 let needs_init = !agent_card_path.exists();
758
759 if needs_init {
760 if let Err(e) = std::fs::create_dir_all(&session_home) {
761 eprintln!(
762 "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
763 );
764 let _ = fs2::FileExt::unlock(&lock_file);
765 return;
766 }
767 match super::run_wire_with_home(&session_home, &["init", "--offline"]) {
772 Ok(status) if status.success() => {}
773 Ok(status) => {
774 eprintln!(
775 "wire {label}: auto-init: `wire init` for `{name}` exited non-zero ({status}) — falling back to default identity"
776 );
777 let _ = fs2::FileExt::unlock(&lock_file);
778 return;
779 }
780 Err(e) => {
781 eprintln!(
782 "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
783 );
784 let _ = fs2::FileExt::unlock(&lock_file);
785 return;
786 }
787 }
788 try_allocate_local_slot(
795 &session_home,
796 &name,
797 "https://wireup.net",
798 "http://127.0.0.1:8771",
799 );
800 } else {
801 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
805 eprintln!(
806 "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
807 );
808 }
809 }
810 let cwd_key = crate::session::normalize_cwd_key(&cwd);
820 let name_for_reg = name.clone();
821 if let Err(e) = crate::session::update_registry(|reg| {
822 reg.by_cwd.insert(cwd_key, name_for_reg);
823 Ok(())
824 }) {
825 eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
826 }
828 let _ = fs2::FileExt::unlock(&lock_file);
831
832 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
833 eprintln!(
834 "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
835 cwd.display(),
836 session_home.display()
837 );
838 }
839 unsafe {
842 std::env::set_var("WIRE_HOME", &session_home);
843 }
844}
845
846fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
847 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
850 if pidfile.exists() {
851 let bytes = std::fs::read(&pidfile).unwrap_or_default();
852 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
853 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
854 } else {
855 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
856 };
857 if let Some(p) = pid {
858 let alive = {
859 #[cfg(target_os = "linux")]
860 {
861 std::path::Path::new(&format!("/proc/{p}")).exists()
862 }
863 #[cfg(not(target_os = "linux"))]
864 {
865 std::process::Command::new("kill")
866 .args(["-0", &p.to_string()])
867 .output()
868 .map(|o| o.status.success())
869 .unwrap_or(false)
870 }
871 };
872 if alive {
873 return Ok(());
874 }
875 }
876 }
877
878 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
881 let log_path = session_home.join("state").join("wire").join("daemon.log");
882 if let Some(parent) = log_path.parent() {
883 std::fs::create_dir_all(parent).ok();
884 }
885 let log_file = std::fs::OpenOptions::new()
886 .create(true)
887 .append(true)
888 .open(&log_path)
889 .with_context(|| format!("opening daemon log {log_path:?}"))?;
890 let log_err = log_file.try_clone()?;
891 std::process::Command::new(&bin)
892 .env("WIRE_HOME", session_home)
893 .env_remove("RUST_LOG")
894 .args(["daemon", "--interval", "5"])
895 .stdout(log_file)
896 .stderr(log_err)
897 .stdin(std::process::Stdio::null())
898 .spawn()
899 .with_context(|| "spawning session-local `wire daemon`")?;
900 Ok(())
901}
902
903pub(super) fn cmd_session_list(as_json: bool) -> Result<()> {
904 let items = crate::session::list_sessions()?;
905 if as_json {
906 println!("{}", serde_json::to_string(&items)?);
907 return Ok(());
908 }
909 if items.is_empty() {
910 println!("no sessions on this machine. `wire session new` to create one.");
911 return Ok(());
912 }
913 println!(
914 "{:<22} {:<24} {:<24} {:<10} CWD",
915 "PERSONA", "NAME", "HANDLE", "DAEMON"
916 );
917 for s in items {
918 let plain = s
922 .character
923 .as_ref()
924 .map(|c| c.short())
925 .unwrap_or_else(|| "?".to_string());
926 let colored = s
927 .character
928 .as_ref()
929 .map(|c| c.colored())
930 .unwrap_or_else(|| "?".to_string());
931 let displayed_width = plain.chars().count() + 1; let pad = 22usize.saturating_sub(displayed_width);
936 println!(
937 "{}{} {:<24} {:<24} {:<10} {}",
938 colored,
939 " ".repeat(pad),
940 s.name,
941 s.handle.as_deref().unwrap_or("?"),
942 if s.daemon_running { "running" } else { "down" },
943 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
944 );
945 }
946 Ok(())
947}
948
949pub(super) fn cmd_session_list_local(as_json: bool) -> Result<()> {
961 let listing = crate::session::list_local_sessions()?;
962 if as_json {
963 println!("{}", serde_json::to_string(&listing)?);
964 return Ok(());
965 }
966
967 if listing.local.is_empty() && listing.federation_only.is_empty() {
968 println!(
969 "no sessions on this machine. `wire session new --with-local` to create one \
970 with a local-relay endpoint (start the relay first: \
971 `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
972 );
973 return Ok(());
974 }
975
976 if listing.local.is_empty() {
977 println!(
978 "no sister sessions reachable via a local relay. \
979 Re-run `wire session new --with-local` to add a Local endpoint, or \
980 start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
981 );
982 } else {
983 let mut keys: Vec<&String> = listing.local.keys().collect();
985 keys.sort();
986 for relay_url in keys {
987 let group = &listing.local[relay_url];
988 println!("LOCAL RELAY: {relay_url}");
989 println!(" {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
990 for s in group {
991 println!(
992 " {:<24} {:<32} {:<10} {}",
993 s.name,
994 s.handle.as_deref().unwrap_or("?"),
995 if s.daemon_running { "running" } else { "down" },
996 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
997 );
998 }
999 println!();
1000 }
1001 }
1002
1003 if !listing.federation_only.is_empty() {
1004 println!("federation-only (no local endpoint):");
1005 for s in &listing.federation_only {
1006 println!(
1007 " {:<24} {:<32} {}",
1008 s.name,
1009 s.handle.as_deref().unwrap_or("?"),
1010 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
1011 );
1012 }
1013 }
1014 Ok(())
1015}
1016
1017pub(super) fn cmd_session_pair_all_local(
1036 settle_secs: u64,
1037 federation_relay: &str,
1038 as_json: bool,
1039) -> Result<()> {
1040 use std::collections::BTreeSet;
1041 use std::time::Duration;
1042
1043 let listing = crate::session::list_local_sessions()?;
1044 let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
1048 Default::default();
1049 for group in listing.local.into_values() {
1050 for s in group {
1051 by_name.entry(s.name.clone()).or_insert(s);
1052 }
1053 }
1054 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
1055
1056 if sessions.len() < 2 {
1057 let msg = format!(
1058 "{} sister session(s) with a local endpoint — need at least 2 to pair.",
1059 sessions.len()
1060 );
1061 if as_json {
1062 println!(
1063 "{}",
1064 serde_json::to_string(&json!({
1065 "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
1066 "pairs_attempted": 0,
1067 "pairs_succeeded": 0,
1068 "pairs_skipped_already_paired": 0,
1069 "pairs_failed": 0,
1070 "note": msg,
1071 }))?
1072 );
1073 } else {
1074 println!("{msg}");
1075 if let Some(s) = sessions.first() {
1076 println!(" - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
1077 }
1078 println!("Use `wire session new --with-local` to add more.");
1079 }
1080 return Ok(());
1081 }
1082
1083 let fed_host = super::host_of_url(federation_relay);
1084 if fed_host.is_empty() {
1085 bail!(
1086 "federation_relay `{federation_relay}` has no parseable host — \
1087 pass a full URL like `https://wireup.net`."
1088 );
1089 }
1090
1091 let mut attempted = 0u32;
1093 let mut succeeded = 0u32;
1094 let mut skipped_already = 0u32;
1095 let mut failed = 0u32;
1096 let mut per_pair: Vec<Value> = Vec::new();
1097
1098 for i in 0..sessions.len() {
1099 for j in (i + 1)..sessions.len() {
1100 let a = &sessions[i];
1101 let b = &sessions[j];
1102 attempted += 1;
1103
1104 let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
1110 let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
1111 let a_pinned_b = super::session_has_peer(&a.home_dir, b_handle);
1112 let b_pinned_a = super::session_has_peer(&b.home_dir, a_handle);
1113 if a_pinned_b && b_pinned_a {
1114 skipped_already += 1;
1115 per_pair.push(json!({
1116 "from": a.name,
1117 "to": b.name,
1118 "status": "already_paired",
1119 }));
1120 continue;
1121 }
1122
1123 let pair_result = drive_bilateral_pair(
1124 &a.home_dir,
1125 &a.name,
1126 &b.home_dir,
1127 &b.name,
1128 &fed_host,
1129 federation_relay,
1130 settle_secs,
1131 );
1132
1133 match pair_result {
1134 Ok(()) => {
1135 succeeded += 1;
1136 per_pair.push(json!({
1137 "from": a.name,
1138 "to": b.name,
1139 "status": "paired",
1140 }));
1141 }
1142 Err(e) => {
1143 failed += 1;
1144 let detail = format!("{e:#}");
1145 per_pair.push(json!({
1146 "from": a.name,
1147 "to": b.name,
1148 "status": "failed",
1149 "error": detail,
1150 }));
1151 }
1152 }
1153
1154 std::thread::sleep(Duration::from_millis(200));
1157 }
1158 }
1159
1160 let _ = BTreeSet::<String>::new(); let summary = json!({
1162 "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
1163 "pairs_attempted": attempted,
1164 "pairs_succeeded": succeeded,
1165 "pairs_skipped_already_paired": skipped_already,
1166 "pairs_failed": failed,
1167 "results": per_pair,
1168 });
1169 if as_json {
1170 println!("{}", serde_json::to_string(&summary)?);
1171 } else {
1172 println!(
1173 "wire session pair-all-local: {} session(s), {} pair(s) attempted",
1174 sessions.len(),
1175 attempted
1176 );
1177 println!(" paired: {succeeded}");
1178 println!(" skipped (already pinned): {skipped_already}");
1179 println!(" failed: {failed}");
1180 for entry in summary["results"].as_array().unwrap_or(&vec![]) {
1181 let from = entry["from"].as_str().unwrap_or("?");
1182 let to = entry["to"].as_str().unwrap_or("?");
1183 let status = entry["status"].as_str().unwrap_or("?");
1184 let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
1185 if err.is_empty() {
1186 println!(" {from:<24} ↔ {to:<24} {status}");
1187 } else {
1188 println!(" {from:<24} ↔ {to:<24} {status} — {err}");
1189 }
1190 }
1191 }
1192 Ok(())
1193}
1194
1195fn drive_bilateral_pair(
1210 a_home: &std::path::Path,
1211 a_name: &str,
1212 b_home: &std::path::Path,
1213 b_name: &str,
1214 _fed_host: &str,
1215 _federation_relay: &str,
1216 settle_secs: u64,
1217) -> Result<()> {
1218 use std::time::Duration;
1219 let bin = std::env::current_exe().context("locating self exe")?;
1220
1221 let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
1222 let out = std::process::Command::new(&bin)
1223 .env("WIRE_HOME", home)
1224 .env_remove("RUST_LOG")
1225 .args(args)
1226 .output()
1227 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
1228 if !out.status.success() {
1229 bail!(
1230 "`wire {}` failed: stderr={}",
1231 args.join(" "),
1232 String::from_utf8_lossy(&out.stderr).trim()
1233 );
1234 }
1235 Ok(())
1236 };
1237
1238 let read_card_handle = |home: &std::path::Path| -> Result<String> {
1243 let card_path = home.join("config").join("wire").join("agent-card.json");
1244 let bytes = std::fs::read(&card_path)
1245 .with_context(|| format!("reading agent-card at {card_path:?}"))?;
1246 let card: Value = serde_json::from_slice(&bytes)?;
1247 card.get("handle")
1248 .and_then(Value::as_str)
1249 .map(str::to_string)
1250 .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
1251 };
1252 let a_handle = read_card_handle(a_home)
1253 .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
1254 let b_handle = read_card_handle(b_home)
1255 .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
1256
1257 run(a_home, &["add", b_name, "--local-sister", "--json"])
1261 .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
1262
1263 std::thread::sleep(Duration::from_secs(settle_secs));
1265
1266 run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
1269 run(b_home, &["accept", &a_handle, "--json"]).with_context(|| {
1270 format!("step 5/8: {b_name} `wire accept {a_handle}` (a session={a_name})")
1271 })?;
1272 run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
1273
1274 std::thread::sleep(Duration::from_secs(settle_secs));
1276
1277 run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
1279 let _ = &b_handle;
1281
1282 Ok(())
1283}
1284
1285pub(super) fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
1286 let name = resolve_session_name(name_arg)?;
1287 let session_home = crate::session::session_dir(&name)?;
1288 if !session_home.exists() {
1289 bail!(
1290 "no session named {name:?} on this machine. `wire session list` to enumerate, \
1291 `wire session new {name}` to create."
1292 );
1293 }
1294 if as_json {
1295 println!(
1296 "{}",
1297 serde_json::to_string(&json!({
1298 "name": name,
1299 "home_dir": session_home.to_string_lossy(),
1300 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
1301 }))?
1302 );
1303 } else {
1304 println!("export WIRE_HOME={}", session_home.to_string_lossy());
1305 }
1306 Ok(())
1307}
1308
1309pub(super) fn cmd_session_current(as_json: bool) -> Result<()> {
1310 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
1311 let registry = crate::session::read_registry().unwrap_or_default();
1312 let cwd_key = crate::session::normalize_cwd_key(&cwd);
1313 let name = registry
1318 .by_cwd
1319 .get(&cwd_key)
1320 .or_else(|| {
1321 registry
1322 .by_cwd
1323 .iter()
1324 .find(|(k, _)| {
1325 crate::session::normalize_cwd_key(std::path::Path::new(k)) == cwd_key
1326 })
1327 .map(|(_, v)| v)
1328 })
1329 .cloned();
1330 if as_json {
1331 println!(
1332 "{}",
1333 serde_json::to_string(&json!({
1334 "cwd": cwd_key,
1335 "session": name,
1336 }))?
1337 );
1338 } else if let Some(n) = name {
1339 println!("{n}");
1340 } else {
1341 println!("(no session registered for this cwd)");
1342 }
1343 Ok(())
1344}
1345
1346pub(super) fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
1347 let name = crate::session::sanitize_name(name_arg);
1348 let session_home = crate::session::session_dir(&name)?;
1349 if !session_home.exists() {
1350 if as_json {
1351 println!(
1352 "{}",
1353 serde_json::to_string(&json!({
1354 "name": name,
1355 "destroyed": false,
1356 "reason": "no such session",
1357 }))?
1358 );
1359 } else {
1360 println!("no session named {name:?} — nothing to destroy.");
1361 }
1362 return Ok(());
1363 }
1364 if !force {
1365 bail!(
1366 "destroying session {name:?} would delete its keypair + state irrecoverably. \
1367 Pass --force to confirm."
1368 );
1369 }
1370
1371 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
1373 if let Ok(bytes) = std::fs::read(&pidfile) {
1374 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
1375 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
1376 } else {
1377 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
1378 };
1379 if let Some(p) = pid {
1380 let _ = std::process::Command::new("kill")
1381 .args(["-TERM", &p.to_string()])
1382 .output();
1383 }
1384 }
1385
1386 std::fs::remove_dir_all(&session_home)
1387 .with_context(|| format!("removing session dir {session_home:?}"))?;
1388
1389 let mut registry = crate::session::read_registry().unwrap_or_default();
1391 registry.by_cwd.retain(|_, v| v != &name);
1392 crate::session::write_registry(®istry)?;
1393
1394 if as_json {
1395 println!(
1396 "{}",
1397 serde_json::to_string(&json!({
1398 "name": name,
1399 "destroyed": true,
1400 }))?
1401 );
1402 } else {
1403 println!("destroyed session {name:?}.");
1404 }
1405 Ok(())
1406}
1407
1408#[cfg(test)]
1409mod coerce_object_root_tests {
1410 use super::coerce_object_root;
1411 use serde_json::json;
1412
1413 #[test]
1414 fn non_object_roots_are_coerced_to_empty_object() {
1415 for mut corrupt in [
1416 json!([]),
1417 json!("corrupt"),
1418 json!(42),
1419 serde_json::Value::Null,
1420 ] {
1421 coerce_object_root(&mut corrupt);
1422 assert!(corrupt.is_object(), "root not coerced: {corrupt}");
1423 }
1424 }
1425
1426 #[test]
1427 fn object_root_is_left_untouched() {
1428 let mut state = json!({"self": {"endpoints": [1, 2]}});
1429 coerce_object_root(&mut state);
1430 assert_eq!(state, json!({"self": {"endpoints": [1, 2]}}));
1431 }
1432}