1use anyhow::{Context, Result, anyhow, bail};
17use clap::{Parser, Subcommand};
18use serde_json::{Value, json};
19
20use crate::{
21 agent_card::{build_agent_card, sign_agent_card},
22 config,
23 signing::{fingerprint, generate_keypair, make_key_id, sign_message_v31, verify_message_v31},
24 trust::{add_self_to_trust, empty_trust},
25};
26
27#[derive(Parser, Debug)]
29#[command(name = "wire", version, about = "Magic-wormhole for AI agents — bilateral signed-message bus", long_about = None)]
30pub struct Cli {
31 #[command(subcommand)]
32 pub command: Command,
33}
34
35#[derive(Subcommand, Debug)]
36pub enum Command {
37 #[command(hide = true)]
53 Init {
54 handle: String,
56 #[arg(long)]
58 name: Option<String>,
59 #[arg(long)]
64 relay: Option<String>,
65 #[arg(long, conflicts_with = "relay")]
70 offline: bool,
71 #[arg(long)]
73 json: bool,
74 },
75 Whoami {
79 #[arg(long)]
80 json: bool,
81 #[arg(long, conflicts_with = "json")]
84 short: bool,
85 #[arg(long, conflicts_with_all = ["json", "short"])]
88 colored: bool,
89 },
90 Peers {
92 #[arg(long)]
93 json: bool,
94 },
95 Completions {
106 #[arg(value_enum)]
108 shell: clap_complete::Shell,
109 },
110 Here {
118 #[arg(long)]
119 json: bool,
120 },
121 Pending {
126 #[arg(long)]
127 json: bool,
128 },
129 Send {
137 peer: String,
139 kind_or_body: String,
144 body: Option<String>,
148 #[arg(long)]
150 deadline: Option<String>,
151 #[arg(long)]
156 no_auto_pair: bool,
157 #[arg(long)]
159 json: bool,
160 },
161 Dial {
178 name: String,
182 message: Option<String>,
186 #[arg(long)]
188 json: bool,
189 },
190 Tail {
198 peer: Option<String>,
200 #[arg(long)]
202 json: bool,
203 #[arg(long, default_value_t = 0)]
205 limit: usize,
206 #[arg(long)]
209 oldest: bool,
210 },
211 Monitor {
222 #[arg(long)]
224 peer: Option<String>,
225 #[arg(long)]
227 json: bool,
228 #[arg(long)]
231 include_handshake: bool,
232 #[arg(long, default_value_t = 500)]
234 interval_ms: u64,
235 #[arg(long, default_value_t = 0)]
237 replay: usize,
238 },
239 Verify {
241 path: String,
243 #[arg(long)]
245 json: bool,
246 },
247 Mcp,
251 RelayServer {
253 #[arg(long, default_value = "127.0.0.1:8770")]
255 bind: String,
256 #[arg(long)]
264 local_only: bool,
265 #[arg(long)]
271 uds: Option<std::path::PathBuf>,
272 },
273 BindRelay {
282 url: String,
284 #[arg(long)]
290 scope: Option<String>,
291 #[arg(long)]
297 replace: bool,
298 #[arg(long)]
304 migrate_pinned: bool,
305 #[arg(long)]
306 json: bool,
307 },
308 AddPeerSlot {
311 handle: String,
313 url: String,
315 slot_id: String,
317 slot_token: String,
319 #[arg(long)]
320 json: bool,
321 },
322 Push {
324 peer: Option<String>,
326 #[arg(long)]
327 json: bool,
328 },
329 Pull {
331 #[arg(long)]
332 json: bool,
333 },
334 Status {
337 #[arg(long)]
339 peer: Option<String>,
340 #[arg(long)]
341 json: bool,
342 },
343 Responder {
345 #[command(subcommand)]
346 command: ResponderCommand,
347 },
348 Pin {
351 card_file: String,
353 #[arg(long)]
354 json: bool,
355 },
356 RotateSlot {
367 #[arg(long)]
370 no_announce: bool,
371 #[arg(long)]
372 json: bool,
373 },
374 ForgetPeer {
378 handle: String,
380 #[arg(long)]
382 purge: bool,
383 #[arg(long)]
384 json: bool,
385 },
386 Daemon {
390 #[arg(long, default_value_t = 5)]
392 interval: u64,
393 #[arg(long)]
395 once: bool,
396 #[arg(long)]
397 json: bool,
398 },
399 #[command(hide = true)] PairHost {
405 #[arg(long)]
407 relay: String,
408 #[arg(long)]
412 yes: bool,
413 #[arg(long, default_value_t = 300)]
415 timeout: u64,
416 #[arg(long)]
422 detach: bool,
423 #[arg(long)]
425 json: bool,
426 },
427 #[command(alias = "join")]
431 #[command(hide = true)] PairJoin {
433 code_phrase: String,
435 #[arg(long)]
437 relay: String,
438 #[arg(long)]
439 yes: bool,
440 #[arg(long, default_value_t = 300)]
441 timeout: u64,
442 #[arg(long)]
444 detach: bool,
445 #[arg(long)]
447 json: bool,
448 },
449 #[command(hide = true)] PairConfirm {
454 code_phrase: String,
456 digits: String,
458 #[arg(long)]
460 json: bool,
461 },
462 #[command(hide = true)] PairList {
465 #[arg(long)]
467 json: bool,
468 #[arg(long)]
472 watch: bool,
473 #[arg(long, default_value_t = 1)]
475 watch_interval: u64,
476 },
477 #[command(hide = true)] PairCancel {
480 code_phrase: String,
481 #[arg(long)]
482 json: bool,
483 },
484 #[command(hide = true)] PairWatch {
495 code_phrase: String,
496 #[arg(long, default_value = "sas_ready")]
498 status: String,
499 #[arg(long, default_value_t = 300)]
501 timeout: u64,
502 #[arg(long)]
504 json: bool,
505 },
506 #[command(hide = true)] Pair {
520 handle: String,
523 #[arg(long)]
526 code: Option<String>,
527 #[arg(long, default_value = "https://wireup.net")]
529 relay: String,
530 #[arg(long)]
532 yes: bool,
533 #[arg(long, default_value_t = 300)]
535 timeout: u64,
536 #[arg(long)]
539 no_setup: bool,
540 #[arg(long)]
545 detach: bool,
546 },
547 #[command(hide = true)] PairAbandon {
554 code_phrase: String,
556 #[arg(long, default_value = "https://wireup.net")]
558 relay: String,
559 },
560 #[command(hide = true)] PairAccept {
567 peer: String,
569 #[arg(long)]
571 json: bool,
572 },
573 #[command(hide = true)] PairReject {
581 peer: String,
583 #[arg(long)]
585 json: bool,
586 },
587 #[command(hide = true)] PairListInbound {
594 #[arg(long)]
596 json: bool,
597 },
598 #[command(subcommand)]
608 Session(SessionCommand),
609 Identity {
614 #[command(subcommand)]
615 cmd: IdentityCommand,
616 },
617 #[command(subcommand)]
622 Mesh(MeshCommand),
623 #[command(subcommand)]
627 Group(GroupCommand),
628 #[command(subcommand)]
631 Enroll(EnrollCommand),
632 Setup {
637 #[arg(long)]
639 apply: bool,
640 #[arg(long)]
646 statusline: bool,
647 #[arg(long)]
650 remove: bool,
651 },
652 Whois {
656 handle: Option<String>,
658 #[arg(long)]
659 json: bool,
660 #[arg(long)]
663 relay: Option<String>,
664 },
665 Add {
671 handle: String,
674 #[arg(long)]
676 relay: Option<String>,
677 #[arg(long)]
685 local_sister: bool,
686 #[arg(long)]
687 json: bool,
688 },
689 Up {
702 relay: Option<String>,
706 #[arg(long)]
709 name: Option<String>,
710 #[arg(long)]
715 with_local: Option<String>,
716 #[arg(long)]
718 no_local: bool,
719 #[arg(long)]
720 json: bool,
721 },
722 Doctor {
729 #[arg(long)]
731 json: bool,
732 #[arg(long, default_value_t = 5)]
734 recent_rejections: usize,
735 },
736 #[command(visible_alias = "update")]
748 Upgrade {
749 #[arg(long)]
751 check: bool,
752 #[arg(long)]
755 local: bool,
756 #[arg(long)]
757 json: bool,
758 },
759 Service {
764 #[command(subcommand)]
765 action: ServiceAction,
766 },
767 Diag {
772 #[command(subcommand)]
773 action: DiagAction,
774 },
775 #[command(hide = true)]
787 Claim {
788 nick: String,
790 #[arg(long)]
792 relay: Option<String>,
793 #[arg(long)]
795 public_url: Option<String>,
796 #[arg(long)]
804 hidden: bool,
805 #[arg(long)]
806 json: bool,
807 },
808 Profile {
818 #[command(subcommand)]
819 action: ProfileAction,
820 },
821 #[command(hide = true)] Invite {
826 #[arg(long, default_value = "https://wireup.net")]
828 relay: String,
829 #[arg(long, default_value_t = 86_400)]
831 ttl: u64,
832 #[arg(long, default_value_t = 1)]
835 uses: u32,
836 #[arg(long)]
840 share: bool,
841 #[arg(long)]
843 json: bool,
844 },
845 Accept {
855 target: String,
857 #[arg(long)]
859 json: bool,
860 },
861 #[command(alias = "invite-accept")]
869 AcceptInvite {
870 url: String,
872 #[arg(long)]
874 json: bool,
875 },
876 Reject {
879 peer: String,
881 #[arg(long)]
883 json: bool,
884 },
885 Notify {
890 #[arg(long, default_value_t = 2)]
892 interval: u64,
893 #[arg(long)]
895 peer: Option<String>,
896 #[arg(long)]
898 once: bool,
899 #[arg(long)]
903 json: bool,
904 },
905}
906
907#[derive(Subcommand, Debug)]
908pub enum DiagAction {
909 Tail {
911 #[arg(long, default_value_t = 20)]
912 limit: usize,
913 #[arg(long)]
914 json: bool,
915 },
916 Enable,
919 Disable,
921 Status {
923 #[arg(long)]
924 json: bool,
925 },
926}
927
928#[derive(Subcommand, Debug)]
933pub enum EnrollCommand {
934 Op {
936 #[arg(long, default_value = "operator")]
938 handle: String,
939 #[arg(long)]
940 json: bool,
941 },
942 OrgCreate {
944 #[arg(long)]
946 handle: String,
947 #[arg(long)]
948 json: bool,
949 },
950 OrgAddMember {
954 op_did: String,
956 #[arg(long)]
958 org: String,
959 #[arg(long)]
960 json: bool,
961 },
962}
963
964#[derive(Subcommand, Debug)]
965pub enum IdentityCommand {
966 Show {
969 #[arg(long)]
970 json: bool,
971 },
972 List {
977 #[arg(long)]
978 json: bool,
979 },
980 #[command(hide = true)]
988 Publish {
989 nick: String,
991 #[arg(long)]
994 relay: Option<String>,
995 #[arg(long, alias = "public")]
998 public_url: Option<String>,
999 #[arg(long)]
1003 hidden: bool,
1004 #[arg(long)]
1005 json: bool,
1006 },
1007 Destroy {
1011 name: String,
1013 #[arg(long)]
1015 force: bool,
1016 #[arg(long)]
1017 json: bool,
1018 },
1019 Create {
1031 #[arg(long)]
1034 name: Option<String>,
1035 #[arg(long, conflicts_with = "local")]
1038 anonymous: bool,
1039 #[arg(long)]
1042 local: bool,
1043 #[arg(long)]
1044 json: bool,
1045 },
1046 Persist {
1051 name: String,
1053 #[arg(long = "as", value_name = "NEW_NAME")]
1055 as_name: Option<String>,
1056 #[arg(long)]
1057 json: bool,
1058 },
1059 Demote {
1069 name: String,
1071 #[arg(long)]
1072 json: bool,
1073 },
1074}
1075
1076#[derive(Subcommand, Debug)]
1077pub enum SessionCommand {
1078 New {
1086 name: Option<String>,
1088 #[arg(long, default_value = "https://wireup.net")]
1090 relay: String,
1091 #[arg(long)]
1098 with_local: bool,
1099 #[arg(long, default_value = "http://127.0.0.1:8771")]
1103 local_relay: String,
1104 #[arg(long)]
1111 with_lan: bool,
1112 #[arg(long)]
1116 lan_relay: Option<String>,
1117 #[arg(long)]
1124 with_uds: bool,
1125 #[arg(long)]
1129 uds_socket: Option<std::path::PathBuf>,
1130 #[arg(long)]
1133 no_daemon: bool,
1134 #[arg(long)]
1142 local_only: bool,
1143 #[arg(long)]
1145 json: bool,
1146 },
1147 List {
1150 #[arg(long)]
1151 json: bool,
1152 },
1153 ListLocal {
1159 #[arg(long)]
1160 json: bool,
1161 },
1162 PairAllLocal {
1178 #[arg(long, default_value_t = 1)]
1183 settle_secs: u64,
1184 #[arg(long, default_value = "https://wireup.net")]
1189 federation_relay: String,
1190 #[arg(long)]
1191 json: bool,
1192 },
1193 MeshStatus {
1207 #[arg(long, default_value_t = 300)]
1212 stale_secs: u64,
1213 #[arg(long)]
1214 json: bool,
1215 },
1216 Env {
1220 name: Option<String>,
1222 #[arg(long)]
1223 json: bool,
1224 },
1225 Current {
1229 #[arg(long)]
1230 json: bool,
1231 },
1232 Bind {
1240 name: Option<String>,
1244 #[arg(long)]
1245 json: bool,
1246 },
1247 Destroy {
1251 name: String,
1252 #[arg(long)]
1254 force: bool,
1255 #[arg(long)]
1256 json: bool,
1257 },
1258}
1259
1260#[derive(Subcommand, Debug)]
1266pub enum GroupCommand {
1267 Create {
1269 name: String,
1271 #[arg(long)]
1272 json: bool,
1273 },
1274 Add {
1276 group: String,
1278 peer: String,
1280 #[arg(long)]
1281 json: bool,
1282 },
1283 Send {
1285 group: String,
1287 message: String,
1289 #[arg(long)]
1290 json: bool,
1291 },
1292 Tail {
1294 group: String,
1296 #[arg(long, default_value_t = 20)]
1298 limit: usize,
1299 #[arg(long)]
1300 json: bool,
1301 },
1302 List {
1304 #[arg(long)]
1305 json: bool,
1306 },
1307 Invite {
1312 group: String,
1314 #[arg(long)]
1315 json: bool,
1316 },
1317 Join {
1321 code: String,
1323 #[arg(long)]
1324 json: bool,
1325 },
1326}
1327
1328#[derive(Subcommand, Debug)]
1330pub enum MeshCommand {
1331 Status {
1334 #[arg(long, default_value_t = 300)]
1336 stale_secs: u64,
1337 #[arg(long)]
1338 json: bool,
1339 },
1340 Broadcast {
1359 #[arg(long, default_value = "claim")]
1362 kind: String,
1363 #[arg(long, default_value = "local")]
1365 scope: String,
1366 #[arg(long)]
1368 exclude: Vec<String>,
1369 #[arg(long)]
1373 noreply: bool,
1374 body: String,
1376 #[arg(long)]
1377 json: bool,
1378 },
1379 Role {
1388 #[command(subcommand)]
1389 action: MeshRoleAction,
1390 },
1391 Route {
1407 role: String,
1409 #[arg(long, default_value = "round-robin")]
1411 strategy: String,
1412 #[arg(long)]
1414 exclude: Vec<String>,
1415 #[arg(long, default_value = "claim")]
1418 kind: String,
1419 body: String,
1421 #[arg(long)]
1422 json: bool,
1423 },
1424}
1425
1426#[derive(Subcommand, Debug)]
1428pub enum MeshRoleAction {
1429 Set {
1434 role: String,
1435 #[arg(long)]
1436 json: bool,
1437 },
1438 Get {
1441 peer: Option<String>,
1442 #[arg(long)]
1443 json: bool,
1444 },
1445 List {
1448 #[arg(long)]
1449 json: bool,
1450 },
1451 Clear {
1454 #[arg(long)]
1455 json: bool,
1456 },
1457}
1458
1459#[derive(Subcommand, Debug)]
1460pub enum ServiceAction {
1461 Install {
1471 #[arg(long)]
1473 local_relay: bool,
1474 #[arg(long)]
1475 json: bool,
1476 },
1477 Uninstall {
1481 #[arg(long)]
1483 local_relay: bool,
1484 #[arg(long)]
1485 json: bool,
1486 },
1487 Status {
1489 #[arg(long)]
1491 local_relay: bool,
1492 #[arg(long)]
1493 json: bool,
1494 },
1495}
1496
1497#[derive(Subcommand, Debug)]
1498pub enum ResponderCommand {
1499 Set {
1501 status: String,
1503 #[arg(long)]
1505 reason: Option<String>,
1506 #[arg(long)]
1508 json: bool,
1509 },
1510 Get {
1512 peer: Option<String>,
1514 #[arg(long)]
1516 json: bool,
1517 },
1518}
1519
1520#[derive(Subcommand, Debug)]
1521pub enum ProfileAction {
1522 Set {
1526 field: String,
1527 value: String,
1528 #[arg(long)]
1529 json: bool,
1530 },
1531 Get {
1533 #[arg(long)]
1534 json: bool,
1535 },
1536 Clear {
1538 field: String,
1539 #[arg(long)]
1540 json: bool,
1541 },
1542}
1543
1544pub fn run() -> Result<()> {
1546 crate::session::maybe_adopt_session_wire_home("cli");
1557 let cli = Cli::parse();
1558 match cli.command {
1559 Command::Init {
1560 handle,
1561 name,
1562 relay,
1563 offline,
1564 json,
1565 } => cmd_init(
1566 Some(&handle),
1567 name.as_deref(),
1568 relay.as_deref(),
1569 offline,
1570 json,
1571 ),
1572 Command::Status { peer, json } => {
1573 if let Some(peer) = peer {
1574 cmd_status_peer(&peer, json)
1575 } else {
1576 cmd_status(json)
1577 }
1578 }
1579 Command::Whoami {
1580 json,
1581 short,
1582 colored,
1583 } => cmd_whoami(json_default(json), short, colored),
1584 Command::Peers { json } => cmd_peers(json_default(json)),
1585 Command::Here { json } => cmd_here(json_default(json)),
1586 Command::Completions { shell } => {
1587 use clap::CommandFactory;
1594 let mut cmd = Cli::command();
1595 clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1596 Ok(())
1597 }
1598 Command::Pending { json } => cmd_pair_list_inbound(json_default(json)),
1599 Command::Reject { peer, json } => cmd_pair_reject(&peer, json_default(json)),
1600 Command::Send {
1601 peer,
1602 kind_or_body,
1603 body,
1604 deadline,
1605 no_auto_pair,
1606 json,
1607 } => {
1608 let (kind, body) = match body {
1611 Some(real_body) => (kind_or_body, real_body),
1612 None => ("claim".to_string(), kind_or_body),
1613 };
1614 cmd_send(
1615 &peer,
1616 &kind,
1617 &body,
1618 deadline.as_deref(),
1619 no_auto_pair,
1620 json_default(json),
1621 )
1622 }
1623 Command::Dial {
1624 name,
1625 message,
1626 json,
1627 } => cmd_dial(&name, message.as_deref(), json_default(json)),
1628 Command::Tail {
1629 peer,
1630 json,
1631 limit,
1632 oldest,
1633 } => cmd_tail(peer.as_deref(), json, limit, oldest),
1634 Command::Monitor {
1635 peer,
1636 json,
1637 include_handshake,
1638 interval_ms,
1639 replay,
1640 } => cmd_monitor(
1641 peer.as_deref(),
1642 json,
1643 include_handshake,
1644 interval_ms,
1645 replay,
1646 ),
1647 Command::Verify { path, json } => cmd_verify(&path, json),
1648 Command::Responder { command } => match command {
1649 ResponderCommand::Set {
1650 status,
1651 reason,
1652 json,
1653 } => cmd_responder_set(&status, reason.as_deref(), json),
1654 ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1655 },
1656 Command::Mcp => cmd_mcp(),
1657 Command::RelayServer {
1658 bind,
1659 local_only,
1660 uds,
1661 } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1662 Command::BindRelay {
1663 url,
1664 scope,
1665 replace,
1666 migrate_pinned,
1667 json,
1668 } => cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1669 Command::AddPeerSlot {
1670 handle,
1671 url,
1672 slot_id,
1673 slot_token,
1674 json,
1675 } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1676 Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1677 Command::Pull { json } => cmd_pull(json),
1678 Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1679 Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1680 Command::ForgetPeer {
1681 handle,
1682 purge,
1683 json,
1684 } => cmd_forget_peer(&handle, purge, json),
1685 Command::Daemon {
1686 interval,
1687 once,
1688 json,
1689 } => cmd_daemon(interval, once, json),
1690 Command::PairHost {
1691 relay,
1692 yes,
1693 timeout,
1694 detach,
1695 json,
1696 } => {
1697 if detach {
1698 cmd_pair_host_detach(&relay, json)
1699 } else {
1700 cmd_pair_host(&relay, yes, timeout)
1701 }
1702 }
1703 Command::PairJoin {
1704 code_phrase,
1705 relay,
1706 yes,
1707 timeout,
1708 detach,
1709 json,
1710 } => {
1711 if detach {
1712 cmd_pair_join_detach(&code_phrase, &relay, json)
1713 } else {
1714 cmd_pair_join(&code_phrase, &relay, yes, timeout)
1715 }
1716 }
1717 Command::PairConfirm {
1718 code_phrase,
1719 digits,
1720 json,
1721 } => cmd_pair_confirm(&code_phrase, &digits, json),
1722 Command::PairList {
1723 json,
1724 watch,
1725 watch_interval,
1726 } => cmd_pair_list(json, watch, watch_interval),
1727 Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1728 Command::PairWatch {
1729 code_phrase,
1730 status,
1731 timeout,
1732 json,
1733 } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1734 Command::Pair {
1735 handle,
1736 code,
1737 relay,
1738 yes,
1739 timeout,
1740 no_setup,
1741 detach,
1742 } => {
1743 if handle.contains('@') && code.is_none() {
1750 cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1751 } else if detach {
1752 cmd_pair_detach(&handle, code.as_deref(), &relay)
1753 } else {
1754 cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1755 }
1756 }
1757 Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1758 Command::PairAccept { peer, json } => {
1759 let j = json_default(json);
1760 deprecation_warn("pair-accept", &format!("accept {peer}"), j);
1761 cmd_pair_accept(&peer, j)
1762 }
1763 Command::PairReject { peer, json } => {
1764 let j = json_default(json);
1765 deprecation_warn("pair-reject", &format!("reject {peer}"), j);
1766 cmd_pair_reject(&peer, j)
1767 }
1768 Command::PairListInbound { json } => {
1769 let j = json_default(json);
1770 deprecation_warn("pair-list-inbound", "pending", j);
1771 cmd_pair_list_inbound(j)
1772 }
1773 Command::Session(cmd) => cmd_session(cmd),
1774 Command::Identity { cmd } => cmd_identity(cmd),
1775 Command::Mesh(cmd) => cmd_mesh(cmd),
1776 Command::Group(cmd) => cmd_group(cmd),
1777 Command::Enroll(cmd) => cmd_enroll(cmd),
1778 Command::Invite {
1779 relay,
1780 ttl,
1781 uses,
1782 share,
1783 json,
1784 } => cmd_invite(&relay, ttl, uses, share, json),
1785 Command::Accept { target, json } => {
1786 let j = json_default(json);
1792 if target.starts_with("wire://pair?") {
1793 deprecation_warn("accept-url", "accept-invite <url>", j);
1794 cmd_accept(&target, j)
1795 } else {
1796 cmd_pair_accept(&target, j)
1797 }
1798 }
1799 Command::AcceptInvite { url, json } => cmd_accept(&url, json_default(json)),
1800 Command::Whois {
1801 handle,
1802 json,
1803 relay,
1804 } => {
1805 match handle.as_deref() {
1814 Some(h) if !h.contains('@') => cmd_whois_local(h, json),
1815 other => cmd_whois(other, json, relay.as_deref()),
1816 }
1817 }
1818 Command::Add {
1819 handle,
1820 relay,
1821 local_sister,
1822 json,
1823 } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1824 Command::Up {
1825 relay,
1826 name,
1827 with_local,
1828 no_local,
1829 json,
1830 } => cmd_up(
1831 relay.as_deref(),
1832 name.as_deref(),
1833 with_local.as_deref(),
1834 no_local,
1835 json,
1836 ),
1837 Command::Doctor {
1838 json,
1839 recent_rejections,
1840 } => cmd_doctor(json, recent_rejections),
1841 Command::Upgrade { check, local, json } => cmd_upgrade(check, local, json),
1842 Command::Service { action } => cmd_service(action),
1843 Command::Diag { action } => cmd_diag(action),
1844 Command::Claim {
1845 nick,
1846 relay,
1847 public_url,
1848 hidden,
1849 json,
1850 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1851 Command::Profile { action } => cmd_profile(action),
1852 Command::Setup {
1853 apply,
1854 statusline,
1855 remove,
1856 } => {
1857 if statusline {
1858 cmd_setup_statusline(apply, remove)
1859 } else {
1860 cmd_setup(apply)
1861 }
1862 }
1863 Command::Notify {
1864 interval,
1865 peer,
1866 once,
1867 json,
1868 } => cmd_notify(interval, peer.as_deref(), once, json),
1869 }
1870}
1871
1872fn cmd_init(
1875 handle: Option<&str>,
1876 name: Option<&str>,
1877 relay: Option<&str>,
1878 offline: bool,
1879 as_json: bool,
1880) -> Result<()> {
1881 if let Some(h) = handle
1887 && !h
1888 .chars()
1889 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1890 {
1891 bail!("handle must be ASCII alphanumeric / '-' / '_' (got {h:?})");
1892 }
1893 if config::is_initialized()? {
1894 bail!(
1895 "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
1896 config::config_dir()?
1897 );
1898 }
1899 let mut resolved_relay: Option<String> = relay.map(str::to_string);
1913 if resolved_relay.is_none() && !offline {
1914 let default_local = "http://127.0.0.1:8771";
1915 let client = crate::relay_client::RelayClient::new(default_local);
1916 if client.check_healthz().is_ok() {
1917 eprintln!(
1918 "wire init: local relay at {default_local} reachable — auto-attaching. \
1919 Use --relay <url> to pick a different relay, --offline to skip."
1920 );
1921 resolved_relay = Some(default_local.to_string());
1922 } else {
1923 use std::io::{BufRead, IsTerminal, Write};
1929 let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
1930 if interactive && std::env::var("WIRE_NO_INTERACTIVE").is_err() {
1931 eprintln!("wire init: no local relay reachable at {default_local}.");
1932 eprint!(
1933 " Bind to public federation relay https://wireup.net instead? \
1934 [Y/n/offline/url]: "
1935 );
1936 let _ = std::io::stderr().flush();
1937 let mut input = String::new();
1938 let _ = std::io::stdin().lock().read_line(&mut input);
1939 let answer = input.trim();
1940 match answer {
1941 "" | "y" | "Y" | "yes" | "YES" => {
1942 eprintln!("wire init: binding to https://wireup.net");
1943 resolved_relay = Some("https://wireup.net".to_string());
1944 }
1945 "n" | "N" | "no" | "NO" => {
1946 bail!(
1947 "wire init: declined federation default; re-run with --relay <url> or --offline."
1948 );
1949 }
1950 "offline" | "OFFLINE" => {
1951 eprintln!(
1952 "wire init: proceeding offline. \
1953 Run `wire bind-relay <url>` before pairing."
1954 );
1955 }
1961 url if url.starts_with("http://") || url.starts_with("https://") => {
1962 eprintln!("wire init: binding to {url}");
1963 resolved_relay = Some(url.to_string());
1964 }
1965 other => {
1966 bail!(
1967 "wire init: unrecognized answer `{other}` — \
1968 expected Y/n/offline/<url>. Re-run with --relay or --offline."
1969 );
1970 }
1971 }
1972 } else {
1973 bail!(
1974 "wire init: no relay specified and no local relay reachable at \
1975 http://127.0.0.1:8771.\n\
1976 Pick one (or just run `wire up`):\n\
1977 • `wire service install --local-relay` — start the local relay, then re-run\n\
1978 • `wire up @wireup.net` — bind to public federation in one command\n\
1979 • `wire init --offline` — generate keypair only \
1980 (peers cannot reach you until you `wire bind-relay <url>` later)"
1981 );
1982 }
1983 }
1984 }
1985 let relay = resolved_relay.as_deref();
1986
1987 config::ensure_dirs()?;
1988 let (sk_seed, pk_bytes) = generate_keypair();
1989 config::write_private_key(&sk_seed)?;
1990
1991 let seed = handle.unwrap_or("agent");
2009 let synth_did = crate::agent_card::did_for_with_key(seed, &pk_bytes);
2010 let character = crate::character::Character::from_did(&synth_did);
2011 let canonical_handle: &str = &character.nickname;
2012 if let Some(typed) = handle
2013 && typed != canonical_handle
2014 {
2015 eprintln!(
2016 "wire init: one-name rule — typed `{typed}` ignored in favor of \
2017 DID-derived persona `{canonical_handle}`. Peers will reach you as `{canonical_handle}`."
2018 );
2019 }
2020
2021 let card = build_agent_card(canonical_handle, &pk_bytes, name, None, None);
2022 let card = crate::enroll::with_op_claims_if_enrolled(card)?;
2025 let signed = sign_agent_card(&card, &sk_seed);
2026 config::write_agent_card(&signed)?;
2027
2028 let mut trust = empty_trust();
2029 add_self_to_trust(&mut trust, canonical_handle, &pk_bytes);
2030 config::write_trust(&trust)?;
2031
2032 let fp = fingerprint(&pk_bytes);
2033 let key_id = make_key_id(canonical_handle, &pk_bytes);
2034 let handle = canonical_handle;
2037
2038 let mut relay_info: Option<(String, String)> = None;
2040 if let Some(url) = relay {
2041 let normalized = url.trim_end_matches('/');
2042 let client = crate::relay_client::RelayClient::new(normalized);
2043 client.check_healthz()?;
2044 let alloc = client.allocate_slot(Some(handle))?;
2045 let mut state = config::read_relay_state()?;
2046 state["self"] = json!({
2047 "relay_url": normalized,
2048 "slot_id": alloc.slot_id.clone(),
2049 "slot_token": alloc.slot_token,
2050 });
2051 config::write_relay_state(&state)?;
2052 relay_info = Some((normalized.to_string(), alloc.slot_id));
2053 }
2054
2055 let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
2056 if as_json {
2057 let mut out = json!({
2058 "did": did_str.clone(),
2059 "fingerprint": fp,
2060 "key_id": key_id,
2061 "config_dir": config::config_dir()?.to_string_lossy(),
2062 });
2063 if let Some((url, slot_id)) = &relay_info {
2064 out["relay_url"] = json!(url);
2065 out["slot_id"] = json!(slot_id);
2066 }
2067 println!("{}", serde_json::to_string(&out)?);
2068 } else {
2069 println!("generated {did_str} (ed25519:{key_id})");
2070 println!(
2071 "config written to {}",
2072 config::config_dir()?.to_string_lossy()
2073 );
2074 if let Some((url, slot_id)) = &relay_info {
2075 println!("bound to relay {url} (slot {slot_id})");
2076 println!();
2077 println!(
2078 "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
2079 );
2080 } else {
2081 println!();
2082 println!(
2083 "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
2084 );
2085 }
2086 }
2087 Ok(())
2088}
2089
2090fn cmd_status(as_json: bool) -> Result<()> {
2093 let initialized = config::is_initialized()?;
2094
2095 let mut summary = json!({
2096 "initialized": initialized,
2097 });
2098
2099 if initialized {
2100 let card = config::read_agent_card()?;
2101 let did = card
2102 .get("did")
2103 .and_then(Value::as_str)
2104 .unwrap_or("")
2105 .to_string();
2106 let handle = card
2110 .get("handle")
2111 .and_then(Value::as_str)
2112 .map(str::to_string)
2113 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2114 let pk_b64 = card
2115 .get("verify_keys")
2116 .and_then(Value::as_object)
2117 .and_then(|m| m.values().next())
2118 .and_then(|v| v.get("key"))
2119 .and_then(Value::as_str)
2120 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2121 let pk_bytes = crate::signing::b64decode(pk_b64)?;
2122 summary["did"] = json!(did);
2123 summary["handle"] = json!(handle);
2124 summary["fingerprint"] = json!(fingerprint(&pk_bytes));
2125 summary["capabilities"] = card
2126 .get("capabilities")
2127 .cloned()
2128 .unwrap_or_else(|| json!([]));
2129
2130 let trust = config::read_trust()?;
2131 let relay_state_for_tier =
2132 config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2133 let mut peers = Vec::new();
2134 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
2135 for (peer_handle, _agent) in agents {
2136 if peer_handle == &handle {
2137 continue; }
2139 peers.push(json!({
2144 "handle": peer_handle,
2145 "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
2146 }));
2147 }
2148 }
2149 summary["peers"] = json!(peers);
2150
2151 let relay_state = config::read_relay_state()?;
2152 summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
2153 if !summary["self_relay"].is_null() {
2154 if let Some(obj) = summary["self_relay"].as_object_mut() {
2156 obj.remove("slot_token");
2157 }
2158 }
2159 summary["peer_slots_count"] = json!(
2160 relay_state
2161 .get("peers")
2162 .and_then(Value::as_object)
2163 .map(|m| m.len())
2164 .unwrap_or(0)
2165 );
2166
2167 let outbox = config::outbox_dir()?;
2169 let inbox = config::inbox_dir()?;
2170 summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
2171 summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
2172
2173 let snap = crate::ensure_up::daemon_liveness();
2179 let mut daemon = json!({
2180 "running": snap.pidfile_alive,
2181 "pid": snap.pidfile_pid,
2182 "all_running_pids": snap.pgrep_pids,
2183 "orphans": snap.orphan_pids,
2184 });
2185 if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
2186 daemon["version"] = json!(d.version);
2187 daemon["bin_path"] = json!(d.bin_path);
2188 daemon["did"] = json!(d.did);
2189 daemon["relay_url"] = json!(d.relay_url);
2190 daemon["started_at"] = json!(d.started_at);
2191 daemon["schema"] = json!(d.schema);
2192 if d.version != env!("CARGO_PKG_VERSION") {
2193 daemon["version_mismatch"] = json!({
2194 "daemon": d.version.clone(),
2195 "cli": env!("CARGO_PKG_VERSION"),
2196 });
2197 }
2198 } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
2199 daemon["pidfile_form"] = json!("legacy-int");
2200 daemon["version_mismatch"] = json!({
2201 "daemon": "<pre-0.5.11>",
2202 "cli": env!("CARGO_PKG_VERSION"),
2203 });
2204 }
2205 summary["daemon"] = daemon;
2206
2207 let pending = crate::pending_pair::list_pending().unwrap_or_default();
2209 let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
2210 for p in &pending {
2211 *counts.entry(p.status.clone()).or_default() += 1;
2212 }
2213 let pending_inbound =
2215 crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
2216 let inbound_handles: Vec<&str> = pending_inbound
2217 .iter()
2218 .map(|p| p.peer_handle.as_str())
2219 .collect();
2220 summary["pending_pairs"] = json!({
2221 "total": pending.len(),
2222 "by_status": counts,
2223 "inbound_count": pending_inbound.len(),
2224 "inbound_handles": inbound_handles,
2225 });
2226 }
2227
2228 if as_json {
2229 println!("{}", serde_json::to_string(&summary)?);
2230 } else if !initialized {
2231 println!("not initialized — run `wire init <handle>` first");
2232 } else {
2233 println!("did: {}", summary["did"].as_str().unwrap_or("?"));
2234 println!(
2235 "fingerprint: {}",
2236 summary["fingerprint"].as_str().unwrap_or("?")
2237 );
2238 println!("capabilities: {}", summary["capabilities"]);
2239 if !summary["self_relay"].is_null() {
2240 println!(
2241 "self relay: {} (slot {})",
2242 summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
2243 summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
2244 );
2245 } else {
2246 println!("self relay: (not bound — run `wire pair-host --relay <url>` to bind)");
2247 }
2248 println!(
2249 "peers: {}",
2250 summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
2251 );
2252 for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
2253 println!(
2254 " - {:<20} tier={}",
2255 p["handle"].as_str().unwrap_or(""),
2256 p["tier"].as_str().unwrap_or("?")
2257 );
2258 }
2259 println!(
2260 "outbox: {} file(s), {} event(s) queued",
2261 summary["outbox"]["files"].as_u64().unwrap_or(0),
2262 summary["outbox"]["events"].as_u64().unwrap_or(0)
2263 );
2264 println!(
2265 "inbox: {} file(s), {} event(s) received",
2266 summary["inbox"]["files"].as_u64().unwrap_or(0),
2267 summary["inbox"]["events"].as_u64().unwrap_or(0)
2268 );
2269 let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
2270 let daemon_pid = summary["daemon"]["pid"]
2271 .as_u64()
2272 .map(|p| p.to_string())
2273 .unwrap_or_else(|| "—".to_string());
2274 let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
2275 let version_suffix = if !daemon_version.is_empty() {
2276 format!(" v{daemon_version}")
2277 } else {
2278 String::new()
2279 };
2280 println!(
2281 "daemon: {} (pid {}{})",
2282 if daemon_running { "running" } else { "DOWN" },
2283 daemon_pid,
2284 version_suffix,
2285 );
2286 if let Some(mm) = summary["daemon"].get("version_mismatch") {
2288 println!(
2289 " !! version mismatch: daemon={} CLI={}. \
2290 run `wire upgrade` to swap atomically.",
2291 mm["daemon"].as_str().unwrap_or("?"),
2292 mm["cli"].as_str().unwrap_or("?"),
2293 );
2294 }
2295 if let Some(orphans) = summary["daemon"]["orphans"].as_array()
2296 && !orphans.is_empty()
2297 {
2298 let pids: Vec<String> = orphans
2299 .iter()
2300 .filter_map(|v| v.as_u64().map(|p| p.to_string()))
2301 .collect();
2302 println!(
2303 " !! orphan daemon process(es): pids {}. \
2304 pgrep saw them but pidfile didn't — likely stale process from \
2305 prior install. Multiple daemons race the relay cursor.",
2306 pids.join(", ")
2307 );
2308 }
2309 let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
2310 let inbound_count = summary["pending_pairs"]["inbound_count"]
2311 .as_u64()
2312 .unwrap_or(0);
2313 if pending_total > 0 {
2314 print!("pending pairs: {pending_total}");
2315 if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
2316 let parts: Vec<String> = obj
2317 .iter()
2318 .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
2319 .collect();
2320 if !parts.is_empty() {
2321 print!(" ({})", parts.join(", "));
2322 }
2323 }
2324 println!();
2325 } else if inbound_count == 0 {
2326 println!("pending pairs: none");
2327 }
2328 if inbound_count > 0 {
2332 let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
2333 .as_array()
2334 .map(|a| {
2335 a.iter()
2336 .filter_map(|v| v.as_str().map(str::to_string))
2337 .collect()
2338 })
2339 .unwrap_or_default();
2340 println!(
2341 "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
2342 handles.join(", "),
2343 );
2344 }
2345 }
2346 Ok(())
2347}
2348
2349fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
2350 if !dir.exists() {
2351 return Ok(json!({"files": 0, "events": 0}));
2352 }
2353 let mut files = 0usize;
2354 let mut events = 0usize;
2355 for entry in std::fs::read_dir(dir)? {
2356 let path = entry?.path();
2357 if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
2358 files += 1;
2359 if let Ok(body) = std::fs::read_to_string(&path) {
2360 events += body.lines().filter(|l| !l.trim().is_empty()).count();
2361 }
2362 }
2363 }
2364 Ok(json!({"files": files, "events": events}))
2365}
2366
2367fn responder_status_allowed(status: &str) -> bool {
2370 matches!(
2371 status,
2372 "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
2373 )
2374}
2375
2376fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
2377 let state = config::read_relay_state()?;
2378 let (label, slot_info) = match peer {
2379 Some(peer) => (
2380 peer.to_string(),
2381 state
2382 .get("peers")
2383 .and_then(|p| p.get(peer))
2384 .ok_or_else(|| {
2385 anyhow!(
2386 "unknown peer {peer:?} in relay state — pair with them first:\n \
2387 wire add {peer}@wireup.net (or {peer}@<their-relay>)\n\
2388 (`wire peers` lists who you've already paired with.)"
2389 )
2390 })?,
2391 ),
2392 None => (
2393 "self".to_string(),
2394 state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
2395 anyhow!("self slot not bound — run `wire bind-relay <url>` first")
2396 })?,
2397 ),
2398 };
2399 let relay_url = slot_info["relay_url"]
2400 .as_str()
2401 .ok_or_else(|| anyhow!("{label} relay_url missing"))?
2402 .to_string();
2403 let slot_id = slot_info["slot_id"]
2404 .as_str()
2405 .ok_or_else(|| anyhow!("{label} slot_id missing"))?
2406 .to_string();
2407 let slot_token = slot_info["slot_token"]
2408 .as_str()
2409 .ok_or_else(|| anyhow!("{label} slot_token missing"))?
2410 .to_string();
2411 Ok((label, relay_url, slot_id, slot_token))
2412}
2413
2414fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
2415 if !responder_status_allowed(status) {
2416 bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
2417 }
2418 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
2419 let now = time::OffsetDateTime::now_utc()
2420 .format(&time::format_description::well_known::Rfc3339)
2421 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2422 let mut record = json!({
2423 "status": status,
2424 "set_at": now,
2425 });
2426 if let Some(reason) = reason {
2427 record["reason"] = json!(reason);
2428 }
2429 if status == "online" {
2430 record["last_success_at"] = json!(now);
2431 }
2432 let client = crate::relay_client::RelayClient::new(&relay_url);
2433 let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
2434 if as_json {
2435 println!("{}", serde_json::to_string(&saved)?);
2436 } else {
2437 let reason = saved
2438 .get("reason")
2439 .and_then(Value::as_str)
2440 .map(|r| format!(" — {r}"))
2441 .unwrap_or_default();
2442 println!(
2443 "responder {}{}",
2444 saved
2445 .get("status")
2446 .and_then(Value::as_str)
2447 .unwrap_or(status),
2448 reason
2449 );
2450 }
2451 Ok(())
2452}
2453
2454fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
2455 let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
2456 let client = crate::relay_client::RelayClient::new(&relay_url);
2457 let health = client.responder_health_get(&slot_id, &slot_token)?;
2458 if as_json {
2459 println!(
2460 "{}",
2461 serde_json::to_string(&json!({
2462 "target": label,
2463 "responder_health": health,
2464 }))?
2465 );
2466 } else if health.is_null() {
2467 println!("{label}: responder health not reported");
2468 } else {
2469 let status = health
2470 .get("status")
2471 .and_then(Value::as_str)
2472 .unwrap_or("unknown");
2473 let reason = health
2474 .get("reason")
2475 .and_then(Value::as_str)
2476 .map(|r| format!(" — {r}"))
2477 .unwrap_or_default();
2478 let last_success = health
2479 .get("last_success_at")
2480 .and_then(Value::as_str)
2481 .map(|t| format!(" (last_success: {t})"))
2482 .unwrap_or_default();
2483 println!("{label}: {status}{reason}{last_success}");
2484 }
2485 Ok(())
2486}
2487
2488fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2489 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2490 let client = crate::relay_client::RelayClient::new(&relay_url);
2491
2492 let started = std::time::Instant::now();
2493 let transport_ok = client.healthz().unwrap_or(false);
2494 let latency_ms = started.elapsed().as_millis() as u64;
2495
2496 let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2497 let now = std::time::SystemTime::now()
2498 .duration_since(std::time::UNIX_EPOCH)
2499 .map(|d| d.as_secs())
2500 .unwrap_or(0);
2501 let attention = match last_pull_at_unix {
2502 Some(last) if now.saturating_sub(last) <= 300 => json!({
2503 "status": "ok",
2504 "last_pull_at_unix": last,
2505 "age_seconds": now.saturating_sub(last),
2506 "event_count": event_count,
2507 }),
2508 Some(last) => json!({
2509 "status": "stale",
2510 "last_pull_at_unix": last,
2511 "age_seconds": now.saturating_sub(last),
2512 "event_count": event_count,
2513 }),
2514 None => json!({
2515 "status": "never_pulled",
2516 "last_pull_at_unix": Value::Null,
2517 "event_count": event_count,
2518 }),
2519 };
2520
2521 let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
2522 let responder = if responder_health.is_null() {
2523 json!({"status": "not_reported", "record": Value::Null})
2524 } else {
2525 json!({
2526 "status": responder_health
2527 .get("status")
2528 .and_then(Value::as_str)
2529 .unwrap_or("unknown"),
2530 "record": responder_health,
2531 })
2532 };
2533
2534 let report = json!({
2535 "peer": peer,
2536 "transport": {
2537 "status": if transport_ok { "ok" } else { "error" },
2538 "relay_url": relay_url,
2539 "latency_ms": latency_ms,
2540 },
2541 "attention": attention,
2542 "responder": responder,
2543 });
2544
2545 if as_json {
2546 println!("{}", serde_json::to_string(&report)?);
2547 } else {
2548 let transport_line = if transport_ok {
2549 format!("ok relay reachable ({latency_ms}ms)")
2550 } else {
2551 "error relay unreachable".to_string()
2552 };
2553 println!("transport {transport_line}");
2554 match report["attention"]["status"].as_str().unwrap_or("unknown") {
2555 "ok" => println!(
2556 "attention ok last pull {}s ago",
2557 report["attention"]["age_seconds"].as_u64().unwrap_or(0)
2558 ),
2559 "stale" => println!(
2560 "attention stale last pull {}m ago",
2561 report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
2562 ),
2563 "never_pulled" => println!("attention never pulled since relay reset"),
2564 other => println!("attention {other}"),
2565 }
2566 if report["responder"]["status"] == "not_reported" {
2567 println!("auto-responder not reported");
2568 } else {
2569 let record = &report["responder"]["record"];
2570 let status = record
2571 .get("status")
2572 .and_then(Value::as_str)
2573 .unwrap_or("unknown");
2574 let reason = record
2575 .get("reason")
2576 .and_then(Value::as_str)
2577 .map(|r| format!(" — {r}"))
2578 .unwrap_or_default();
2579 println!("auto-responder {status}{reason}");
2580 }
2581 }
2582 Ok(())
2583}
2584
2585fn current_cwd_display() -> String {
2593 let cwd = match std::env::current_dir() {
2594 Ok(c) => c,
2595 Err(_) => return String::from("?"),
2596 };
2597 if let Some(home) = dirs::home_dir()
2598 && let Ok(rel) = cwd.strip_prefix(&home)
2599 {
2600 let rel_str = rel.to_string_lossy();
2602 if rel_str.is_empty() {
2603 return String::from("~");
2604 }
2605 return format!("~/{}", rel_str);
2606 }
2607 cwd.to_string_lossy().into_owned()
2608}
2609
2610fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
2611 if !config::is_initialized()? {
2612 bail!("not initialized — run `wire init <handle>` first");
2613 }
2614 let card = config::read_agent_card()?;
2615 let did = card
2616 .get("did")
2617 .and_then(Value::as_str)
2618 .unwrap_or("")
2619 .to_string();
2620 let handle = card
2621 .get("handle")
2622 .and_then(Value::as_str)
2623 .map(str::to_string)
2624 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2625 let character = crate::character::Character::from_did(&did);
2629
2630 let cwd_display = current_cwd_display();
2636
2637 if short {
2640 println!("{} · {}", character.short(), cwd_display);
2641 return Ok(());
2642 }
2643 if colored {
2644 println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
2645 return Ok(());
2646 }
2647
2648 let pk_b64 = card
2649 .get("verify_keys")
2650 .and_then(Value::as_object)
2651 .and_then(|m| m.values().next())
2652 .and_then(|v| v.get("key"))
2653 .and_then(Value::as_str)
2654 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2655 let pk_bytes = crate::signing::b64decode(pk_b64)?;
2656 let fp = fingerprint(&pk_bytes);
2657 let key_id = make_key_id(&handle, &pk_bytes);
2658 let capabilities = card
2659 .get("capabilities")
2660 .cloned()
2661 .unwrap_or_else(|| json!(["wire/v3.1"]));
2662
2663 if as_json {
2664 let has_override = false;
2668 println!(
2669 "{}",
2670 serde_json::to_string(&json!({
2671 "did": did,
2672 "handle": handle,
2673 "fingerprint": fp,
2674 "key_id": key_id,
2675 "public_key_b64": pk_b64,
2676 "capabilities": capabilities,
2677 "config_dir": config::config_dir()?.to_string_lossy(),
2678 "persona": character,
2679 "persona_override": has_override,
2680 }))?
2681 );
2682 } else {
2683 println!("{}", character.colored());
2684 println!("{did} (ed25519:{key_id})");
2685 println!("fingerprint: {fp}");
2686 println!("capabilities: {capabilities}");
2687 }
2688 Ok(())
2689}
2690
2691fn cmd_enroll(cmd: EnrollCommand) -> Result<()> {
2694 match cmd {
2695 EnrollCommand::Op { handle, json } => {
2696 let (sk, pk) = crate::signing::generate_keypair();
2697 crate::config::write_op_key(&sk)?;
2698 crate::config::write_op_handle(&handle)?;
2699 let op_did = crate::agent_card::did_for_op(&handle, &pk);
2700 let op_pubkey = crate::signing::b64encode(&pk);
2701 if json {
2702 println!(
2703 "{}",
2704 serde_json::to_string(&json!({"op_did": op_did, "op_pubkey": op_pubkey}))?
2705 );
2706 } else {
2707 println!(
2708 "→ operator enrolled\n op_did: {op_did}\n op_pubkey: {op_pubkey}\n key saved 0600 at {:?}",
2709 crate::config::op_key_path()?
2710 );
2711 }
2712 Ok(())
2713 }
2714 EnrollCommand::OrgCreate { handle, json } => {
2715 let (sk, pk) = crate::signing::generate_keypair();
2716 let org_did = crate::agent_card::did_for_org(&handle, &pk);
2717 crate::config::write_org_key(&org_did, &sk)?;
2718 let org_pubkey = crate::signing::b64encode(&pk);
2719 if json {
2720 println!(
2721 "{}",
2722 serde_json::to_string(&json!({"org_did": org_did, "org_pubkey": org_pubkey}))?
2723 );
2724 } else {
2725 println!(
2726 "→ organization created\n org_did: {org_did}\n org_pubkey: {org_pubkey}\n key saved 0600 at {:?}",
2727 crate::config::org_key_path(&org_did)?
2728 );
2729 }
2730 Ok(())
2731 }
2732 EnrollCommand::OrgAddMember { op_did, org, json } => {
2733 if !crate::agent_card::is_op_did(&op_did) {
2734 bail!("not a valid operator DID (did:wire:op:<handle>-<32hex>): {op_did}");
2735 }
2736 let org_sk = crate::config::read_org_key(&org).with_context(|| {
2737 format!("no stored key for org {org} — run `wire enroll org-create` first")
2738 })?;
2739 let org_pk = ed25519_dalek::SigningKey::from_bytes(&org_sk)
2740 .verifying_key()
2741 .to_bytes();
2742 let member_cert = crate::enroll::issue_member_cert(&org_sk, &op_did)?;
2743 let org_pubkey = crate::signing::b64encode(&org_pk);
2744 crate::config::add_membership(&org, &org_pubkey, &member_cert)?;
2747 if json {
2748 println!(
2749 "{}",
2750 serde_json::to_string(&json!({
2751 "org_did": org, "org_pubkey": org_pubkey, "member_cert": member_cert
2752 }))?
2753 );
2754 } else {
2755 println!(
2756 "→ membership issued for {op_did}\n add to the operator's card org_memberships[]:\n {{\"org_did\": \"{org}\", \"org_pubkey\": \"{org_pubkey}\", \"member_cert\": \"{member_cert}\"}}"
2757 );
2758 }
2759 Ok(())
2760 }
2761 }
2762}
2763
2764fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
2765 match cmd {
2766 IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
2773 IdentityCommand::List { json } => cmd_session_list(json),
2774 IdentityCommand::Publish {
2775 nick,
2776 relay,
2777 public_url,
2778 hidden,
2779 json,
2780 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
2781 IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
2782 IdentityCommand::Create {
2783 name,
2784 anonymous,
2785 local: _,
2786 json,
2787 } => cmd_identity_create(name.as_deref(), anonymous, json),
2788 IdentityCommand::Persist {
2789 name,
2790 as_name,
2791 json,
2792 } => cmd_identity_persist(&name, as_name.as_deref(), json),
2793 IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
2794 }
2795}
2796
2797fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
2802 if anonymous {
2803 let rand_suffix = format!("{:08x}", rand::random::<u32>());
2805 let anon_name = name
2806 .map(crate::session::sanitize_name)
2807 .unwrap_or_else(|| format!("anon-{rand_suffix}"));
2808 let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
2809 std::fs::create_dir_all(&anon_root)
2810 .with_context(|| format!("creating anon root {anon_root:?}"))?;
2811 let session_home = anon_root.join("sessions").join(&anon_name);
2813 std::fs::create_dir_all(&session_home)?;
2814 let status = run_wire_with_home(&session_home, &["init", &anon_name, "--offline"])?;
2815 if !status.success() {
2816 bail!("anonymous identity init failed: {status}");
2817 }
2818 let marker = anon_root.join("anon-marker.json");
2821 std::fs::write(
2822 &marker,
2823 serde_json::to_vec_pretty(&serde_json::json!({
2824 "name": anon_name,
2825 "session_home": session_home.to_string_lossy(),
2826 "created_at": time::OffsetDateTime::now_utc()
2827 .format(&time::format_description::well_known::Rfc3339)
2828 .unwrap_or_default(),
2829 "kind": "anonymous",
2830 }))?,
2831 )?;
2832 let card = serde_json::from_slice::<Value>(&std::fs::read(
2833 session_home
2834 .join("config")
2835 .join("wire")
2836 .join("agent-card.json"),
2837 )?)?;
2838 let did = card
2839 .get("did")
2840 .and_then(Value::as_str)
2841 .unwrap_or("")
2842 .to_string();
2843 if as_json {
2844 println!(
2845 "{}",
2846 serde_json::to_string(&json!({
2847 "kind": "anonymous",
2848 "name": anon_name,
2849 "did": did,
2850 "session_home": session_home.to_string_lossy(),
2851 "anon_root": anon_root.to_string_lossy(),
2852 }))?
2853 );
2854 } else {
2855 println!("created anonymous identity `{anon_name}` ({did})");
2856 println!(
2857 " session_home: {} (dies on reboot — /tmp)",
2858 session_home.display()
2859 );
2860 println!();
2861 println!("activate in this shell:");
2862 println!(" export WIRE_HOME={}", session_home.display());
2863 println!();
2864 println!("promote to persistent later with:");
2865 println!(" wire identity persist {anon_name}");
2866 }
2867 return Ok(());
2868 }
2869 let name_arg = name.map(|s| s.to_string());
2871 cmd_session_new(
2872 name_arg.as_deref(),
2873 "https://wireup.net",
2874 false,
2875 "http://127.0.0.1:8771",
2876 false,
2877 None,
2878 false,
2879 None,
2880 true, true, as_json,
2883 )
2884}
2885
2886fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
2889 let temp = std::env::temp_dir();
2891 let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2892 for entry in std::fs::read_dir(&temp)?.flatten() {
2893 let path = entry.path();
2894 if !path
2895 .file_name()
2896 .and_then(|s| s.to_str())
2897 .map(|s| s.starts_with("wire-anon-"))
2898 .unwrap_or(false)
2899 {
2900 continue;
2901 }
2902 let marker = path.join("anon-marker.json");
2903 if let Ok(bytes) = std::fs::read(&marker)
2904 && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
2905 && json.get("name").and_then(Value::as_str) == Some(name)
2906 {
2907 let session_home = json
2908 .get("session_home")
2909 .and_then(Value::as_str)
2910 .map(std::path::PathBuf::from)
2911 .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
2912 found = Some((path, session_home));
2913 break;
2914 }
2915 }
2916 let (anon_root, anon_session_home) = found.ok_or_else(|| {
2917 anyhow!(
2918 "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
2919 run `wire identity list` to see available identities"
2920 )
2921 })?;
2922
2923 let new_name = as_name.unwrap_or(name);
2924 let new_session_home = crate::session::session_dir(new_name)?;
2925 if new_session_home.exists() {
2926 bail!(
2927 "target session `{new_name}` already exists at {new_session_home:?} — \
2928 pick a different name with --as <new-name>"
2929 );
2930 }
2931
2932 if let Some(parent) = new_session_home.parent() {
2934 std::fs::create_dir_all(parent)?;
2935 }
2936 std::fs::rename(&anon_session_home, &new_session_home)
2937 .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
2938
2939 let _ = std::fs::remove_dir_all(&anon_root);
2941
2942 let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
2945 let cwd_key = crate::session::normalize_cwd_key(&cwd);
2946 let new_name_for_reg = new_name.to_string();
2947 if let Err(e) = crate::session::update_registry(|reg| {
2948 reg.by_cwd.insert(cwd_key, new_name_for_reg);
2949 Ok(())
2950 }) {
2951 eprintln!("wire identity persist: failed to update registry: {e:#}");
2952 }
2953
2954 if as_json {
2955 println!(
2956 "{}",
2957 serde_json::to_string(&json!({
2958 "kind": "persisted",
2959 "from_name": name,
2960 "to_name": new_name,
2961 "session_home": new_session_home.to_string_lossy(),
2962 }))?
2963 );
2964 } else {
2965 println!("persisted anonymous identity `{name}` → local session `{new_name}`");
2966 println!(
2967 " session_home: {} (survives reboot)",
2968 new_session_home.display()
2969 );
2970 println!(" registered cwd: {}", cwd.display());
2971 }
2972 Ok(())
2973}
2974
2975fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
2981 let sessions = crate::session::list_sessions()?;
2982 let session = sessions
2983 .iter()
2984 .find(|s| s.name == name)
2985 .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
2986 let relay_state_path = session
2987 .home_dir
2988 .join("config")
2989 .join("wire")
2990 .join("relay.json");
2991 if !relay_state_path.exists() {
2992 bail!("session `{name}` has no relay state — already demoted?");
2993 }
2994 let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
2995 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2996 let had_fed = self_obj
2997 .get("relay_url")
2998 .and_then(Value::as_str)
2999 .map(|u| {
3000 u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
3001 })
3002 .unwrap_or(false);
3003 if !had_fed {
3004 if as_json {
3005 println!(
3006 "{}",
3007 serde_json::to_string(
3008 &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
3009 )?
3010 );
3011 } else {
3012 println!("session `{name}` has no federation slot — nothing to demote");
3013 }
3014 return Ok(());
3015 }
3016 if let Some(self_mut) = state
3019 .as_object_mut()
3020 .and_then(|m| m.get_mut("self"))
3021 .and_then(|s| s.as_object_mut())
3022 {
3023 self_mut.remove("relay_url");
3024 self_mut.remove("slot_id");
3025 self_mut.remove("slot_token");
3026 if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
3027 eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
3028 }
3029 }
3030 std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
3031
3032 if as_json {
3033 println!(
3034 "{}",
3035 serde_json::to_string(
3036 &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
3037 )?
3038 );
3039 } else {
3040 println!("demoted `{name}` from federation → local");
3041 println!(" relay slot binding removed; keypair + agent-card retained");
3042 println!(" re-publish with `wire identity publish <nick>`");
3043 }
3044 Ok(())
3045}
3046
3047fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
3048 let raw = crate::trust::get_tier(trust, handle);
3049 if raw != "VERIFIED" {
3050 return raw.to_string();
3051 }
3052 let token = relay_state
3053 .get("peers")
3054 .and_then(|p| p.get(handle))
3055 .and_then(|p| p.get("slot_token"))
3056 .and_then(Value::as_str)
3057 .unwrap_or("");
3058 if token.is_empty() {
3059 "PENDING_ACK".to_string()
3060 } else {
3061 raw.to_string()
3062 }
3063}
3064
3065fn cmd_peers(as_json: bool) -> Result<()> {
3066 let trust = config::read_trust()?;
3067 let agents = trust
3068 .get("agents")
3069 .and_then(Value::as_object)
3070 .cloned()
3071 .unwrap_or_default();
3072 let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
3073
3074 let mut self_did: Option<String> = None;
3075 if let Ok(card) = config::read_agent_card() {
3076 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
3077 }
3078
3079 let mut peers = Vec::new();
3080 for (handle, agent) in agents.iter() {
3081 let did = agent
3082 .get("did")
3083 .and_then(Value::as_str)
3084 .unwrap_or("")
3085 .to_string();
3086 if Some(did.as_str()) == self_did.as_deref() {
3087 continue; }
3089 let tier = effective_peer_tier(&trust, &relay_state, handle);
3090 let capabilities = agent
3091 .get("card")
3092 .and_then(|c| c.get("capabilities"))
3093 .cloned()
3094 .unwrap_or_else(|| json!([]));
3095 let character = if did.is_empty() {
3100 None
3101 } else {
3102 let card_obj = agent.get("card");
3103 Some(match card_obj {
3104 Some(card) => crate::character::Character::from_card(card),
3105 None => crate::character::Character::from_did(&did),
3106 })
3107 };
3108 peers.push(json!({
3109 "handle": handle,
3110 "did": did,
3111 "tier": tier,
3112 "capabilities": capabilities,
3113 "persona": character,
3114 }));
3115 }
3116
3117 if as_json {
3118 println!("{}", serde_json::to_string(&peers)?);
3119 } else if peers.is_empty() {
3120 println!("no peers pinned (run `wire join <code>` to pair)");
3121 } else {
3122 for p in &peers {
3128 let char_json = &p["persona"];
3129 let (colored_char, plain_len): (String, usize) = match char_json {
3130 serde_json::Value::Null => ("?".to_string(), 1),
3131 v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
3132 Ok(c) => {
3133 let plain = c.short().chars().count() + 1; (c.colored(), plain)
3135 }
3136 Err(_) => ("?".to_string(), 1),
3137 },
3138 };
3139 let pad = 22usize.saturating_sub(plain_len);
3140 println!(
3141 "{}{} {:<20} {:<10} {}",
3142 colored_char,
3143 " ".repeat(pad),
3144 p["handle"].as_str().unwrap_or(""),
3145 p["tier"].as_str().unwrap_or(""),
3146 p["did"].as_str().unwrap_or(""),
3147 );
3148 }
3149 }
3150 Ok(())
3151}
3152
3153fn maybe_warn_peer_attentiveness(peer: &str) {
3163 let state = match config::read_relay_state() {
3164 Ok(s) => s,
3165 Err(_) => return,
3166 };
3167 let p = state.get("peers").and_then(|p| p.get(peer));
3168 let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
3169 Some(s) if !s.is_empty() => s,
3170 _ => return,
3171 };
3172 let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
3173 Some(s) if !s.is_empty() => s,
3174 _ => return,
3175 };
3176 let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
3177 Some(s) if !s.is_empty() => s.to_string(),
3178 _ => match state
3179 .get("self")
3180 .and_then(|s| s.get("relay_url"))
3181 .and_then(Value::as_str)
3182 {
3183 Some(s) if !s.is_empty() => s.to_string(),
3184 _ => return,
3185 },
3186 };
3187 let client = crate::relay_client::RelayClient::new(&relay_url);
3188 let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
3189 Ok(t) => t,
3190 Err(_) => return,
3191 };
3192 let now = std::time::SystemTime::now()
3193 .duration_since(std::time::UNIX_EPOCH)
3194 .map(|d| d.as_secs())
3195 .unwrap_or(0);
3196 match last_pull {
3197 None => {
3198 eprintln!(
3199 "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
3200 );
3201 }
3202 Some(t) if now.saturating_sub(t) > 300 => {
3203 let mins = now.saturating_sub(t) / 60;
3204 eprintln!(
3205 "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
3206 );
3207 }
3208 _ => {}
3209 }
3210}
3211
3212pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
3213 let trimmed = input.trim();
3214 if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
3215 {
3216 return Ok(trimmed.to_string());
3217 }
3218 let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
3219 let n: i64 = amount
3220 .parse()
3221 .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
3222 if n <= 0 {
3223 bail!("deadline duration must be positive: {input:?}");
3224 }
3225 let duration = match unit {
3226 "m" => time::Duration::minutes(n),
3227 "h" => time::Duration::hours(n),
3228 "d" => time::Duration::days(n),
3229 _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
3230 };
3231 Ok((time::OffsetDateTime::now_utc() + duration)
3232 .format(&time::format_description::well_known::Rfc3339)
3233 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
3234}
3235
3236fn cmd_send(
3237 peer: &str,
3238 kind: &str,
3239 body_arg: &str,
3240 deadline: Option<&str>,
3241 no_auto_pair: bool,
3245 as_json: bool,
3246) -> Result<()> {
3247 if !config::is_initialized()? {
3248 bail!("not initialized — run `wire init <handle>` first");
3249 }
3250 let peer_in = crate::agent_card::bare_handle(peer).to_string();
3251 let peer = match resolve_peer_handle(&peer_in) {
3258 Ok(Some(resolved)) if resolved != peer_in => {
3259 eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
3260 resolved
3261 }
3262 Ok(Some(canonical)) => canonical, Ok(None) => peer_in, Err(ResolveError::Ambiguous(candidates)) => bail!(
3265 "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
3266 Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
3267 candidates.len(),
3268 candidates.join(", ")
3269 ),
3270 Err(ResolveError::NotFound) => peer_in, };
3272
3273 let peer_is_pinned = config::read_relay_state()
3280 .ok()
3281 .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
3282 .map(|peers| peers.contains_key(&peer))
3283 .unwrap_or(false);
3284 if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) {
3285 if no_auto_pair {
3286 bail!(
3287 "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \
3288 and --no-auto-pair was passed. Run `wire dial {peer}` first, \
3289 then re-run send."
3290 );
3291 }
3292 eprintln!(
3293 "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \
3294 Pass --no-auto-pair to refuse implicit dialing."
3295 );
3296 cmd_add_local_sister(&sister_name, true).map_err(|e| {
3297 anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}")
3298 })?;
3299 }
3300
3301 let peer = peer.as_str();
3302 let sk_seed = config::read_private_key()?;
3303 let card = config::read_agent_card()?;
3304 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3305 let handle = crate::agent_card::display_handle_from_did(did).to_string();
3306 let pk_b64 = card
3307 .get("verify_keys")
3308 .and_then(Value::as_object)
3309 .and_then(|m| m.values().next())
3310 .and_then(|v| v.get("key"))
3311 .and_then(Value::as_str)
3312 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
3313 let pk_bytes = crate::signing::b64decode(pk_b64)?;
3314
3315 let body_value: Value = if body_arg == "-" {
3320 use std::io::Read;
3321 let mut raw = String::new();
3322 std::io::stdin()
3323 .read_to_string(&mut raw)
3324 .with_context(|| "reading body from stdin")?;
3325 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
3328 } else if let Some(path) = body_arg.strip_prefix('@') {
3329 let raw =
3330 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
3331 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
3332 } else {
3333 Value::String(body_arg.to_string())
3334 };
3335
3336 let kind_id = parse_kind(kind)?;
3337
3338 let now = time::OffsetDateTime::now_utc()
3339 .format(&time::format_description::well_known::Rfc3339)
3340 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
3341
3342 let mut event = json!({
3343 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
3344 "timestamp": now,
3345 "from": did,
3346 "to": format!("did:wire:{peer}"),
3347 "type": kind,
3348 "kind": kind_id,
3349 "body": body_value,
3350 });
3351 if let Some(deadline) = deadline {
3352 event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
3353 }
3354 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
3355 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
3356
3357 maybe_warn_peer_attentiveness(peer);
3362
3363 let line = serde_json::to_vec(&signed)?;
3368 let outbox = config::append_outbox_record(peer, &line)?;
3369
3370 if as_json {
3371 println!(
3372 "{}",
3373 serde_json::to_string(&json!({
3374 "event_id": event_id,
3375 "status": "queued",
3376 "peer": peer,
3377 "outbox": outbox.to_string_lossy(),
3378 }))?
3379 );
3380 } else {
3381 println!(
3382 "queued event {event_id} → {peer} (outbox: {})",
3383 outbox.display()
3384 );
3385 }
3386 Ok(())
3387}
3388
3389fn parse_kind(s: &str) -> Result<u32> {
3390 if let Ok(n) = s.parse::<u32>() {
3391 return Ok(n);
3392 }
3393 for (id, name) in crate::signing::kinds() {
3394 if *name == s {
3395 return Ok(*id);
3396 }
3397 }
3398 Ok(1)
3400}
3401
3402fn cmd_here(as_json: bool) -> Result<()> {
3408 let initialized = config::is_initialized().unwrap_or(false);
3409
3410 let (self_did, self_handle, self_character) = if initialized {
3412 let card = config::read_agent_card().ok();
3413 let did = card
3414 .as_ref()
3415 .and_then(|c| c.get("did").and_then(Value::as_str))
3416 .unwrap_or("")
3417 .to_string();
3418 let handle = if did.is_empty() {
3419 String::new()
3420 } else {
3421 crate::agent_card::display_handle_from_did(&did).to_string()
3422 };
3423 let character = if did.is_empty() {
3424 None
3425 } else {
3426 Some(crate::character::Character::from_did(&did))
3428 };
3429 (did, handle, character)
3430 } else {
3431 (String::new(), String::new(), None)
3432 };
3433
3434 let cwd = std::env::current_dir()
3435 .map(|p| p.to_string_lossy().into_owned())
3436 .unwrap_or_default();
3437 let wire_home = std::env::var("WIRE_HOME").unwrap_or_default();
3438
3439 let mut sisters: Vec<Value> = Vec::new();
3441 if let Ok(listing) = crate::session::list_local_sessions() {
3442 for group in listing.local.values() {
3443 for s in group {
3444 if s.handle.as_deref() == Some(self_handle.as_str()) {
3445 continue; }
3447 let ch = s.did.as_deref().map(crate::character::Character::from_did);
3448 sisters.push(json!({
3449 "session": s.name,
3450 "handle": s.handle,
3451 "persona": ch,
3452 }));
3453 }
3454 }
3455 }
3456
3457 let mut peers: Vec<Value> = Vec::new();
3459 if initialized
3460 && let Ok(trust) = config::read_trust()
3461 && let Some(agents) = trust.get("agents").and_then(Value::as_object)
3462 {
3463 for (handle, agent) in agents {
3464 if handle == &self_handle {
3465 continue; }
3467 let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3468 let ch = if did.is_empty() {
3469 None
3470 } else {
3471 Some(crate::character::Character::from_did(did))
3472 };
3473 peers.push(json!({
3474 "handle": handle,
3475 "did": did,
3476 "tier": agent.get("tier").and_then(Value::as_str).unwrap_or("UNKNOWN"),
3477 "persona": ch,
3478 }));
3479 }
3480 }
3481
3482 if as_json {
3483 println!(
3484 "{}",
3485 serde_json::to_string(&json!({
3486 "self": {
3487 "handle": self_handle,
3488 "did": self_did,
3489 "persona": self_character,
3490 "cwd": cwd,
3491 "wire_home": wire_home,
3492 },
3493 "sister_sessions": sisters,
3494 "pinned_peers": peers,
3495 }))?
3496 );
3497 return Ok(());
3498 }
3499
3500 if !initialized {
3502 println!("not initialized — run `wire init <handle>` to bootstrap.");
3503 return Ok(());
3504 }
3505 let glyph = self_character
3506 .as_ref()
3507 .map(crate::character::emoji_with_fallback)
3508 .unwrap_or_else(|| "?".to_string());
3509 let nick = self_character
3510 .as_ref()
3511 .map(|c| c.nickname.clone())
3512 .unwrap_or_default();
3513 println!("you are {glyph} {nick} ({self_handle})");
3514 if !cwd.is_empty() {
3515 println!(" cwd: {cwd}");
3516 }
3517 let render_glyph = |character: &Value| -> String {
3522 let emoji = character
3523 .get("emoji")
3524 .and_then(Value::as_str)
3525 .unwrap_or("?");
3526 let nickname = character
3527 .get("nickname")
3528 .and_then(Value::as_str)
3529 .unwrap_or("?");
3530 if crate::character::terminal_supports_emoji() {
3531 return emoji.to_string();
3532 }
3533 let synth = crate::character::Character {
3536 nickname: nickname.to_string(),
3537 emoji: emoji.to_string(),
3538 palette: crate::character::Palette {
3539 primary_hex: String::new(),
3540 accent_hex: String::new(),
3541 ansi256_primary: 0,
3542 ansi256_accent: 0,
3543 },
3544 };
3545 crate::character::emoji_with_fallback(&synth)
3546 };
3547 if !sisters.is_empty() {
3548 println!();
3549 println!("sister sessions on this machine:");
3550 for s in &sisters {
3551 let session = s["session"].as_str().unwrap_or("?");
3552 let ch_nick = s["persona"]["nickname"].as_str().unwrap_or("?");
3553 let glyph = render_glyph(&s["persona"]);
3554 println!(" {glyph} {ch_nick} ({session})");
3555 }
3556 }
3557 if !peers.is_empty() {
3558 println!();
3559 println!("pinned peers:");
3560 for p in &peers {
3561 let handle = p["handle"].as_str().unwrap_or("?");
3562 let tier = p["tier"].as_str().unwrap_or("");
3563 let ch_nick = p["persona"]["nickname"].as_str().unwrap_or("?");
3564 let glyph = render_glyph(&p["persona"]);
3565 println!(" {glyph} {ch_nick} ({handle}) [{tier}]");
3566 }
3567 }
3568 if sisters.is_empty() && peers.is_empty() {
3569 println!();
3570 println!(
3571 "no neighbors yet — `wire session new` to add a sister, or `wire dial <peer>` to reach out."
3572 );
3573 }
3574 Ok(())
3575}
3576
3577fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
3589 if name.contains('@') {
3590 cmd_add(name, None, false, true)
3596 .map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
3597 if let Some(msg) = message {
3598 let bare = name.split('@').next().unwrap_or(name);
3600 cmd_send(bare, "claim", msg, None, false, as_json)?;
3601 }
3602 return Ok(());
3603 }
3604
3605 let resolution = match resolve_name_to_target(name) {
3610 Ok(r) => r,
3611 Err(e) if as_json => {
3612 let pool = known_local_names();
3613 let suggestions = closest_candidates(name, &pool, 3, 3);
3614 println!(
3615 "{}",
3616 serde_json::to_string(&json!({
3617 "name_input": name,
3618 "found": false,
3619 "candidates": suggestions,
3620 "error": format!("{e:#}"),
3621 }))?
3622 );
3623 return Ok(());
3624 }
3625 Err(e) => return Err(e),
3626 };
3627 let mut steps: Vec<Value> = Vec::new();
3628
3629 match &resolution {
3630 DialTarget::PinnedPeer { handle, .. } => {
3631 steps.push(json!({
3632 "step": "resolved",
3633 "kind": "already_pinned",
3634 "handle": handle,
3635 }));
3636 }
3637 DialTarget::LocalSister { session_name, .. } => {
3638 steps.push(json!({
3639 "step": "resolved",
3640 "kind": "local_sister",
3641 "session": session_name,
3642 }));
3643 cmd_add_local_sister(session_name, true).map_err(|e| {
3649 anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
3650 })?;
3651 steps.push(json!({
3652 "step": "paired",
3653 "via": "local_sister",
3654 }));
3655 }
3656 }
3657
3658 let send_handle = match &resolution {
3659 DialTarget::PinnedPeer { handle, .. } => handle.clone(),
3660 DialTarget::LocalSister { handle, .. } => handle.clone(),
3661 };
3662
3663 let send_result = if let Some(msg) = message {
3664 let r = cmd_send(&send_handle, "claim", msg, None, false, true);
3665 match &r {
3666 Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
3667 Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
3668 }
3669 Some(r)
3670 } else {
3671 None
3672 };
3673
3674 if as_json {
3675 println!(
3676 "{}",
3677 serde_json::to_string(&json!({
3678 "name_input": name,
3679 "resolved_handle": send_handle,
3680 "steps": steps,
3681 }))?
3682 );
3683 } else {
3684 println!("wire dial: resolved `{name}` → handle `{send_handle}`");
3685 for s in &steps {
3686 let step = s.get("step").and_then(Value::as_str).unwrap_or("?");
3687 println!(" - {step}");
3688 }
3689 if message.is_some() {
3690 println!(" (use `wire tail {send_handle}` to read replies)");
3691 }
3692 }
3693 if let Some(Err(e)) = send_result {
3694 return Err(e);
3695 }
3696 Ok(())
3697}
3698
3699fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
3705 let resolution = match resolve_name_to_target(name) {
3711 Ok(r) => r,
3712 Err(e) if as_json => {
3713 let pool = known_local_names();
3714 let suggestions = closest_candidates(name, &pool, 3, 3);
3715 println!(
3716 "{}",
3717 serde_json::to_string(&json!({
3718 "name_input": name,
3719 "found": false,
3720 "candidates": suggestions,
3721 "error": format!("{e:#}"),
3722 }))?
3723 );
3724 return Ok(());
3725 }
3726 Err(e) => return Err(e),
3727 };
3728 match resolution {
3729 DialTarget::PinnedPeer {
3730 handle,
3731 did,
3732 nickname,
3733 emoji,
3734 tier,
3735 } => {
3736 if as_json {
3737 println!(
3738 "{}",
3739 serde_json::to_string(&json!({
3740 "kind": "pinned_peer",
3741 "handle": handle,
3742 "did": did,
3743 "nickname": nickname,
3744 "emoji": emoji,
3745 "tier": tier,
3746 }))?
3747 );
3748 } else {
3749 let n = nickname.as_deref().unwrap_or("(no character)");
3750 let e = emoji.as_deref().unwrap_or("?");
3751 println!("{e} {n}");
3752 println!(" handle: {handle}");
3753 println!(" did: {did}");
3754 println!(" tier: {tier}");
3755 println!(" reach: pinned peer (already in trust ring + slot pinned)");
3756 }
3757 }
3758 DialTarget::LocalSister {
3759 session_name,
3760 handle,
3761 did,
3762 nickname,
3763 emoji,
3764 } => {
3765 if as_json {
3766 println!(
3767 "{}",
3768 serde_json::to_string(&json!({
3769 "kind": "local_sister",
3770 "session_name": session_name,
3771 "handle": handle,
3772 "did": did,
3773 "nickname": nickname,
3774 "emoji": emoji,
3775 }))?
3776 );
3777 } else {
3778 let n = nickname.as_deref().unwrap_or("(no character)");
3779 let e = emoji.as_deref().unwrap_or("?");
3780 println!("{e} {n}");
3781 println!(" session: {session_name}");
3782 println!(" handle: {handle}");
3783 println!(
3784 " did: {}",
3785 did.as_deref().unwrap_or("(card unreadable)")
3786 );
3787 println!(" reach: local sister on this machine — `wire dial {n}` pairs us");
3788 }
3789 }
3790 }
3791 Ok(())
3792}
3793
3794enum DialTarget {
3795 PinnedPeer {
3796 handle: String,
3797 did: String,
3798 nickname: Option<String>,
3799 emoji: Option<String>,
3800 tier: String,
3801 },
3802 LocalSister {
3803 session_name: String,
3804 handle: String,
3805 did: Option<String>,
3806 nickname: Option<String>,
3807 emoji: Option<String>,
3808 },
3809}
3810
3811fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
3815 let needle = name.trim();
3816 if needle.is_empty() {
3817 bail!("empty name");
3818 }
3819
3820 if config::is_initialized().unwrap_or(false) {
3823 let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
3824 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
3825 for (handle_key, agent) in agents {
3826 let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3827 if did.is_empty() {
3828 continue;
3829 }
3830 let handle = handle_key.clone();
3831 let character = crate::character::Character::from_did(did);
3832 let tier = agent
3833 .get("tier")
3834 .and_then(Value::as_str)
3835 .unwrap_or("UNKNOWN")
3836 .to_string();
3837 let matches = handle.eq_ignore_ascii_case(needle)
3838 || did.eq_ignore_ascii_case(needle)
3839 || character.nickname.eq_ignore_ascii_case(needle);
3840 if matches {
3841 return Ok(DialTarget::PinnedPeer {
3842 handle,
3843 did: did.to_string(),
3844 nickname: Some(character.nickname),
3845 emoji: Some(character.emoji.to_string()),
3846 tier,
3847 });
3848 }
3849 }
3850 }
3851 }
3852
3853 if let Some(session_name) = crate::session::resolve_local_sister(needle) {
3855 let sessions = crate::session::list_sessions().unwrap_or_default();
3856 let s = sessions.iter().find(|s| s.name == session_name);
3857 if let Some(s) = s {
3858 return Ok(DialTarget::LocalSister {
3859 session_name: s.name.clone(),
3860 handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
3861 did: s.did.clone(),
3862 nickname: s.character.as_ref().map(|c| c.nickname.clone()),
3863 emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
3864 });
3865 }
3866 }
3867
3868 let pool = known_local_names();
3873 let suggestions = closest_candidates(name, &pool, 3, 3);
3874 if suggestions.is_empty() {
3875 bail!(
3876 "no peer matched `{name}`.\n\
3877 Tried: pinned peers (`wire peers`) + local sister sessions \
3878 (`wire session list-local`).\n\
3879 For cross-machine federation: `wire dial <handle>@<relay-domain>`."
3880 );
3881 }
3882 bail!(
3883 "no peer matched `{name}`.\n\
3884 Did you mean: {}?\n\
3885 List all: `wire peers`, `wire session list-local`.",
3886 suggestions
3887 .iter()
3888 .map(|s| format!("`{s}`"))
3889 .collect::<Vec<_>>()
3890 .join(", ")
3891 );
3892}
3893
3894fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize, oldest: bool) -> Result<()> {
3910 let inbox = config::inbox_dir()?;
3911 if !inbox.exists() {
3912 if !as_json {
3913 eprintln!("no inbox yet — daemon hasn't run, or no events received");
3914 }
3915 return Ok(());
3916 }
3917 let trust = config::read_trust()?;
3918
3919 let entries: Vec<_> = std::fs::read_dir(&inbox)?
3920 .filter_map(|e| e.ok())
3921 .map(|e| e.path())
3922 .filter(|p| {
3923 p.extension().map(|x| x == "jsonl").unwrap_or(false)
3924 && match peer {
3925 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
3926 None => true,
3927 }
3928 })
3929 .collect();
3930
3931 let mut events: Vec<(String, usize, Value)> = Vec::new();
3937 for path in &entries {
3938 let body = std::fs::read_to_string(path)?;
3939 for (idx, line) in body.lines().enumerate() {
3940 let event: Value = match serde_json::from_str(line) {
3941 Ok(v) => v,
3942 Err(_) => continue,
3943 };
3944 let ts = event
3945 .get("timestamp")
3946 .and_then(Value::as_str)
3947 .unwrap_or("")
3948 .to_string();
3949 events.push((ts, idx, event));
3950 }
3951 }
3952 events.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
3953
3954 let total = events.len();
3956 let window: &[(String, usize, Value)] = if limit == 0 {
3957 &events[..]
3958 } else if oldest {
3959 &events[..limit.min(total)]
3960 } else {
3961 let start = total.saturating_sub(limit);
3962 &events[start..]
3963 };
3964
3965 for (_, _, event) in window {
3966 let verified = verify_message_v31(event, &trust).is_ok();
3967 if as_json {
3968 let mut event_with_meta = event.clone();
3969 if let Some(obj) = event_with_meta.as_object_mut() {
3970 obj.insert("verified".into(), json!(verified));
3971 }
3972 println!("{}", serde_json::to_string(&event_with_meta)?);
3973 } else {
3974 let ts = event
3975 .get("timestamp")
3976 .and_then(Value::as_str)
3977 .unwrap_or("?");
3978 let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
3979 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
3980 let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
3981 let summary = event
3982 .get("body")
3983 .map(|b| match b {
3984 Value::String(s) => s.clone(),
3985 _ => b.to_string(),
3986 })
3987 .unwrap_or_default();
3988 let mark = if verified { "✓" } else { "✗" };
3989 let deadline = event
3990 .get("time_sensitive_until")
3991 .and_then(Value::as_str)
3992 .map(|d| format!(" deadline: {d}"))
3993 .unwrap_or_default();
3994 println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
3995 }
3996 }
3997 Ok(())
3998}
3999
4000fn monitor_is_noise_kind(kind: &str) -> bool {
4006 matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
4007}
4008
4009fn resolve_persona(peer_handle: &str) -> Option<crate::character::Character> {
4013 let trust = config::read_trust().ok()?;
4014 let agent = trust.get("agents").and_then(|a| a.get(peer_handle))?;
4015 if let Some(card) = agent.get("card") {
4016 Some(crate::character::Character::from_card(card))
4017 } else {
4018 let did = agent.get("did").and_then(Value::as_str)?;
4019 Some(crate::character::Character::from_did(did))
4020 }
4021}
4022
4023fn persona_label(peer_handle: &str) -> String {
4025 match resolve_persona(peer_handle) {
4026 Some(ch) => format!("{} {}", ch.emoji, ch.nickname),
4027 None => peer_handle.to_string(),
4028 }
4029}
4030
4031fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
4039 if as_json {
4040 Ok(serde_json::to_string(e)?)
4041 } else {
4042 let eid_short: String = e.event_id.chars().take(12).collect();
4043 let body = e.body_preview.replace('\n', " ");
4044 let ts: String = e.timestamp.chars().take(19).collect();
4045 Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
4046 }
4047}
4048
4049fn cmd_monitor(
4065 peer_filter: Option<&str>,
4066 as_json: bool,
4067 include_handshake: bool,
4068 interval_ms: u64,
4069 replay: usize,
4070) -> Result<()> {
4071 let inbox_dir = config::inbox_dir()?;
4072 if !inbox_dir.exists() && !as_json {
4073 eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
4074 }
4075 crate::session::warn_on_identity_collision(std::process::id(), "monitor");
4080 if replay > 0 && inbox_dir.exists() {
4086 let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
4087 for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
4088 let path = entry.path();
4089 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
4090 continue;
4091 }
4092 let peer = match path.file_stem().and_then(|s| s.to_str()) {
4093 Some(s) => s.to_string(),
4094 None => continue,
4095 };
4096 if let Some(filter) = peer_filter
4097 && peer != filter
4098 {
4099 continue;
4100 }
4101 let body = std::fs::read_to_string(&path).unwrap_or_default();
4102 for line in body.lines() {
4103 let line = line.trim();
4104 if line.is_empty() {
4105 continue;
4106 }
4107 let signed: Value = match serde_json::from_str(line) {
4108 Ok(v) => v,
4109 Err(_) => continue,
4110 };
4111 let ev = crate::inbox_watch::InboxEvent::from_signed(
4112 &peer, signed, true,
4113 );
4114 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
4115 continue;
4116 }
4117 all.push(ev);
4118 }
4119 }
4120 all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
4123 let start = all.len().saturating_sub(replay);
4124 for ev in &all[start..] {
4125 println!("{}", monitor_render(ev, as_json)?);
4126 }
4127 use std::io::Write;
4128 std::io::stdout().flush().ok();
4129 }
4130
4131 let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
4134 let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
4135
4136 loop {
4137 let events = match w.poll() {
4144 Ok(evs) => evs,
4145 Err(e) => {
4146 eprintln!("wire monitor: poll error (continuing to watch): {e:#}");
4147 std::thread::sleep(sleep_dur);
4148 continue;
4149 }
4150 };
4151 let mut wrote = false;
4152 for ev in events {
4153 if let Some(filter) = peer_filter
4154 && ev.peer != filter
4155 {
4156 continue;
4157 }
4158 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
4159 continue;
4160 }
4161 println!("{}", monitor_render(&ev, as_json)?);
4162 wrote = true;
4163 }
4164 if wrote {
4165 use std::io::Write;
4166 std::io::stdout().flush().ok();
4167 }
4168 std::thread::sleep(sleep_dur);
4169 }
4170}
4171
4172#[cfg(test)]
4173mod tier_tests {
4174 use super::*;
4175 use serde_json::json;
4176
4177 fn trust_with(handle: &str, tier: &str) -> Value {
4178 json!({
4179 "version": 1,
4180 "agents": {
4181 handle: {
4182 "tier": tier,
4183 "did": format!("did:wire:{handle}"),
4184 "card": {"capabilities": ["wire/v3.1"]}
4185 }
4186 }
4187 })
4188 }
4189
4190 #[test]
4191 fn pending_ack_when_verified_but_no_slot_token() {
4192 let trust = trust_with("willard", "VERIFIED");
4196 let relay_state = json!({
4197 "peers": {
4198 "willard": {
4199 "relay_url": "https://relay",
4200 "slot_id": "abc",
4201 "slot_token": "",
4202 }
4203 }
4204 });
4205 assert_eq!(
4206 effective_peer_tier(&trust, &relay_state, "willard"),
4207 "PENDING_ACK"
4208 );
4209 }
4210
4211 #[test]
4212 fn verified_when_slot_token_present() {
4213 let trust = trust_with("willard", "VERIFIED");
4214 let relay_state = json!({
4215 "peers": {
4216 "willard": {
4217 "relay_url": "https://relay",
4218 "slot_id": "abc",
4219 "slot_token": "tok123",
4220 }
4221 }
4222 });
4223 assert_eq!(
4224 effective_peer_tier(&trust, &relay_state, "willard"),
4225 "VERIFIED"
4226 );
4227 }
4228
4229 #[test]
4230 fn raw_tier_passes_through_for_non_verified() {
4231 let trust = trust_with("willard", "UNTRUSTED");
4234 let relay_state = json!({
4235 "peers": {"willard": {"slot_token": ""}}
4236 });
4237 assert_eq!(
4238 effective_peer_tier(&trust, &relay_state, "willard"),
4239 "UNTRUSTED"
4240 );
4241 }
4242
4243 #[test]
4244 fn pending_ack_when_relay_state_missing_peer() {
4245 let trust = trust_with("willard", "VERIFIED");
4249 let relay_state = json!({"peers": {}});
4250 assert_eq!(
4251 effective_peer_tier(&trust, &relay_state, "willard"),
4252 "PENDING_ACK"
4253 );
4254 }
4255}
4256
4257#[cfg(test)]
4258mod monitor_tests {
4259 use super::*;
4260 use crate::inbox_watch::InboxEvent;
4261 use serde_json::Value;
4262
4263 fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
4264 InboxEvent {
4265 peer: peer.to_string(),
4266 event_id: "abcd1234567890ef".to_string(),
4267 kind: kind.to_string(),
4268 body_preview: body.to_string(),
4269 verified: true,
4270 timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
4271 raw: Value::Null,
4272 }
4273 }
4274
4275 #[test]
4276 fn monitor_filter_drops_handshake_kinds_by_default() {
4277 assert!(monitor_is_noise_kind("pair_drop"));
4282 assert!(monitor_is_noise_kind("pair_drop_ack"));
4283 assert!(monitor_is_noise_kind("heartbeat"));
4284
4285 assert!(!monitor_is_noise_kind("claim"));
4287 assert!(!monitor_is_noise_kind("decision"));
4288 assert!(!monitor_is_noise_kind("ack"));
4289 assert!(!monitor_is_noise_kind("request"));
4290 assert!(!monitor_is_noise_kind("note"));
4291 assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
4295 }
4296
4297 #[test]
4298 fn monitor_render_plain_is_one_short_line() {
4299 let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
4300 let line = monitor_render(&e, false).unwrap();
4301 assert!(!line.contains('\n'), "render must be one line: {line}");
4303 assert!(line.contains("willard"));
4305 assert!(line.contains("claim"));
4306 assert!(line.contains("real v8 train"));
4307 assert!(line.contains("abcd12345678"));
4309 assert!(
4310 !line.contains("abcd1234567890ef"),
4311 "should truncate full id"
4312 );
4313 assert!(line.contains("2026-05-15T23:14:07"));
4315 }
4316
4317 #[test]
4318 fn monitor_render_strips_newlines_from_body() {
4319 let e = ev("spark", "claim", "line one\nline two\nline three");
4324 let line = monitor_render(&e, false).unwrap();
4325 assert!(!line.contains('\n'), "newlines must be stripped: {line}");
4326 assert!(line.contains("line one line two line three"));
4327 }
4328
4329 #[test]
4330 fn monitor_render_json_is_valid_jsonl() {
4331 let e = ev("spark", "claim", "hi");
4332 let line = monitor_render(&e, true).unwrap();
4333 assert!(!line.contains('\n'));
4334 let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
4335 assert_eq!(parsed["peer"], "spark");
4336 assert_eq!(parsed["kind"], "claim");
4337 assert_eq!(parsed["body_preview"], "hi");
4338 }
4339
4340 #[test]
4341 fn monitor_does_not_drop_on_verified_null() {
4342 let mut e = ev("spark", "claim", "from disk with verified=null");
4353 e.verified = false; let line = monitor_render(&e, false).unwrap();
4355 assert!(line.contains("from disk with verified=null"));
4356 assert!(!monitor_is_noise_kind("claim"));
4358 }
4359}
4360
4361fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
4364 let body = if path == "-" {
4365 let mut buf = String::new();
4366 use std::io::Read;
4367 std::io::stdin().read_to_string(&mut buf)?;
4368 buf
4369 } else {
4370 std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
4371 };
4372 let event: Value = serde_json::from_str(&body)?;
4373 let trust = config::read_trust()?;
4374 match verify_message_v31(&event, &trust) {
4375 Ok(()) => {
4376 if as_json {
4377 println!("{}", serde_json::to_string(&json!({"verified": true}))?);
4378 } else {
4379 println!("verified ✓");
4380 }
4381 Ok(())
4382 }
4383 Err(e) => {
4384 let reason = e.to_string();
4385 if as_json {
4386 println!(
4387 "{}",
4388 serde_json::to_string(&json!({"verified": false, "reason": reason}))?
4389 );
4390 } else {
4391 eprintln!("FAILED: {reason}");
4392 }
4393 std::process::exit(1);
4394 }
4395 }
4396}
4397
4398fn cmd_mcp() -> Result<()> {
4401 crate::mcp::run()
4402}
4403
4404fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
4405 if let Some(socket_path) = uds {
4410 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4411 std::path::PathBuf::from(home)
4412 .join("state")
4413 .join("wire-relay")
4414 .join("uds")
4415 } else {
4416 dirs::state_dir()
4417 .or_else(dirs::data_local_dir)
4418 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4419 .join("wire-relay")
4420 .join("uds")
4421 };
4422 let runtime = tokio::runtime::Builder::new_multi_thread()
4423 .enable_all()
4424 .build()?;
4425 return runtime.block_on(crate::relay_server::serve_uds(
4426 socket_path.to_path_buf(),
4427 base,
4428 ));
4429 }
4430 if local_only {
4434 validate_loopback_bind(bind)?;
4435 }
4436 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4442 std::path::PathBuf::from(home)
4443 .join("state")
4444 .join("wire-relay")
4445 } else {
4446 dirs::state_dir()
4447 .or_else(dirs::data_local_dir)
4448 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4449 .join("wire-relay")
4450 };
4451 let state_dir = if local_only { base.join("local") } else { base };
4452 let runtime = tokio::runtime::Builder::new_multi_thread()
4453 .enable_all()
4454 .build()?;
4455 runtime.block_on(crate::relay_server::serve_with_mode(
4456 bind,
4457 state_dir,
4458 crate::relay_server::ServerMode { local_only },
4459 ))
4460}
4461
4462fn validate_loopback_bind(bind: &str) -> Result<()> {
4480 let host = if let Some(stripped) = bind.strip_prefix('[') {
4482 let close = stripped
4483 .find(']')
4484 .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
4485 stripped[..close].to_string()
4486 } else {
4487 bind.rsplit_once(':')
4488 .map(|(h, _)| h.to_string())
4489 .unwrap_or_else(|| bind.to_string())
4490 };
4491 use std::net::{IpAddr, ToSocketAddrs};
4492 let probe = format!("{host}:0");
4493 let resolved: Vec<_> = probe
4494 .to_socket_addrs()
4495 .with_context(|| format!("resolving bind host {host:?}"))?
4496 .collect();
4497 if resolved.is_empty() {
4498 bail!("--local-only: bind host {host:?} resolved to no addresses");
4499 }
4500 for addr in &resolved {
4501 let ip = addr.ip();
4502 let is_acceptable = match ip {
4503 IpAddr::V4(v4) => {
4504 v4.is_loopback() || v4.is_private() || {
4505 let octets = v4.octets();
4507 octets[0] == 100 && (64..=127).contains(&octets[1])
4508 }
4509 }
4510 IpAddr::V6(v6) => v6.is_loopback(), };
4512 if !is_acceptable {
4513 bail!(
4514 "--local-only refuses non-private bind: {host:?} resolves to {} \
4515 which is not loopback (127/8, ::1), RFC 1918 private \
4516 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
4517 (100.64.0.0/10). Remove --local-only to bind publicly.",
4518 ip
4519 );
4520 }
4521 }
4522 Ok(())
4523}
4524
4525fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
4528 use crate::endpoints::EndpointScope;
4529 match s.to_lowercase().as_str() {
4530 "federation" | "fed" => Ok(EndpointScope::Federation),
4531 "local" => Ok(EndpointScope::Local),
4532 "lan" => Ok(EndpointScope::Lan),
4533 "uds" => Ok(EndpointScope::Uds),
4534 other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
4535 }
4536}
4537
4538fn cmd_bind_relay(
4544 url: &str,
4545 scope: Option<&str>,
4546 replace: bool,
4547 migrate_pinned: bool,
4548 as_json: bool,
4549) -> Result<()> {
4550 use crate::endpoints::{Endpoint, self_endpoints};
4551
4552 if !config::is_initialized()? {
4553 bail!("not initialized — run `wire init <handle>` first");
4554 }
4555 let card = config::read_agent_card()?;
4556 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4557 let handle = crate::agent_card::display_handle_from_did(did).to_string();
4558
4559 let normalized_raw = url.trim_end_matches('/');
4560 let normalized_owned = strip_relay_url_userinfo(normalized_raw);
4564 let normalized = normalized_owned.as_str();
4565 assert_relay_url_clean_for_publish(normalized)?;
4569 let new_scope = match scope {
4570 Some(s) => parse_scope(s)?,
4571 None => crate::endpoints::infer_scope_from_url(normalized),
4572 };
4573
4574 let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
4575 let pinned: Vec<String> = existing
4576 .get("peers")
4577 .and_then(|p| p.as_object())
4578 .map(|o| o.keys().cloned().collect())
4579 .unwrap_or_default();
4580
4581 let existing_eps = self_endpoints(&existing);
4582 let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
4583
4584 let destructive = replace || is_rebind_same;
4591 if destructive && !pinned.is_empty() && !migrate_pinned {
4592 let list = pinned.join(", ");
4593 let why = if replace {
4594 "`--replace` drops your other slot(s)"
4595 } else {
4596 "re-binding the same relay rotates its slot"
4597 };
4598 bail!(
4599 "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
4600 pinned to your CURRENT slot and would keep pushing to a slot you no longer \
4601 read.\n\n\
4602 SAFE PATHS:\n\
4603 • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
4604 slots — no black-hole.\n\
4605 • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
4606 • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
4607 peer out-of-band.\n\n\
4608 Issue #7 (silent black-hole on relay change) caught this.",
4609 n = pinned.len(),
4610 );
4611 }
4612
4613 let client = crate::relay_client::RelayClient::new(normalized);
4614 client.check_healthz()?;
4615 let alloc = client.allocate_slot(Some(&handle))?;
4616
4617 if destructive && !pinned.is_empty() {
4618 eprintln!(
4619 "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
4620 until they re-pin: {peers}",
4621 mode = if replace { "replacing" } else { "rotating" },
4622 n = pinned.len(),
4623 peers = pinned.join(", "),
4624 );
4625 }
4626
4627 let mut state = existing;
4631 if replace {
4632 state["self"] = Value::Null;
4633 }
4634 crate::endpoints::upsert_self_endpoint(
4635 &mut state,
4636 Endpoint {
4637 relay_url: normalized.to_string(),
4638 slot_id: alloc.slot_id.clone(),
4639 slot_token: alloc.slot_token.clone(),
4640 scope: new_scope,
4641 },
4642 );
4643 config::write_relay_state(&state)?;
4644 let eps = self_endpoints(&state);
4645
4646 let scope_str = format!("{new_scope:?}").to_lowercase();
4647 if as_json {
4648 println!(
4649 "{}",
4650 serde_json::to_string(&json!({
4651 "relay_url": normalized,
4652 "slot_id": alloc.slot_id,
4653 "scope": scope_str,
4654 "endpoints": eps.len(),
4655 "additive": !replace,
4656 "slot_token_present": true,
4657 }))?
4658 );
4659 } else {
4660 println!(
4661 "bound {scope_str} slot on {normalized} (slot {})",
4662 alloc.slot_id
4663 );
4664 println!(
4665 "self now has {n} endpoint(s): {list}",
4666 n = eps.len(),
4667 list = eps
4668 .iter()
4669 .map(|e| format!("{}({:?})", e.relay_url, e.scope))
4670 .collect::<Vec<_>>()
4671 .join(", "),
4672 );
4673 }
4674 Ok(())
4675}
4676
4677fn cmd_add_peer_slot(
4680 handle: &str,
4681 url: &str,
4682 slot_id: &str,
4683 slot_token: &str,
4684 as_json: bool,
4685) -> Result<()> {
4686 use crate::endpoints::{Endpoint, infer_scope_from_url, pin_peer_endpoints};
4687 let mut state = config::read_relay_state()?;
4688
4689 let new_ep = Endpoint {
4696 relay_url: url.to_string(),
4697 slot_id: slot_id.to_string(),
4698 slot_token: slot_token.to_string(),
4699 scope: infer_scope_from_url(url),
4700 };
4701 let mut endpoints: Vec<Endpoint> = state
4702 .get("peers")
4703 .and_then(|p| p.get(handle))
4704 .and_then(|e| e.get("endpoints"))
4705 .and_then(|a| serde_json::from_value::<Vec<Endpoint>>(a.clone()).ok())
4706 .unwrap_or_default();
4707 if endpoints.is_empty()
4709 && let Some(peer) = state.get("peers").and_then(|p| p.get(handle))
4710 && let (Some(ru), Some(si), Some(st)) = (
4711 peer.get("relay_url").and_then(Value::as_str),
4712 peer.get("slot_id").and_then(Value::as_str),
4713 peer.get("slot_token").and_then(Value::as_str),
4714 )
4715 {
4716 endpoints.push(Endpoint {
4717 relay_url: ru.to_string(),
4718 slot_id: si.to_string(),
4719 slot_token: st.to_string(),
4720 scope: infer_scope_from_url(ru),
4721 });
4722 }
4723 if let Some(existing) = endpoints
4725 .iter_mut()
4726 .find(|e| e.relay_url == new_ep.relay_url)
4727 {
4728 *existing = new_ep;
4729 } else {
4730 endpoints.push(new_ep);
4731 }
4732 let n = endpoints.len();
4733 pin_peer_endpoints(&mut state, handle, &endpoints)?;
4734 config::write_relay_state(&state)?;
4735 if as_json {
4736 println!(
4737 "{}",
4738 serde_json::to_string(&json!({
4739 "handle": handle,
4740 "relay_url": url,
4741 "slot_id": slot_id,
4742 "added": true,
4743 "endpoint_count": n,
4744 }))?
4745 );
4746 } else {
4747 println!(
4748 "pinned peer slot for {handle} at {url} ({slot_id}) — peer now has {n} endpoint(s)"
4749 );
4750 }
4751 Ok(())
4752}
4753
4754fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
4757 let mut state = config::read_relay_state()?;
4758 let peers = state["peers"].as_object().cloned().unwrap_or_default();
4759 if peers.is_empty() {
4760 bail!(
4761 "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
4762 );
4763 }
4764 let outbox_dir = config::outbox_dir()?;
4765 if outbox_dir.exists() {
4770 let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
4771 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
4772 let path = entry.path();
4773 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
4774 continue;
4775 }
4776 let stem = match path.file_stem().and_then(|s| s.to_str()) {
4777 Some(s) => s.to_string(),
4778 None => continue,
4779 };
4780 if pinned.contains(&stem) {
4781 continue;
4782 }
4783 let bare = crate::agent_card::bare_handle(&stem);
4786 if pinned.contains(bare) {
4787 eprintln!(
4788 "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
4789 Merge with: `cat {} >> {}` then delete the FQDN file.",
4790 stem,
4791 path.display(),
4792 outbox_dir.join(format!("{bare}.jsonl")).display(),
4793 );
4794 }
4795 }
4796 }
4797 if !outbox_dir.exists() {
4798 if as_json {
4799 println!(
4800 "{}",
4801 serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
4802 );
4803 } else {
4804 println!("phyllis: nothing to dial out — write a message first with `wire send`");
4805 }
4806 return Ok(());
4807 }
4808
4809 let mut pushed = Vec::new();
4810 let mut skipped = Vec::new();
4811
4812 let mut rotated_this_push: std::collections::HashSet<String> = std::collections::HashSet::new();
4817 let mut state_dirty = false;
4820
4821 for (peer_handle, _) in peers.iter() {
4827 if let Some(want) = peer_filter
4828 && peer_handle != want
4829 {
4830 continue;
4831 }
4832 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
4833 if !outbox.exists() {
4834 continue;
4835 }
4836 let mut ordered_endpoints =
4837 crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
4838 if ordered_endpoints.is_empty() {
4839 for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
4843 let event: Value = match serde_json::from_str(line) {
4844 Ok(v) => v,
4845 Err(_) => continue,
4846 };
4847 let event_id = event
4848 .get("event_id")
4849 .and_then(Value::as_str)
4850 .unwrap_or("")
4851 .to_string();
4852 skipped.push(json!({
4853 "peer": peer_handle,
4854 "event_id": event_id,
4855 "reason": "no reachable endpoint pinned for peer",
4856 }));
4857 }
4858 continue;
4859 }
4860 let body = std::fs::read_to_string(&outbox)?;
4861 for line in body.lines() {
4862 let event: Value = match serde_json::from_str(line) {
4863 Ok(v) => v,
4864 Err(_) => continue,
4865 };
4866 let event_id = event
4867 .get("event_id")
4868 .and_then(Value::as_str)
4869 .unwrap_or("")
4870 .to_string();
4871
4872 let last_err: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
4881 match crate::relay_client::try_post_event_with_failover(
4882 &ordered_endpoints,
4883 &event,
4884 |endpoint, ev| {
4885 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4886 match client.post_event(&endpoint.slot_id, &endpoint.slot_token, ev) {
4887 Ok(resp) => Ok(resp),
4888 Err(e) => {
4889 *last_err.borrow_mut() =
4890 Some(crate::relay_client::format_transport_error(&e));
4891 Err(e)
4892 }
4893 }
4894 },
4895 ) {
4896 Ok((endpoint, resp)) => {
4897 if resp.status == "duplicate" {
4898 skipped.push(json!({
4899 "peer": peer_handle,
4900 "event_id": event_id,
4901 "reason": "duplicate",
4902 "endpoint": endpoint.relay_url,
4903 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4904 }));
4905 } else {
4906 pushed.push(json!({
4907 "peer": peer_handle,
4908 "event_id": event_id,
4909 "endpoint": endpoint.relay_url,
4910 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4911 }));
4912 }
4913 }
4914 Err(_) => {
4915 let last_err_text = last_err.borrow().clone().unwrap_or_default();
4925 let mut delivered_via_retry: Option<(crate::endpoints::Endpoint, _)> = None;
4926 match try_reresolve_peer_on_slot_4xx(
4927 &mut state,
4928 peer_handle,
4929 &last_err_text,
4930 &rotated_this_push,
4931 ) {
4932 Ok(true) => {
4933 rotated_this_push.insert(peer_handle.clone());
4935 state_dirty = true;
4936 ordered_endpoints = crate::endpoints::peer_endpoints_in_priority_order(
4941 &state,
4942 peer_handle,
4943 );
4944 *last_err.borrow_mut() = None;
4945 if let Ok((endpoint, resp)) =
4946 crate::relay_client::try_post_event_with_failover(
4947 &ordered_endpoints,
4948 &event,
4949 |endpoint, ev| {
4950 let client = crate::relay_client::RelayClient::new(
4951 &endpoint.relay_url,
4952 );
4953 match client.post_event(
4954 &endpoint.slot_id,
4955 &endpoint.slot_token,
4956 ev,
4957 ) {
4958 Ok(resp) => Ok(resp),
4959 Err(e) => {
4960 *last_err.borrow_mut() = Some(
4961 crate::relay_client::format_transport_error(&e),
4962 );
4963 Err(e)
4964 }
4965 }
4966 },
4967 )
4968 {
4969 delivered_via_retry = Some((endpoint, resp));
4970 }
4971 }
4972 Ok(false) => {
4973 }
4977 Err(e) => {
4978 *last_err.borrow_mut() = Some(format!(
4983 "{}; re-resolve also failed: {e:#}",
4984 last_err.borrow().clone().unwrap_or_default()
4985 ));
4986 rotated_this_push.insert(peer_handle.clone());
4988 }
4989 }
4990 if let Some((endpoint, resp)) = delivered_via_retry {
4991 if resp.status == "duplicate" {
4992 skipped.push(json!({
4993 "peer": peer_handle,
4994 "event_id": event_id,
4995 "reason": "duplicate",
4996 "endpoint": endpoint.relay_url,
4997 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4998 "via": "slot_reresolve_retry",
4999 }));
5000 } else {
5001 pushed.push(json!({
5002 "peer": peer_handle,
5003 "event_id": event_id,
5004 "endpoint": endpoint.relay_url,
5005 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5006 "via": "slot_reresolve_retry",
5007 }));
5008 }
5009 } else {
5010 skipped.push(json!({
5015 "peer": peer_handle,
5016 "event_id": event_id,
5017 "reason": last_err
5018 .borrow()
5019 .clone()
5020 .unwrap_or_else(|| "all endpoints failed".to_string()),
5021 }));
5022 }
5023 }
5024 }
5025 }
5026 }
5027
5028 if state_dirty && let Err(e) = config::write_relay_state(&state) {
5033 eprintln!(
5034 "wire push: WARN failed to persist rotated peer slots: {e:#}. \
5035 Slot rotation will be re-attempted on next push."
5036 );
5037 }
5038
5039 if as_json {
5040 println!(
5041 "{}",
5042 serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
5043 );
5044 } else {
5045 println!(
5046 "pushed {} event(s); skipped {} ({})",
5047 pushed.len(),
5048 skipped.len(),
5049 if skipped.is_empty() {
5050 "none"
5051 } else {
5052 "see --json for detail"
5053 }
5054 );
5055 }
5056 Ok(())
5057}
5058
5059fn cmd_pull(as_json: bool) -> Result<()> {
5062 let state = config::read_relay_state()?;
5063 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
5064 if self_state.is_null() {
5065 bail!("self slot not bound — run `wire bind-relay <url>` first");
5066 }
5067
5068 let endpoints = crate::endpoints::self_endpoints(&state);
5077 if endpoints.is_empty() {
5078 bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
5079 }
5080
5081 let inbox_dir = config::inbox_dir()?;
5082 config::ensure_dirs()?;
5083
5084 let mut total_seen = 0usize;
5085 let mut all_written: Vec<Value> = Vec::new();
5086 let mut all_rejected: Vec<Value> = Vec::new();
5087 let mut all_blocked = false;
5088 let mut all_advance_cursor_to: Option<String> = None;
5089
5090 for endpoint in &endpoints {
5091 let cursor_key = endpoint_cursor_key(endpoint.scope);
5092 let last_event_id = self_state
5093 .get(&cursor_key)
5094 .and_then(Value::as_str)
5095 .map(str::to_string);
5096 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
5097 let events = match client.list_events(
5098 &endpoint.slot_id,
5099 &endpoint.slot_token,
5100 last_event_id.as_deref(),
5101 Some(1000),
5102 ) {
5103 Ok(ev) => ev,
5104 Err(e) => {
5105 eprintln!(
5109 "wire pull: endpoint {} ({:?}) errored: {}; continuing",
5110 endpoint.relay_url,
5111 endpoint.scope,
5112 crate::relay_client::format_transport_error(&e),
5113 );
5114 continue;
5115 }
5116 };
5117 total_seen += events.len();
5118 let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
5119 all_written.extend(result.written.iter().cloned());
5120 all_rejected.extend(result.rejected.iter().cloned());
5121 if result.blocked {
5122 all_blocked = true;
5123 }
5124 if let Some(eid) = result.advance_cursor_to.clone() {
5127 if endpoint.scope == crate::endpoints::EndpointScope::Federation {
5128 all_advance_cursor_to = Some(eid.clone());
5129 }
5130 let key = cursor_key.clone();
5131 config::update_relay_state(|state| {
5132 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5133 self_obj.insert(key, Value::String(eid));
5134 }
5135 Ok(())
5136 })?;
5137 }
5138 }
5139
5140 let result = crate::pull::PullResult {
5145 written: all_written,
5146 rejected: all_rejected,
5147 blocked: all_blocked,
5148 advance_cursor_to: all_advance_cursor_to,
5149 };
5150 let events_len = total_seen;
5151
5152 if as_json {
5156 println!(
5157 "{}",
5158 serde_json::to_string(&json!({
5159 "written": result.written,
5160 "rejected": result.rejected,
5161 "total_seen": events_len,
5162 "cursor_blocked": result.blocked,
5163 "cursor_advanced_to": result.advance_cursor_to,
5164 }))?
5165 );
5166 } else {
5167 let blocking = result
5168 .rejected
5169 .iter()
5170 .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
5171 .count();
5172 if blocking > 0 {
5173 println!(
5174 "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
5175 events_len,
5176 result.written.len(),
5177 result.rejected.len(),
5178 blocking,
5179 );
5180 } else {
5181 println!(
5182 "pulled {} event(s); wrote {}; rejected {}",
5183 events_len,
5184 result.written.len(),
5185 result.rejected.len(),
5186 );
5187 }
5188 }
5189 Ok(())
5190}
5191
5192fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
5197 match scope {
5198 crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
5199 crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
5200 crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
5201 crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
5202 }
5203}
5204
5205fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
5208 if !config::is_initialized()? {
5209 bail!("not initialized — run `wire init <handle>` first");
5210 }
5211 let mut state = config::read_relay_state()?;
5212 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
5213 if self_state.is_null() {
5214 bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
5215 }
5216 let primary = crate::endpoints::self_primary_endpoint(&state)
5220 .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
5221 let url = primary.relay_url.clone();
5222 let old_slot_id = primary.slot_id.clone();
5223 let old_slot_token = primary.slot_token.clone();
5224
5225 let card = config::read_agent_card()?;
5227 let did = card
5228 .get("did")
5229 .and_then(Value::as_str)
5230 .unwrap_or("")
5231 .to_string();
5232 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
5233 let pk_b64 = card
5234 .get("verify_keys")
5235 .and_then(Value::as_object)
5236 .and_then(|m| m.values().next())
5237 .and_then(|v| v.get("key"))
5238 .and_then(Value::as_str)
5239 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
5240 .to_string();
5241 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
5242 let sk_seed = config::read_private_key()?;
5243
5244 let normalized = url.trim_end_matches('/').to_string();
5246 let client = crate::relay_client::RelayClient::new(&normalized);
5247 client
5248 .check_healthz()
5249 .context("aborting rotation; old slot still valid")?;
5250 let alloc = client.allocate_slot(Some(&handle))?;
5251 let new_slot_id = alloc.slot_id.clone();
5252 let new_slot_token = alloc.slot_token.clone();
5253
5254 let mut announced: Vec<String> = Vec::new();
5261 if !no_announce {
5262 let now = time::OffsetDateTime::now_utc()
5263 .format(&time::format_description::well_known::Rfc3339)
5264 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
5265 let body = json!({
5266 "reason": "operator-initiated slot rotation",
5267 "new_relay_url": url,
5268 "new_slot_id": new_slot_id,
5269 });
5273 let peers = state["peers"].as_object().cloned().unwrap_or_default();
5274 for (peer_handle, _peer_info) in peers.iter() {
5275 let event = json!({
5276 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
5277 "timestamp": now.clone(),
5278 "from": did,
5279 "to": format!("did:wire:{peer_handle}"),
5280 "type": "wire_close",
5281 "kind": 1201,
5282 "body": body.clone(),
5283 });
5284 let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
5285 Ok(s) => s,
5286 Err(e) => {
5287 eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
5288 continue;
5289 }
5290 };
5291 let peer_info = match state["peers"].get(peer_handle) {
5296 Some(p) => p.clone(),
5297 None => continue,
5298 };
5299 let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
5300 let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
5301 let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
5302 if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
5303 continue;
5304 }
5305 let peer_client = if peer_url == url {
5306 client.clone()
5307 } else {
5308 crate::relay_client::RelayClient::new(peer_url)
5309 };
5310 match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
5311 Ok(_) => announced.push(peer_handle.clone()),
5312 Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
5313 }
5314 }
5315 }
5316
5317 state["self"] = json!({
5319 "relay_url": url,
5320 "slot_id": new_slot_id,
5321 "slot_token": new_slot_token,
5322 });
5323 config::write_relay_state(&state)?;
5324
5325 if as_json {
5326 println!(
5327 "{}",
5328 serde_json::to_string(&json!({
5329 "rotated": true,
5330 "old_slot_id": old_slot_id,
5331 "new_slot_id": new_slot_id,
5332 "relay_url": url,
5333 "announced_to": announced,
5334 }))?
5335 );
5336 } else {
5337 println!("rotated slot on {url}");
5338 println!(
5339 " old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
5340 );
5341 println!(" new slot_id: {new_slot_id}");
5342 if !announced.is_empty() {
5343 println!(
5344 " announced wire_close (kind=1201) to: {}",
5345 announced.join(", ")
5346 );
5347 }
5348 println!();
5349 println!("next steps:");
5350 println!(" - peers see the wire_close event in their next `wire pull`");
5351 println!(
5352 " - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
5353 );
5354 println!(" (or full re-pair via `wire pair-host`/`wire join`)");
5355 println!(" - until they do, you'll receive but they won't be able to reach you");
5356 let _ = old_slot_token;
5358 }
5359 Ok(())
5360}
5361
5362fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
5365 let mut trust = config::read_trust()?;
5366 let mut removed_from_trust = false;
5367 if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
5368 && agents.remove(handle).is_some()
5369 {
5370 removed_from_trust = true;
5371 }
5372 config::write_trust(&trust)?;
5373
5374 let mut state = config::read_relay_state()?;
5375 let mut removed_from_relay = false;
5376 if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
5377 && peers.remove(handle).is_some()
5378 {
5379 removed_from_relay = true;
5380 }
5381 config::write_relay_state(&state)?;
5382
5383 let mut purged: Vec<String> = Vec::new();
5384 if purge {
5385 for dir in [config::inbox_dir()?, config::outbox_dir()?] {
5386 let path = dir.join(format!("{handle}.jsonl"));
5387 if path.exists() {
5388 std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
5389 purged.push(path.to_string_lossy().into());
5390 }
5391 }
5392 }
5393
5394 if !removed_from_trust && !removed_from_relay {
5395 if as_json {
5396 println!(
5397 "{}",
5398 serde_json::to_string(&json!({
5399 "removed": false,
5400 "reason": format!("peer {handle:?} not pinned"),
5401 }))?
5402 );
5403 } else {
5404 eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
5405 }
5406 return Ok(());
5407 }
5408
5409 if as_json {
5410 println!(
5411 "{}",
5412 serde_json::to_string(&json!({
5413 "handle": handle,
5414 "removed_from_trust": removed_from_trust,
5415 "removed_from_relay_state": removed_from_relay,
5416 "purged_files": purged,
5417 }))?
5418 );
5419 } else {
5420 println!("forgot peer {handle:?}");
5421 if removed_from_trust {
5422 println!(" - removed from trust.json");
5423 }
5424 if removed_from_relay {
5425 println!(" - removed from relay.json");
5426 }
5427 if !purged.is_empty() {
5428 for p in &purged {
5429 println!(" - deleted {p}");
5430 }
5431 } else if !purge {
5432 println!(" (inbox/outbox files preserved; pass --purge to delete them)");
5433 }
5434 }
5435 Ok(())
5436}
5437
5438fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
5441 if !config::is_initialized()? {
5442 bail!("not initialized — run `wire init <handle>` first");
5443 }
5444 if !once {
5449 crate::session::warn_on_identity_collision(std::process::id(), "daemon");
5450 }
5451 let interval = std::time::Duration::from_secs(interval_secs.max(1));
5452
5453 if !as_json {
5454 if once {
5455 eprintln!("wire daemon: single sync cycle, then exit");
5456 } else {
5457 eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
5458 }
5459 }
5460
5461 if let Err(e) = crate::pending_pair::cleanup_on_startup() {
5465 eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
5466 }
5467
5468 let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
5474 if !once {
5475 crate::daemon_stream::spawn_stream_subscriber(wake_tx);
5476 }
5477
5478 loop {
5479 let pushed = run_sync_push().unwrap_or_else(|e| {
5480 eprintln!("daemon: push error: {e:#}");
5481 json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
5482 });
5483 let pulled = run_sync_pull().unwrap_or_else(|e| {
5484 eprintln!("daemon: pull error: {e:#}");
5485 json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
5486 });
5487 let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
5488 eprintln!("daemon: pending-pair tick error: {e:#}");
5489 json!({"transitions": []})
5490 });
5491
5492 if as_json {
5493 println!(
5494 "{}",
5495 serde_json::to_string(&json!({
5496 "ts": time::OffsetDateTime::now_utc()
5497 .format(&time::format_description::well_known::Rfc3339)
5498 .unwrap_or_default(),
5499 "push": pushed,
5500 "pull": pulled,
5501 "pairs": pairs,
5502 }))?
5503 );
5504 } else {
5505 let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
5506 let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
5507 let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
5508 let pair_transitions = pairs["transitions"]
5509 .as_array()
5510 .map(|a| a.len())
5511 .unwrap_or(0);
5512 if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
5513 eprintln!(
5514 "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
5515 );
5516 }
5517 if let Some(arr) = pairs["transitions"].as_array() {
5519 for t in arr {
5520 eprintln!(
5521 " pair {} : {} → {}",
5522 t.get("code").and_then(Value::as_str).unwrap_or("?"),
5523 t.get("from").and_then(Value::as_str).unwrap_or("?"),
5524 t.get("to").and_then(Value::as_str).unwrap_or("?")
5525 );
5526 if let Some(sas) = t.get("sas").and_then(Value::as_str)
5527 && t.get("to").and_then(Value::as_str) == Some("sas_ready")
5528 {
5529 eprintln!(" SAS digits: {}-{}", &sas[..3], &sas[3..]);
5530 eprintln!(
5531 " Run: wire pair-confirm {} {}",
5532 t.get("code").and_then(Value::as_str).unwrap_or("?"),
5533 sas
5534 );
5535 }
5536 }
5537 }
5538 }
5539
5540 if once {
5541 return Ok(());
5542 }
5543 match wake_rx.recv_timeout(interval) {
5556 Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
5557 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
5558 std::thread::sleep(interval);
5559 }
5560 }
5561 while wake_rx.try_recv().is_ok() {}
5562 }
5563}
5564
5565fn run_sync_push() -> Result<Value> {
5568 let state = config::read_relay_state()?;
5569 let peers = state["peers"].as_object().cloned().unwrap_or_default();
5570 if peers.is_empty() {
5571 return Ok(json!({"pushed": [], "skipped": []}));
5572 }
5573 let outbox_dir = config::outbox_dir()?;
5574 if !outbox_dir.exists() {
5575 return Ok(json!({"pushed": [], "skipped": []}));
5576 }
5577 let mut pushed = Vec::new();
5578 let mut skipped = Vec::new();
5579 for (peer_handle, slot_info) in peers.iter() {
5580 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5581 if !outbox.exists() {
5582 continue;
5583 }
5584 let url = slot_info["relay_url"].as_str().unwrap_or("");
5585 let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
5586 let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
5587 if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
5588 continue;
5589 }
5590 let client = crate::relay_client::RelayClient::new(url);
5591 let body = std::fs::read_to_string(&outbox)?;
5592 for line in body.lines() {
5593 let event: Value = match serde_json::from_str(line) {
5594 Ok(v) => v,
5595 Err(_) => continue,
5596 };
5597 let event_id = event
5598 .get("event_id")
5599 .and_then(Value::as_str)
5600 .unwrap_or("")
5601 .to_string();
5602 match client.post_event(slot_id, slot_token, &event) {
5603 Ok(resp) => {
5604 if resp.status == "duplicate" {
5605 skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
5606 } else {
5607 pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
5608 }
5609 }
5610 Err(e) => {
5611 let reason = crate::relay_client::format_transport_error(&e);
5615 skipped
5616 .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
5617 }
5618 }
5619 }
5620 }
5621 Ok(json!({"pushed": pushed, "skipped": skipped}))
5622}
5623
5624fn run_sync_pull() -> Result<Value> {
5632 let state = config::read_relay_state()?;
5633 if state.get("self").map(Value::is_null).unwrap_or(true) {
5634 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5635 }
5636 let endpoints = crate::endpoints::self_endpoints(&state);
5643 if endpoints.is_empty() {
5644 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5645 }
5646 let inbox_dir = config::inbox_dir()?;
5647 config::ensure_dirs()?;
5648
5649 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
5654 let legacy_cursor = self_obj
5655 .get("last_pulled_event_id")
5656 .and_then(Value::as_str)
5657 .map(str::to_string);
5658 let primary_slot = crate::endpoints::self_primary_endpoint(&state).map(|e| e.slot_id);
5659 let mut cursors: serde_json::Map<String, Value> = self_obj
5660 .get("cursors")
5661 .and_then(Value::as_object)
5662 .cloned()
5663 .unwrap_or_default();
5664
5665 let mut all_written: Vec<Value> = Vec::new();
5666 let mut all_rejected: Vec<Value> = Vec::new();
5667 let mut total_seen = 0usize;
5668 let mut blocked_any = false;
5669
5670 for ep in &endpoints {
5671 if ep.relay_url.is_empty() {
5672 continue;
5673 }
5674 let cursor = cursors
5675 .get(&ep.slot_id)
5676 .and_then(Value::as_str)
5677 .map(str::to_string)
5678 .or_else(|| {
5679 if Some(&ep.slot_id) == primary_slot.as_ref() {
5680 legacy_cursor.clone()
5681 } else {
5682 None
5683 }
5684 });
5685 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
5686 let events =
5689 match client.list_events(&ep.slot_id, &ep.slot_token, cursor.as_deref(), Some(1000)) {
5690 Ok(e) => e,
5691 Err(e) => {
5692 eprintln!(
5693 "daemon: pull error on {} slot {} (continuing): {e:#}",
5694 ep.relay_url, ep.slot_id
5695 );
5696 continue;
5697 }
5698 };
5699 total_seen += events.len();
5700 let result = crate::pull::process_events(&events, cursor, &inbox_dir)?;
5703 if let Some(eid) = &result.advance_cursor_to {
5704 cursors.insert(ep.slot_id.clone(), Value::String(eid.clone()));
5705 }
5706 blocked_any |= result.blocked;
5707 all_written.extend(result.written);
5708 all_rejected.extend(result.rejected);
5709 }
5710
5711 let primary_cursor = primary_slot
5715 .as_ref()
5716 .and_then(|s| cursors.get(s))
5717 .and_then(Value::as_str)
5718 .map(str::to_string);
5719 config::update_relay_state(|state| {
5720 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5721 self_obj.insert("cursors".into(), Value::Object(cursors.clone()));
5722 if let Some(pc) = &primary_cursor {
5723 self_obj.insert("last_pulled_event_id".into(), Value::String(pc.clone()));
5724 }
5725 }
5726 Ok(())
5727 })?;
5728
5729 Ok(json!({
5730 "written": all_written,
5731 "rejected": all_rejected,
5732 "total_seen": total_seen,
5733 "cursor_blocked": blocked_any,
5734 "endpoints_pulled": endpoints.len(),
5735 }))
5736}
5737
5738fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
5741 let body =
5742 std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
5743 let card: Value =
5744 serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
5745 crate::agent_card::verify_agent_card(&card)
5746 .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
5747
5748 let mut trust = config::read_trust()?;
5749 crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
5750
5751 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
5752 let handle = crate::agent_card::display_handle_from_did(did).to_string();
5753 config::write_trust(&trust)?;
5754
5755 if as_json {
5756 println!(
5757 "{}",
5758 serde_json::to_string(&json!({
5759 "handle": handle,
5760 "did": did,
5761 "tier": "VERIFIED",
5762 "pinned": true,
5763 }))?
5764 );
5765 } else {
5766 println!("pinned {handle} ({did}) at tier VERIFIED");
5767 }
5768 Ok(())
5769}
5770
5771fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
5774 pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
5775}
5776
5777fn cmd_pair_join(
5778 code_phrase: &str,
5779 relay_url: &str,
5780 auto_yes: bool,
5781 timeout_secs: u64,
5782) -> Result<()> {
5783 pair_orchestrate(
5784 relay_url,
5785 Some(code_phrase),
5786 "guest",
5787 auto_yes,
5788 timeout_secs,
5789 )
5790}
5791
5792fn pair_orchestrate(
5798 relay_url: &str,
5799 code_in: Option<&str>,
5800 role: &str,
5801 auto_yes: bool,
5802 timeout_secs: u64,
5803) -> Result<()> {
5804 use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
5805
5806 let mut s = pair_session_open(role, relay_url, code_in)?;
5807
5808 if role == "host" {
5809 eprintln!();
5810 eprintln!("share this code phrase with your peer:");
5811 eprintln!();
5812 eprintln!(" {}", s.code);
5813 eprintln!();
5814 eprintln!(
5815 "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
5816 s.code
5817 );
5818 } else {
5819 eprintln!();
5820 eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
5821 }
5822
5823 const HEARTBEAT_SECS: u64 = 10;
5828 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5829 let started = std::time::Instant::now();
5830 let mut last_heartbeat = started;
5831 let formatted = loop {
5832 if let Some(sas) = pair_session_try_sas(&mut s)? {
5833 break sas;
5834 }
5835 let now = std::time::Instant::now();
5836 if now >= deadline {
5837 return Err(anyhow!(
5838 "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
5839 ));
5840 }
5841 if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
5842 let elapsed = now.duration_since(started).as_secs();
5843 eprintln!(" ... still waiting ({elapsed}s / {timeout_secs}s)");
5844 last_heartbeat = now;
5845 }
5846 std::thread::sleep(std::time::Duration::from_millis(250));
5847 };
5848
5849 eprintln!();
5850 eprintln!("SAS digits (must match peer's terminal):");
5851 eprintln!();
5852 eprintln!(" {formatted}");
5853 eprintln!();
5854
5855 if !auto_yes {
5858 eprint!("does this match your peer's terminal? [y/N]: ");
5859 use std::io::Write;
5860 std::io::stderr().flush().ok();
5861 let mut input = String::new();
5862 std::io::stdin().read_line(&mut input)?;
5863 let trimmed = input.trim().to_lowercase();
5864 if trimmed != "y" && trimmed != "yes" {
5865 bail!("SAS confirmation declined — aborting pairing");
5866 }
5867 }
5868 s.sas_confirmed = true;
5869
5870 let result = pair_session_finalize(&mut s, timeout_secs)?;
5872
5873 let peer_did = result["paired_with"].as_str().unwrap_or("");
5874 let peer_role = if role == "host" { "guest" } else { "host" };
5875 eprintln!("paired with {peer_did} (peer role: {peer_role})");
5876 eprintln!("peer card pinned at tier VERIFIED");
5877 eprintln!(
5878 "peer relay slot saved to {}",
5879 config::relay_state_path()?.display()
5880 );
5881
5882 println!("{}", serde_json::to_string(&result)?);
5883 Ok(())
5884}
5885
5886fn cmd_pair(
5892 handle: &str,
5893 code: Option<&str>,
5894 relay: &str,
5895 auto_yes: bool,
5896 timeout_secs: u64,
5897 no_setup: bool,
5898) -> Result<()> {
5899 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5902 let did = init_result
5903 .get("did")
5904 .and_then(|v| v.as_str())
5905 .unwrap_or("(unknown)")
5906 .to_string();
5907 let already = init_result
5908 .get("already_initialized")
5909 .and_then(|v| v.as_bool())
5910 .unwrap_or(false);
5911 if already {
5912 println!("(identity {did} already initialized — reusing)");
5913 } else {
5914 println!("initialized {did}");
5915 }
5916 println!();
5917
5918 match code {
5920 None => {
5921 println!("hosting pair on {relay} (no code = host) ...");
5922 cmd_pair_host(relay, auto_yes, timeout_secs)?;
5923 }
5924 Some(c) => {
5925 println!("joining pair with code {c} on {relay} ...");
5926 cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
5927 }
5928 }
5929
5930 if !no_setup {
5932 println!();
5933 println!("registering wire as MCP server in detected client configs ...");
5934 if let Err(e) = cmd_setup(true) {
5935 eprintln!("warn: setup --apply failed: {e}");
5937 eprintln!(" pair succeeded; you can re-run `wire setup --apply` manually.");
5938 }
5939 }
5940
5941 println!();
5942 println!("pair complete. Next steps:");
5943 println!(" wire daemon start # background sync of inbox/outbox vs relay");
5944 println!(" wire send <peer> claim <msg> # send your peer something");
5945 println!(" wire tail # watch incoming events");
5946 Ok(())
5947}
5948
5949fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
5955 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5956 let did = init_result
5957 .get("did")
5958 .and_then(|v| v.as_str())
5959 .unwrap_or("(unknown)")
5960 .to_string();
5961 let already = init_result
5962 .get("already_initialized")
5963 .and_then(|v| v.as_bool())
5964 .unwrap_or(false);
5965 if already {
5966 println!("(identity {did} already initialized — reusing)");
5967 } else {
5968 println!("initialized {did}");
5969 }
5970 println!();
5971 match code {
5972 None => cmd_pair_host_detach(relay, false),
5973 Some(c) => cmd_pair_join_detach(c, relay, false),
5974 }
5975}
5976
5977fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
5978 if !config::is_initialized()? {
5979 bail!("not initialized — run `wire init <handle>` first");
5980 }
5981 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5982 Ok(b) => b,
5983 Err(e) => {
5984 if !as_json {
5985 eprintln!(
5986 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5987 );
5988 }
5989 false
5990 }
5991 };
5992 let code = crate::sas::generate_code_phrase();
5993 let code_hash = crate::pair_session::derive_code_hash(&code);
5994 let now = time::OffsetDateTime::now_utc()
5995 .format(&time::format_description::well_known::Rfc3339)
5996 .unwrap_or_default();
5997 let p = crate::pending_pair::PendingPair {
5998 code: code.clone(),
5999 code_hash,
6000 role: "host".to_string(),
6001 relay_url: relay_url.to_string(),
6002 status: "request_host".to_string(),
6003 sas: None,
6004 peer_did: None,
6005 created_at: now,
6006 last_error: None,
6007 pair_id: None,
6008 our_slot_id: None,
6009 our_slot_token: None,
6010 spake2_seed_b64: None,
6011 };
6012 crate::pending_pair::write_pending(&p)?;
6013 if as_json {
6014 println!(
6015 "{}",
6016 serde_json::to_string(&json!({
6017 "state": "queued",
6018 "code_phrase": code,
6019 "relay_url": relay_url,
6020 "role": "host",
6021 "daemon_spawned": daemon_spawned,
6022 }))?
6023 );
6024 } else {
6025 if daemon_spawned {
6026 println!("(started wire daemon in background)");
6027 }
6028 println!("detached pair-host queued. Share this code with your peer:\n");
6029 println!(" {code}\n");
6030 println!("Next steps:");
6031 println!(" wire pair-list # check status");
6032 println!(" wire pair-confirm {code} <digits> # when SAS shows up");
6033 println!(" wire pair-cancel {code} # to abort");
6034 }
6035 Ok(())
6036}
6037
6038fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
6039 if !config::is_initialized()? {
6040 bail!("not initialized — run `wire init <handle>` first");
6041 }
6042 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
6043 Ok(b) => b,
6044 Err(e) => {
6045 if !as_json {
6046 eprintln!(
6047 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
6048 );
6049 }
6050 false
6051 }
6052 };
6053 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6054 let code_hash = crate::pair_session::derive_code_hash(&code);
6055 let now = time::OffsetDateTime::now_utc()
6056 .format(&time::format_description::well_known::Rfc3339)
6057 .unwrap_or_default();
6058 let p = crate::pending_pair::PendingPair {
6059 code: code.clone(),
6060 code_hash,
6061 role: "guest".to_string(),
6062 relay_url: relay_url.to_string(),
6063 status: "request_guest".to_string(),
6064 sas: None,
6065 peer_did: None,
6066 created_at: now,
6067 last_error: None,
6068 pair_id: None,
6069 our_slot_id: None,
6070 our_slot_token: None,
6071 spake2_seed_b64: None,
6072 };
6073 crate::pending_pair::write_pending(&p)?;
6074 if as_json {
6075 println!(
6076 "{}",
6077 serde_json::to_string(&json!({
6078 "state": "queued",
6079 "code_phrase": code,
6080 "relay_url": relay_url,
6081 "role": "guest",
6082 "daemon_spawned": daemon_spawned,
6083 }))?
6084 );
6085 } else {
6086 if daemon_spawned {
6087 println!("(started wire daemon in background)");
6088 }
6089 println!("detached pair-join queued for code {code}.");
6090 println!(
6091 "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
6092 );
6093 }
6094 Ok(())
6095}
6096
6097fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
6098 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6099 let typed: String = typed_digits
6100 .chars()
6101 .filter(|c| c.is_ascii_digit())
6102 .collect();
6103 if typed.len() != 6 {
6104 bail!(
6105 "expected 6 digits (got {} after stripping non-digits)",
6106 typed.len()
6107 );
6108 }
6109 let mut p = crate::pending_pair::read_pending(&code)?
6110 .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
6111 if p.status != "sas_ready" {
6112 bail!(
6113 "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
6114 p.status
6115 );
6116 }
6117 let stored = p
6118 .sas
6119 .as_ref()
6120 .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
6121 .clone();
6122 if stored == typed {
6123 p.status = "confirmed".to_string();
6124 crate::pending_pair::write_pending(&p)?;
6125 if as_json {
6126 println!(
6127 "{}",
6128 serde_json::to_string(&json!({
6129 "state": "confirmed",
6130 "code_phrase": code,
6131 }))?
6132 );
6133 } else {
6134 println!("digits match. Daemon will finalize the handshake on its next tick.");
6135 println!("Run `wire peers` after a few seconds to confirm.");
6136 }
6137 } else {
6138 p.status = "aborted".to_string();
6139 p.last_error = Some(format!(
6140 "SAS digit mismatch (typed {typed}, expected {stored})"
6141 ));
6142 let client = crate::relay_client::RelayClient::new(&p.relay_url);
6143 let _ = client.pair_abandon(&p.code_hash);
6144 crate::pending_pair::write_pending(&p)?;
6145 crate::os_notify::toast(
6146 &format!("wire — pair aborted ({})", p.code),
6147 p.last_error.as_deref().unwrap_or("digits mismatch"),
6148 );
6149 if as_json {
6150 println!(
6151 "{}",
6152 serde_json::to_string(&json!({
6153 "state": "aborted",
6154 "code_phrase": code,
6155 "error": "digits mismatch",
6156 }))?
6157 );
6158 }
6159 bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
6160 }
6161 Ok(())
6162}
6163
6164fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
6165 if watch {
6166 return cmd_pair_list_watch(watch_interval_secs);
6167 }
6168 let spake2_items = crate::pending_pair::list_pending()?;
6169 let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
6170 if as_json {
6171 println!("{}", serde_json::to_string(&spake2_items)?);
6176 return Ok(());
6177 }
6178 if spake2_items.is_empty() && inbound_items.is_empty() {
6179 println!("no pending pair sessions.");
6180 return Ok(());
6181 }
6182 if !inbound_items.is_empty() {
6185 println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
6186 println!(
6187 "{:<20} {:<35} {:<25} NEXT STEP",
6188 "PEER", "RELAY", "RECEIVED"
6189 );
6190 for p in &inbound_items {
6191 println!(
6192 "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
6193 p.peer_handle,
6194 p.peer_relay_url,
6195 p.received_at,
6196 peer = p.peer_handle,
6197 );
6198 }
6199 println!();
6200 }
6201 if !spake2_items.is_empty() {
6202 println!("SPAKE2 SESSIONS");
6203 println!(
6204 "{:<15} {:<8} {:<18} {:<10} NOTE",
6205 "CODE", "ROLE", "STATUS", "SAS"
6206 );
6207 for p in spake2_items {
6208 let sas = p
6209 .sas
6210 .as_ref()
6211 .map(|d| format!("{}-{}", &d[..3], &d[3..]))
6212 .unwrap_or_else(|| "—".to_string());
6213 let note = p
6214 .last_error
6215 .as_deref()
6216 .or(p.peer_did.as_deref())
6217 .unwrap_or("");
6218 println!(
6219 "{:<15} {:<8} {:<18} {:<10} {}",
6220 p.code, p.role, p.status, sas, note
6221 );
6222 }
6223 }
6224 Ok(())
6225}
6226
6227fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
6239 use std::collections::HashMap;
6240 use std::io::Write;
6241 let interval = std::time::Duration::from_secs(interval_secs.max(1));
6242 let mut prev: HashMap<String, String> = HashMap::new();
6245 {
6246 let items = crate::pending_pair::list_pending()?;
6247 for p in &items {
6248 println!("{}", serde_json::to_string(&p)?);
6249 prev.insert(p.code.clone(), p.status.clone());
6250 }
6251 let _ = std::io::stdout().flush();
6253 }
6254 loop {
6255 std::thread::sleep(interval);
6256 let items = match crate::pending_pair::list_pending() {
6257 Ok(v) => v,
6258 Err(_) => continue,
6259 };
6260 let mut cur: HashMap<String, String> = HashMap::new();
6261 for p in &items {
6262 cur.insert(p.code.clone(), p.status.clone());
6263 match prev.get(&p.code) {
6264 None => {
6265 println!("{}", serde_json::to_string(&p)?);
6267 }
6268 Some(prev_status) if prev_status != &p.status => {
6269 println!("{}", serde_json::to_string(&p)?);
6271 }
6272 _ => {}
6273 }
6274 }
6275 for code in prev.keys() {
6276 if !cur.contains_key(code) {
6277 println!(
6280 "{}",
6281 serde_json::to_string(&json!({
6282 "code": code,
6283 "status": "removed",
6284 "_synthetic": true,
6285 }))?
6286 );
6287 }
6288 }
6289 let _ = std::io::stdout().flush();
6290 prev = cur;
6291 }
6292}
6293
6294fn cmd_pair_watch(
6298 code_phrase: &str,
6299 target_status: &str,
6300 timeout_secs: u64,
6301 as_json: bool,
6302) -> Result<()> {
6303 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6304 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
6305 let mut last_seen_status: Option<String> = None;
6306 loop {
6307 let p_opt = crate::pending_pair::read_pending(&code)?;
6308 let now = std::time::Instant::now();
6309 match p_opt {
6310 None => {
6311 if last_seen_status.is_some() {
6315 if as_json {
6316 println!(
6317 "{}",
6318 serde_json::to_string(&json!({"state": "finalized", "code": code}))?
6319 );
6320 } else {
6321 println!("pair {code} finalized (file removed)");
6322 }
6323 return Ok(());
6324 } else {
6325 if as_json {
6326 println!(
6327 "{}",
6328 serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
6329 );
6330 }
6331 std::process::exit(1);
6332 }
6333 }
6334 Some(p) => {
6335 let cur = p.status.clone();
6336 if Some(cur.clone()) != last_seen_status {
6337 if as_json {
6338 println!("{}", serde_json::to_string(&p)?);
6340 }
6341 last_seen_status = Some(cur.clone());
6342 }
6343 if cur == target_status {
6344 if !as_json {
6345 let sas_str = p
6346 .sas
6347 .as_ref()
6348 .map(|s| format!("{}-{}", &s[..3], &s[3..]))
6349 .unwrap_or_else(|| "—".to_string());
6350 println!("pair {code} reached {target_status} (SAS: {sas_str})");
6351 }
6352 return Ok(());
6353 }
6354 if cur == "aborted" || cur == "aborted_restart" {
6355 if !as_json {
6356 let err = p.last_error.as_deref().unwrap_or("(no detail)");
6357 eprintln!("pair {code} {cur}: {err}");
6358 }
6359 std::process::exit(1);
6360 }
6361 }
6362 }
6363 if now >= deadline {
6364 if !as_json {
6365 eprintln!(
6366 "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
6367 );
6368 }
6369 std::process::exit(2);
6370 }
6371 std::thread::sleep(std::time::Duration::from_millis(250));
6372 }
6373}
6374
6375fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
6376 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6377 let p = crate::pending_pair::read_pending(&code)?
6378 .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
6379 let client = crate::relay_client::RelayClient::new(&p.relay_url);
6380 let _ = client.pair_abandon(&p.code_hash);
6381 crate::pending_pair::delete_pending(&code)?;
6382 if as_json {
6383 println!(
6384 "{}",
6385 serde_json::to_string(&json!({
6386 "state": "cancelled",
6387 "code_phrase": code,
6388 }))?
6389 );
6390 } else {
6391 println!("cancelled pending pair {code} (relay slot released, file removed).");
6392 }
6393 Ok(())
6394}
6395
6396fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
6399 let code = crate::sas::parse_code_phrase(code_phrase)?;
6402 let code_hash = crate::pair_session::derive_code_hash(code);
6403 let client = crate::relay_client::RelayClient::new(relay_url);
6404 client.pair_abandon(&code_hash)?;
6405 println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
6406 println!("host can now issue a fresh code; guest can re-join.");
6407 Ok(())
6408}
6409
6410fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
6413 let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
6414
6415 let share_payload: Option<Value> = if share {
6418 let client = reqwest::blocking::Client::new();
6419 let single_use = if uses == 1 { Some(1u32) } else { None };
6420 let body = json!({
6421 "invite_url": url,
6422 "ttl_seconds": ttl,
6423 "uses": single_use,
6424 });
6425 let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
6426 let resp = client.post(&endpoint).json(&body).send()?;
6427 if !resp.status().is_success() {
6428 let code = resp.status();
6429 let txt = resp.text().unwrap_or_default();
6430 bail!("relay {code} on /v1/invite/register: {txt}");
6431 }
6432 let parsed: Value = resp.json()?;
6433 let token = parsed
6434 .get("token")
6435 .and_then(Value::as_str)
6436 .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
6437 .to_string();
6438 let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
6439 let curl_line = format!("curl -fsSL {share_url} | sh");
6440 Some(json!({
6441 "token": token,
6442 "share_url": share_url,
6443 "curl": curl_line,
6444 "expires_unix": parsed.get("expires_unix"),
6445 }))
6446 } else {
6447 None
6448 };
6449
6450 if as_json {
6451 let mut out = json!({
6452 "invite_url": url,
6453 "ttl_secs": ttl,
6454 "uses": uses,
6455 "relay": relay,
6456 });
6457 if let Some(s) = &share_payload {
6458 out["share"] = s.clone();
6459 }
6460 println!("{}", serde_json::to_string(&out)?);
6461 } else if let Some(s) = share_payload {
6462 let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
6463 eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
6464 eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
6465 println!("{curl}");
6466 } else {
6467 eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
6468 eprintln!("# TTL: {ttl}s. Uses: {uses}.");
6469 println!("{url}");
6470 }
6471 Ok(())
6472}
6473
6474fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
6475 let resolved = if url.starts_with("http://") || url.starts_with("https://") {
6479 let sep = if url.contains('?') { '&' } else { '?' };
6480 let resolve_url = format!("{url}{sep}format=url");
6481 let client = reqwest::blocking::Client::new();
6482 let resp = client
6483 .get(&resolve_url)
6484 .send()
6485 .with_context(|| format!("GET {resolve_url}"))?;
6486 if !resp.status().is_success() {
6487 bail!("could not resolve short URL {url} (HTTP {})", resp.status());
6488 }
6489 let body = resp.text().unwrap_or_default().trim().to_string();
6490 if !body.starts_with("wire://pair?") {
6491 bail!(
6492 "short URL {url} did not resolve to a wire:// invite. \
6493 (got: {}{})",
6494 body.chars().take(80).collect::<String>(),
6495 if body.chars().count() > 80 { "…" } else { "" }
6496 );
6497 }
6498 body
6499 } else {
6500 url.to_string()
6501 };
6502
6503 let result = crate::pair_invite::accept_invite(&resolved)?;
6504 if as_json {
6505 println!("{}", serde_json::to_string(&result)?);
6506 } else {
6507 let did = result
6508 .get("paired_with")
6509 .and_then(Value::as_str)
6510 .unwrap_or("?");
6511 println!("paired with {did}");
6512 println!(
6513 "you can now: wire send {} <kind> <body>",
6514 crate::agent_card::display_handle_from_did(did)
6515 );
6516 }
6517 Ok(())
6518}
6519
6520fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
6523 if let Some(h) = handle {
6524 let parsed = crate::pair_profile::parse_handle(h)?;
6525 if config::is_initialized()? {
6528 let card = config::read_agent_card()?;
6529 let local_handle = card
6530 .get("profile")
6531 .and_then(|p| p.get("handle"))
6532 .and_then(Value::as_str)
6533 .map(str::to_string);
6534 if local_handle.as_deref() == Some(h) {
6535 return cmd_whois(None, as_json, None);
6536 }
6537 }
6538 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6540 if as_json {
6541 println!("{}", serde_json::to_string(&resolved)?);
6542 } else {
6543 print_resolved_profile(&resolved);
6544 }
6545 return Ok(());
6546 }
6547 let card = config::read_agent_card()?;
6548 if as_json {
6549 let profile = card.get("profile").cloned().unwrap_or(Value::Null);
6550 println!(
6551 "{}",
6552 serde_json::to_string(&json!({
6553 "did": card.get("did").cloned().unwrap_or(Value::Null),
6554 "profile": profile,
6555 }))?
6556 );
6557 } else {
6558 print!("{}", crate::pair_profile::render_self_summary()?);
6559 }
6560 Ok(())
6561}
6562
6563fn print_resolved_profile(resolved: &Value) {
6564 let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
6565 let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
6566 let relay = resolved
6567 .get("relay_url")
6568 .and_then(Value::as_str)
6569 .unwrap_or("");
6570 let slot = resolved
6571 .get("slot_id")
6572 .and_then(Value::as_str)
6573 .unwrap_or("");
6574 let profile = resolved
6575 .get("card")
6576 .and_then(|c| c.get("profile"))
6577 .cloned()
6578 .unwrap_or(Value::Null);
6579 println!("{did}");
6580 println!(" nick: {nick}");
6581 if !relay.is_empty() {
6582 println!(" relay_url: {relay}");
6583 }
6584 if !slot.is_empty() {
6585 println!(" slot_id: {slot}");
6586 }
6587 let pick =
6588 |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
6589 if let Some(s) = pick("display_name") {
6590 println!(" display_name: {s}");
6591 }
6592 if let Some(s) = pick("emoji") {
6593 println!(" emoji: {s}");
6594 }
6595 if let Some(s) = pick("motto") {
6596 println!(" motto: {s}");
6597 }
6598 if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
6599 let joined: Vec<String> = arr
6600 .iter()
6601 .filter_map(|v| v.as_str().map(str::to_string))
6602 .collect();
6603 println!(" vibe: {}", joined.join(", "));
6604 }
6605 if let Some(s) = pick("pronouns") {
6606 println!(" pronouns: {s}");
6607 }
6608}
6609
6610fn host_of_url(url: &str) -> String {
6618 let no_scheme = url
6619 .trim_start_matches("https://")
6620 .trim_start_matches("http://");
6621 no_scheme
6622 .split('/')
6623 .next()
6624 .unwrap_or("")
6625 .split(':')
6626 .next()
6627 .unwrap_or("")
6628 .to_string()
6629}
6630
6631fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
6635 const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
6637 let peer_domain = peer_domain.trim().to_ascii_lowercase();
6638 if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
6639 return true;
6640 }
6641 let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
6644 if !our_host.is_empty() && our_host == peer_domain {
6645 return true;
6646 }
6647 false
6648}
6649
6650fn resolve_local_session<'a>(
6668 sessions: &'a [crate::session::SessionInfo],
6669 input: &str,
6670) -> Result<&'a crate::session::SessionInfo, ResolveError> {
6671 if let Some(s) = sessions.iter().find(|s| s.name == input) {
6674 return Ok(s);
6675 }
6676 let nick_matches: Vec<&crate::session::SessionInfo> = sessions
6677 .iter()
6678 .filter(|s| {
6679 s.character
6680 .as_ref()
6681 .map(|c| c.nickname == input)
6682 .unwrap_or(false)
6683 })
6684 .collect();
6685 match nick_matches.len() {
6686 0 => Err(ResolveError::NotFound),
6687 1 => Ok(nick_matches[0]),
6688 _ => Err(ResolveError::Ambiguous(
6689 nick_matches.iter().map(|s| s.name.clone()).collect(),
6690 )),
6691 }
6692}
6693
6694#[derive(Debug)]
6695enum ResolveError {
6696 NotFound,
6697 Ambiguous(Vec<String>),
6698}
6699
6700fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
6716 let trust = match config::read_trust() {
6717 Ok(t) => t,
6718 Err(_) => return Ok(None),
6719 };
6720 let agents = match trust.get("agents").and_then(|a| a.as_object()) {
6721 Some(a) => a,
6722 None => return Ok(None),
6723 };
6724 if agents.contains_key(input) {
6725 return Ok(Some(input.to_string()));
6726 }
6727 let mut nick_matches: Vec<String> = Vec::new();
6728 for (handle, agent) in agents.iter() {
6729 let character = match agent.get("card") {
6733 Some(card) => crate::character::Character::from_card(card),
6734 None => match agent.get("did").and_then(Value::as_str) {
6735 Some(did) => crate::character::Character::from_did(did),
6736 None => continue,
6737 },
6738 };
6739 if character.nickname == input {
6740 nick_matches.push(handle.clone());
6741 }
6742 }
6743 match nick_matches.len() {
6744 0 => Ok(None),
6745 1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
6746 _ => Err(ResolveError::Ambiguous(nick_matches)),
6747 }
6748}
6749
6750fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
6751 let sessions = crate::session::list_sessions()?;
6753 let sister = match resolve_local_session(&sessions, sister_name) {
6754 Ok(s) => s,
6755 Err(ResolveError::NotFound) => bail!(
6756 "no sister session named `{sister_name}` (matched by session name or character nickname). \
6757 Run `wire session list` to see what's available."
6758 ),
6759 Err(ResolveError::Ambiguous(candidates)) => bail!(
6760 "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
6761 Disambiguate by passing the session name (one of those listed) instead of the nickname.",
6762 candidates.len(),
6763 candidates.join(", ")
6764 ),
6765 };
6766 if sister.name != sister_name {
6769 eprintln!(
6770 "wire add: resolved nickname `{sister_name}` → session `{}`",
6771 sister.name
6772 );
6773 }
6774
6775 let our_card = config::read_agent_card()
6778 .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
6779 let our_did = our_card
6780 .get("did")
6781 .and_then(Value::as_str)
6782 .ok_or_else(|| anyhow!("agent-card missing did"))?
6783 .to_string();
6784 if let Some(sister_did) = sister.did.as_deref()
6785 && sister_did == our_did
6786 {
6787 bail!("refusing to add self (`{sister_name}` is this very session)");
6788 }
6789
6790 let sister_card_path = sister
6792 .home_dir
6793 .join("config")
6794 .join("wire")
6795 .join("agent-card.json");
6796 let sister_card: Value = serde_json::from_slice(
6797 &std::fs::read(&sister_card_path)
6798 .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
6799 )
6800 .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
6801 let sister_relay_state: Value = std::fs::read(
6802 sister
6803 .home_dir
6804 .join("config")
6805 .join("wire")
6806 .join("relay.json"),
6807 )
6808 .ok()
6809 .and_then(|b| serde_json::from_slice(&b).ok())
6810 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
6811
6812 let sister_did = sister_card
6813 .get("did")
6814 .and_then(Value::as_str)
6815 .ok_or_else(|| anyhow!("sister card missing did"))?
6816 .to_string();
6817 let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
6818
6819 let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
6823 if sister_endpoints.is_empty() {
6824 bail!(
6825 "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
6826 );
6827 }
6828 let sister_local = sister_endpoints
6829 .iter()
6830 .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
6831 let delivery_endpoint = match sister_local {
6832 Some(e) => e.clone(),
6833 None => sister_endpoints[0].clone(),
6834 };
6835
6836 let our_relay_state = config::read_relay_state()?;
6842 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6843 if our_endpoints.is_empty() {
6844 bail!(
6845 "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
6846 );
6847 }
6848 let our_advertised = our_endpoints
6849 .iter()
6850 .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
6851 .cloned()
6852 .unwrap_or_else(|| our_endpoints[0].clone());
6853
6854 let mut trust = config::read_trust()?;
6858 crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
6859 config::write_trust(&trust)?;
6860 let mut relay_state = config::read_relay_state()?;
6861 crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
6862 config::write_relay_state(&relay_state)?;
6863
6864 let sk_seed = config::read_private_key()?;
6867 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6868 let pk_b64 = our_card
6869 .get("verify_keys")
6870 .and_then(Value::as_object)
6871 .and_then(|m| m.values().next())
6872 .and_then(|v| v.get("key"))
6873 .and_then(Value::as_str)
6874 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6875 let pk_bytes = crate::signing::b64decode(pk_b64)?;
6876 let now = time::OffsetDateTime::now_utc()
6877 .format(&time::format_description::well_known::Rfc3339)
6878 .unwrap_or_default();
6879 let mut body = json!({
6880 "card": our_card,
6881 "relay_url": our_advertised.relay_url,
6882 "slot_id": our_advertised.slot_id,
6883 "slot_token": our_advertised.slot_token,
6884 });
6885 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6886 let event = json!({
6887 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6888 "timestamp": now,
6889 "from": our_did,
6890 "to": sister_did,
6891 "type": "pair_drop",
6892 "kind": 1100u32,
6893 "body": body,
6894 });
6895 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6896 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6897
6898 let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
6902 client
6903 .post_event(
6904 &delivery_endpoint.slot_id,
6905 &delivery_endpoint.slot_token,
6906 &signed,
6907 )
6908 .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
6909
6910 if as_json {
6911 println!(
6912 "{}",
6913 serde_json::to_string(&json!({
6914 "handle": sister_name,
6915 "paired_with": sister_did,
6916 "peer_handle": sister_handle,
6917 "event_id": event_id,
6918 "delivered_via": match delivery_endpoint.scope {
6919 crate::endpoints::EndpointScope::Local => "local",
6920 crate::endpoints::EndpointScope::Lan => "lan",
6921 crate::endpoints::EndpointScope::Uds => "uds",
6922 crate::endpoints::EndpointScope::Federation => "federation",
6923 },
6924 "status": "drop_sent",
6925 }))?
6926 );
6927 } else {
6928 let scope = match delivery_endpoint.scope {
6929 crate::endpoints::EndpointScope::Local => "local",
6930 crate::endpoints::EndpointScope::Lan => "lan",
6931 crate::endpoints::EndpointScope::Uds => "uds",
6932 crate::endpoints::EndpointScope::Federation => "federation",
6933 };
6934 println!(
6935 "→ found sister `{sister_name}` (did={sister_did})\n→ pinned peer locally\n→ pair_drop delivered to {scope} slot on {}\nawaiting pair_drop_ack from {sister_handle} to complete bilateral pin.",
6936 delivery_endpoint.relay_url
6937 );
6938 }
6939 Ok(())
6940}
6941
6942fn cmd_add(
6943 handle_arg: &str,
6944 relay_override: Option<&str>,
6945 local_sister: bool,
6946 as_json: bool,
6947) -> Result<()> {
6948 if local_sister {
6956 let resolved = crate::session::resolve_local_sister(handle_arg)
6957 .unwrap_or_else(|| handle_arg.to_string());
6958 return cmd_add_local_sister(&resolved, as_json);
6959 }
6960 if !handle_arg.contains('@')
6961 && let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
6962 {
6963 eprintln!(
6964 "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
6965 — routing via --local-sister (disk-read card, no relay lookup)."
6966 );
6967 return cmd_add_local_sister(&resolved, as_json);
6968 }
6969 if !handle_arg.contains('@') {
6970 bail!(
6971 "`{handle_arg}` doesn't match any local sister session and has no \
6972 @<relay> suffix for federation.\n\
6973 — Local sisters: `wire session list-local` (operator types name OR \
6974 character nickname)\n\
6975 — Federation: `wire add <handle>@<relay-domain>` (e.g. \
6976 `wire add alice@wireup.net`)"
6977 );
6978 }
6979 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
6980
6981 let (our_did, our_relay, our_slot_id, our_slot_token) =
6983 crate::pair_invite::ensure_self_with_relay(relay_override)?;
6984 if our_did == format!("did:wire:{}", parsed.nick) {
6985 bail!("refusing to add self (handle matches own DID)");
6987 }
6988
6989 if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
6999 return cmd_add_accept_pending(
7000 handle_arg,
7001 &parsed.nick,
7002 &pending,
7003 &our_relay,
7004 &our_slot_id,
7005 &our_slot_token,
7006 as_json,
7007 );
7008 }
7009
7010 if !is_known_relay_domain(&parsed.domain, &our_relay) {
7027 eprintln!(
7028 "wire add: WARN unfamiliar relay domain `{}`.",
7029 parsed.domain
7030 );
7031 eprintln!(
7032 " This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
7033 host_of_url(&our_relay)
7034 );
7035 eprintln!(
7036 " and not on the known-good list. If you meant `{}@wireup.net`, ",
7037 parsed.nick
7038 );
7039 eprintln!(
7040 " run `wire add {}@wireup.net` instead. Otherwise verify with your",
7041 parsed.nick
7042 );
7043 eprintln!(" peer out-of-band that they actually run a relay at this domain");
7044 eprintln!(" before relying on the pair. (See issue #9.4.)");
7045 }
7046
7047 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
7049 let peer_card = resolved
7050 .get("card")
7051 .cloned()
7052 .ok_or_else(|| anyhow!("resolved missing card"))?;
7053 let peer_did = resolved
7054 .get("did")
7055 .and_then(Value::as_str)
7056 .ok_or_else(|| anyhow!("resolved missing did"))?
7057 .to_string();
7058 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
7059
7060 reject_self_pair_after_resolution(&our_did, &peer_did)?;
7065
7066 let peer_slot_id = resolved
7067 .get("slot_id")
7068 .and_then(Value::as_str)
7069 .ok_or_else(|| anyhow!("resolved missing slot_id"))?
7070 .to_string();
7071 let peer_relay = resolved
7072 .get("relay_url")
7073 .and_then(Value::as_str)
7074 .map(str::to_string)
7075 .or_else(|| relay_override.map(str::to_string))
7076 .unwrap_or_else(|| format!("https://{}", parsed.domain));
7077
7078 let mut trust = config::read_trust()?;
7080 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
7081 config::write_trust(&trust)?;
7082 let mut relay_state = config::read_relay_state()?;
7083 let mut endpoints: Vec<crate::endpoints::Endpoint> = relay_state
7096 .get("peers")
7097 .and_then(|p| p.get(&peer_handle))
7098 .and_then(|e| e.get("endpoints"))
7099 .and_then(|a| serde_json::from_value::<Vec<crate::endpoints::Endpoint>>(a.clone()).ok())
7100 .unwrap_or_default();
7101 let fed_token = endpoints
7102 .iter()
7103 .find(|e| {
7104 e.relay_url == peer_relay && e.scope == crate::endpoints::EndpointScope::Federation
7105 })
7106 .map(|e| e.slot_token.clone())
7107 .unwrap_or_default();
7108 let fed_ep = crate::endpoints::Endpoint {
7109 relay_url: peer_relay.clone(),
7110 slot_id: peer_slot_id.clone(),
7111 slot_token: fed_token, scope: crate::endpoints::EndpointScope::Federation,
7113 };
7114 if let Some(existing) = endpoints
7115 .iter_mut()
7116 .find(|e| e.relay_url == fed_ep.relay_url)
7117 {
7118 *existing = fed_ep;
7119 } else {
7120 endpoints.push(fed_ep);
7121 }
7122 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints)?;
7123 config::write_relay_state(&relay_state)?;
7124
7125 let our_card = config::read_agent_card()?;
7128 let sk_seed = config::read_private_key()?;
7129 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
7130 let pk_b64 = our_card
7131 .get("verify_keys")
7132 .and_then(Value::as_object)
7133 .and_then(|m| m.values().next())
7134 .and_then(|v| v.get("key"))
7135 .and_then(Value::as_str)
7136 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
7137 let pk_bytes = crate::signing::b64decode(pk_b64)?;
7138 let now = time::OffsetDateTime::now_utc()
7139 .format(&time::format_description::well_known::Rfc3339)
7140 .unwrap_or_default();
7141 let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
7146 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
7147 let mut body = json!({
7148 "card": our_card,
7149 "relay_url": our_relay,
7150 "slot_id": our_slot_id,
7151 "slot_token": our_slot_token,
7152 });
7153 if !our_endpoints.is_empty() {
7154 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
7155 }
7156 let event = json!({
7157 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7158 "timestamp": now,
7159 "from": our_did,
7160 "to": peer_did,
7161 "type": "pair_drop",
7162 "kind": 1100u32,
7163 "body": body,
7164 });
7165 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
7166
7167 let client = crate::relay_client::RelayClient::new(&peer_relay);
7169 let resp = client.handle_intro(&parsed.nick, &signed)?;
7170 let event_id = signed
7171 .get("event_id")
7172 .and_then(Value::as_str)
7173 .unwrap_or("")
7174 .to_string();
7175
7176 if as_json {
7177 println!(
7178 "{}",
7179 serde_json::to_string(&json!({
7180 "handle": handle_arg,
7181 "paired_with": peer_did,
7182 "peer_handle": peer_handle,
7183 "event_id": event_id,
7184 "drop_response": resp,
7185 "status": "drop_sent",
7186 }))?
7187 );
7188 } else {
7189 println!(
7190 "→ resolved {handle_arg} (did={peer_did})\n→ pinned peer locally\n→ intro dropped to {peer_relay}\nawaiting pair_drop_ack from {peer_handle} to complete bilateral pin."
7191 );
7192 }
7193 Ok(())
7194}
7195
7196fn cmd_add_accept_pending(
7203 handle_arg: &str,
7204 peer_nick: &str,
7205 pending: &crate::pending_inbound_pair::PendingInboundPair,
7206 _our_relay: &str,
7207 _our_slot_id: &str,
7208 _our_slot_token: &str,
7209 as_json: bool,
7210) -> Result<()> {
7211 let mut trust = config::read_trust()?;
7214 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
7215 config::write_trust(&trust)?;
7216
7217 let mut relay_state = config::read_relay_state()?;
7223 let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
7224 vec![crate::endpoints::Endpoint::federation(
7225 pending.peer_relay_url.clone(),
7226 pending.peer_slot_id.clone(),
7227 pending.peer_slot_token.clone(),
7228 )]
7229 } else {
7230 pending.peer_endpoints.clone()
7231 };
7232 crate::endpoints::pin_peer_endpoints(
7233 &mut relay_state,
7234 &pending.peer_handle,
7235 &endpoints_to_pin,
7236 )?;
7237 config::write_relay_state(&relay_state)?;
7238
7239 crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &endpoints_to_pin).with_context(
7244 || {
7245 format!(
7246 "pair_drop_ack send to {} (across {} endpoint(s)) failed",
7247 pending.peer_handle,
7248 endpoints_to_pin.len()
7249 )
7250 },
7251 )?;
7252
7253 crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
7255
7256 if as_json {
7257 println!(
7258 "{}",
7259 serde_json::to_string(&json!({
7260 "handle": handle_arg,
7261 "paired_with": pending.peer_did,
7262 "peer_handle": pending.peer_handle,
7263 "status": "bilateral_accepted",
7264 "via": "pending_inbound",
7265 }))?
7266 );
7267 } else {
7268 println!(
7269 "→ accepted pending pair from {peer}\n→ pinned VERIFIED, slot_token recorded\n→ shipped our slot_token back via pair_drop_ack\nbilateral pair complete. Send with `wire send {peer} \"...\"`.",
7270 peer = pending.peer_handle,
7271 );
7272 }
7273 Ok(())
7274}
7275
7276fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
7283 let nick = crate::agent_card::bare_handle(peer_nick);
7284 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
7285 anyhow!(
7286 "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
7287 or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
7288 )
7289 })?;
7290 let (_our_did, our_relay, our_slot_id, our_slot_token) =
7291 crate::pair_invite::ensure_self_with_relay(None)?;
7292 let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
7293 cmd_add_accept_pending(
7294 &handle_arg,
7295 nick,
7296 &pending,
7297 &our_relay,
7298 &our_slot_id,
7299 &our_slot_token,
7300 as_json,
7301 )
7302}
7303
7304fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
7307 let items = crate::pending_inbound_pair::list_pending_inbound()?;
7308 if as_json {
7309 println!("{}", serde_json::to_string(&items)?);
7310 return Ok(());
7311 }
7312 if items.is_empty() {
7313 println!("no pending pair requests — your inbox is clear.");
7314 return Ok(());
7315 }
7316 let plural = if items.len() == 1 { "" } else { "s" };
7323 println!("{} pending pair request{plural}:\n", items.len());
7324 for p in &items {
7325 let ch = crate::character::Character::from_did(&p.peer_did);
7326 let glyph = crate::character::emoji_with_fallback(&ch);
7327 println!(
7330 " {glyph} {nick} ({handle}) wants to pair with you",
7331 nick = ch.nickname,
7332 handle = p.peer_handle,
7333 );
7334 }
7335 println!();
7336 println!(
7337 "→ to accept any: `wire accept <name>` (e.g. `wire accept {first}`)",
7338 first = items
7339 .first()
7340 .map(|p| {
7341 let ch = crate::character::Character::from_did(&p.peer_did);
7342 ch.nickname
7343 })
7344 .unwrap_or_else(|| "<name>".to_string())
7345 );
7346 println!("→ to refuse: `wire reject <name>`");
7347 Ok(())
7348}
7349
7350fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
7354 let nick = crate::agent_card::bare_handle(peer_nick);
7355 let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
7356 crate::pending_inbound_pair::consume_pending_inbound(nick)?;
7357
7358 if as_json {
7359 println!(
7360 "{}",
7361 serde_json::to_string(&json!({
7362 "peer": nick,
7363 "rejected": existed.is_some(),
7364 "had_pending": existed.is_some(),
7365 }))?
7366 );
7367 } else if existed.is_some() {
7368 println!(
7369 "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
7370 );
7371 } else {
7372 println!("no pending pair from {nick} — nothing to reject");
7373 }
7374 Ok(())
7375}
7376
7377fn cmd_group(cmd: GroupCommand) -> Result<()> {
7388 match cmd {
7389 GroupCommand::Create { name, json } => cmd_group_create(&name, json),
7390 GroupCommand::Add { group, peer, json } => cmd_group_add(&group, &peer, json),
7391 GroupCommand::Send {
7392 group,
7393 message,
7394 json,
7395 } => cmd_group_send(&group, &message, json),
7396 GroupCommand::Tail { group, limit, json } => cmd_group_tail(&group, limit, json),
7397 GroupCommand::List { json } => cmd_group_list(json),
7398 GroupCommand::Invite { group, json } => cmd_group_invite(&group, json),
7399 GroupCommand::Join { code, json } => cmd_group_join(&code, json),
7400 }
7401}
7402
7403fn group_self() -> Result<(String, String, String, String)> {
7406 let card = config::read_agent_card()?;
7407 let did = card
7408 .get("did")
7409 .and_then(Value::as_str)
7410 .ok_or_else(|| anyhow!("agent-card missing did — run `wire up` first"))?
7411 .to_string();
7412 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7413 let pk_b64 = card
7414 .get("verify_keys")
7415 .and_then(Value::as_object)
7416 .and_then(|m| m.values().next())
7417 .and_then(|v| v.get("key"))
7418 .and_then(Value::as_str)
7419 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
7420 .to_string();
7421 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7422 let key_id = make_key_id(&handle, &pk_bytes);
7423 Ok((did, handle, key_id, pk_b64))
7424}
7425
7426fn group_room_relay_url() -> Result<String> {
7429 use crate::endpoints::EndpointScope;
7430 let state = config::read_relay_state()?;
7431 let eps = crate::endpoints::self_endpoints(&state);
7432 let pick = eps
7433 .iter()
7434 .find(|e| e.scope == EndpointScope::Federation)
7435 .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Lan))
7436 .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Local))
7437 .or_else(|| eps.first());
7438 match pick {
7439 Some(e) if !e.relay_url.is_empty() => Ok(e.relay_url.clone()),
7440 _ => bail!("no relay endpoint on this identity — run `wire up --relay <url>` first"),
7441 }
7442}
7443
7444fn distribute_group_invite(group: &crate::group::Group, self_did: &str) -> Result<usize> {
7448 let (_, self_handle, _, pk_b64) = group_self()?;
7449 let sk_seed = config::read_private_key()?;
7450 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7451 let now_iso = time::OffsetDateTime::now_utc()
7452 .format(&time::format_description::well_known::Rfc3339)
7453 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7454 let group_json = serde_json::to_value(group)?;
7455 let mut delivered = 0usize;
7456 for handle in group.other_member_handles(self_did) {
7457 let event = json!({
7458 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7459 "timestamp": now_iso,
7460 "from": self_did,
7461 "to": format!("did:wire:{handle}"),
7462 "type": "group_invite",
7463 "kind": parse_kind("group_invite")?,
7464 "body": group_json,
7465 });
7466 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
7467 .map_err(|e| anyhow!("signing group_invite for `{handle}`: {e:?}"))?;
7468 let line = serde_json::to_vec(&signed)?;
7469 if config::append_outbox_record(&handle, &line).is_ok() {
7470 delivered += 1;
7471 }
7472 }
7473 Ok(delivered)
7474}
7475
7476fn introduce_pin(
7483 trust: &mut Value,
7484 handle: &str,
7485 did: &str,
7486 key_id: &str,
7487 key: &str,
7488 group_id: &str,
7489) -> bool {
7490 let now = time::OffsetDateTime::now_utc()
7491 .format(&time::format_description::well_known::Rfc3339)
7492 .unwrap_or_default();
7493 let agents = trust
7494 .as_object_mut()
7495 .expect("trust is an object")
7496 .entry("agents")
7497 .or_insert_with(|| json!({}));
7498 let key_rec = json!({"key_id": key_id, "key": key, "added_at": now, "active": true});
7499 match agents.get_mut(handle) {
7500 Some(existing) => {
7501 let keys = existing
7504 .as_object_mut()
7505 .and_then(|o| o.get_mut("public_keys"))
7506 .and_then(Value::as_array_mut);
7507 if let Some(keys) = keys {
7508 let have = keys
7509 .iter()
7510 .any(|k| k.get("key_id").and_then(Value::as_str) == Some(key_id));
7511 if !have {
7512 keys.push(key_rec);
7513 return true;
7514 }
7515 }
7516 false
7517 }
7518 None => {
7519 agents[handle] = json!({
7521 "tier": "UNTRUSTED",
7522 "did": did,
7523 "public_keys": [key_rec],
7524 "introduced_via": group_id,
7525 "pinned_at": now,
7526 });
7527 true
7528 }
7529 }
7530}
7531
7532fn ingest_group_invites() -> Result<()> {
7538 let inbox = config::inbox_dir()?;
7539 if !inbox.exists() {
7540 return Ok(());
7541 }
7542 let (self_did, ..) = group_self()?;
7543 let trust_now = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
7544 let mut best: std::collections::HashMap<String, crate::group::Group> =
7546 std::collections::HashMap::new();
7547
7548 for entry in std::fs::read_dir(&inbox)?.flatten() {
7549 let path = entry.path();
7550 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
7551 continue;
7552 }
7553 for line in std::fs::read_to_string(&path).unwrap_or_default().lines() {
7554 let event: Value = match serde_json::from_str(line) {
7555 Ok(v) => v,
7556 Err(_) => continue,
7557 };
7558 if event.get("type").and_then(Value::as_str) != Some("group_invite") {
7559 continue;
7560 }
7561 if verify_message_v31(&event, &trust_now).is_err() {
7564 continue;
7565 }
7566 let Some(body) = event.get("body") else {
7567 continue;
7568 };
7569 let group: crate::group::Group = match serde_json::from_value(body.clone()) {
7570 Ok(g) => g,
7571 Err(_) => continue,
7572 };
7573 if group.creator_did == self_did {
7574 continue; }
7576 let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
7578 if from_did != group.creator_did {
7579 continue;
7580 }
7581 let creator_handle = crate::agent_card::display_handle_from_did(&group.creator_did);
7584 let creator_key = trust_now
7585 .get("agents")
7586 .and_then(|a| a.get(creator_handle))
7587 .and_then(|a| a.get("public_keys"))
7588 .and_then(Value::as_array)
7589 .and_then(|ks| ks.first())
7590 .and_then(|k| k.get("key"))
7591 .and_then(Value::as_str)
7592 .and_then(|b| crate::signing::b64decode(b).ok());
7593 let Some(creator_key) = creator_key else {
7594 continue;
7595 };
7596 if !group.verify(&creator_key) {
7597 continue;
7598 }
7599 match best.get(&group.id) {
7600 Some(prev) if prev.epoch >= group.epoch => {}
7601 _ => {
7602 best.insert(group.id.clone(), group);
7603 }
7604 }
7605 }
7606 }
7607
7608 if best.is_empty() {
7609 return Ok(());
7610 }
7611 let mut trust = config::read_trust()?;
7612 for group in best.values() {
7613 if let Ok(local) = crate::group::load_group(&group.id)
7615 && local.epoch >= group.epoch
7616 {
7617 continue;
7618 }
7619 crate::group::save_group(group)?;
7620 for m in &group.members {
7621 if m.did == self_did || m.key.is_empty() {
7622 continue;
7623 }
7624 introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
7625 }
7626 }
7627 config::write_trust(&trust)?;
7628 Ok(())
7629}
7630
7631fn cmd_group_create(name: &str, as_json: bool) -> Result<()> {
7632 if !config::is_initialized()? {
7633 bail!("not initialized — run `wire up` first");
7634 }
7635 let (did, handle, key_id, pk_b64) = group_self()?;
7636 let relay_url = group_room_relay_url()?;
7637 let client = crate::relay_client::RelayClient::new(&relay_url);
7639 let room = client
7640 .allocate_slot(Some(&format!("group:{name}")))
7641 .with_context(|| format!("allocating group room on {relay_url}"))?;
7642 let id = format!("g{:016x}", rand::random::<u64>());
7643 let mut group = crate::group::Group::new(id.clone(), name.to_string(), handle, did.clone());
7644 group.set_room(relay_url, room.slot_id, room.slot_token);
7645 group.set_member_keys(&did, key_id, pk_b64)?;
7646 let sk = config::read_private_key()?;
7647 group.sign(&sk)?;
7648 crate::group::save_group(&group)?;
7649 if as_json {
7650 println!(
7651 "{}",
7652 serde_json::to_string(&json!({
7653 "id": id, "name": name, "members": 1, "relay_url": group.relay_url
7654 }))?
7655 );
7656 } else {
7657 println!(
7658 "created group `{name}` (id {id}) — room on {}. You are the creator.",
7659 group.relay_url
7660 );
7661 println!(" add peers: `wire group add {id} <peer>` talk: `wire group send {id} \"hi\"`");
7662 }
7663 Ok(())
7664}
7665
7666fn cmd_group_add(group_ref: &str, peer: &str, as_json: bool) -> Result<()> {
7667 let (self_did, ..) = group_self()?;
7668 let mut group = crate::group::resolve_group(group_ref)?;
7669 if group.creator_did != self_did {
7670 bail!("only the group creator can add members (the creator signs the roster)");
7671 }
7672 let bare = crate::agent_card::bare_handle(peer).to_string();
7674 let trust = config::read_trust()?;
7675 let agent = trust
7676 .get("agents")
7677 .and_then(|a| a.get(&bare))
7678 .ok_or_else(|| {
7679 anyhow!("`{bare}` is not a pinned peer — pair first (`wire dial {bare}@<relay>`)")
7680 })?;
7681 let tier = agent
7682 .get("tier")
7683 .and_then(Value::as_str)
7684 .unwrap_or("UNTRUSTED");
7685 if tier != "VERIFIED" {
7686 bail!(
7687 "`{bare}` is {tier}, not VERIFIED — only verified peers can be added as Members (T22 consent)"
7688 );
7689 }
7690 let peer_did = agent
7691 .get("did")
7692 .and_then(Value::as_str)
7693 .ok_or_else(|| anyhow!("trust entry for `{bare}` is missing a did"))?
7694 .to_string();
7695 let key = agent
7698 .get("public_keys")
7699 .and_then(Value::as_array)
7700 .and_then(|ks| {
7701 ks.iter()
7702 .find(|k| k.get("active").and_then(Value::as_bool).unwrap_or(true))
7703 })
7704 .ok_or_else(|| anyhow!("no active pinned key for `{bare}` in trust"))?;
7705 let peer_key_id = key
7706 .get("key_id")
7707 .and_then(Value::as_str)
7708 .unwrap_or_default()
7709 .to_string();
7710 let peer_pk = key
7711 .get("key")
7712 .and_then(Value::as_str)
7713 .unwrap_or_default()
7714 .to_string();
7715
7716 group.add_member(
7717 bare.clone(),
7718 peer_did.clone(),
7719 crate::group::GroupTier::Member,
7720 )?;
7721 group.set_member_keys(&peer_did, peer_key_id, peer_pk)?;
7722 let sk = config::read_private_key()?;
7723 group.sign(&sk)?;
7724 crate::group::save_group(&group)?;
7725 let delivered = distribute_group_invite(&group, &self_did).unwrap_or(0);
7728 if as_json {
7729 println!(
7730 "{}",
7731 serde_json::to_string(&json!({
7732 "group": group.id, "added": bare, "epoch": group.epoch,
7733 "members": group.members.len(), "invites_queued": delivered
7734 }))?
7735 );
7736 } else {
7737 println!(
7738 "added `{bare}` to `{}` — now {} member(s), epoch {} ({delivered} invite(s) queued; run `wire push`)",
7739 group.name,
7740 group.members.len(),
7741 group.epoch
7742 );
7743 }
7744 Ok(())
7745}
7746
7747fn cmd_group_send(group_ref: &str, message: &str, as_json: bool) -> Result<()> {
7748 if !config::is_initialized()? {
7749 bail!("not initialized — run `wire up` first");
7750 }
7751 ingest_group_invites()?;
7752 let (self_did, self_handle, _, pk_b64) = group_self()?;
7753 let group = crate::group::resolve_group(group_ref)?;
7754 if group.slot_id.is_empty() || group.relay_url.is_empty() {
7759 bail!(
7760 "group `{}` has no room slot (legacy/partial group)",
7761 group.name
7762 );
7763 }
7764 let sk_seed = config::read_private_key()?;
7765 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7766 let now_iso = time::OffsetDateTime::now_utc()
7767 .format(&time::format_description::well_known::Rfc3339)
7768 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7769 let event = json!({
7770 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7771 "timestamp": now_iso,
7772 "from": self_did,
7773 "to": format!("did:wire:group:{}", group.id),
7774 "type": "group_msg",
7775 "kind": parse_kind("group_msg")?,
7776 "body": {
7777 "group_id": group.id,
7778 "group_name": group.name,
7779 "epoch": group.epoch,
7780 "text": message,
7781 },
7782 });
7783 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
7784 .map_err(|e| anyhow!("signing group_msg: {e:?}"))?;
7785 let client = crate::relay_client::RelayClient::new(&group.relay_url);
7787 client
7788 .post_event(&group.slot_id, &group.slot_token, &signed)
7789 .with_context(|| {
7790 format!(
7791 "posting to group room {} on {}",
7792 group.slot_id, group.relay_url
7793 )
7794 })?;
7795 if as_json {
7796 println!(
7797 "{}",
7798 serde_json::to_string(&json!({
7799 "group": group.id, "epoch": group.epoch, "status": "posted",
7800 "members": group.members.len()
7801 }))?
7802 );
7803 } else {
7804 println!(
7805 "group `{}`: posted to the room ({} member(s))",
7806 group.name,
7807 group.members.len()
7808 );
7809 }
7810 Ok(())
7811}
7812
7813fn cmd_group_tail(group_ref: &str, limit: usize, as_json: bool) -> Result<()> {
7814 ingest_group_invites()?;
7815 let group = crate::group::resolve_group(group_ref)?;
7816 if group.slot_id.is_empty() || group.relay_url.is_empty() {
7817 bail!(
7818 "group `{}` has no room slot (legacy/partial group)",
7819 group.name
7820 );
7821 }
7822 let mut trust = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
7823 let client = crate::relay_client::RelayClient::new(&group.relay_url);
7824 let fetch = if limit == 0 {
7826 1000
7827 } else {
7828 (limit * 4).min(1000)
7829 };
7830 let events = client
7831 .list_events(&group.slot_id, &group.slot_token, None, Some(fetch))
7832 .with_context(|| {
7833 format!(
7834 "pulling group room {} on {}",
7835 group.slot_id, group.relay_url
7836 )
7837 })?;
7838
7839 let mut trust_changed = false;
7845 for event in &events {
7846 if event.get("type").and_then(Value::as_str) != Some("group_join") {
7847 continue;
7848 }
7849 if let Some((h, did, kid, key)) = group_join_pin_material(event)
7850 && introduce_pin(&mut trust, &h, &did, &kid, &key, &group.id)
7851 {
7852 trust_changed = true;
7853 }
7854 }
7855 if trust_changed {
7856 let _ = config::write_trust(&trust);
7857 }
7858
7859 enum Line {
7862 Msg {
7863 from: String,
7864 text: String,
7865 verified: bool,
7866 },
7867 Join {
7868 who: String,
7869 },
7870 }
7871 let mut timeline: Vec<(String, Line)> = Vec::new();
7872 for event in &events {
7873 let ty = event.get("type").and_then(Value::as_str).unwrap_or("");
7874 let body = match event.get("body") {
7875 Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok(),
7876 Some(v) => Some(v.clone()),
7877 None => None,
7878 };
7879 let Some(body) = body else { continue };
7880 if body.get("group_id").and_then(Value::as_str) != Some(group.id.as_str()) {
7881 continue;
7882 }
7883 let ts = event
7884 .get("timestamp")
7885 .and_then(Value::as_str)
7886 .unwrap_or("")
7887 .to_string();
7888 let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
7889 let from_handle = crate::agent_card::display_handle_from_did(from_did).to_string();
7890 match ty {
7891 "group_msg" => {
7892 let text = body
7893 .get("text")
7894 .and_then(Value::as_str)
7895 .unwrap_or("")
7896 .to_string();
7897 let verified = verify_message_v31(event, &trust).is_ok();
7898 timeline.push((
7899 ts,
7900 Line::Msg {
7901 from: from_handle,
7902 text,
7903 verified,
7904 },
7905 ));
7906 }
7907 "group_join" => timeline.push((ts, Line::Join { who: from_handle })),
7908 _ => {}
7909 }
7910 }
7911 timeline.sort_by(|a, b| a.0.cmp(&b.0));
7912 let start = if limit > 0 {
7913 timeline.len().saturating_sub(limit)
7914 } else {
7915 0
7916 };
7917 let recent = &timeline[start..];
7918 if as_json {
7919 let arr: Vec<Value> = recent
7920 .iter()
7921 .map(|(ts, l)| match l {
7922 Line::Msg {
7923 from,
7924 text,
7925 verified,
7926 } => {
7927 json!({"ts": ts, "type": "msg", "from": from, "text": text, "verified": verified})
7928 }
7929 Line::Join { who } => json!({"ts": ts, "type": "join", "from": who}),
7930 })
7931 .collect();
7932 println!(
7933 "{}",
7934 serde_json::to_string(
7935 &json!({"group": group.id, "name": group.name, "messages": arr})
7936 )?
7937 );
7938 } else if recent.is_empty() {
7939 println!("group `{}`: no messages yet", group.name);
7940 } else {
7941 for (ts, l) in recent {
7942 let short_ts: String = ts.chars().take(19).collect();
7943 match l {
7944 Line::Msg {
7945 from,
7946 text,
7947 verified,
7948 } => {
7949 let mark = if *verified { "✓" } else { "✗" };
7950 println!("[{short_ts}] {} {mark}: {text}", persona_label(from));
7951 }
7952 Line::Join { who } => println!("[{short_ts}] {} joined", persona_label(who)),
7953 }
7954 }
7955 }
7956 Ok(())
7957}
7958
7959fn group_join_pin_material(event: &Value) -> Option<(String, String, String, String)> {
7965 let body = match event.get("body") {
7966 Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok()?,
7967 Some(v) => v.clone(),
7968 None => return None,
7969 };
7970 let card = body.get("joiner_card")?;
7971 let mut tmp = json!({"agents": {}});
7973 crate::trust::add_agent_card_pin(&mut tmp, card, Some("UNTRUSTED"));
7974 if verify_message_v31(event, &tmp).is_err() {
7975 return None;
7976 }
7977 let did = card.get("did").and_then(Value::as_str)?.to_string();
7978 let handle = card
7979 .get("handle")
7980 .and_then(Value::as_str)
7981 .map(str::to_string)
7982 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
7983 let (kid_full, krec) = card
7984 .get("verify_keys")
7985 .and_then(Value::as_object)
7986 .and_then(|m| m.iter().next())?;
7987 let key_id = kid_full
7988 .strip_prefix("ed25519:")
7989 .unwrap_or(kid_full)
7990 .to_string();
7991 let key = krec.get("key").and_then(Value::as_str)?.to_string();
7992 Some((handle, did, key_id, key))
7993}
7994
7995fn cmd_group_invite(group_ref: &str, as_json: bool) -> Result<()> {
7998 let group = crate::group::resolve_group(group_ref)?;
7999 if group.slot_id.is_empty() || group.relay_url.is_empty() {
8000 bail!(
8001 "group `{}` has no room slot — nothing to invite into",
8002 group.name
8003 );
8004 }
8005 if group.creator_sig.is_empty() {
8006 bail!(
8007 "group `{}` roster is unsigned — add a member or recreate before inviting",
8008 group.name
8009 );
8010 }
8011 let payload = serde_json::to_vec(&group)?;
8012 let code = format!("wire-group:{}", crate::signing::b64encode(&payload));
8013 if as_json {
8014 println!(
8015 "{}",
8016 serde_json::to_string(&json!({"group": group.id, "name": group.name, "code": code}))?
8017 );
8018 } else {
8019 println!(
8020 "join code for `{}` — share ONLY with people you want in the room (it IS the room key):\n",
8021 group.name
8022 );
8023 println!("{code}\n");
8024 println!("they run: wire group join <code>");
8025 }
8026 Ok(())
8027}
8028
8029fn cmd_group_join(code: &str, as_json: bool) -> Result<()> {
8033 if !config::is_initialized()? {
8034 bail!("not initialized — run `wire up` first");
8035 }
8036 let raw = code.trim();
8037 let b64 = raw.strip_prefix("wire-group:").unwrap_or(raw);
8038 let payload =
8039 crate::signing::b64decode(b64).map_err(|_| anyhow!("invalid join code (not base64)"))?;
8040 let group: crate::group::Group = serde_json::from_slice(&payload)
8041 .map_err(|_| anyhow!("invalid join code (not a group payload)"))?;
8042 if group.slot_id.is_empty() || group.relay_url.is_empty() {
8043 bail!("join code carries no room coords");
8044 }
8045 let creator_key = group
8048 .members
8049 .iter()
8050 .find(|m| m.did == group.creator_did)
8051 .map(|m| m.key.clone())
8052 .filter(|k| !k.is_empty())
8053 .and_then(|k| crate::signing::b64decode(&k).ok())
8054 .ok_or_else(|| anyhow!("join code is missing the creator's key"))?;
8055 if !group.verify(&creator_key) {
8056 bail!("join code failed its signature check (tampered or corrupt)");
8057 }
8058 let (self_did, self_handle, _, _) = group_self()?;
8059 if group.creator_did == self_did {
8060 bail!("you created group `{}` — you're already in it", group.name);
8061 }
8062
8063 crate::group::save_group(&group)?;
8065 let mut trust = config::read_trust()?;
8066 for m in &group.members {
8067 if m.did == self_did || m.key.is_empty() {
8068 continue;
8069 }
8070 introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
8071 }
8072 config::write_trust(&trust)?;
8073
8074 let card = config::read_agent_card()?;
8076 let sk_seed = config::read_private_key()?;
8077 let pk_b64 = card
8078 .get("verify_keys")
8079 .and_then(Value::as_object)
8080 .and_then(|m| m.values().next())
8081 .and_then(|v| v.get("key"))
8082 .and_then(Value::as_str)
8083 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8084 let pk_bytes = crate::signing::b64decode(pk_b64)?;
8085 let now_iso = time::OffsetDateTime::now_utc()
8086 .format(&time::format_description::well_known::Rfc3339)
8087 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8088 let event = json!({
8089 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8090 "timestamp": now_iso,
8091 "from": self_did,
8092 "to": format!("did:wire:group:{}", group.id),
8093 "type": "group_join",
8094 "kind": parse_kind("group_join")?,
8095 "body": {
8096 "group_id": group.id,
8097 "group_name": group.name,
8098 "epoch": group.epoch,
8099 "joiner_card": card,
8100 "text": "joined",
8101 },
8102 });
8103 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
8104 .map_err(|e| anyhow!("signing group_join: {e:?}"))?;
8105 let client = crate::relay_client::RelayClient::new(&group.relay_url);
8106 let announced = client
8107 .post_event(&group.slot_id, &group.slot_token, &signed)
8108 .is_ok();
8109
8110 if as_json {
8111 println!(
8112 "{}",
8113 serde_json::to_string(&json!({
8114 "group": group.id, "name": group.name, "joined": true,
8115 "members": group.members.len(), "announced": announced
8116 }))?
8117 );
8118 } else {
8119 println!(
8120 "joined group `{}` ({} member(s)) at Introduced tier.",
8121 group.name,
8122 group.members.len()
8123 );
8124 if announced {
8125 println!(" announced to the room — members will verify your messages.");
8126 } else {
8127 println!(
8128 " ⚠ couldn't reach the room relay to announce; retry a `wire group send` so members can verify you."
8129 );
8130 }
8131 println!(
8132 " read: `wire group tail {}` talk: `wire group send {} \"hi\"`",
8133 group.id, group.id
8134 );
8135 }
8136 Ok(())
8137}
8138
8139fn cmd_group_list(as_json: bool) -> Result<()> {
8140 let groups = crate::group::list_groups()?;
8141 if as_json {
8142 let arr: Vec<Value> = groups
8143 .iter()
8144 .map(|g| {
8145 json!({
8146 "id": g.id,
8147 "name": g.name,
8148 "epoch": g.epoch,
8149 "members": g.members.iter().map(|m| json!({"handle": m.handle, "tier": m.tier.as_str()})).collect::<Vec<_>>(),
8150 })
8151 })
8152 .collect();
8153 println!("{}", serde_json::to_string(&json!({"groups": arr}))?);
8154 } else if groups.is_empty() {
8155 println!("no groups yet — create one with `wire group create <name>`");
8156 } else {
8157 for g in &groups {
8158 println!(
8159 "{} ({}) — {} member(s), epoch {}",
8160 g.name,
8161 g.id,
8162 g.members.len(),
8163 g.epoch
8164 );
8165 for m in &g.members {
8166 println!(" {} [{}]", m.handle, m.tier.as_str());
8167 }
8168 }
8169 }
8170 Ok(())
8171}
8172
8173fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
8176 match cmd {
8177 MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
8178 MeshCommand::Broadcast {
8179 kind,
8180 scope,
8181 exclude,
8182 noreply,
8183 body,
8184 json,
8185 } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
8186 MeshCommand::Role { action } => cmd_mesh_role(action),
8187 MeshCommand::Route {
8188 role,
8189 strategy,
8190 exclude,
8191 kind,
8192 body,
8193 json,
8194 } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
8195 }
8196}
8197
8198fn cmd_mesh_route(
8203 role: &str,
8204 strategy: &str,
8205 exclude: &[String],
8206 kind: &str,
8207 body_arg: &str,
8208 as_json: bool,
8209) -> Result<()> {
8210 use std::time::Instant;
8211
8212 if !config::is_initialized()? {
8213 bail!("not initialized — run `wire init <handle>` first");
8214 }
8215 let strategy = strategy.to_ascii_lowercase();
8216 if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
8217 bail!("unknown strategy `{strategy}` — use round-robin | first | random");
8218 }
8219
8220 let state = config::read_relay_state()?;
8223 let pinned: std::collections::BTreeSet<String> = state["peers"]
8224 .as_object()
8225 .map(|m| m.keys().cloned().collect())
8226 .unwrap_or_default();
8227
8228 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
8229
8230 let sessions = crate::session::list_sessions()?;
8235 let mut candidates: Vec<(String, Option<String>)> = Vec::new(); for s in &sessions {
8237 let handle = match s.handle.as_ref() {
8238 Some(h) => h.clone(),
8239 None => continue,
8240 };
8241 if exclude_set.contains(handle.as_str()) {
8242 continue;
8243 }
8244 if !pinned.contains(&handle) {
8245 continue;
8246 }
8247 let card_path = s
8248 .home_dir
8249 .join("config")
8250 .join("wire")
8251 .join("agent-card.json");
8252 let card_role = std::fs::read(&card_path)
8253 .ok()
8254 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
8255 .and_then(|c| {
8256 c.get("profile")
8257 .and_then(|p| p.get("role"))
8258 .and_then(Value::as_str)
8259 .map(str::to_string)
8260 });
8261 if card_role.as_deref() == Some(role) {
8262 candidates.push((handle, s.did.clone()));
8263 }
8264 }
8265
8266 candidates.sort_by(|a, b| a.0.cmp(&b.0));
8267 candidates.dedup_by(|a, b| a.0 == b.0);
8268
8269 if candidates.is_empty() {
8270 bail!(
8271 "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
8272 );
8273 }
8274
8275 let chosen = match strategy.as_str() {
8276 "first" => candidates[0].clone(),
8277 "random" => {
8278 use rand::Rng;
8279 let idx = rand::thread_rng().gen_range(0..candidates.len());
8280 candidates[idx].clone()
8281 }
8282 "round-robin" => {
8283 let cursor_path = mesh_route_cursor_path()?;
8288 let mut cursors: std::collections::BTreeMap<String, String> =
8289 read_mesh_route_cursors(&cursor_path);
8290 let last = cursors.get(role).cloned();
8291 let pick = match last {
8292 None => candidates[0].clone(),
8293 Some(last_h) => candidates
8294 .iter()
8295 .find(|(h, _)| h.as_str() > last_h.as_str())
8296 .cloned()
8297 .unwrap_or_else(|| candidates[0].clone()),
8298 };
8299 cursors.insert(role.to_string(), pick.0.clone());
8300 write_mesh_route_cursors(&cursor_path, &cursors)?;
8301 pick
8302 }
8303 _ => unreachable!(),
8304 };
8305
8306 let (chosen_handle, _chosen_did) = chosen;
8307
8308 let body_value: Value = if body_arg == "-" {
8310 use std::io::Read;
8311 let mut raw = String::new();
8312 std::io::stdin()
8313 .read_to_string(&mut raw)
8314 .with_context(|| "reading body from stdin")?;
8315 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
8316 } else if let Some(path) = body_arg.strip_prefix('@') {
8317 let raw =
8318 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
8319 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
8320 } else {
8321 Value::String(body_arg.to_string())
8322 };
8323
8324 let sk_seed = config::read_private_key()?;
8325 let card = config::read_agent_card()?;
8326 let did = card
8327 .get("did")
8328 .and_then(Value::as_str)
8329 .ok_or_else(|| anyhow!("agent-card missing did"))?
8330 .to_string();
8331 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
8332 let pk_b64 = card
8333 .get("verify_keys")
8334 .and_then(Value::as_object)
8335 .and_then(|m| m.values().next())
8336 .and_then(|v| v.get("key"))
8337 .and_then(Value::as_str)
8338 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8339 let pk_bytes = crate::signing::b64decode(pk_b64)?;
8340
8341 let kind_id = parse_kind(kind)?;
8342 let now_iso = time::OffsetDateTime::now_utc()
8343 .format(&time::format_description::well_known::Rfc3339)
8344 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8345
8346 let event = json!({
8347 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8348 "timestamp": now_iso,
8349 "from": did,
8350 "to": format!("did:wire:{chosen_handle}"),
8351 "type": kind,
8352 "kind": kind_id,
8353 "body": json!({
8354 "content": body_value,
8355 "routed_via": {
8356 "role": role,
8357 "strategy": strategy,
8358 },
8359 }),
8360 });
8361 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
8362 .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
8363 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
8364
8365 let line = serde_json::to_vec(&signed)?;
8366 config::append_outbox_record(&chosen_handle, &line)?;
8367
8368 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
8369 if endpoints.is_empty() {
8370 bail!(
8371 "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
8372 );
8373 }
8374 let start = Instant::now();
8375 let mut delivered = false;
8376 let mut last_err: Option<String> = None;
8377 let mut via_scope: Option<String> = None;
8378 for ep in &endpoints {
8379 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
8384 Ok(_) => {
8385 delivered = true;
8386 via_scope = Some(
8387 match ep.scope {
8388 crate::endpoints::EndpointScope::Local => "local",
8389 crate::endpoints::EndpointScope::Lan => "lan",
8390 crate::endpoints::EndpointScope::Uds => "uds",
8391 crate::endpoints::EndpointScope::Federation => "federation",
8392 }
8393 .to_string(),
8394 );
8395 break;
8396 }
8397 Err(e) => last_err = Some(format!("{e:#}")),
8398 }
8399 }
8400 let rtt_ms = start.elapsed().as_millis() as u64;
8401
8402 let summary = json!({
8403 "role": role,
8404 "strategy": strategy,
8405 "routed_to": chosen_handle,
8406 "event_id": event_id,
8407 "delivered": delivered,
8408 "delivered_via": via_scope,
8409 "rtt_ms": rtt_ms,
8410 "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
8411 "error": last_err,
8412 });
8413
8414 if as_json {
8415 println!("{}", serde_json::to_string(&summary)?);
8416 } else if delivered {
8417 let via = via_scope.as_deref().unwrap_or("?");
8418 println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
8419 } else {
8420 let err = last_err.as_deref().unwrap_or("no endpoints reachable");
8421 bail!("delivery to `{chosen_handle}` failed: {err}");
8422 }
8423 Ok(())
8424}
8425
8426fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
8427 Ok(config::state_dir()?.join("mesh-route-cursor.json"))
8428}
8429
8430fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
8431 std::fs::read(path)
8432 .ok()
8433 .and_then(|b| serde_json::from_slice(&b).ok())
8434 .unwrap_or_default()
8435}
8436
8437fn write_mesh_route_cursors(
8438 path: &std::path::Path,
8439 cursors: &std::collections::BTreeMap<String, String>,
8440) -> Result<()> {
8441 if let Some(parent) = path.parent() {
8442 std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
8443 }
8444 let body = serde_json::to_vec_pretty(cursors)?;
8445 std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
8446 Ok(())
8447}
8448
8449fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
8454 match action {
8455 MeshRoleAction::Set { role, json } => {
8456 validate_role_tag(&role)?;
8457 let new_profile =
8458 crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
8459 if json {
8460 println!(
8461 "{}",
8462 serde_json::to_string(&json!({
8463 "role": role,
8464 "profile": new_profile,
8465 }))?
8466 );
8467 } else {
8468 println!("self role = {role} (signed into agent-card)");
8469 }
8470 }
8471 MeshRoleAction::Get { peer, json } => {
8472 let (who, role) = match peer.as_deref() {
8473 None => {
8474 let card = config::read_agent_card()?;
8475 let role = card
8476 .get("profile")
8477 .and_then(|p| p.get("role"))
8478 .and_then(Value::as_str)
8479 .map(str::to_string);
8480 let who = card
8481 .get("did")
8482 .and_then(Value::as_str)
8483 .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
8484 .unwrap_or_else(|| "self".to_string());
8485 (who, role)
8486 }
8487 Some(handle) => {
8488 let bare = crate::agent_card::bare_handle(handle).to_string();
8489 let trust = config::read_trust()?;
8490 let role = trust
8491 .get("agents")
8492 .and_then(|a| a.get(&bare))
8493 .and_then(|a| a.get("card"))
8494 .and_then(|c| c.get("profile"))
8495 .and_then(|p| p.get("role"))
8496 .and_then(Value::as_str)
8497 .map(str::to_string);
8498 (bare, role)
8499 }
8500 };
8501 if json {
8502 println!(
8503 "{}",
8504 serde_json::to_string(&json!({
8505 "handle": who,
8506 "role": role,
8507 }))?
8508 );
8509 } else {
8510 match role {
8511 Some(r) => println!("{who}: {r}"),
8512 None => println!("{who}: (unset)"),
8513 }
8514 }
8515 }
8516 MeshRoleAction::List { json } => {
8517 let mut self_did: Option<String> = None;
8518 if let Ok(card) = config::read_agent_card() {
8519 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
8520 }
8521 let sessions = crate::session::list_sessions()?;
8522 let mut rows: Vec<Value> = Vec::new();
8523 for s in &sessions {
8524 let card_path = s
8525 .home_dir
8526 .join("config")
8527 .join("wire")
8528 .join("agent-card.json");
8529 let role = std::fs::read(&card_path)
8530 .ok()
8531 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
8532 .and_then(|c| {
8533 c.get("profile")
8534 .and_then(|p| p.get("role"))
8535 .and_then(Value::as_str)
8536 .map(str::to_string)
8537 });
8538 let is_self = match (&self_did, &s.did) {
8539 (Some(a), Some(b)) => a == b,
8540 _ => false,
8541 };
8542 rows.push(json!({
8543 "name": s.name,
8544 "handle": s.handle,
8545 "role": role,
8546 "self": is_self,
8547 }));
8548 }
8549 rows.sort_by(|a, b| {
8550 a["name"]
8551 .as_str()
8552 .unwrap_or("")
8553 .cmp(b["name"].as_str().unwrap_or(""))
8554 });
8555 if json {
8556 println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
8557 } else if rows.is_empty() {
8558 println!("no sister sessions on this machine.");
8559 } else {
8560 println!("SISTER ROLES (this machine):");
8561 for r in &rows {
8562 let name = r["name"].as_str().unwrap_or("?");
8563 let role = r["role"].as_str().unwrap_or("(unset)");
8564 let marker = if r["self"].as_bool().unwrap_or(false) {
8565 " ← you"
8566 } else {
8567 ""
8568 };
8569 println!(" {name:<24} {role}{marker}");
8570 }
8571 }
8572 }
8573 MeshRoleAction::Clear { json } => {
8574 let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
8575 if json {
8576 println!(
8577 "{}",
8578 serde_json::to_string(&json!({
8579 "cleared": true,
8580 "profile": new_profile,
8581 }))?
8582 );
8583 } else {
8584 println!("self role cleared");
8585 }
8586 }
8587 }
8588 Ok(())
8589}
8590
8591fn validate_role_tag(role: &str) -> Result<()> {
8596 if role.is_empty() {
8597 bail!("role must not be empty (use `wire mesh role --clear` to unset)");
8598 }
8599 if role.len() > 32 {
8600 bail!("role too long ({} chars; max 32)", role.len());
8601 }
8602 for c in role.chars() {
8603 if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
8604 bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
8605 }
8606 }
8607 Ok(())
8608}
8609
8610fn cmd_mesh_broadcast(
8630 kind: &str,
8631 scope_str: &str,
8632 exclude: &[String],
8633 _noreply: bool,
8634 body_arg: &str,
8635 as_json: bool,
8636) -> Result<()> {
8637 use std::time::Instant;
8638
8639 if !config::is_initialized()? {
8640 bail!("not initialized — run `wire init <handle>` first");
8641 }
8642
8643 let scope = match scope_str {
8644 "local" => crate::endpoints::EndpointScope::Local,
8645 "federation" => crate::endpoints::EndpointScope::Federation,
8646 "both" => {
8647 crate::endpoints::EndpointScope::Local
8651 }
8652 other => bail!("unknown scope `{other}` — use local | federation | both"),
8653 };
8654 let any_scope = scope_str == "both";
8655
8656 let state = config::read_relay_state()?;
8657 let peers = state["peers"].as_object().cloned().unwrap_or_default();
8658 if peers.is_empty() {
8659 bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
8660 }
8661
8662 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
8663
8664 struct Target {
8668 handle: String,
8669 endpoints: Vec<crate::endpoints::Endpoint>,
8670 }
8671 let mut targets: Vec<Target> = Vec::new();
8672 let mut skipped_wrong_scope: Vec<String> = Vec::new();
8673 let mut skipped_excluded: Vec<String> = Vec::new();
8674 for handle in peers.keys() {
8675 if exclude_set.contains(handle.as_str()) {
8676 skipped_excluded.push(handle.clone());
8677 continue;
8678 }
8679 let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
8680 let filtered: Vec<crate::endpoints::Endpoint> = ordered
8681 .into_iter()
8682 .filter(|ep| any_scope || ep.scope == scope)
8683 .collect();
8684 if filtered.is_empty() {
8685 skipped_wrong_scope.push(handle.clone());
8686 continue;
8687 }
8688 targets.push(Target {
8689 handle: handle.clone(),
8690 endpoints: filtered,
8691 });
8692 }
8693
8694 if targets.is_empty() {
8695 bail!(
8696 "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
8697 skipped_excluded.len(),
8698 skipped_wrong_scope.len()
8699 );
8700 }
8701
8702 let sk_seed = config::read_private_key()?;
8704 let card = config::read_agent_card()?;
8705 let did = card
8706 .get("did")
8707 .and_then(Value::as_str)
8708 .ok_or_else(|| anyhow!("agent-card missing did"))?
8709 .to_string();
8710 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
8711 let pk_b64 = card
8712 .get("verify_keys")
8713 .and_then(Value::as_object)
8714 .and_then(|m| m.values().next())
8715 .and_then(|v| v.get("key"))
8716 .and_then(Value::as_str)
8717 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8718 let pk_bytes = crate::signing::b64decode(pk_b64)?;
8719
8720 let body_value: Value = if body_arg == "-" {
8721 use std::io::Read;
8722 let mut raw = String::new();
8723 std::io::stdin()
8724 .read_to_string(&mut raw)
8725 .with_context(|| "reading body from stdin")?;
8726 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
8727 } else if let Some(path) = body_arg.strip_prefix('@') {
8728 let raw =
8729 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
8730 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
8731 } else {
8732 Value::String(body_arg.to_string())
8733 };
8734
8735 let kind_id = parse_kind(kind)?;
8736 let now_iso = time::OffsetDateTime::now_utc()
8737 .format(&time::format_description::well_known::Rfc3339)
8738 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8739
8740 let broadcast_id = generate_broadcast_id();
8741 let target_count = targets.len();
8742
8743 let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
8747 Vec::with_capacity(targets.len());
8748 for t in &targets {
8749 let body = json!({
8750 "content": body_value,
8751 "broadcast_id": broadcast_id,
8752 "broadcast_target_count": target_count,
8753 });
8754 let event = json!({
8755 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8756 "timestamp": now_iso,
8757 "from": did,
8758 "to": format!("did:wire:{}", t.handle),
8759 "type": kind,
8760 "kind": kind_id,
8761 "body": body,
8762 });
8763 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
8764 .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
8765 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
8766 signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
8767 }
8768
8769 for (peer, _, signed, _) in &signed_per_peer {
8773 let line = serde_json::to_vec(signed)?;
8774 config::append_outbox_record(peer, &line)?;
8775 }
8776
8777 use std::sync::mpsc;
8781 let (tx, rx) = mpsc::channel::<Value>();
8782 std::thread::scope(|s| {
8783 for (peer, endpoints, signed, event_id) in &signed_per_peer {
8784 let tx = tx.clone();
8785 let peer = peer.clone();
8786 let event_id = event_id.clone();
8787 let endpoints = endpoints.clone();
8788 let signed = signed.clone();
8789 s.spawn(move || {
8790 let start = Instant::now();
8791 let mut delivered = false;
8792 let mut last_err: Option<String> = None;
8793 let mut delivered_via: Option<String> = None;
8794 for ep in &endpoints {
8795 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
8800 Ok(_) => {
8801 delivered = true;
8802 delivered_via = Some(
8803 match ep.scope {
8804 crate::endpoints::EndpointScope::Local => "local",
8805 crate::endpoints::EndpointScope::Lan => "lan",
8806 crate::endpoints::EndpointScope::Uds => "uds",
8807 crate::endpoints::EndpointScope::Federation => "federation",
8808 }
8809 .to_string(),
8810 );
8811 break;
8812 }
8813 Err(e) => last_err = Some(format!("{e:#}")),
8814 }
8815 }
8816 let rtt_ms = start.elapsed().as_millis() as u64;
8817 let _ = tx.send(json!({
8818 "peer": peer,
8819 "event_id": event_id,
8820 "delivered": delivered,
8821 "delivered_via": delivered_via,
8822 "rtt_ms": rtt_ms,
8823 "error": last_err,
8824 }));
8825 });
8826 }
8827 });
8828 drop(tx);
8829
8830 let mut results: Vec<Value> = rx.iter().collect();
8831 results.sort_by(|a, b| {
8832 a["peer"]
8833 .as_str()
8834 .unwrap_or("")
8835 .cmp(b["peer"].as_str().unwrap_or(""))
8836 });
8837
8838 let delivered = results
8839 .iter()
8840 .filter(|r| r["delivered"].as_bool().unwrap_or(false))
8841 .count();
8842 let failed = results.len() - delivered;
8843
8844 let summary = json!({
8845 "broadcast_id": broadcast_id,
8846 "kind": kind,
8847 "scope": scope_str,
8848 "target_count": target_count,
8849 "delivered": delivered,
8850 "failed": failed,
8851 "skipped_excluded": skipped_excluded,
8852 "skipped_wrong_scope": skipped_wrong_scope,
8853 "results": results,
8854 });
8855
8856 if as_json {
8857 println!("{}", serde_json::to_string(&summary)?);
8858 return Ok(());
8859 }
8860
8861 println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
8862 for r in &results {
8863 let peer = r["peer"].as_str().unwrap_or("?");
8864 let delivered = r["delivered"].as_bool().unwrap_or(false);
8865 let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
8866 let via = r["delivered_via"].as_str().unwrap_or("");
8867 if delivered {
8868 println!(" {peer:<24} ✓ delivered ({rtt}ms, {via})");
8869 } else {
8870 let err = r["error"].as_str().unwrap_or("?");
8871 println!(" {peer:<24} ✗ failed — {err}");
8872 }
8873 }
8874 if !skipped_excluded.is_empty() {
8875 println!(" excluded: {}", skipped_excluded.join(", "));
8876 }
8877 if !skipped_wrong_scope.is_empty() {
8878 println!(
8879 " skipped (wrong scope): {}",
8880 skipped_wrong_scope.join(", ")
8881 );
8882 }
8883 println!("broadcast_id: {broadcast_id}");
8884 Ok(())
8885}
8886
8887fn generate_broadcast_id() -> String {
8891 use rand::RngCore;
8892 let mut buf = [0u8; 16];
8893 rand::thread_rng().fill_bytes(&mut buf);
8894 let h = hex::encode(buf);
8895 format!(
8896 "{}-{}-{}-{}-{}",
8897 &h[0..8],
8898 &h[8..12],
8899 &h[12..16],
8900 &h[16..20],
8901 &h[20..32],
8902 )
8903}
8904
8905fn cmd_session(cmd: SessionCommand) -> Result<()> {
8906 match cmd {
8907 SessionCommand::New {
8908 name,
8909 relay,
8910 with_local,
8911 local_relay,
8912 with_lan,
8913 lan_relay,
8914 with_uds,
8915 uds_socket,
8916 no_daemon,
8917 local_only,
8918 json,
8919 } => cmd_session_new(
8920 name.as_deref(),
8921 &relay,
8922 with_local,
8923 &local_relay,
8924 with_lan,
8925 lan_relay.as_deref(),
8926 with_uds,
8927 uds_socket.as_deref(),
8928 no_daemon,
8929 local_only,
8930 json,
8931 ),
8932 SessionCommand::List { json } => cmd_session_list(json),
8933 SessionCommand::ListLocal { json } => cmd_session_list_local(json),
8934 SessionCommand::PairAllLocal {
8935 settle_secs,
8936 federation_relay,
8937 json,
8938 } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
8939 SessionCommand::MeshStatus { stale_secs, json } => {
8940 cmd_session_mesh_status(stale_secs, json)
8941 }
8942 SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
8943 SessionCommand::Current { json } => cmd_session_current(json),
8944 SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
8945 SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
8946 }
8947}
8948
8949fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
8950 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
8951 let cwd_str = crate::session::normalize_cwd_key(&cwd);
8952
8953 let resolved_name = match name_arg {
8954 Some(n) => crate::session::sanitize_name(n),
8955 None => crate::session::sanitize_name(
8956 cwd.file_name()
8957 .and_then(|s| s.to_str())
8958 .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
8959 ),
8960 };
8961
8962 let session_home = crate::session::session_dir(&resolved_name)?;
8963 if !session_home.exists() {
8964 bail!(
8965 "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
8966 session_home.display()
8967 );
8968 }
8969
8970 let prior = crate::session::read_registry()
8971 .ok()
8972 .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
8973 if prior.as_deref() == Some(resolved_name.as_str()) {
8974 if json {
8975 println!(
8976 "{}",
8977 serde_json::to_string(&json!({
8978 "cwd": cwd_str,
8979 "session": resolved_name,
8980 "changed": false,
8981 }))?
8982 );
8983 } else {
8984 println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
8985 }
8986 return Ok(());
8987 }
8988 if let Some(prior_name) = &prior {
8989 eprintln!(
8990 "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
8991 );
8992 }
8993
8994 crate::session::update_registry(|reg| {
8995 reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
8996 Ok(())
8997 })?;
8998
8999 if json {
9000 println!(
9001 "{}",
9002 serde_json::to_string(&json!({
9003 "cwd": cwd_str,
9004 "session": resolved_name,
9005 "changed": true,
9006 "previous": prior,
9007 }))?
9008 );
9009 } else {
9010 println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
9011 println!("(next `wire` invocation from this cwd will auto-detect into this session)");
9012 }
9013 Ok(())
9014}
9015
9016fn resolve_session_name(name: Option<&str>) -> Result<String> {
9017 if let Some(n) = name {
9018 return Ok(crate::session::sanitize_name(n));
9019 }
9020 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9021 let registry = crate::session::read_registry().unwrap_or_default();
9022 Ok(crate::session::derive_name_from_cwd(&cwd, ®istry))
9023}
9024
9025#[allow(clippy::too_many_arguments)] fn cmd_session_new(
9029 name_arg: Option<&str>,
9030 relay: &str,
9031 with_local: bool,
9032 local_relay: &str,
9033 with_lan: bool,
9034 lan_relay: Option<&str>,
9035 with_uds: bool,
9036 uds_socket: Option<&std::path::Path>,
9037 no_daemon: bool,
9038 local_only: bool,
9039 as_json: bool,
9040) -> Result<()> {
9041 let with_local = with_local || local_only;
9044 if with_lan && lan_relay.is_none() {
9046 bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
9047 }
9048 if with_uds && uds_socket.is_none() {
9050 bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
9051 }
9052 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9053 let mut registry = crate::session::read_registry().unwrap_or_default();
9054 let name = match name_arg {
9055 Some(n) => crate::session::sanitize_name(n),
9056 None => crate::session::derive_name_from_cwd(&cwd, ®istry),
9057 };
9058 let session_home = crate::session::session_dir(&name)?;
9059
9060 let already_exists = session_home.exists()
9061 && session_home
9062 .join("config")
9063 .join("wire")
9064 .join("agent-card.json")
9065 .exists();
9066 if already_exists {
9067 registry
9071 .by_cwd
9072 .insert(cwd.to_string_lossy().into_owned(), name.clone());
9073 crate::session::write_registry(®istry)?;
9074 let info = render_session_info(&name, &session_home, &cwd)?;
9075 emit_session_new_result(&info, "already_exists", as_json)?;
9076 if !no_daemon {
9077 ensure_session_daemon(&session_home)?;
9078 }
9079 return Ok(());
9080 }
9081
9082 std::fs::create_dir_all(&session_home)
9083 .with_context(|| format!("creating session dir {session_home:?}"))?;
9084
9085 let init_args: Vec<&str> = if local_only {
9094 vec!["init", &name, "--offline"]
9095 } else {
9096 vec!["init", &name, "--relay", relay]
9097 };
9098 let init_status = run_wire_with_home(&session_home, &init_args)?;
9099 if !init_status.success() {
9100 let how = if local_only {
9101 format!("`wire init {name}` (local-only)")
9102 } else {
9103 format!("`wire init {name} --relay {relay}`")
9104 };
9105 bail!("{how} failed inside session dir {session_home:?}");
9106 }
9107
9108 let effective_handle = if local_only {
9113 name.clone()
9114 } else {
9115 let mut claim_attempt = 0u32;
9116 let mut effective = name.clone();
9117 loop {
9118 claim_attempt += 1;
9119 let status =
9120 run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
9121 if status.success() {
9122 break;
9123 }
9124 if claim_attempt >= 5 {
9125 bail!(
9126 "5 failed attempts to claim a handle on {relay} for session {name}. \
9127 Try `wire session destroy {name} --force` and re-run with a different name, \
9128 or use `--local-only` if you don't need a federation address."
9129 );
9130 }
9131 let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
9132 let suffix = crate::session::derive_name_from_cwd(&attempt_path, ®istry);
9133 let token = suffix
9134 .rsplit('-')
9135 .next()
9136 .filter(|t| t.len() == 4)
9137 .map(str::to_string)
9138 .unwrap_or_else(|| format!("{claim_attempt}"));
9139 effective = format!("{name}-{token}");
9140 }
9141 effective
9142 };
9143
9144 registry
9147 .by_cwd
9148 .insert(cwd.to_string_lossy().into_owned(), name.clone());
9149 crate::session::write_registry(®istry)?;
9150
9151 if with_local {
9162 try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
9163 if local_only {
9164 let relay_state_path = session_home.join("config").join("wire").join("relay.json");
9169 let state: Value = std::fs::read(&relay_state_path)
9170 .ok()
9171 .and_then(|b| serde_json::from_slice(&b).ok())
9172 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
9173 let endpoints = crate::endpoints::self_endpoints(&state);
9174 let has_local = endpoints
9175 .iter()
9176 .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
9177 if !has_local {
9178 bail!(
9179 "--local-only requested but local-relay probe at {local_relay} failed — \
9180 ensure the local relay is running (`wire service install --local-relay`), \
9181 then re-run `wire session new {name} --local-only`."
9182 );
9183 }
9184 }
9185 }
9186
9187 if with_lan && let Some(lan_url) = lan_relay {
9191 try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
9192 }
9193 if with_uds && let Some(socket_path) = uds_socket {
9195 try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
9196 }
9197
9198 if !no_daemon {
9199 ensure_session_daemon(&session_home)?;
9200 }
9201
9202 let info = render_session_info(&name, &session_home, &cwd)?;
9203 emit_session_new_result(&info, "created", as_json)
9204}
9205
9206#[cfg(unix)]
9216fn try_allocate_uds_slot(
9217 session_home: &std::path::Path,
9218 handle: &str,
9219 uds_socket: &std::path::Path,
9220) {
9221 let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
9224 Ok((200, _)) => true,
9225 Ok((status, body)) => {
9226 eprintln!(
9227 "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
9228 String::from_utf8_lossy(&body)
9229 );
9230 return;
9231 }
9232 Err(e) => {
9233 eprintln!(
9234 "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
9235 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
9236 );
9237 return;
9238 }
9239 };
9240 if !healthz {
9241 return;
9242 }
9243
9244 let alloc_body = serde_json::json!({"handle": handle}).to_string();
9246 let (status, body) = match crate::relay_client::uds_request(
9247 uds_socket,
9248 "POST",
9249 "/v1/slot/allocate",
9250 &[("Content-Type", "application/json")],
9251 alloc_body.as_bytes(),
9252 ) {
9253 Ok(r) => r,
9254 Err(e) => {
9255 eprintln!(
9256 "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
9257 );
9258 return;
9259 }
9260 };
9261 if status >= 300 {
9262 eprintln!(
9263 "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
9264 String::from_utf8_lossy(&body)
9265 );
9266 return;
9267 }
9268 let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
9269 Ok(a) => a,
9270 Err(e) => {
9271 eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
9272 return;
9273 }
9274 };
9275
9276 let state_path = session_home.join("config").join("wire").join("relay.json");
9277 let mut state: serde_json::Value = std::fs::read(&state_path)
9278 .ok()
9279 .and_then(|b| serde_json::from_slice(&b).ok())
9280 .unwrap_or_else(|| serde_json::json!({}));
9281
9282 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
9283 .get("self")
9284 .and_then(|s| s.get("endpoints"))
9285 .and_then(|e| e.as_array())
9286 .map(|arr| {
9287 arr.iter()
9288 .filter_map(|v| {
9289 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
9290 })
9291 .collect()
9292 })
9293 .unwrap_or_default();
9294 endpoints.push(crate::endpoints::Endpoint::uds(
9295 format!("unix://{}", uds_socket.display()),
9296 alloc.slot_id.clone(),
9297 alloc.slot_token.clone(),
9298 ));
9299
9300 let self_obj = state
9301 .as_object_mut()
9302 .expect("relay_state root is an object")
9303 .entry("self")
9304 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9305 if !self_obj.is_object() {
9306 *self_obj = serde_json::Value::Object(serde_json::Map::new());
9307 }
9308 if let Some(obj) = self_obj.as_object_mut() {
9309 obj.insert(
9310 "endpoints".into(),
9311 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9312 );
9313 }
9314 if let Err(e) = std::fs::write(
9315 &state_path,
9316 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
9317 ) {
9318 eprintln!("wire session new: failed to write {state_path:?}: {e}");
9319 return;
9320 }
9321 eprintln!(
9322 "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
9323 uds_socket.display(),
9324 alloc.slot_id
9325 );
9326}
9327
9328#[cfg(not(unix))]
9329fn try_allocate_uds_slot(
9330 _session_home: &std::path::Path,
9331 _handle: &str,
9332 _uds_socket: &std::path::Path,
9333) {
9334 eprintln!(
9335 "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
9336 );
9337}
9338
9339fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
9349 let probe = match crate::relay_client::build_blocking_client(Some(
9350 std::time::Duration::from_millis(500),
9351 )) {
9352 Ok(c) => c,
9353 Err(e) => {
9354 eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
9355 return;
9356 }
9357 };
9358 let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
9359 match probe.get(&healthz_url).send() {
9360 Ok(resp) if resp.status().is_success() => {}
9361 Ok(resp) => {
9362 eprintln!(
9363 "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
9364 resp.status()
9365 );
9366 return;
9367 }
9368 Err(e) => {
9369 eprintln!(
9370 "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
9371 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
9372 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
9373 );
9374 return;
9375 }
9376 };
9377
9378 let lan_client = crate::relay_client::RelayClient::new(lan_relay);
9379 let alloc = match lan_client.allocate_slot(Some(handle)) {
9380 Ok(a) => a,
9381 Err(e) => {
9382 eprintln!(
9383 "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
9384 );
9385 return;
9386 }
9387 };
9388
9389 let state_path = session_home.join("config").join("wire").join("relay.json");
9390 let mut state: serde_json::Value = std::fs::read(&state_path)
9391 .ok()
9392 .and_then(|b| serde_json::from_slice(&b).ok())
9393 .unwrap_or_else(|| serde_json::json!({}));
9394
9395 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
9398 .get("self")
9399 .and_then(|s| s.get("endpoints"))
9400 .and_then(|e| e.as_array())
9401 .map(|arr| {
9402 arr.iter()
9403 .filter_map(|v| {
9404 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
9405 })
9406 .collect()
9407 })
9408 .unwrap_or_default();
9409 endpoints.push(crate::endpoints::Endpoint::lan(
9410 lan_relay.trim_end_matches('/').to_string(),
9411 alloc.slot_id.clone(),
9412 alloc.slot_token.clone(),
9413 ));
9414
9415 let self_obj = state
9416 .as_object_mut()
9417 .expect("relay_state root is an object")
9418 .entry("self")
9419 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9420 if !self_obj.is_object() {
9421 *self_obj = serde_json::Value::Object(serde_json::Map::new());
9422 }
9423 if let Some(obj) = self_obj.as_object_mut() {
9424 obj.insert(
9425 "endpoints".into(),
9426 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9427 );
9428 }
9429 if let Err(e) = std::fs::write(
9430 &state_path,
9431 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
9432 ) {
9433 eprintln!("wire session new: failed to write {state_path:?}: {e}");
9434 return;
9435 }
9436 eprintln!(
9437 "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
9438 alloc.slot_id
9439 );
9440}
9441
9442fn try_allocate_local_slot(
9450 session_home: &std::path::Path,
9451 handle: &str,
9452 _federation_relay: &str,
9453 local_relay: &str,
9454) {
9455 let probe = match crate::relay_client::build_blocking_client(Some(
9458 std::time::Duration::from_millis(500),
9459 )) {
9460 Ok(c) => c,
9461 Err(e) => {
9462 eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
9463 return;
9464 }
9465 };
9466 let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
9467 match probe.get(&healthz_url).send() {
9468 Ok(resp) if resp.status().is_success() => {}
9469 Ok(resp) => {
9470 eprintln!(
9471 "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
9472 resp.status()
9473 );
9474 return;
9475 }
9476 Err(e) => {
9477 eprintln!(
9478 "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
9479 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
9480 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
9481 );
9482 return;
9483 }
9484 };
9485
9486 let local_client = crate::relay_client::RelayClient::new(local_relay);
9488 let alloc = match local_client.allocate_slot(Some(handle)) {
9489 Ok(a) => a,
9490 Err(e) => {
9491 eprintln!(
9492 "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
9493 );
9494 return;
9495 }
9496 };
9497
9498 let state_path = session_home.join("config").join("wire").join("relay.json");
9513 let mut state: serde_json::Value = std::fs::read(&state_path)
9514 .ok()
9515 .and_then(|b| serde_json::from_slice(&b).ok())
9516 .unwrap_or_else(|| serde_json::json!({}));
9517 let fed_endpoint = state.get("self").and_then(|s| {
9520 let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
9521 let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
9522 let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
9523 Some(crate::endpoints::Endpoint::federation(
9524 url.to_string(),
9525 slot_id.to_string(),
9526 slot_token.to_string(),
9527 ))
9528 });
9529
9530 let local_endpoint = crate::endpoints::Endpoint::local(
9531 local_relay.trim_end_matches('/').to_string(),
9532 alloc.slot_id.clone(),
9533 alloc.slot_token.clone(),
9534 );
9535
9536 let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
9537 if let Some(f) = fed_endpoint.clone() {
9538 endpoints.push(f);
9539 }
9540 endpoints.push(local_endpoint);
9541
9542 let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
9552 Some(f) => (f.relay_url, f.slot_id, f.slot_token),
9553 None => (
9554 local_relay.trim_end_matches('/').to_string(),
9555 alloc.slot_id.clone(),
9556 alloc.slot_token.clone(),
9557 ),
9558 };
9559 let self_obj = state
9560 .as_object_mut()
9561 .expect("relay_state root is an object")
9562 .entry("self")
9563 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9564 if !self_obj.is_object() {
9567 *self_obj = serde_json::Value::Object(serde_json::Map::new());
9568 }
9569 if let Some(obj) = self_obj.as_object_mut() {
9570 obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
9571 obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
9572 obj.insert(
9573 "slot_token".into(),
9574 serde_json::Value::String(legacy_slot_token),
9575 );
9576 obj.insert(
9577 "endpoints".into(),
9578 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9579 );
9580 }
9581
9582 if let Err(e) = std::fs::write(
9583 &state_path,
9584 serde_json::to_vec_pretty(&state).unwrap_or_default(),
9585 ) {
9586 eprintln!(
9587 "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
9588 );
9589 return;
9590 }
9591 eprintln!(
9592 "wire session new: local slot allocated on {local_relay} (slot_id={})",
9593 alloc.slot_id
9594 );
9595}
9596
9597fn render_session_info(
9598 name: &str,
9599 session_home: &std::path::Path,
9600 cwd: &std::path::Path,
9601) -> Result<serde_json::Value> {
9602 let card_path = session_home
9603 .join("config")
9604 .join("wire")
9605 .join("agent-card.json");
9606 let (did, handle) = if card_path.exists() {
9607 let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
9608 let did = card
9609 .get("did")
9610 .and_then(Value::as_str)
9611 .unwrap_or("")
9612 .to_string();
9613 let handle = card
9614 .get("handle")
9615 .and_then(Value::as_str)
9616 .map(str::to_string)
9617 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
9618 (did, handle)
9619 } else {
9620 (String::new(), String::new())
9621 };
9622 Ok(json!({
9623 "name": name,
9624 "home_dir": session_home.to_string_lossy(),
9625 "cwd": cwd.to_string_lossy(),
9626 "did": did,
9627 "handle": handle,
9628 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
9629 }))
9630}
9631
9632fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
9633 if as_json {
9634 let mut obj = info.clone();
9635 obj["status"] = json!(status);
9636 println!("{}", serde_json::to_string(&obj)?);
9637 } else {
9638 let name = info["name"].as_str().unwrap_or("?");
9639 let handle = info["handle"].as_str().unwrap_or("?");
9640 let home = info["home_dir"].as_str().unwrap_or("?");
9641 let did = info["did"].as_str().unwrap_or("?");
9642 let export = info["export"].as_str().unwrap_or("?");
9643 let prefix = if status == "already_exists" {
9644 "session already exists (re-registered cwd)"
9645 } else {
9646 "session created"
9647 };
9648 println!(
9649 "{prefix}\n name: {name}\n handle: {handle}\n did: {did}\n home: {home}\n\nactivate with:\n {export}"
9650 );
9651 }
9652 Ok(())
9653}
9654
9655fn run_wire_with_home(
9656 session_home: &std::path::Path,
9657 args: &[&str],
9658) -> Result<std::process::ExitStatus> {
9659 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
9660 let status = std::process::Command::new(&bin)
9661 .env("WIRE_HOME", session_home)
9662 .env_remove("RUST_LOG")
9663 .env("WIRE_AUTO_INIT", "0")
9666 .args(args)
9667 .status()
9668 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
9669 Ok(status)
9670}
9671
9672pub fn maybe_auto_init_cwd_session(label: &str) {
9691 if std::env::var("WIRE_HOME").is_ok() {
9692 return; }
9694 if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
9695 return; }
9697 let cwd = match std::env::current_dir() {
9698 Ok(c) => c,
9699 Err(_) => return,
9700 };
9701 if crate::session::detect_session_wire_home(&cwd).is_some() {
9704 return;
9705 }
9706
9707 use fs2::FileExt;
9724 let sessions_root = match crate::session::sessions_root() {
9725 Ok(r) => r,
9726 Err(_) => return,
9727 };
9728 if let Err(e) = std::fs::create_dir_all(&sessions_root) {
9729 eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
9730 return;
9731 }
9732 let lock_path = sessions_root.join(".auto-init.lock");
9733 let lock_file = match std::fs::OpenOptions::new()
9734 .create(true)
9735 .truncate(false)
9736 .read(true)
9737 .write(true)
9738 .open(&lock_path)
9739 {
9740 Ok(f) => f,
9741 Err(e) => {
9742 eprintln!(
9743 "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
9744 );
9745 return;
9746 }
9747 };
9748 if let Err(e) = lock_file.lock_exclusive() {
9749 eprintln!(
9750 "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
9751 );
9752 return;
9753 }
9754 let registry = crate::session::read_registry().unwrap_or_default();
9759 let name = crate::session::derive_name_from_cwd(&cwd, ®istry);
9760 let session_home = match crate::session::session_dir(&name) {
9761 Ok(h) => h,
9762 Err(_) => {
9763 let _ = fs2::FileExt::unlock(&lock_file);
9764 return;
9765 }
9766 };
9767 let agent_card_path = session_home
9768 .join("config")
9769 .join("wire")
9770 .join("agent-card.json");
9771 let needs_init = !agent_card_path.exists();
9772
9773 if needs_init {
9774 if let Err(e) = std::fs::create_dir_all(&session_home) {
9775 eprintln!(
9776 "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
9777 );
9778 let _ = fs2::FileExt::unlock(&lock_file);
9779 return;
9780 }
9781 match run_wire_with_home(&session_home, &["init", &name, "--offline"]) {
9786 Ok(status) if status.success() => {}
9787 Ok(status) => {
9788 eprintln!(
9789 "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
9790 );
9791 let _ = fs2::FileExt::unlock(&lock_file);
9792 return;
9793 }
9794 Err(e) => {
9795 eprintln!(
9796 "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
9797 );
9798 let _ = fs2::FileExt::unlock(&lock_file);
9799 return;
9800 }
9801 }
9802 try_allocate_local_slot(
9809 &session_home,
9810 &name,
9811 "https://wireup.net",
9812 "http://127.0.0.1:8771",
9813 );
9814 } else {
9815 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
9819 eprintln!(
9820 "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
9821 );
9822 }
9823 }
9824 let cwd_key = crate::session::normalize_cwd_key(&cwd);
9834 let name_for_reg = name.clone();
9835 if let Err(e) = crate::session::update_registry(|reg| {
9836 reg.by_cwd.insert(cwd_key, name_for_reg);
9837 Ok(())
9838 }) {
9839 eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
9840 }
9842 let _ = fs2::FileExt::unlock(&lock_file);
9845
9846 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
9847 eprintln!(
9848 "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
9849 cwd.display(),
9850 session_home.display()
9851 );
9852 }
9853 unsafe {
9856 std::env::set_var("WIRE_HOME", &session_home);
9857 }
9858}
9859
9860fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
9861 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
9864 if pidfile.exists() {
9865 let bytes = std::fs::read(&pidfile).unwrap_or_default();
9866 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
9867 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
9868 } else {
9869 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
9870 };
9871 if let Some(p) = pid {
9872 let alive = {
9873 #[cfg(target_os = "linux")]
9874 {
9875 std::path::Path::new(&format!("/proc/{p}")).exists()
9876 }
9877 #[cfg(not(target_os = "linux"))]
9878 {
9879 std::process::Command::new("kill")
9880 .args(["-0", &p.to_string()])
9881 .output()
9882 .map(|o| o.status.success())
9883 .unwrap_or(false)
9884 }
9885 };
9886 if alive {
9887 return Ok(());
9888 }
9889 }
9890 }
9891
9892 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
9895 let log_path = session_home.join("state").join("wire").join("daemon.log");
9896 if let Some(parent) = log_path.parent() {
9897 std::fs::create_dir_all(parent).ok();
9898 }
9899 let log_file = std::fs::OpenOptions::new()
9900 .create(true)
9901 .append(true)
9902 .open(&log_path)
9903 .with_context(|| format!("opening daemon log {log_path:?}"))?;
9904 let log_err = log_file.try_clone()?;
9905 std::process::Command::new(&bin)
9906 .env("WIRE_HOME", session_home)
9907 .env_remove("RUST_LOG")
9908 .args(["daemon", "--interval", "5"])
9909 .stdout(log_file)
9910 .stderr(log_err)
9911 .stdin(std::process::Stdio::null())
9912 .spawn()
9913 .with_context(|| "spawning session-local `wire daemon`")?;
9914 Ok(())
9915}
9916
9917fn cmd_session_list(as_json: bool) -> Result<()> {
9918 let items = crate::session::list_sessions()?;
9919 if as_json {
9920 println!("{}", serde_json::to_string(&items)?);
9921 return Ok(());
9922 }
9923 if items.is_empty() {
9924 println!("no sessions on this machine. `wire session new` to create one.");
9925 return Ok(());
9926 }
9927 println!(
9928 "{:<22} {:<24} {:<24} {:<10} CWD",
9929 "PERSONA", "NAME", "HANDLE", "DAEMON"
9930 );
9931 for s in items {
9932 let plain = s
9936 .character
9937 .as_ref()
9938 .map(|c| c.short())
9939 .unwrap_or_else(|| "?".to_string());
9940 let colored = s
9941 .character
9942 .as_ref()
9943 .map(|c| c.colored())
9944 .unwrap_or_else(|| "?".to_string());
9945 let displayed_width = plain.chars().count() + 1; let pad = 22usize.saturating_sub(displayed_width);
9950 println!(
9951 "{}{} {:<24} {:<24} {:<10} {}",
9952 colored,
9953 " ".repeat(pad),
9954 s.name,
9955 s.handle.as_deref().unwrap_or("?"),
9956 if s.daemon_running { "running" } else { "down" },
9957 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
9958 );
9959 }
9960 Ok(())
9961}
9962
9963fn cmd_session_list_local(as_json: bool) -> Result<()> {
9975 let listing = crate::session::list_local_sessions()?;
9976 if as_json {
9977 println!("{}", serde_json::to_string(&listing)?);
9978 return Ok(());
9979 }
9980
9981 if listing.local.is_empty() && listing.federation_only.is_empty() {
9982 println!(
9983 "no sessions on this machine. `wire session new --with-local` to create one \
9984 with a local-relay endpoint (start the relay first: \
9985 `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
9986 );
9987 return Ok(());
9988 }
9989
9990 if listing.local.is_empty() {
9991 println!(
9992 "no sister sessions reachable via a local relay. \
9993 Re-run `wire session new --with-local` to add a Local endpoint, or \
9994 start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
9995 );
9996 } else {
9997 let mut keys: Vec<&String> = listing.local.keys().collect();
9999 keys.sort();
10000 for relay_url in keys {
10001 let group = &listing.local[relay_url];
10002 println!("LOCAL RELAY: {relay_url}");
10003 println!(" {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
10004 for s in group {
10005 println!(
10006 " {:<24} {:<32} {:<10} {}",
10007 s.name,
10008 s.handle.as_deref().unwrap_or("?"),
10009 if s.daemon_running { "running" } else { "down" },
10010 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
10011 );
10012 }
10013 println!();
10014 }
10015 }
10016
10017 if !listing.federation_only.is_empty() {
10018 println!("federation-only (no local endpoint):");
10019 for s in &listing.federation_only {
10020 println!(
10021 " {:<24} {:<32} {}",
10022 s.name,
10023 s.handle.as_deref().unwrap_or("?"),
10024 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
10025 );
10026 }
10027 }
10028 Ok(())
10029}
10030
10031fn cmd_session_pair_all_local(
10050 settle_secs: u64,
10051 federation_relay: &str,
10052 as_json: bool,
10053) -> Result<()> {
10054 use std::collections::BTreeSet;
10055 use std::time::Duration;
10056
10057 let listing = crate::session::list_local_sessions()?;
10058 let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
10062 Default::default();
10063 for group in listing.local.into_values() {
10064 for s in group {
10065 by_name.entry(s.name.clone()).or_insert(s);
10066 }
10067 }
10068 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
10069
10070 if sessions.len() < 2 {
10071 let msg = format!(
10072 "{} sister session(s) with a local endpoint — need at least 2 to pair.",
10073 sessions.len()
10074 );
10075 if as_json {
10076 println!(
10077 "{}",
10078 serde_json::to_string(&json!({
10079 "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
10080 "pairs_attempted": 0,
10081 "pairs_succeeded": 0,
10082 "pairs_skipped_already_paired": 0,
10083 "pairs_failed": 0,
10084 "note": msg,
10085 }))?
10086 );
10087 } else {
10088 println!("{msg}");
10089 if let Some(s) = sessions.first() {
10090 println!(" - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
10091 }
10092 println!("Use `wire session new --with-local` to add more.");
10093 }
10094 return Ok(());
10095 }
10096
10097 let fed_host = host_of_url(federation_relay);
10098 if fed_host.is_empty() {
10099 bail!(
10100 "federation_relay `{federation_relay}` has no parseable host — \
10101 pass a full URL like `https://wireup.net`."
10102 );
10103 }
10104
10105 let mut attempted = 0u32;
10107 let mut succeeded = 0u32;
10108 let mut skipped_already = 0u32;
10109 let mut failed = 0u32;
10110 let mut per_pair: Vec<Value> = Vec::new();
10111
10112 for i in 0..sessions.len() {
10113 for j in (i + 1)..sessions.len() {
10114 let a = &sessions[i];
10115 let b = &sessions[j];
10116 attempted += 1;
10117
10118 let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
10124 let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
10125 let a_pinned_b = session_has_peer(&a.home_dir, b_handle);
10126 let b_pinned_a = session_has_peer(&b.home_dir, a_handle);
10127 if a_pinned_b && b_pinned_a {
10128 skipped_already += 1;
10129 per_pair.push(json!({
10130 "from": a.name,
10131 "to": b.name,
10132 "status": "already_paired",
10133 }));
10134 continue;
10135 }
10136
10137 let pair_result = drive_bilateral_pair(
10138 &a.home_dir,
10139 &a.name,
10140 &b.home_dir,
10141 &b.name,
10142 &fed_host,
10143 federation_relay,
10144 settle_secs,
10145 );
10146
10147 match pair_result {
10148 Ok(()) => {
10149 succeeded += 1;
10150 per_pair.push(json!({
10151 "from": a.name,
10152 "to": b.name,
10153 "status": "paired",
10154 }));
10155 }
10156 Err(e) => {
10157 failed += 1;
10158 let detail = format!("{e:#}");
10159 per_pair.push(json!({
10160 "from": a.name,
10161 "to": b.name,
10162 "status": "failed",
10163 "error": detail,
10164 }));
10165 }
10166 }
10167
10168 std::thread::sleep(Duration::from_millis(200));
10171 }
10172 }
10173
10174 let _ = BTreeSet::<String>::new(); let summary = json!({
10176 "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
10177 "pairs_attempted": attempted,
10178 "pairs_succeeded": succeeded,
10179 "pairs_skipped_already_paired": skipped_already,
10180 "pairs_failed": failed,
10181 "results": per_pair,
10182 });
10183 if as_json {
10184 println!("{}", serde_json::to_string(&summary)?);
10185 } else {
10186 println!(
10187 "wire session pair-all-local: {} session(s), {} pair(s) attempted",
10188 sessions.len(),
10189 attempted
10190 );
10191 println!(" paired: {succeeded}");
10192 println!(" skipped (already pinned): {skipped_already}");
10193 println!(" failed: {failed}");
10194 for entry in summary["results"].as_array().unwrap_or(&vec![]) {
10195 let from = entry["from"].as_str().unwrap_or("?");
10196 let to = entry["to"].as_str().unwrap_or("?");
10197 let status = entry["status"].as_str().unwrap_or("?");
10198 let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
10199 if err.is_empty() {
10200 println!(" {from:<24} ↔ {to:<24} {status}");
10201 } else {
10202 println!(" {from:<24} ↔ {to:<24} {status} — {err}");
10203 }
10204 }
10205 }
10206 Ok(())
10207}
10208
10209fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
10212 val_session_relay_state(session_home)
10213 .and_then(|v| v.get("peers").cloned())
10214 .and_then(|p| p.get(peer_name).cloned())
10215 .is_some()
10216}
10217
10218fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
10223 let path = session_home.join("config").join("wire").join("relay.json");
10224 let bytes = std::fs::read(&path).ok()?;
10225 serde_json::from_slice(&bytes).ok()
10226}
10227
10228fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
10232 use std::collections::BTreeMap;
10233
10234 let listing = crate::session::list_local_sessions()?;
10237 let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
10238 for group in listing.local.into_values() {
10239 for s in group {
10240 by_name.entry(s.name.clone()).or_insert(s);
10241 }
10242 }
10243 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
10244 let federation_only = listing.federation_only;
10245
10246 if sessions.is_empty() {
10247 let msg = "no sister sessions with a local endpoint on this machine.".to_string();
10248 if as_json {
10249 println!(
10250 "{}",
10251 serde_json::to_string(&json!({
10252 "sessions": [],
10253 "edges": [],
10254 "local_relay": null,
10255 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
10256 "summary": {
10257 "session_count": 0,
10258 "edge_count": 0,
10259 "healthy": 0,
10260 "stale": 0,
10261 "asymmetric": 0,
10262 },
10263 "note": msg,
10264 }))?
10265 );
10266 } else {
10267 println!("{msg}");
10268 println!("Use `wire session new --with-local` to create one.");
10269 }
10270 return Ok(());
10271 }
10272
10273 struct SessionState {
10275 view: crate::session::LocalSessionView,
10276 relay_state: Value,
10277 local_relay_url: Option<String>,
10278 }
10279 let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
10280 for s in sessions {
10281 let relay_state = val_session_relay_state(&s.home_dir)
10282 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
10283 let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
10284 sstates.push(SessionState {
10285 view: s,
10286 relay_state,
10287 local_relay_url,
10288 });
10289 }
10290
10291 let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
10294 for s in &sstates {
10295 if let Some(url) = &s.local_relay_url
10296 && !local_relays.contains_key(url)
10297 {
10298 let healthy = probe_relay_healthz(url);
10299 local_relays.insert(url.clone(), healthy);
10300 }
10301 }
10302
10303 let now = std::time::SystemTime::now()
10304 .duration_since(std::time::UNIX_EPOCH)
10305 .map(|d| d.as_secs())
10306 .unwrap_or(0);
10307
10308 let mut edges: Vec<Value> = Vec::new();
10312 let mut healthy_count = 0u32;
10313 let mut stale_count = 0u32;
10314 let mut asymmetric_count = 0u32;
10315
10316 for i in 0..sstates.len() {
10317 for j in (i + 1)..sstates.len() {
10318 let a = &sstates[i];
10319 let b = &sstates[j];
10320 let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
10325 let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
10326 let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
10327 let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
10328
10329 let bilateral = a_to_b.pinned && b_to_a.pinned;
10330 let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
10334 (Some("local"), _) | (_, Some("local")) => "local",
10335 (Some("federation"), _) | (_, Some("federation")) => "federation",
10336 _ => "unknown",
10337 };
10338
10339 let mut status = if bilateral { "healthy" } else { "asymmetric" };
10342 if bilateral {
10343 let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
10344 Some(s) => s > stale_secs,
10345 None => d.probed,
10346 });
10347 if either_stale {
10348 status = "stale";
10349 }
10350 }
10351
10352 match status {
10353 "healthy" => healthy_count += 1,
10354 "stale" => stale_count += 1,
10355 "asymmetric" => asymmetric_count += 1,
10356 _ => {}
10357 }
10358
10359 edges.push(json!({
10360 "from": a.view.name,
10361 "to": b.view.name,
10362 "bilateral": bilateral,
10363 "scope": scope,
10364 "status": status,
10365 "directions": {
10366 a.view.name.clone(): direction_summary(&a_to_b),
10367 b.view.name.clone(): direction_summary(&b_to_a),
10368 },
10369 }));
10370 }
10371 }
10372
10373 let summary = json!({
10374 "sessions": sstates.iter().map(|s| json!({
10375 "name": s.view.name,
10376 "handle": s.view.handle,
10377 "cwd": s.view.cwd,
10378 "daemon_running": s.view.daemon_running,
10379 "local_relay": s.local_relay_url,
10380 })).collect::<Vec<_>>(),
10381 "edges": edges,
10382 "local_relays": local_relays.iter().map(|(url, healthy)| json!({
10383 "url": url,
10384 "healthy": healthy,
10385 })).collect::<Vec<_>>(),
10386 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
10387 "summary": {
10388 "session_count": sstates.len(),
10389 "edge_count": edges.len(),
10390 "healthy": healthy_count,
10391 "stale": stale_count,
10392 "asymmetric": asymmetric_count,
10393 "stale_threshold_secs": stale_secs,
10394 },
10395 });
10396
10397 if as_json {
10398 println!("{}", serde_json::to_string(&summary)?);
10399 return Ok(());
10400 }
10401
10402 println!(
10403 "wire mesh: {} session(s), {} edge(s)",
10404 sstates.len(),
10405 edges.len()
10406 );
10407 for (url, healthy) in &local_relays {
10408 let tick = if *healthy { "✓" } else { "✗" };
10409 println!(" local-relay {url} {tick}");
10410 }
10411 if !federation_only.is_empty() {
10412 print!(" federation-only sessions:");
10413 for f in &federation_only {
10414 print!(" {}", f.name);
10415 }
10416 println!();
10417 }
10418
10419 let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
10421 let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
10422 print!("\n{:>col_w$}", "", col_w = col_w);
10423 for n in &names {
10424 print!("{:>col_w$}", n, col_w = col_w);
10425 }
10426 println!();
10427 for (i, row) in names.iter().enumerate() {
10428 print!("{:>col_w$}", row, col_w = col_w);
10429 for (j, col) in names.iter().enumerate() {
10430 let cell = if i == j {
10431 "self".to_string()
10432 } else {
10433 let d = probe_directed_edge(&sstates[i].relay_state, col, now);
10434 match d.scope.as_deref() {
10435 Some("local") => "local".to_string(),
10436 Some("federation") => "fed".to_string(),
10437 _ => "—".to_string(),
10438 }
10439 };
10440 print!("{:>col_w$}", cell, col_w = col_w);
10441 }
10442 println!();
10443 }
10444
10445 println!("\nHealth (stale threshold: {stale_secs}s):");
10446 for e in &edges {
10447 let from = e["from"].as_str().unwrap_or("?");
10448 let to = e["to"].as_str().unwrap_or("?");
10449 let scope = e["scope"].as_str().unwrap_or("?");
10450 let status = e["status"].as_str().unwrap_or("?");
10451 let mark = match status {
10452 "healthy" => "✓",
10453 "stale" => "⚠",
10454 "asymmetric" => "!",
10455 _ => "?",
10456 };
10457 let dirs = e["directions"].as_object().cloned().unwrap_or_default();
10458 let mut details: Vec<String> = Vec::new();
10459 for (who, d) in &dirs {
10460 let silent = d.get("silent_secs").and_then(Value::as_u64);
10461 let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
10462 let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
10463 let label = match (pinned, probed, silent) {
10464 (false, _, _) => format!("{who} has not pinned"),
10465 (true, false, _) => format!("{who} pinned but no endpoint to probe"),
10466 (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
10467 (true, true, Some(s)) => format!("{who} silent {s}s"),
10468 (true, true, None) => format!("{who} never pulled"),
10469 };
10470 details.push(label);
10471 }
10472 println!(
10473 " {mark} {from} ↔ {to} scope={scope} {status:>10} [{}]",
10474 details.join(" | ")
10475 );
10476 }
10477 Ok(())
10478}
10479
10480#[derive(Default)]
10481struct DirectedEdge {
10482 pinned: bool,
10483 scope: Option<String>,
10484 last_pull_at_unix: Option<u64>,
10485 silent_secs: Option<u64>,
10486 probed: bool,
10487 event_count: usize,
10488}
10489
10490fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
10496 let pinned = from_state
10497 .get("peers")
10498 .and_then(|p| p.get(to_name))
10499 .is_some();
10500 if !pinned {
10501 return DirectedEdge::default();
10502 }
10503 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
10504 let ep = match endpoints.into_iter().next() {
10505 Some(e) => e,
10506 None => {
10507 return DirectedEdge {
10508 pinned: true,
10509 ..Default::default()
10510 };
10511 }
10512 };
10513 let scope = Some(
10514 match ep.scope {
10515 crate::endpoints::EndpointScope::Local => "local",
10516 crate::endpoints::EndpointScope::Lan => "lan",
10517 crate::endpoints::EndpointScope::Uds => "uds",
10518 crate::endpoints::EndpointScope::Federation => "federation",
10519 }
10520 .to_string(),
10521 );
10522 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
10523 let (count, last) = client
10524 .slot_state(&ep.slot_id, &ep.slot_token)
10525 .unwrap_or((0, None));
10526 let silent = last.map(|t| now.saturating_sub(t));
10527 DirectedEdge {
10528 pinned: true,
10529 scope,
10530 last_pull_at_unix: last,
10531 silent_secs: silent,
10532 probed: true,
10533 event_count: count,
10534 }
10535}
10536
10537fn direction_summary(d: &DirectedEdge) -> Value {
10538 json!({
10539 "pinned": d.pinned,
10540 "scope": d.scope,
10541 "probed": d.probed,
10542 "last_pull_at_unix": d.last_pull_at_unix,
10543 "silent_secs": d.silent_secs,
10544 "event_count": d.event_count,
10545 })
10546}
10547
10548fn probe_relay_healthz(url: &str) -> bool {
10550 let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
10551 let client = match reqwest::blocking::Client::builder()
10552 .timeout(std::time::Duration::from_millis(500))
10553 .build()
10554 {
10555 Ok(c) => c,
10556 Err(_) => return false,
10557 };
10558 match client.get(&probe_url).send() {
10559 Ok(r) => r.status().is_success(),
10560 Err(_) => false,
10561 }
10562}
10563
10564fn drive_bilateral_pair(
10579 a_home: &std::path::Path,
10580 a_name: &str,
10581 b_home: &std::path::Path,
10582 b_name: &str,
10583 _fed_host: &str,
10584 _federation_relay: &str,
10585 settle_secs: u64,
10586) -> Result<()> {
10587 use std::time::Duration;
10588 let bin = std::env::current_exe().context("locating self exe")?;
10589
10590 let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
10591 let out = std::process::Command::new(&bin)
10592 .env("WIRE_HOME", home)
10593 .env_remove("RUST_LOG")
10594 .args(args)
10595 .output()
10596 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
10597 if !out.status.success() {
10598 bail!(
10599 "`wire {}` failed: stderr={}",
10600 args.join(" "),
10601 String::from_utf8_lossy(&out.stderr).trim()
10602 );
10603 }
10604 Ok(())
10605 };
10606
10607 let read_card_handle = |home: &std::path::Path| -> Result<String> {
10612 let card_path = home.join("config").join("wire").join("agent-card.json");
10613 let bytes = std::fs::read(&card_path)
10614 .with_context(|| format!("reading agent-card at {card_path:?}"))?;
10615 let card: Value = serde_json::from_slice(&bytes)?;
10616 card.get("handle")
10617 .and_then(Value::as_str)
10618 .map(str::to_string)
10619 .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
10620 };
10621 let a_handle = read_card_handle(a_home)
10622 .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
10623 let b_handle = read_card_handle(b_home)
10624 .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
10625
10626 run(a_home, &["add", b_name, "--local-sister", "--json"])
10630 .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
10631
10632 std::thread::sleep(Duration::from_secs(settle_secs));
10634
10635 run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
10638 run(b_home, &["pair-accept", &a_handle, "--json"]).with_context(|| {
10639 format!("step 5/8: {b_name} `wire pair-accept {a_handle}` (a session={a_name})")
10640 })?;
10641 run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
10642
10643 std::thread::sleep(Duration::from_secs(settle_secs));
10645
10646 run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
10648 let _ = &b_handle;
10650
10651 Ok(())
10652}
10653
10654fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
10655 let name = resolve_session_name(name_arg)?;
10656 let session_home = crate::session::session_dir(&name)?;
10657 if !session_home.exists() {
10658 bail!(
10659 "no session named {name:?} on this machine. `wire session list` to enumerate, \
10660 `wire session new {name}` to create."
10661 );
10662 }
10663 if as_json {
10664 println!(
10665 "{}",
10666 serde_json::to_string(&json!({
10667 "name": name,
10668 "home_dir": session_home.to_string_lossy(),
10669 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
10670 }))?
10671 );
10672 } else {
10673 println!("export WIRE_HOME={}", session_home.to_string_lossy());
10674 }
10675 Ok(())
10676}
10677
10678fn cmd_session_current(as_json: bool) -> Result<()> {
10679 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
10680 let registry = crate::session::read_registry().unwrap_or_default();
10681 let cwd_key = crate::session::normalize_cwd_key(&cwd);
10682 let name = registry
10687 .by_cwd
10688 .get(&cwd_key)
10689 .or_else(|| {
10690 registry
10691 .by_cwd
10692 .iter()
10693 .find(|(k, _)| {
10694 crate::session::normalize_cwd_key(std::path::Path::new(k)) == cwd_key
10695 })
10696 .map(|(_, v)| v)
10697 })
10698 .cloned();
10699 if as_json {
10700 println!(
10701 "{}",
10702 serde_json::to_string(&json!({
10703 "cwd": cwd_key,
10704 "session": name,
10705 }))?
10706 );
10707 } else if let Some(n) = name {
10708 println!("{n}");
10709 } else {
10710 println!("(no session registered for this cwd)");
10711 }
10712 Ok(())
10713}
10714
10715fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
10716 let name = crate::session::sanitize_name(name_arg);
10717 let session_home = crate::session::session_dir(&name)?;
10718 if !session_home.exists() {
10719 if as_json {
10720 println!(
10721 "{}",
10722 serde_json::to_string(&json!({
10723 "name": name,
10724 "destroyed": false,
10725 "reason": "no such session",
10726 }))?
10727 );
10728 } else {
10729 println!("no session named {name:?} — nothing to destroy.");
10730 }
10731 return Ok(());
10732 }
10733 if !force {
10734 bail!(
10735 "destroying session {name:?} would delete its keypair + state irrecoverably. \
10736 Pass --force to confirm."
10737 );
10738 }
10739
10740 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
10742 if let Ok(bytes) = std::fs::read(&pidfile) {
10743 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
10744 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
10745 } else {
10746 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
10747 };
10748 if let Some(p) = pid {
10749 let _ = std::process::Command::new("kill")
10750 .args(["-TERM", &p.to_string()])
10751 .output();
10752 }
10753 }
10754
10755 std::fs::remove_dir_all(&session_home)
10756 .with_context(|| format!("removing session dir {session_home:?}"))?;
10757
10758 let mut registry = crate::session::read_registry().unwrap_or_default();
10760 registry.by_cwd.retain(|_, v| v != &name);
10761 crate::session::write_registry(®istry)?;
10762
10763 if as_json {
10764 println!(
10765 "{}",
10766 serde_json::to_string(&json!({
10767 "name": name,
10768 "destroyed": true,
10769 }))?
10770 );
10771 } else {
10772 println!("destroyed session {name:?}.");
10773 }
10774 Ok(())
10775}
10776
10777fn cmd_diag(action: DiagAction) -> Result<()> {
10780 let state = config::state_dir()?;
10781 let knob = state.join("diag.enabled");
10782 let log_path = state.join("diag.jsonl");
10783 match action {
10784 DiagAction::Tail { limit, json } => {
10785 let entries = crate::diag::tail(limit);
10786 if json {
10787 for e in entries {
10788 println!("{}", serde_json::to_string(&e)?);
10789 }
10790 } else if entries.is_empty() {
10791 println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
10792 } else {
10793 for e in entries {
10794 let ts = e["ts"].as_u64().unwrap_or(0);
10795 let ty = e["type"].as_str().unwrap_or("?");
10796 let pid = e["pid"].as_u64().unwrap_or(0);
10797 let payload = e["payload"].to_string();
10798 println!("[{ts}] pid={pid} {ty} {payload}");
10799 }
10800 }
10801 }
10802 DiagAction::Enable => {
10803 config::ensure_dirs()?;
10804 std::fs::write(&knob, "1")?;
10805 println!("wire diag: enabled at {knob:?}");
10806 }
10807 DiagAction::Disable => {
10808 if knob.exists() {
10809 std::fs::remove_file(&knob)?;
10810 }
10811 println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
10812 }
10813 DiagAction::Status { json } => {
10814 let enabled = crate::diag::is_enabled();
10815 let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
10816 if json {
10817 println!(
10818 "{}",
10819 serde_json::to_string(&serde_json::json!({
10820 "enabled": enabled,
10821 "log_path": log_path,
10822 "log_size_bytes": size,
10823 }))?
10824 );
10825 } else {
10826 println!("wire diag status");
10827 println!(" enabled: {enabled}");
10828 println!(" log: {log_path:?}");
10829 println!(" log size: {size} bytes");
10830 }
10831 }
10832 }
10833 Ok(())
10834}
10835
10836fn cmd_service(action: ServiceAction) -> Result<()> {
10839 let kind = |local_relay: bool| {
10840 if local_relay {
10841 crate::service::ServiceKind::LocalRelay
10842 } else {
10843 crate::service::ServiceKind::Daemon
10844 }
10845 };
10846 let (report, as_json) = match action {
10847 ServiceAction::Install { local_relay, json } => {
10848 (crate::service::install_kind(kind(local_relay))?, json)
10849 }
10850 ServiceAction::Uninstall { local_relay, json } => {
10851 (crate::service::uninstall_kind(kind(local_relay))?, json)
10852 }
10853 ServiceAction::Status { local_relay, json } => {
10854 (crate::service::status_kind(kind(local_relay))?, json)
10855 }
10856 };
10857 if as_json {
10858 println!("{}", serde_json::to_string(&report)?);
10859 } else {
10860 println!("wire service {}", report.action);
10861 println!(" platform: {}", report.platform);
10862 println!(" unit: {}", report.unit_path);
10863 println!(" status: {}", report.status);
10864 println!(" detail: {}", report.detail);
10865 }
10866 Ok(())
10867}
10868
10869const CRATE_NAME: &str = "slancha-wire";
10872
10873fn release_asset_triple() -> Option<(&'static str, &'static str)> {
10877 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
10878 {
10879 return Some(("x86_64-pc-windows-msvc", ".exe"));
10880 }
10881 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
10882 {
10883 return Some(("aarch64-apple-darwin", ""));
10884 }
10885 #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
10886 {
10887 return Some(("x86_64-apple-darwin", ""));
10888 }
10889 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
10890 {
10891 return Some(("x86_64-unknown-linux-musl", ""));
10892 }
10893 #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
10894 {
10895 return Some(("aarch64-unknown-linux-musl", ""));
10896 }
10897 #[allow(unreachable_code)]
10898 None
10899}
10900
10901fn fetch_latest_published_version() -> Result<String> {
10903 let url = format!("https://crates.io/api/v1/crates/{CRATE_NAME}");
10904 let client = reqwest::blocking::Client::builder()
10905 .timeout(std::time::Duration::from_secs(20))
10906 .build()?;
10907 let resp = client
10908 .get(&url)
10909 .header(
10911 "User-Agent",
10912 format!("wire/{} (self-update)", env!("CARGO_PKG_VERSION")),
10913 )
10914 .send()?;
10915 if !resp.status().is_success() {
10916 bail!("crates.io returned {} for {CRATE_NAME}", resp.status());
10917 }
10918 let v: Value = resp.json()?;
10919 v.get("crate")
10920 .and_then(|c| {
10921 c.get("max_stable_version")
10922 .or_else(|| c.get("newest_version"))
10923 })
10924 .and_then(Value::as_str)
10925 .map(str::to_string)
10926 .ok_or_else(|| anyhow!("crates.io response missing crate.max_stable_version"))
10927}
10928
10929fn version_is_newer(latest: &str, current: &str) -> bool {
10932 let parse = |s: &str| -> (u64, u64, u64) {
10933 let core = s.split('-').next().unwrap_or(s);
10934 let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
10935 (
10936 it.next().unwrap_or(0),
10937 it.next().unwrap_or(0),
10938 it.next().unwrap_or(0),
10939 )
10940 };
10941 parse(latest) > parse(current)
10942}
10943
10944fn cargo_on_path() -> bool {
10945 std::process::Command::new("cargo")
10946 .arg("--version")
10947 .stdout(std::process::Stdio::null())
10948 .stderr(std::process::Stdio::null())
10949 .status()
10950 .map(|s| s.success())
10951 .unwrap_or(false)
10952}
10953
10954fn self_update_from_release(latest: &str) -> Result<()> {
10957 let (triple, ext) = release_asset_triple().ok_or_else(|| {
10958 anyhow!(
10959 "no prebuilt release binary for this platform — install a Rust toolchain and re-run, \
10960 or `cargo install {CRATE_NAME}`"
10961 )
10962 })?;
10963 let base =
10964 format!("https://github.com/SlanchaAi/wire/releases/download/v{latest}/wire-{triple}{ext}");
10965 let client = reqwest::blocking::Client::builder()
10966 .timeout(std::time::Duration::from_secs(120))
10967 .build()?;
10968 let resp = client
10969 .get(&base)
10970 .header("User-Agent", "wire-self-update")
10971 .send()?;
10972 if !resp.status().is_success() {
10973 bail!("downloading {base} returned {}", resp.status());
10974 }
10975 let bytes = resp.bytes()?;
10976
10977 if let Ok(sha) = client
10979 .get(format!("{base}.sha256"))
10980 .header("User-Agent", "wire-self-update")
10981 .send()
10982 && sha.status().is_success()
10983 {
10984 let expected = sha
10985 .text()?
10986 .split_whitespace()
10987 .next()
10988 .unwrap_or("")
10989 .to_string();
10990 if !expected.is_empty() {
10991 use sha2::{Digest, Sha256};
10992 let mut h = Sha256::new();
10993 h.update(&bytes);
10994 let actual = hex::encode(h.finalize());
10995 if expected != actual {
10996 bail!(
10997 "SHA-256 mismatch — expected {expected}, got {actual} (aborting, binary NOT replaced)"
10998 );
10999 }
11000 }
11001 }
11002
11003 let exe = std::env::current_exe().context("locating current exe")?;
11004 let dir = exe
11005 .parent()
11006 .ok_or_else(|| anyhow!("current exe has no parent dir"))?;
11007 let tmp = dir.join(format!(".wire-update-{}", std::process::id()));
11008 std::fs::write(&tmp, &bytes).with_context(|| format!("writing {tmp:?}"))?;
11009 #[cfg(unix)]
11010 {
11011 use std::os::unix::fs::PermissionsExt;
11012 let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
11013 std::fs::rename(&tmp, &exe).with_context(|| format!("replacing {exe:?}"))?;
11016 }
11017 #[cfg(windows)]
11018 {
11019 let old = exe.with_extension("old");
11022 let _ = std::fs::remove_file(&old);
11023 std::fs::rename(&exe, &old)
11024 .with_context(|| format!("renaming running exe {exe:?} aside"))?;
11025 std::fs::rename(&tmp, &exe).with_context(|| format!("installing new exe at {exe:?}"))?;
11026 }
11027 Ok(())
11028}
11029
11030struct UpdateOutcome {
11032 current: String,
11033 latest: String,
11034 available: bool,
11036 installed: bool,
11038 via: Option<&'static str>,
11040}
11041
11042fn self_update_step(install: bool) -> Result<UpdateOutcome> {
11046 let current = env!("CARGO_PKG_VERSION").to_string();
11047 let latest = fetch_latest_published_version().context("checking crates.io for latest wire")?;
11048 let available = version_is_newer(&latest, ¤t);
11049 if !install || !available {
11050 return Ok(UpdateOutcome {
11051 current,
11052 latest,
11053 available,
11054 installed: false,
11055 via: None,
11056 });
11057 }
11058 let via = if cargo_on_path() {
11059 eprintln!(
11060 "wire upgrade: {current} → {latest} — installing via `cargo install {CRATE_NAME}` …"
11061 );
11062 let status = std::process::Command::new("cargo")
11063 .args([
11064 "install",
11065 CRATE_NAME,
11066 "--version",
11067 &latest,
11068 "--force",
11069 "--locked",
11070 ])
11071 .status()
11072 .context("running cargo install")?;
11073 if !status.success() {
11074 bail!("`cargo install {CRATE_NAME}` failed");
11075 }
11076 "cargo install"
11077 } else {
11078 eprintln!(
11079 "wire upgrade: {current} → {latest} — no `cargo` on PATH, downloading the prebuilt release binary …"
11080 );
11081 self_update_from_release(&latest)?;
11082 "prebuilt release binary"
11083 };
11084 Ok(UpdateOutcome {
11085 current,
11086 latest,
11087 available,
11088 installed: true,
11089 via: Some(via),
11090 })
11091}
11092
11093fn upgrade_kill_set(
11114 my_pid: Option<u32>,
11115 found_daemon_pids: &[u32],
11116 owned_session_pids: &std::collections::HashSet<u32>,
11117) -> Vec<u32> {
11118 let mut k: Vec<u32> = Vec::new();
11119 if let Some(p) = my_pid {
11120 k.push(p);
11121 }
11122 for &p in found_daemon_pids {
11123 if !owned_session_pids.contains(&p) && Some(p) != my_pid {
11124 k.push(p); }
11126 }
11127 k.sort_unstable();
11128 k.dedup();
11129 k
11130}
11131
11132#[derive(Debug, Clone)]
11139struct PathWireBinary {
11140 path: std::path::PathBuf,
11143 canonical: std::path::PathBuf,
11147 sha256: Option<String>,
11150 mtime: Option<std::time::SystemTime>,
11152 path_index: usize,
11155 is_current_exe: bool,
11161}
11162
11163impl PathWireBinary {
11164 fn is_active(&self) -> bool {
11166 self.path_index == 0
11167 }
11168 fn sha256_short(&self) -> String {
11171 self.sha256
11172 .as_deref()
11173 .map(|s| s[..s.len().min(8)].to_string())
11174 .unwrap_or_else(|| "????????".to_string())
11175 }
11176 fn mtime_display(&self) -> String {
11178 let Some(ts) = self.mtime else {
11179 return "?".to_string();
11180 };
11181 let secs = match ts.duration_since(std::time::UNIX_EPOCH) {
11182 Ok(d) => d.as_secs() as i64,
11183 Err(_) => return "?".to_string(),
11184 };
11185 time::OffsetDateTime::from_unix_timestamp(secs)
11186 .ok()
11187 .and_then(|dt| {
11188 dt.format(&time::format_description::well_known::Rfc3339)
11189 .ok()
11190 })
11191 .unwrap_or_else(|| "?".to_string())
11192 }
11193}
11194
11195fn sha256_file(p: &std::path::Path) -> Result<String> {
11197 use sha2::{Digest, Sha256};
11198 let mut f = std::fs::File::open(p).with_context(|| format!("opening {}", p.display()))?;
11199 let mut h = Sha256::new();
11200 std::io::copy(&mut f, &mut h).with_context(|| format!("hashing {}", p.display()))?;
11201 Ok(hex::encode(h.finalize()))
11202}
11203
11204fn enumerate_path_wire_binaries() -> Vec<PathWireBinary> {
11218 let path = std::env::var("PATH").unwrap_or_default();
11219 let current_exe_canon: Option<std::path::PathBuf> = std::env::current_exe()
11220 .ok()
11221 .and_then(|p| p.canonicalize().ok());
11222 enumerate_path_wire_binaries_from(&path, current_exe_canon.as_deref())
11223}
11224
11225fn enumerate_path_wire_binaries_from(
11230 path: &str,
11231 current_exe_canon: Option<&std::path::Path>,
11232) -> Vec<PathWireBinary> {
11233 if path.is_empty() {
11234 return Vec::new();
11235 }
11236 let separator = if cfg!(windows) { ';' } else { ':' };
11241 let names: &[&str] = if cfg!(windows) {
11242 &["wire.exe", "wire"]
11246 } else {
11247 &["wire"]
11248 };
11249
11250 let mut seen: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
11251 let mut out: Vec<PathWireBinary> = Vec::new();
11252 for dir in path.split(separator) {
11253 if dir.is_empty() {
11254 continue;
11255 }
11256 for name in names {
11257 let candidate = std::path::PathBuf::from(dir).join(name);
11258 if !candidate.is_file() {
11261 continue;
11262 }
11263 let canon = candidate
11264 .canonicalize()
11265 .unwrap_or_else(|_| candidate.clone());
11266 if !seen.insert(canon.clone()) {
11267 break;
11270 }
11271 let meta = std::fs::metadata(&canon).ok();
11272 let mtime = meta.as_ref().and_then(|m| m.modified().ok());
11273 let sha256 = sha256_file(&canon).ok();
11274 let is_current_exe = current_exe_canon
11275 .map(|c| c == canon.as_path())
11276 .unwrap_or(false);
11277 let path_index = out.len();
11278 out.push(PathWireBinary {
11279 path: candidate,
11280 canonical: canon,
11281 sha256,
11282 mtime,
11283 path_index,
11284 is_current_exe,
11285 });
11286 break;
11289 }
11290 }
11291 out
11292}
11293
11294fn path_shadow_warning(bins: &[PathWireBinary]) -> Option<String> {
11306 let any_current = bins.iter().any(|b| b.is_current_exe);
11307 let multi = bins.len() >= 2;
11308 let off_path = !bins.is_empty() && !any_current;
11309 let none_on_path = bins.is_empty();
11310 if !multi && !off_path && !none_on_path {
11311 return None;
11312 }
11313 let mut out = String::new();
11314 if multi {
11315 out.push_str(&format!(
11316 "WARN: {} distinct `wire` binaries on PATH — older entries can shadow your fresh install:\n",
11317 bins.len()
11318 ));
11319 for b in bins {
11320 let mut tags: Vec<&str> = Vec::new();
11321 if b.is_active() {
11322 tags.push("ACTIVE (bare `wire` resolves here)");
11323 }
11324 if b.is_current_exe {
11325 tags.push("THIS upgrade ran against this binary");
11326 }
11327 let tag_str = if tags.is_empty() {
11328 String::new()
11329 } else {
11330 format!(" ← {}", tags.join("; "))
11331 };
11332 out.push_str(&format!(
11333 " [{}] {} (sha256:{} mtime:{}){}\n",
11334 b.path_index,
11335 b.path.display(),
11336 b.sha256_short(),
11337 b.mtime_display(),
11338 tag_str,
11339 ));
11340 }
11341 if !any_current {
11342 out.push_str(
11343 " NOTE: none of the PATH-resident binaries is the one running this `wire upgrade`.\n",
11344 );
11345 out.push_str(
11346 " Your upgrade will NOT affect bare `wire` calls in shells, scripts, or peer agents.\n",
11347 );
11348 } else if !bins[0].is_current_exe {
11349 out.push_str(
11350 " Bare `wire` calls (shells, scripts, daemons, peer agents) will use the\n",
11351 );
11352 out.push_str(
11353 " ACTIVE binary [0], NOT the one you just upgraded. Recommended fixes:\n",
11354 );
11355 out.push_str(&format!(
11356 " - rm {} (or symlink it to the upgraded binary)\n",
11357 bins[0].path.display(),
11358 ));
11359 out.push_str(
11360 " - or reorder PATH so the upgraded binary's directory precedes the active one\n",
11361 );
11362 out.push_str(" Verify with: which -a wire\n");
11363 }
11364 } else if off_path {
11365 let active = &bins[0];
11367 out.push_str("WARN: this `wire upgrade` is running against an off-PATH binary;\n");
11368 out.push_str(&format!(
11369 " bare `wire` resolves to {} (sha256:{}),\n",
11370 active.path.display(),
11371 active.sha256_short(),
11372 ));
11373 out.push_str(
11374 " which was NOT touched by this upgrade. Shells, scripts, and peer agents\n",
11375 );
11376 out.push_str(" will continue to invoke the old binary.\n");
11377 } else if none_on_path {
11378 out.push_str("WARN: no `wire` binary on PATH; bare `wire` will fail in future shells.\n");
11379 out.push_str(" This upgrade ran against an absolute-path invocation only.\n");
11380 }
11381 Some(out.trim_end().to_string())
11382}
11383
11384#[cfg(test)]
11385mod upgrade_tests {
11386 use super::*;
11387 use std::collections::HashSet;
11388
11389 #[test]
11390 fn upgrade_kill_set_is_session_scoped() {
11391 let owned: HashSet<u32> = [100, 200].into_iter().collect();
11393 let k = upgrade_kill_set(Some(100), &[100, 200, 999], &owned);
11395 assert!(k.contains(&100), "must kill my own daemon (to replace it)");
11396 assert!(k.contains(&999), "must sweep a true orphan");
11397 assert!(!k.contains(&200), "must SPARE a sibling session's daemon");
11398
11399 assert_eq!(
11403 upgrade_kill_set(Some(100), &[], &owned),
11404 vec![100],
11405 "own daemon killed even when the process scan is empty"
11406 );
11407
11408 assert_eq!(upgrade_kill_set(None, &[999], &HashSet::new()), vec![999]);
11410 }
11411
11412 fn write_fake_wire(dir: &std::path::Path, body: &[u8]) -> std::path::PathBuf {
11420 use std::io::Write;
11421 let p = dir.join("wire");
11422 let mut f = std::fs::File::create(&p).expect("create fake wire");
11423 f.write_all(body).expect("write fake wire");
11424 drop(f);
11425 #[cfg(unix)]
11426 {
11427 use std::os::unix::fs::PermissionsExt;
11428 let mut perm = std::fs::metadata(&p).unwrap().permissions();
11429 perm.set_mode(0o755);
11430 std::fs::set_permissions(&p, perm).unwrap();
11431 }
11432 p
11433 }
11434
11435 #[test]
11436 #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11437 fn enumerate_finds_no_binaries_when_path_empty() {
11438 let bins = enumerate_path_wire_binaries_from("", None);
11439 assert!(
11440 bins.is_empty(),
11441 "empty PATH yields no binaries, got {bins:?}"
11442 );
11443 }
11444
11445 #[test]
11446 #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11447 fn enumerate_detects_two_distinct_binaries_in_path_order() {
11448 let d1 = tempfile::tempdir().unwrap();
11449 let d2 = tempfile::tempdir().unwrap();
11450 let p1 = write_fake_wire(d1.path(), b"#!/bin/sh\necho A\n");
11451 let p2 = write_fake_wire(d2.path(), b"#!/bin/sh\necho B\n");
11452 let path = format!("{}:{}", d1.path().display(), d2.path().display());
11453
11454 let bins = enumerate_path_wire_binaries_from(&path, None);
11455 assert_eq!(bins.len(), 2, "expected two distinct binaries: {bins:?}");
11456 assert_eq!(bins[0].path_index, 0);
11457 assert_eq!(bins[1].path_index, 1);
11458 assert!(bins[0].is_active(), "first PATH entry is active");
11459 assert!(!bins[1].is_active(), "second PATH entry is not active");
11460 assert_ne!(
11462 bins[0].sha256, bins[1].sha256,
11463 "distinct contents must hash differently"
11464 );
11465 assert_eq!(bins[0].path, p1);
11467 assert_eq!(bins[1].path, p2);
11468 }
11469
11470 #[test]
11471 #[cfg_attr(windows, ignore = "PATH separator + symlink semantics differ")]
11472 fn enumerate_collapses_symlink_chains_to_one_entry() {
11473 let real_dir = tempfile::tempdir().unwrap();
11474 let link_dir = tempfile::tempdir().unwrap();
11475 let real = write_fake_wire(real_dir.path(), b"#!/bin/sh\necho real\n");
11476 let link = link_dir.path().join("wire");
11477 #[cfg(unix)]
11478 std::os::unix::fs::symlink(&real, &link).unwrap();
11479
11480 let path = format!(
11484 "{}:{}",
11485 link_dir.path().display(),
11486 real_dir.path().display()
11487 );
11488 let bins = enumerate_path_wire_binaries_from(&path, None);
11489 assert_eq!(
11490 bins.len(),
11491 1,
11492 "symlink chain must collapse to a single entry: {bins:?}"
11493 );
11494 assert!(bins[0].is_active());
11495 assert_eq!(bins[0].path, link);
11497 assert_eq!(bins[0].canonical, real.canonicalize().unwrap());
11498 }
11499
11500 #[test]
11501 #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11502 fn shadow_warning_off_path_when_current_exe_not_on_path() {
11503 let d = tempfile::tempdir().unwrap();
11506 write_fake_wire(d.path(), b"#!/bin/sh\necho only\n");
11507 let elsewhere = tempfile::tempdir().unwrap();
11508 let cur = elsewhere.path().join("not-on-path-wire");
11509 let bins = enumerate_path_wire_binaries_from(&d.path().display().to_string(), Some(&cur));
11510 assert_eq!(bins.len(), 1);
11511 assert!(!bins[0].is_current_exe);
11512 let warn = path_shadow_warning(&bins).expect("off-path single bin must warn");
11513 assert!(
11514 warn.contains("off-PATH binary"),
11515 "off-path WARN must mention off-PATH; got: {warn}"
11516 );
11517 }
11518
11519 #[test]
11520 fn shadow_warning_fires_when_no_binaries_at_all() {
11521 let bins: Vec<PathWireBinary> = Vec::new();
11522 let warn = path_shadow_warning(&bins).expect("empty must warn");
11523 assert!(warn.contains("no `wire` binary on PATH"), "got: {warn}");
11524 }
11525
11526 #[test]
11527 #[cfg_attr(windows, ignore = "PATH separator differs")]
11528 fn shadow_warning_multi_binaries_names_active_and_recommends_fix() {
11529 let d1 = tempfile::tempdir().unwrap();
11530 let d2 = tempfile::tempdir().unwrap();
11531 write_fake_wire(d1.path(), b"published\n");
11532 write_fake_wire(d2.path(), b"head\n");
11533 let path = format!("{}:{}", d1.path().display(), d2.path().display());
11534 let bins = enumerate_path_wire_binaries_from(&path, None);
11535 let warn = path_shadow_warning(&bins).expect("two distinct bins must warn");
11536 assert!(warn.contains("2 distinct"), "got: {warn}");
11537 assert!(warn.contains("ACTIVE"), "must mark the active binary");
11538 assert!(
11539 warn.contains("which -a wire") || warn.contains("none of the PATH-resident"),
11540 "must guide the operator to a fix; got: {warn}"
11541 );
11542 }
11543}
11544
11545fn cmd_upgrade(check_only: bool, local: bool, as_json: bool) -> Result<()> {
11546 let update: Option<UpdateOutcome> = if local {
11552 None
11553 } else {
11554 match self_update_step(!check_only) {
11555 Ok(o) => Some(o),
11556 Err(e) => {
11557 if !check_only {
11558 eprintln!("wire upgrade: update check skipped — {e:#}");
11559 }
11560 None
11561 }
11562 }
11563 };
11564 if let Some(o) = &update
11565 && o.installed
11566 {
11567 eprintln!(
11568 "wire upgrade: installed {} (was {}, via {}); restarting the daemon on the new binary.",
11569 o.latest,
11570 o.current,
11571 o.via.unwrap_or("self-update")
11572 );
11573 }
11574
11575 let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
11584 let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
11585 let running_pids: Vec<u32> = daemon_pids
11586 .iter()
11587 .chain(relay_pids.iter())
11588 .copied()
11589 .collect();
11590
11591 let record = crate::ensure_up::read_pid_record("daemon");
11593 let recorded_version: Option<String> = match &record {
11594 crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
11595 crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
11596 _ => None,
11597 };
11598 let cli_version = env!("CARGO_PKG_VERSION").to_string();
11599
11600 let my_daemon_pid = record.pid();
11614 let owned_session_pids: std::collections::HashSet<u32> = crate::session::list_sessions()
11615 .unwrap_or_default()
11616 .iter()
11617 .filter_map(|s| crate::session::session_daemon_pid(&s.home_dir))
11618 .collect();
11619 let kill_set = upgrade_kill_set(my_daemon_pid, &daemon_pids, &owned_session_pids);
11620 if check_only {
11623 let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
11625 .unwrap_or_default()
11626 .iter()
11627 .filter(|s| s.daemon_running)
11628 .map(|s| s.name.clone())
11629 .collect();
11630 let path_bins = enumerate_path_wire_binaries();
11631 let path_dupes: Vec<String> = path_bins
11632 .iter()
11633 .map(|b| b.canonical.to_string_lossy().into_owned())
11634 .collect();
11635 let path_binaries_detail: Vec<serde_json::Value> = path_bins
11636 .iter()
11637 .map(|b| {
11638 json!({
11639 "path": b.path.to_string_lossy(),
11640 "canonical": b.canonical.to_string_lossy(),
11641 "sha256": b.sha256,
11642 "mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
11643 "path_index": b.path_index,
11644 "is_active": b.is_active(),
11645 "is_current_exe": b.is_current_exe,
11646 })
11647 })
11648 .collect();
11649 let path_warning_check = path_shadow_warning(&path_bins);
11650 let installed_service_kinds: Vec<&'static str> = [
11653 (crate::service::ServiceKind::Daemon, "daemon"),
11654 (crate::service::ServiceKind::LocalRelay, "local-relay"),
11655 ]
11656 .into_iter()
11657 .filter_map(|(k, label)| {
11658 crate::service::status_kind(k)
11659 .ok()
11660 .filter(|r| r.status != "absent")
11661 .map(|_| label)
11662 })
11663 .collect();
11664 let (update_latest, update_available) = match &update {
11665 Some(o) => (Some(o.latest.clone()), o.available),
11666 None => (None, false),
11667 };
11668 let report = json!({
11669 "running_pids": running_pids,
11670 "running_daemons": daemon_pids,
11671 "running_relay_servers": relay_pids,
11672 "pidfile_version": recorded_version,
11673 "cli_version": cli_version,
11674 "latest_published": update_latest,
11675 "update_available": update_available,
11676 "would_kill": kill_set,
11677 "would_refresh_services": installed_service_kinds,
11678 "session_daemons_running": sessions_with_daemons,
11679 "path_binaries": path_dupes,
11680 "path_binaries_detail": path_binaries_detail,
11681 "path_duplicate_warning": path_dupes.len() > 1,
11682 "path_warning": path_warning_check,
11683 });
11684 if as_json {
11685 println!("{}", serde_json::to_string(&report)?);
11686 } else {
11687 println!("wire upgrade --check");
11688 println!(" cli version: {cli_version}");
11689 match (&update_latest, update_available) {
11690 (Some(l), true) => println!(" latest published: {l} (UPDATE AVAILABLE)"),
11691 (Some(l), false) => println!(" latest published: {l} (up to date)"),
11692 (None, _) => println!(" latest published: (crates.io check skipped)"),
11693 }
11694 println!(
11695 " pidfile version: {}",
11696 recorded_version.as_deref().unwrap_or("(missing)")
11697 );
11698 if running_pids.is_empty() {
11699 println!(" running daemons: none");
11700 println!(" running relays: none");
11701 } else {
11702 if daemon_pids.is_empty() {
11703 println!(" running daemons: none");
11704 } else {
11705 let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
11706 println!(" running daemons: pids {}", p.join(", "));
11707 }
11708 if relay_pids.is_empty() {
11709 println!(" running relays: none");
11710 } else {
11711 let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
11712 println!(" running relays: pids {}", p.join(", "));
11713 }
11714 println!(" would kill all + spawn fresh");
11715 }
11716 if !installed_service_kinds.is_empty() {
11717 println!(
11718 " would refresh: {} installed service unit(s) → new binary path",
11719 installed_service_kinds.join(", ")
11720 );
11721 }
11722 if !sessions_with_daemons.is_empty() {
11723 println!(
11724 " session daemons: {} (would respawn under new binary)",
11725 sessions_with_daemons.join(", ")
11726 );
11727 }
11728 if let Some(w) = &path_warning_check {
11729 println!(" PATH check:");
11730 for line in w.lines() {
11731 println!(" {line}");
11732 }
11733 }
11734 }
11735 return Ok(());
11736 }
11737
11738 for pid in &kill_set {
11750 let _ = crate::platform::kill_process(*pid, false); }
11752 if !kill_set.is_empty() {
11753 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
11755 while std::time::Instant::now() < deadline && kill_set.iter().any(|p| process_alive_pid(*p))
11756 {
11757 std::thread::sleep(std::time::Duration::from_millis(50));
11758 }
11759 for pid in &kill_set {
11762 if process_alive_pid(*pid) {
11763 let _ = crate::platform::kill_process(*pid, true);
11764 }
11765 }
11766 std::thread::sleep(std::time::Duration::from_millis(200)); }
11768 let killed: Vec<u32> = kill_set
11770 .iter()
11771 .copied()
11772 .filter(|p| !process_alive_pid(*p))
11773 .collect();
11774
11775 let pidfile = config::state_dir()?.join("daemon.pid");
11778 if pidfile.exists() {
11779 let _ = std::fs::remove_file(&pidfile);
11780 }
11781
11782 let path_bins = enumerate_path_wire_binaries();
11794 let path_dupes: Vec<String> = path_bins
11795 .iter()
11796 .map(|b| b.canonical.to_string_lossy().into_owned())
11797 .collect();
11798 let path_binaries_detail: Vec<Value> = path_bins
11799 .iter()
11800 .map(|b| {
11801 json!({
11802 "path": b.path.to_string_lossy(),
11803 "canonical": b.canonical.to_string_lossy(),
11804 "sha256": b.sha256,
11805 "mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
11806 "path_index": b.path_index,
11807 "is_active": b.is_active(),
11808 "is_current_exe": b.is_current_exe,
11809 })
11810 })
11811 .collect();
11812 let path_warning = path_shadow_warning(&path_bins);
11813
11814 let mut service_refreshes: Vec<Value> = Vec::new();
11828 for kind in [
11829 crate::service::ServiceKind::Daemon,
11830 crate::service::ServiceKind::LocalRelay,
11831 ] {
11832 let already_installed = crate::service::status_kind(kind)
11833 .map(|r| r.status != "absent")
11834 .unwrap_or(false);
11835 if !already_installed {
11836 continue;
11837 }
11838 match crate::service::install_kind(kind) {
11839 Ok(rep) => service_refreshes.push(json!({
11840 "kind": rep.kind,
11841 "platform": rep.platform,
11842 "status": rep.status,
11843 "unit_path": rep.unit_path,
11844 "action": "refreshed",
11845 })),
11846 Err(e) => service_refreshes.push(json!({
11847 "kind": format!("{kind:?}"),
11848 "action": "refresh_failed",
11849 "error": format!("{e:#}"),
11850 })),
11851 }
11852 }
11853
11854 let spawned = crate::ensure_up::ensure_daemon_running()?;
11860
11861 let session_respawns: Vec<Value> = Vec::new();
11866
11867 let new_record = crate::ensure_up::read_pid_record("daemon");
11868 let new_pid = new_record.pid();
11869 let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
11870 Some(d.version.clone())
11871 } else {
11872 None
11873 };
11874
11875 if as_json {
11876 println!(
11877 "{}",
11878 serde_json::to_string(&json!({
11879 "killed": killed,
11880 "found_daemons": daemon_pids,
11881 "spared_relay_servers": relay_pids,
11882 "service_refreshes": service_refreshes,
11883 "spawned_fresh_daemon": spawned,
11884 "new_pid": new_pid,
11885 "new_version": new_version,
11886 "cli_version": cli_version,
11887 "session_respawns": session_respawns,
11888 "path_binaries": path_dupes,
11889 "path_binaries_detail": path_binaries_detail,
11890 "path_warning": path_warning,
11891 }))?
11892 );
11893 } else {
11894 if killed.is_empty() {
11895 println!("wire upgrade: no stale wire processes running");
11896 } else {
11897 let killed_list = killed
11898 .iter()
11899 .map(|p| p.to_string())
11900 .collect::<Vec<_>>()
11901 .join(", ");
11902 if relay_pids.is_empty() {
11907 println!(
11908 "wire upgrade: killed {} daemon(s) [{killed_list}]",
11909 killed.len()
11910 );
11911 } else {
11912 let relay_list = relay_pids
11913 .iter()
11914 .map(|p| p.to_string())
11915 .collect::<Vec<_>>()
11916 .join(", ");
11917 println!(
11918 "wire upgrade: killed {} daemon(s) [{killed_list}]; spared {} shared relay-server(s) [{relay_list}]",
11919 killed.len(),
11920 relay_pids.len()
11921 );
11922 }
11923 }
11924 if !service_refreshes.is_empty() {
11925 println!(
11926 "wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
11927 service_refreshes.len()
11928 );
11929 for r in &service_refreshes {
11930 let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
11931 let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
11932 let status = r.get("status").and_then(Value::as_str).unwrap_or("");
11933 let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
11934 if action == "refreshed" {
11935 println!(" - {kind}: {action} ({status}, {platform})");
11936 } else {
11937 let err = r.get("error").and_then(Value::as_str).unwrap_or("");
11938 println!(" - {kind}: {action} ({err})");
11939 }
11940 }
11941 }
11942 if spawned {
11943 println!(
11944 "wire upgrade: spawned fresh daemon (pid {} v{})",
11945 new_pid
11946 .map(|p| p.to_string())
11947 .unwrap_or_else(|| "?".to_string()),
11948 new_version.as_deref().unwrap_or(&cli_version),
11949 );
11950 } else {
11951 println!("wire upgrade: daemon was already running on current binary");
11952 }
11953 if !session_respawns.is_empty() {
11954 println!(
11955 "wire upgrade: refreshed {} session daemon(s):",
11956 session_respawns.len()
11957 );
11958 for r in &session_respawns {
11959 let h = r["session_home"].as_str().unwrap_or("?");
11960 let s = r["status"].as_str().unwrap_or("?");
11961 let label = std::path::Path::new(h)
11962 .file_name()
11963 .map(|f| f.to_string_lossy().into_owned())
11964 .unwrap_or_else(|| h.to_string());
11965 println!(" {label:<24} {s}");
11966 }
11967 }
11968 if let Some(msg) = &path_warning {
11969 eprintln!("wire upgrade: {msg}");
11970 }
11971 }
11972 Ok(())
11973}
11974
11975fn json_default(explicit: bool) -> bool {
11985 if explicit {
11986 return true;
11987 }
11988 if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
11989 return false;
11990 }
11991 use std::io::IsTerminal;
11992 !std::io::stdout().is_terminal()
11993}
11994
11995fn process_alive_pid(pid: u32) -> bool {
11996 crate::platform::process_alive(pid)
12001}
12002
12003fn levenshtein_ci(a: &str, b: &str) -> usize {
12009 let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
12010 let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
12011 let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
12012 let (m, n) = (a.len(), b.len());
12013 if m == 0 {
12014 return n;
12015 }
12016 let mut prev: Vec<usize> = (0..=m).collect();
12017 let mut curr = vec![0usize; m + 1];
12018 for j in 1..=n {
12019 curr[0] = j;
12020 for i in 1..=m {
12021 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
12022 curr[i] = std::cmp::min(
12023 std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
12024 prev[i - 1] + cost,
12025 );
12026 }
12027 std::mem::swap(&mut prev, &mut curr);
12028 }
12029 prev[m]
12030}
12031
12032pub fn closest_candidates(
12036 needle: &str,
12037 pool: &[String],
12038 max_distance: usize,
12039 max_results: usize,
12040) -> Vec<String> {
12041 let mut scored: Vec<(usize, &String)> = pool
12042 .iter()
12043 .map(|c| (levenshtein_ci(needle, c), c))
12044 .filter(|(d, _)| *d <= max_distance)
12045 .collect();
12046 scored.sort_by_key(|(d, _)| *d);
12047 scored
12048 .into_iter()
12049 .take(max_results)
12050 .map(|(_, c)| c.clone())
12051 .collect()
12052}
12053
12054fn known_local_names() -> Vec<String> {
12059 let mut names: Vec<String> = Vec::new();
12060 if let Ok(trust) = config::read_trust() {
12061 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
12067 for (handle, agent) in agents {
12068 names.push(handle.clone());
12069 if let Some(did) = agent.get("did").and_then(Value::as_str) {
12070 let ch = crate::character::Character::from_did(did);
12071 names.push(ch.nickname);
12072 }
12073 }
12074 }
12075 }
12076 if let Ok(sessions) = crate::session::list_sessions() {
12077 for s in sessions {
12078 names.push(s.name.clone());
12079 if let Some(h) = &s.handle {
12080 names.push(h.clone());
12081 }
12082 if let Some(ch) = &s.character {
12083 names.push(ch.nickname.clone());
12084 }
12085 }
12086 }
12087 names.sort();
12088 names.dedup();
12089 names
12090}
12091
12092fn deprecation_warn(verb: &str, replacement: &str, json_mode: bool) {
12100 if json_mode {
12101 return;
12102 }
12103 let key = format!("WIRE_DEPRECATION_NAGGED_{}", verb.replace('-', "_"));
12111 if std::env::var(&key).is_ok() {
12112 return;
12113 }
12114 unsafe {
12118 std::env::set_var(&key, "1");
12119 }
12120 eprintln!(
12121 "wire {verb}: DEPRECATED in v0.9 — use `wire {replacement}`. \
12122 Will be removed in v1.0 (target 2026-Q3). \
12123 Suppress: set WIRE_DEPRECATION_NAGGED_{}=1.",
12124 verb.replace('-', "_")
12125 );
12126}
12127
12128#[derive(Clone, Debug, serde::Serialize)]
12132pub struct DoctorCheck {
12133 pub id: String,
12136 pub status: String,
12138 pub detail: String,
12140 #[serde(skip_serializing_if = "Option::is_none")]
12142 pub fix: Option<String>,
12143}
12144
12145impl DoctorCheck {
12146 fn pass(id: &str, detail: impl Into<String>) -> Self {
12147 Self {
12148 id: id.into(),
12149 status: "PASS".into(),
12150 detail: detail.into(),
12151 fix: None,
12152 }
12153 }
12154 fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
12155 Self {
12156 id: id.into(),
12157 status: "WARN".into(),
12158 detail: detail.into(),
12159 fix: Some(fix.into()),
12160 }
12161 }
12162 fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
12163 Self {
12164 id: id.into(),
12165 status: "FAIL".into(),
12166 detail: detail.into(),
12167 fix: Some(fix.into()),
12168 }
12169 }
12170}
12171
12172fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
12177 let checks: Vec<DoctorCheck> = vec![
12178 check_daemon_health(),
12179 check_daemon_pid_consistency(),
12180 check_relay_reachable(),
12181 check_pair_rejections(recent_rejections),
12182 check_cursor_progress(),
12183 check_peer_staleness(7),
12184 check_and_heal_self_userinfo_endpoints(),
12185 ];
12186
12187 let fails = checks.iter().filter(|c| c.status == "FAIL").count();
12188 let warns = checks.iter().filter(|c| c.status == "WARN").count();
12189
12190 if as_json {
12191 println!(
12192 "{}",
12193 serde_json::to_string(&json!({
12194 "checks": checks,
12195 "fail_count": fails,
12196 "warn_count": warns,
12197 "ok": fails == 0,
12198 }))?
12199 );
12200 } else {
12201 println!("wire doctor — {} checks", checks.len());
12202 for c in &checks {
12203 let bullet = match c.status.as_str() {
12204 "PASS" => "✓",
12205 "WARN" => "!",
12206 "FAIL" => "✗",
12207 _ => "?",
12208 };
12209 println!(" {bullet} [{}] {}: {}", c.status, c.id, c.detail);
12210 if let Some(fix) = &c.fix {
12211 println!(" fix: {fix}");
12212 }
12213 }
12214 println!();
12215 if fails == 0 && warns == 0 {
12216 println!("ALL GREEN");
12217 } else {
12218 println!("{fails} FAIL, {warns} WARN");
12219 }
12220 }
12221
12222 if fails > 0 {
12223 std::process::exit(1);
12224 }
12225 Ok(())
12226}
12227
12228fn check_daemon_health() -> DoctorCheck {
12235 let snap = crate::ensure_up::daemon_liveness();
12241 let pgrep_pids = &snap.pgrep_pids;
12242 let pidfile_pid = snap.pidfile_pid;
12243 let pidfile_alive = snap.pidfile_alive;
12244 let orphan_pids = &snap.orphan_pids;
12245
12246 let fmt_pids = |xs: &[u32]| -> String {
12247 xs.iter()
12248 .map(|p| p.to_string())
12249 .collect::<Vec<_>>()
12250 .join(", ")
12251 };
12252
12253 match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
12254 (0, _, _) => DoctorCheck::fail(
12255 "daemon",
12256 "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
12257 "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
12258 ),
12259 (1, true, true) => DoctorCheck::pass(
12261 "daemon",
12262 format!(
12263 "one daemon running (pid {}, matches pidfile)",
12264 pgrep_pids[0]
12265 ),
12266 ),
12267 (n, true, false) => DoctorCheck::fail(
12269 "daemon",
12270 format!(
12271 "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
12272 The orphans race the relay cursor — they advance past events your current binary can't process. \
12273 (Issue #2 exact class.)",
12274 fmt_pids(pgrep_pids),
12275 pidfile_pid.unwrap(),
12276 fmt_pids(orphan_pids),
12277 ),
12278 "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
12279 ),
12280 (n, false, _) => DoctorCheck::fail(
12282 "daemon",
12283 format!(
12284 "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
12285 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
12286 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
12287 fmt_pids(pgrep_pids),
12288 match pidfile_pid {
12289 Some(p) => format!("claims pid {p} which is dead"),
12290 None => "is missing".to_string(),
12291 },
12292 ),
12293 "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
12294 ),
12295 (n, true, true) => DoctorCheck::warn(
12297 "daemon",
12298 format!(
12299 "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
12300 fmt_pids(pgrep_pids)
12301 ),
12302 "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
12303 ),
12304 }
12305}
12306
12307fn check_daemon_pid_consistency() -> DoctorCheck {
12319 let snap = crate::ensure_up::daemon_liveness();
12320 match &snap.record {
12321 crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
12322 "daemon_pid_consistency",
12323 "no daemon.pid yet — fresh box or daemon never started",
12324 ),
12325 crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
12326 "daemon_pid_consistency",
12327 format!("daemon.pid is corrupt: {reason}"),
12328 "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
12329 ),
12330 crate::ensure_up::PidRecord::LegacyInt(pid) => {
12331 let pid = *pid;
12334 if !crate::ensure_up::pid_is_alive(pid) {
12335 return DoctorCheck::warn(
12336 "daemon_pid_consistency",
12337 format!(
12338 "daemon.pid (legacy-int) points at pid {pid} which is not running. \
12339 Stale pidfile from a crashed pre-0.5.11 daemon. \
12340 (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
12341 ),
12342 "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
12343 );
12344 }
12345 DoctorCheck::warn(
12346 "daemon_pid_consistency",
12347 format!(
12348 "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
12349 Daemon was started by a pre-0.5.11 binary."
12350 ),
12351 "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
12352 )
12353 }
12354 crate::ensure_up::PidRecord::Json(d) => {
12355 if !snap.pidfile_alive {
12359 return DoctorCheck::warn(
12360 "daemon_pid_consistency",
12361 format!(
12362 "daemon.pid records pid {pid} (v{version}) but that process is not running — \
12363 pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
12364 silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
12365 pid = d.pid,
12366 version = d.version,
12367 ),
12368 "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
12369 (kills any orphan daemon advancing the cursor without coordination)",
12370 );
12371 }
12372 let mut issues: Vec<String> = Vec::new();
12373 if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
12374 issues.push(format!(
12375 "schema={} (expected {})",
12376 d.schema,
12377 crate::ensure_up::DAEMON_PID_SCHEMA
12378 ));
12379 }
12380 let cli_version = env!("CARGO_PKG_VERSION");
12381 if d.version != cli_version {
12382 issues.push(format!("version daemon={} cli={cli_version}", d.version));
12383 }
12384 if !std::path::Path::new(&d.bin_path).exists() {
12385 issues.push(format!("bin_path {} missing on disk", d.bin_path));
12386 }
12387 if let Ok(card) = config::read_agent_card()
12389 && let Some(current_did) = card.get("did").and_then(Value::as_str)
12390 && let Some(recorded_did) = &d.did
12391 && recorded_did != current_did
12392 {
12393 issues.push(format!(
12394 "did daemon={recorded_did} config={current_did} — identity drift"
12395 ));
12396 }
12397 if let Ok(state) = config::read_relay_state()
12398 && let Some(current_relay) = state
12399 .get("self")
12400 .and_then(|s| s.get("relay_url"))
12401 .and_then(Value::as_str)
12402 && let Some(recorded_relay) = &d.relay_url
12403 && recorded_relay != current_relay
12404 {
12405 issues.push(format!(
12406 "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
12407 ));
12408 }
12409 if issues.is_empty() {
12410 DoctorCheck::pass(
12411 "daemon_pid_consistency",
12412 format!(
12413 "daemon v{} bound to {} as {}",
12414 d.version,
12415 d.relay_url.as_deref().unwrap_or("?"),
12416 d.did.as_deref().unwrap_or("?")
12417 ),
12418 )
12419 } else {
12420 DoctorCheck::warn(
12421 "daemon_pid_consistency",
12422 format!("daemon pidfile drift: {}", issues.join("; ")),
12423 "`wire upgrade` to atomically restart daemon with current config".to_string(),
12424 )
12425 }
12426 }
12427 }
12428}
12429
12430fn check_relay_reachable() -> DoctorCheck {
12432 let state = match config::read_relay_state() {
12433 Ok(s) => s,
12434 Err(e) => {
12435 return DoctorCheck::fail(
12436 "relay",
12437 format!("could not read relay state: {e}"),
12438 "run `wire up <handle>@<relay>` to bootstrap",
12439 );
12440 }
12441 };
12442 let url = state
12443 .get("self")
12444 .and_then(|s| s.get("relay_url"))
12445 .and_then(Value::as_str)
12446 .unwrap_or("");
12447 if url.is_empty() {
12448 return DoctorCheck::warn(
12449 "relay",
12450 "no relay bound — wire send/pull will not work",
12451 "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
12452 );
12453 }
12454 let client = crate::relay_client::RelayClient::new(url);
12455 match client.check_healthz() {
12456 Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
12457 Err(e) => DoctorCheck::fail(
12458 "relay",
12459 format!("{url} unreachable: {e}"),
12460 format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
12461 ),
12462 }
12463}
12464
12465fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
12469 let path = match config::state_dir() {
12470 Ok(d) => d.join("pair-rejected.jsonl"),
12471 Err(e) => {
12472 return DoctorCheck::warn(
12473 "pair_rejections",
12474 format!("could not resolve state dir: {e}"),
12475 "set WIRE_HOME or fix XDG_STATE_HOME",
12476 );
12477 }
12478 };
12479 if !path.exists() {
12480 return DoctorCheck::pass(
12481 "pair_rejections",
12482 "no pair-rejected.jsonl — no recorded pair failures",
12483 );
12484 }
12485 let body = match std::fs::read_to_string(&path) {
12486 Ok(b) => b,
12487 Err(e) => {
12488 return DoctorCheck::warn(
12489 "pair_rejections",
12490 format!("could not read {path:?}: {e}"),
12491 "check file permissions",
12492 );
12493 }
12494 };
12495 let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
12496 if lines.is_empty() {
12497 return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
12498 }
12499 let total = lines.len();
12500 let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
12501 let mut summary: Vec<String> = Vec::new();
12502 for line in &recent {
12503 if let Ok(rec) = serde_json::from_str::<Value>(line) {
12504 let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
12505 let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
12506 summary.push(format!("{peer}/{code}"));
12507 }
12508 }
12509 DoctorCheck::warn(
12510 "pair_rejections",
12511 format!(
12512 "{total} pair failures recorded. recent: [{}]",
12513 summary.join(", ")
12514 ),
12515 format!(
12516 "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
12517 ),
12518 )
12519}
12520
12521fn check_and_heal_self_userinfo_endpoints() -> DoctorCheck {
12585 let mut state = match config::read_relay_state() {
12586 Ok(s) => s,
12587 Err(_) => {
12588 return DoctorCheck::pass(
12589 "self-userinfo-endpoints",
12590 "no relay state yet — nothing published to heal".to_string(),
12591 );
12592 }
12593 };
12594 let self_block = match state.get_mut("self").and_then(Value::as_object_mut) {
12595 Some(s) => s,
12596 None => {
12597 return DoctorCheck::pass(
12598 "self-userinfo-endpoints",
12599 "no self block in relay state — nothing published to heal".to_string(),
12600 );
12601 }
12602 };
12603
12604 let mut stripped: Vec<String> = Vec::new();
12605 let mut clean_seed: Option<(String, String, String)> = None;
12606
12607 if let Some(endpoints) = self_block
12608 .get_mut("endpoints")
12609 .and_then(Value::as_array_mut)
12610 {
12611 endpoints.retain(|ep| {
12612 let url = ep.get("relay_url").and_then(Value::as_str).unwrap_or("");
12613 if assert_relay_url_clean_for_publish(url).is_err() {
12617 stripped.push(url.to_string());
12618 false
12619 } else {
12620 if clean_seed.is_none() {
12621 clean_seed = Some((
12622 url.to_string(),
12623 ep.get("slot_id")
12624 .and_then(Value::as_str)
12625 .unwrap_or("")
12626 .to_string(),
12627 ep.get("slot_token")
12628 .and_then(Value::as_str)
12629 .unwrap_or("")
12630 .to_string(),
12631 ));
12632 }
12633 true
12634 }
12635 });
12636 }
12637
12638 let mut legacy_healed = false;
12643 let legacy_url = self_block
12644 .get("relay_url")
12645 .and_then(Value::as_str)
12646 .unwrap_or("")
12647 .to_string();
12648 if !legacy_url.is_empty() && assert_relay_url_clean_for_publish(&legacy_url).is_err() {
12649 if let Some((url, sid, tok)) = &clean_seed {
12650 self_block.insert("relay_url".to_string(), Value::String(url.clone()));
12651 self_block.insert("slot_id".to_string(), Value::String(sid.clone()));
12652 self_block.insert("slot_token".to_string(), Value::String(tok.clone()));
12653 legacy_healed = true;
12654 stripped.push(format!("(legacy top-level) {legacy_url}"));
12655 } else {
12656 return DoctorCheck::warn(
12661 "self-userinfo-endpoints",
12662 format!(
12663 "your published endpoint is malformed (`{legacy_url}` — handle as URL \
12664 userinfo, the bug PR #61 prevents going forward) AND no clean endpoint \
12665 exists to fall back to. Inbound POSTs to this endpoint 4xx; bilateral \
12666 pairing can't complete."
12667 ),
12668 "Bind a clean federation slot first, then re-run doctor to heal: \
12669 `wire bind-relay https://wireup.net` (or your own relay). The bind \
12670 adds a clean endpoint additively; the next `wire doctor` run then \
12671 strips the malformed one safely. Finally re-publish your card with \
12672 `wire claim <your-persona>` so the phonebook serves the clean shape."
12673 .to_string(),
12674 );
12675 }
12676 }
12677
12678 if stripped.is_empty() && !legacy_healed {
12679 return DoctorCheck::pass(
12680 "self-userinfo-endpoints",
12681 "no malformed endpoints in self-state".to_string(),
12682 );
12683 }
12684
12685 if let Err(e) = config::write_relay_state(&state) {
12689 return DoctorCheck::warn(
12690 "self-userinfo-endpoints",
12691 format!(
12692 "detected {} malformed userinfo-bearing endpoint(s) in self-state but \
12693 failed to persist the heal: {e:#}. Found: {}",
12694 stripped.len(),
12695 stripped.join(", ")
12696 ),
12697 "re-run `wire doctor` — likely a transient lock contention".to_string(),
12698 );
12699 }
12700
12701 DoctorCheck::warn(
12702 "self-userinfo-endpoints",
12703 format!(
12704 "healed {} malformed endpoint(s) in self-state on disk: {}. \
12705 These were the `https://<handle>@<host>` shape that PR #61 prevents \
12706 at the write side but couldn't retroactively scrub from existing \
12707 operators. relay.json is now clean.",
12708 stripped.len(),
12709 stripped.join(", ")
12710 ),
12711 "re-publish your agent-card to the phonebook so peers resolve to the \
12712 clean endpoint: `wire claim <your-persona>` (find your persona with \
12713 `wire whoami`)."
12714 .to_string(),
12715 )
12716}
12717
12718fn check_peer_staleness(max_silent_days: u64) -> DoctorCheck {
12719 let state = match config::read_relay_state() {
12720 Ok(s) => s,
12721 Err(_) => {
12722 return DoctorCheck::pass(
12723 "peer-staleness",
12724 "no relay state yet — nothing pinned to check".to_string(),
12725 );
12726 }
12727 };
12728 let peers = match state.get("peers").and_then(Value::as_object) {
12729 Some(p) => p,
12730 None => {
12731 return DoctorCheck::pass("peer-staleness", "no pinned peers".to_string());
12732 }
12733 };
12734 if peers.is_empty() {
12735 return DoctorCheck::pass("peer-staleness", "no pinned peers".to_string());
12736 }
12737 let inbox_dir = match config::inbox_dir() {
12738 Ok(d) => d,
12739 Err(_) => {
12740 return DoctorCheck::warn(
12741 "peer-staleness",
12742 "could not resolve inbox dir; skipping peer-staleness check".to_string(),
12743 "check `wire status` for state-dir resolution".to_string(),
12744 );
12745 }
12746 };
12747 let threshold = std::time::Duration::from_secs(max_silent_days * 24 * 60 * 60);
12748 let now = std::time::SystemTime::now();
12749 let mut stale: Vec<(String, u64, &'static str)> = Vec::new();
12750 for (peer, _info) in peers {
12751 let path = inbox_dir.join(format!("{peer}.jsonl"));
12752 let (age_days, kind) = match std::fs::metadata(&path) {
12753 Ok(meta) => match meta
12754 .modified()
12755 .ok()
12756 .and_then(|m| now.duration_since(m).ok())
12757 {
12758 Some(d) if d > threshold => (d.as_secs() / (24 * 60 * 60), "silent"),
12759 Some(_) => continue, None => (0, "unknown-mtime"),
12761 },
12762 Err(_) => (max_silent_days + 1, "no-inbox-file"),
12763 };
12764 stale.push((peer.clone(), age_days, kind));
12765 }
12766 if stale.is_empty() {
12767 return DoctorCheck::pass(
12768 "peer-staleness",
12769 format!(
12770 "all {} pinned peer(s) have inbox traffic within the last {max_silent_days} day(s)",
12771 peers.len()
12772 ),
12773 );
12774 }
12775 let detail = stale
12776 .iter()
12777 .map(|(p, d, k)| match *k {
12778 "no-inbox-file" => format!("{p} (no inbox file)"),
12779 "unknown-mtime" => format!("{p} (unknown last-event time)"),
12780 _ => format!("{p} ({d}d silent)"),
12781 })
12782 .collect::<Vec<_>>()
12783 .join(", ");
12784 DoctorCheck::warn(
12785 "peer-staleness",
12786 format!(
12787 "{} pinned peer(s) silent for >{max_silent_days}d: {detail}. \
12788 If the peer re-bound their relay slot, our pin is now stale — \
12789 we push successfully to a dead slot and they never see us \
12790 (asymmetric failure, both sides report green).",
12791 stale.len()
12792 ),
12793 "re-pair with `wire add <peer>@<relay>` to refresh the slot. \
12794 Once issue #15 lands, this also auto-resolves on 410 Gone."
12795 .to_string(),
12796 )
12797}
12798
12799fn check_cursor_progress() -> DoctorCheck {
12800 let state = match config::read_relay_state() {
12801 Ok(s) => s,
12802 Err(e) => {
12803 return DoctorCheck::warn(
12804 "cursor",
12805 format!("could not read relay state: {e}"),
12806 "check ~/Library/Application Support/wire/relay.json",
12807 );
12808 }
12809 };
12810 let cursor = state
12811 .get("self")
12812 .and_then(|s| s.get("last_pulled_event_id"))
12813 .and_then(Value::as_str)
12814 .map(|s| s.chars().take(16).collect::<String>())
12815 .unwrap_or_else(|| "<none>".to_string());
12816 DoctorCheck::pass(
12817 "cursor",
12818 format!(
12819 "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
12820 ),
12821 )
12822}
12823
12824#[cfg(test)]
12825mod doctor_tests {
12826 use super::*;
12827
12828 #[test]
12829 fn doctor_check_constructors_set_status_correctly() {
12830 let p = DoctorCheck::pass("x", "ok");
12835 assert_eq!(p.status, "PASS");
12836 assert_eq!(p.fix, None);
12837
12838 let w = DoctorCheck::warn("x", "watch out", "do this");
12839 assert_eq!(w.status, "WARN");
12840 assert_eq!(w.fix, Some("do this".to_string()));
12841
12842 let f = DoctorCheck::fail("x", "broken", "fix it");
12843 assert_eq!(f.status, "FAIL");
12844 assert_eq!(f.fix, Some("fix it".to_string()));
12845 }
12846
12847 #[test]
12848 fn check_pair_rejections_no_file_is_pass() {
12849 config::test_support::with_temp_home(|| {
12852 config::ensure_dirs().unwrap();
12853 let c = check_pair_rejections(5);
12854 assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
12855 });
12856 }
12857
12858 #[test]
12859 fn check_pair_rejections_with_entries_warns() {
12860 config::test_support::with_temp_home(|| {
12864 config::ensure_dirs().unwrap();
12865 crate::pair_invite::record_pair_rejection(
12866 "willard",
12867 "pair_drop_ack_send_failed",
12868 "POST 502",
12869 );
12870 let c = check_pair_rejections(5);
12871 assert_eq!(c.status, "WARN");
12872 assert!(c.detail.contains("1 pair failures"));
12873 assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
12874 });
12875 }
12876
12877 #[test]
12878 fn check_peer_staleness_no_peers_is_pass() {
12879 config::test_support::with_temp_home(|| {
12882 config::ensure_dirs().unwrap();
12883 let c = check_peer_staleness(7);
12884 assert_eq!(c.status, "PASS", "no peers should be PASS, got {c:?}");
12885 });
12886 }
12887
12888 #[test]
12889 fn check_peer_staleness_pinned_with_no_inbox_file_warns() {
12890 config::test_support::with_temp_home(|| {
12895 config::ensure_dirs().unwrap();
12896 let mut state = json!({
12898 "peers": {
12899 "stale-peer": {
12900 "relay_url": "https://wireup.net",
12901 "slot_id": "deadslot",
12902 "slot_token": "tok",
12903 }
12904 }
12905 });
12906 state["self"] = json!({});
12907 config::write_relay_state(&state).unwrap();
12908
12909 let c = check_peer_staleness(7);
12910 assert_eq!(
12911 c.status, "WARN",
12912 "pinned peer with no inbox file must surface: {c:?}"
12913 );
12914 assert!(
12915 c.detail.contains("stale-peer"),
12916 "WARN must name the silent peer so the operator can act: {}",
12917 c.detail
12918 );
12919 assert!(
12920 c.detail.contains("asymmetric")
12921 || c.detail.contains("stale")
12922 || c.detail.contains("dead slot"),
12923 "WARN must surface the failure-mode language so the operator \
12924 finds the diagnosis without re-tracing: {}",
12925 c.detail
12926 );
12927 assert!(
12928 c.fix
12929 .as_ref()
12930 .is_some_and(|f| f.contains("wire add") && f.contains("#15")),
12931 "fix pointer must reference both the manual re-pair AND the \
12932 follow-up issue (#15) that will automate this: {:?}",
12933 c.fix
12934 );
12935 });
12936 }
12937
12938 #[test]
12939 fn check_peer_staleness_pinned_with_fresh_inbox_is_pass() {
12940 config::test_support::with_temp_home(|| {
12944 config::ensure_dirs().unwrap();
12945 let mut state = json!({
12946 "peers": {
12947 "active-peer": {
12948 "relay_url": "https://wireup.net",
12949 "slot_id": "freshslot",
12950 "slot_token": "tok",
12951 }
12952 }
12953 });
12954 state["self"] = json!({});
12955 config::write_relay_state(&state).unwrap();
12956
12957 let inbox = config::inbox_dir().unwrap();
12958 std::fs::create_dir_all(&inbox).unwrap();
12959 std::fs::write(
12960 inbox.join("active-peer.jsonl"),
12961 "{\"event_id\":\"recent\"}\n",
12962 )
12963 .unwrap();
12964
12965 let c = check_peer_staleness(7);
12966 assert_eq!(c.status, "PASS", "fresh inbox should not warn: {c:?}");
12967 });
12968 }
12969
12970 #[test]
12971 fn check_self_userinfo_no_state_is_pass() {
12972 config::test_support::with_temp_home(|| {
12976 let c = check_and_heal_self_userinfo_endpoints();
12978 assert_eq!(c.status, "PASS", "no state should be PASS, got {c:?}");
12979 });
12980 }
12981
12982 #[test]
12983 fn check_self_userinfo_clean_state_is_pass_no_mutation() {
12984 config::test_support::with_temp_home(|| {
12988 config::ensure_dirs().unwrap();
12989 let state = json!({
12990 "self": {
12991 "endpoints": [
12992 {
12993 "relay_url": "https://wireup.net",
12994 "scope": "Federation",
12995 "slot_id": "abc",
12996 "slot_token": "tok"
12997 }
12998 ],
12999 "relay_url": "https://wireup.net",
13000 "slot_id": "abc",
13001 "slot_token": "tok"
13002 },
13003 "peers": {}
13004 });
13005 config::write_relay_state(&state).unwrap();
13006
13007 let c = check_and_heal_self_userinfo_endpoints();
13008 assert_eq!(c.status, "PASS", "clean state should be PASS: {c:?}");
13009
13010 let after = config::read_relay_state().unwrap();
13012 assert_eq!(after, state, "PASS path must NOT mutate relay.json");
13013 });
13014 }
13015
13016 #[test]
13017 fn check_self_userinfo_heals_malformed_endpoint_and_promotes_clean() {
13018 config::test_support::with_temp_home(|| {
13025 config::ensure_dirs().unwrap();
13026 let state = json!({
13027 "self": {
13028 "endpoints": [
13029 {
13030 "relay_url": "https://copilot-agent@wireup.net",
13031 "scope": "Federation",
13032 "slot_id": "stale-id",
13033 "slot_token": "stale-token"
13034 },
13035 {
13036 "relay_url": "https://wireup.net",
13037 "scope": "Federation",
13038 "slot_id": "clean-id",
13039 "slot_token": "clean-token"
13040 }
13041 ],
13042 "relay_url": "https://copilot-agent@wireup.net",
13043 "slot_id": "stale-id",
13044 "slot_token": "stale-token"
13045 },
13046 "peers": {}
13047 });
13048 config::write_relay_state(&state).unwrap();
13049
13050 let c = check_and_heal_self_userinfo_endpoints();
13051 assert_eq!(c.status, "WARN", "heal should report WARN: {c:?}");
13052 assert!(
13053 c.detail.contains("healed") && c.detail.contains("copilot-agent@wireup.net"),
13054 "WARN must name the stripped URL so the operator sees what changed: {}",
13055 c.detail
13056 );
13057 assert!(
13058 c.fix.as_ref().is_some_and(|f| f.contains("wire claim")),
13059 "fix must point at re-publishing the agent-card so the phonebook entry \
13060 matches the healed state on disk: {:?}",
13061 c.fix
13062 );
13063
13064 let after = config::read_relay_state().unwrap();
13068 let endpoints = after["self"]["endpoints"].as_array().unwrap();
13069 assert_eq!(endpoints.len(), 1, "malformed endpoint must be removed");
13070 assert_eq!(endpoints[0]["relay_url"], "https://wireup.net");
13071 assert_eq!(after["self"]["relay_url"], "https://wireup.net");
13072 assert_eq!(after["self"]["slot_id"], "clean-id");
13073 assert_eq!(after["self"]["slot_token"], "clean-token");
13074 });
13075 }
13076
13077 #[test]
13078 fn check_self_userinfo_no_clean_fallback_warns_without_mutating() {
13079 config::test_support::with_temp_home(|| {
13085 config::ensure_dirs().unwrap();
13086 let state = json!({
13087 "self": {
13088 "endpoints": [
13089 {
13090 "relay_url": "https://copilot-agent@wireup.net",
13091 "scope": "Federation",
13092 "slot_id": "stale-id",
13093 "slot_token": "stale-token"
13094 }
13095 ],
13096 "relay_url": "https://copilot-agent@wireup.net",
13097 "slot_id": "stale-id",
13098 "slot_token": "stale-token"
13099 },
13100 "peers": {}
13101 });
13102 config::write_relay_state(&state).unwrap();
13103
13104 let c = check_and_heal_self_userinfo_endpoints();
13105 assert_eq!(c.status, "WARN");
13106 assert!(
13107 c.fix
13108 .as_ref()
13109 .is_some_and(|f| f.contains("wire bind-relay") && f.contains("wire claim")),
13110 "no-clean-fallback fix must require BOTH a clean bind AND a re-claim: {:?}",
13111 c.fix
13112 );
13113
13114 let after = config::read_relay_state().unwrap();
13117 assert_eq!(
13118 after, state,
13119 "no-clean-fallback path must NOT mutate state (would strand operator)"
13120 );
13121 });
13122 }
13123}
13124
13125fn cmd_up(
13137 relay_arg: Option<&str>,
13138 name: Option<&str>,
13139 with_local: Option<&str>,
13140 no_local: bool,
13141 as_json: bool,
13142) -> Result<()> {
13143 let relay_url = match relay_arg {
13147 Some(r) => {
13148 let r = r.trim_start_matches('@');
13149 if r.starts_with("http://") || r.starts_with("https://") {
13150 r.to_string()
13151 } else {
13152 format!("https://{r}")
13153 }
13154 }
13155 None => crate::pair_invite::DEFAULT_RELAY.to_string(),
13156 };
13157
13158 let relay_url = strip_relay_url_userinfo(&relay_url);
13165
13166 let mut report: Vec<(String, String)> = Vec::new();
13167 let mut step = |stage: &str, detail: String| {
13168 report.push((stage.to_string(), detail.clone()));
13169 if !as_json {
13170 eprintln!("wire up: {stage} — {detail}");
13171 }
13172 };
13173
13174 if config::is_initialized()? {
13177 step("init", "already initialized".to_string());
13178 } else {
13179 cmd_init(
13180 None,
13181 name,
13182 Some(&relay_url),
13183 false,
13184 false,
13185 )?;
13186 step("init", format!("created identity bound to {relay_url}"));
13187 }
13188
13189 let canonical = {
13191 let card = config::read_agent_card()?;
13192 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
13193 crate::agent_card::display_handle_from_did(did).to_string()
13194 };
13195 step("identity", format!("persona is `{canonical}`"));
13196
13197 let relay_state = config::read_relay_state()?;
13201 let bound_relay = relay_state
13202 .get("self")
13203 .and_then(|s| s.get("relay_url"))
13204 .and_then(Value::as_str)
13205 .unwrap_or("")
13206 .to_string();
13207 if bound_relay.is_empty() {
13208 cmd_bind_relay(
13212 &relay_url, None, false, false, false,
13214 )?;
13215 step("bind-relay", format!("bound to {relay_url}"));
13216 } else if bound_relay != relay_url {
13217 step(
13218 "bind-relay",
13219 format!(
13220 "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
13221 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
13222 ),
13223 );
13224 } else {
13225 step("bind-relay", format!("already bound to {bound_relay}"));
13226 }
13227
13228 match cmd_claim(
13231 &canonical,
13232 Some(&relay_url),
13233 None,
13234 false,
13235 false,
13236 ) {
13237 Ok(()) => step(
13238 "claim",
13239 format!("{canonical}@{} claimed", strip_proto(&relay_url)),
13240 ),
13241 Err(e) => step(
13242 "claim",
13243 format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
13244 ),
13245 }
13246
13247 if no_local {
13252 step("local-slot", "skipped (--no-local)".to_string());
13253 } else {
13254 let local_url = with_local
13255 .unwrap_or("http://127.0.0.1:8771")
13256 .trim_end_matches('/');
13257 let already_local = crate::endpoints::self_endpoints(
13258 &config::read_relay_state().unwrap_or_else(|_| json!({})),
13259 )
13260 .iter()
13261 .any(|e| e.relay_url == local_url);
13262 if relay_url.trim_end_matches('/') == local_url || already_local {
13263 step("local-slot", "already covered".to_string());
13264 } else if crate::relay_client::RelayClient::new(local_url)
13265 .check_healthz()
13266 .is_ok()
13267 {
13268 match cmd_bind_relay(
13269 local_url,
13270 Some("local"),
13271 false,
13272 false,
13273 false,
13274 ) {
13275 Ok(()) => step(
13276 "local-slot",
13277 format!("dual-bound local relay {local_url} for sister routing"),
13278 ),
13279 Err(e) => step("local-slot", format!("skipped local relay: {e}")),
13280 }
13281 } else {
13282 step(
13283 "local-slot",
13284 format!(
13285 "no local relay reachable at {local_url} — federation only \
13286 (sisters resolve via session-list)"
13287 ),
13288 );
13289 }
13290 }
13291
13292 match crate::ensure_up::ensure_daemon_running() {
13294 Ok(true) => step("daemon", "started fresh background daemon".to_string()),
13295 Ok(false) => step("daemon", "already running".to_string()),
13296 Err(e) => step(
13297 "daemon",
13298 format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
13299 ),
13300 }
13301
13302 let summary =
13304 "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
13305 `wire monitor` to watch incoming events."
13306 .to_string();
13307 step("ready", summary.clone());
13308
13309 if as_json {
13310 let steps_json: Vec<_> = report
13311 .iter()
13312 .map(|(k, v)| json!({"stage": k, "detail": v}))
13313 .collect();
13314 println!(
13315 "{}",
13316 serde_json::to_string(&json!({
13317 "nick": canonical,
13318 "relay": relay_url,
13319 "steps": steps_json,
13320 }))?
13321 );
13322 }
13323 Ok(())
13324}
13325
13326fn strip_proto(url: &str) -> String {
13328 url.trim_start_matches("https://")
13329 .trim_start_matches("http://")
13330 .to_string()
13331}
13332
13333fn error_smells_like_slot_4xx(last_err: &str) -> bool {
13410 fn is_token_boundary(b: u8) -> bool {
13411 matches!(b, b' ' | b':' | b'\t' | b'\n' | b'\r')
13412 }
13413 let bytes = last_err.as_bytes();
13414 for code in ["410", "404"] {
13415 let code_bytes = code.as_bytes();
13416 let mut search_from = 0usize;
13417 while let Some(rel) = last_err[search_from..].find(code) {
13418 let abs = search_from + rel;
13419 let end = abs + code_bytes.len();
13420 let before_ok = abs == 0 || is_token_boundary(bytes[abs - 1]);
13421 let after_ok = end == bytes.len() || is_token_boundary(bytes[end]);
13422 if before_ok && after_ok {
13423 return true;
13424 }
13425 search_from = abs + 1;
13429 }
13430 }
13431 false
13432}
13433
13434fn try_reresolve_peer_on_slot_4xx(
13469 state: &mut Value,
13470 peer_handle: &str,
13471 last_err: &str,
13472 already_tried: &std::collections::HashSet<String>,
13473) -> Result<bool> {
13474 if !error_smells_like_slot_4xx(last_err) {
13475 return Ok(false);
13477 }
13478 if already_tried.contains(peer_handle) {
13479 return Ok(false);
13481 }
13482 let peer_entry = state
13484 .get("peers")
13485 .and_then(|p| p.get(peer_handle))
13486 .ok_or_else(|| anyhow!("peer `{peer_handle}` not in relay_state"))?;
13487 let peer_relay = peer_entry
13488 .get("endpoints")
13489 .and_then(Value::as_array)
13490 .and_then(|arr| {
13491 arr.iter().find(|e| {
13492 e.get("scope").and_then(Value::as_str) == Some("federation")
13493 || e.get("scope").and_then(Value::as_str) == Some("Federation")
13494 })
13495 })
13496 .and_then(|e| e.get("relay_url").and_then(Value::as_str))
13497 .or_else(|| peer_entry.get("relay_url").and_then(Value::as_str))
13498 .ok_or_else(|| {
13499 anyhow!("peer `{peer_handle}` has no federation endpoint to re-resolve against")
13500 })?
13501 .to_string();
13502 let domain = peer_relay
13505 .trim_start_matches("https://")
13506 .trim_start_matches("http://")
13507 .split('/')
13508 .next()
13509 .unwrap_or(&peer_relay)
13510 .to_string();
13511 let handle = crate::pair_profile::Handle {
13512 nick: peer_handle.to_string(),
13513 domain,
13514 };
13515 let resolved = crate::pair_profile::resolve_handle(&handle, Some(&peer_relay))?;
13516 let new_slot_id = resolved
13517 .get("slot_id")
13518 .and_then(Value::as_str)
13519 .ok_or_else(|| anyhow!("re-resolved payload missing slot_id"))?
13520 .to_string();
13521 let peers = state
13523 .get_mut("peers")
13524 .and_then(Value::as_object_mut)
13525 .ok_or_else(|| anyhow!("relay_state.peers missing or wrong shape"))?;
13526 let peer_entry = peers
13527 .get_mut(peer_handle)
13528 .ok_or_else(|| anyhow!("peer `{peer_handle}` disappeared from state mid-resolve"))?;
13529 let current_slot_id = peer_entry
13530 .get("endpoints")
13531 .and_then(Value::as_array)
13532 .and_then(|arr| {
13533 arr.iter().find(|e| {
13534 let scope = e.get("scope").and_then(Value::as_str);
13535 scope == Some("federation") || scope == Some("Federation")
13536 })
13537 })
13538 .and_then(|e| e.get("slot_id").and_then(Value::as_str))
13539 .unwrap_or("")
13540 .to_string();
13541 if current_slot_id == new_slot_id {
13542 return Ok(false);
13544 }
13545 if let Some(endpoints) = peer_entry
13554 .get_mut("endpoints")
13555 .and_then(Value::as_array_mut)
13556 {
13557 for ep in endpoints.iter_mut() {
13558 let scope = ep.get("scope").and_then(Value::as_str);
13559 if scope == Some("federation") || scope == Some("Federation") {
13560 ep["slot_id"] = Value::String(new_slot_id.clone());
13561 ep["slot_token"] = Value::String(String::new());
13562 }
13563 }
13564 }
13565 peer_entry["slot_id"] = Value::String(new_slot_id.clone());
13568 peer_entry["slot_token"] = Value::String(String::new());
13569 eprintln!(
13570 "wire push: peer `{peer_handle}` rotated their relay slot (was `{current_slot_id}`, \
13571 now `{new_slot_id}`); pin updated in place. Re-pair via `wire add \
13572 {peer_handle}@<relay>` to refresh the slot_token."
13573 );
13574 Ok(true)
13575}
13576
13577fn reject_self_pair_after_resolution(our_did: &str, peer_did: &str) -> Result<()> {
13578 if our_did == peer_did {
13579 bail!(
13580 "refusing to self-pair: resolved peer DID `{peer_did}` matches your own \
13581 DID. Two terminals can collapse onto one wire identity when the per-\
13582 session key isn't reaching the wire process (issue #30 / #29).\n\n\
13583 Diagnose:\n \
13584 • `wire whoami` in each terminal — DIDs MUST differ.\n \
13585 • `echo $WIRE_SESSION_ID` (bash) / `echo $env:WIRE_SESSION_ID` \
13586 (PowerShell) — must be set + distinct per session.\n\n\
13587 Force distinct identities before relaunching the agent:\n \
13588 • bash/zsh: `export WIRE_SESSION_ID=\"$(uuidgen)\"`\n \
13589 • PowerShell: `$env:WIRE_SESSION_ID = [guid]::NewGuid().ToString()`"
13590 );
13591 }
13592 Ok(())
13593}
13594
13595fn strip_relay_url_userinfo(url: &str) -> String {
13596 let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
13599 let rest = &url[authority_start..];
13600 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
13601 let authority = &rest[..authority_end];
13602
13603 let Some(at_pos) = authority.find('@') else {
13604 return url.to_string();
13605 };
13606
13607 let userinfo = &authority[..at_pos];
13608 let host = &authority[at_pos + 1..];
13609 let scheme = &url[..authority_start];
13610 let tail = &rest[authority_end..];
13611 let cleaned = format!("{scheme}{host}{tail}");
13612
13613 eprintln!(
13614 "wire: ignoring `{userinfo}@` prefix on relay URL `{url}` — \
13615 in v0.11+ your handle is DID-derived (one-name rule), so the relay URL \
13616 is just the bare relay. Binding to `{cleaned}` instead."
13617 );
13618
13619 cleaned
13620}
13621
13622fn assert_relay_url_clean_for_publish(url: &str) -> Result<()> {
13630 let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
13631 let rest = &url[authority_start..];
13632 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
13633 let authority = &rest[..authority_end];
13634 if authority.contains('@') {
13635 bail!(
13636 "internal invariant violated: relay URL `{url}` still carries userinfo at \
13637 the persist/publish boundary — `strip_relay_url_userinfo` must be called \
13638 before this point. Refusing to publish a malformed endpoint."
13639 );
13640 }
13641 Ok(())
13642}
13643
13644fn cmd_pair_megacommand(
13658 handle_arg: &str,
13659 relay_override: Option<&str>,
13660 timeout_secs: u64,
13661 _as_json: bool,
13662) -> Result<()> {
13663 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
13664 let peer_handle = parsed.nick.clone();
13665
13666 eprintln!("wire pair: resolving {handle_arg}...");
13667 cmd_add(
13668 handle_arg,
13669 relay_override,
13670 false,
13671 false,
13672 )?;
13673
13674 eprintln!(
13675 "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
13676 to ack (their daemon must be running + pulling)..."
13677 );
13678
13679 let _ = run_sync_pull();
13683
13684 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
13685 let poll_interval = std::time::Duration::from_millis(500);
13686
13687 loop {
13688 let _ = run_sync_pull();
13690 let relay_state = config::read_relay_state()?;
13691 let peer_entry = relay_state
13692 .get("peers")
13693 .and_then(|p| p.get(&peer_handle))
13694 .cloned();
13695 let token = peer_entry
13696 .as_ref()
13697 .and_then(|e| e.get("slot_token"))
13698 .and_then(Value::as_str)
13699 .unwrap_or("");
13700
13701 if !token.is_empty() {
13702 let trust = config::read_trust()?;
13704 let pinned_in_trust = trust
13705 .get("agents")
13706 .and_then(|a| a.get(&peer_handle))
13707 .is_some();
13708 println!(
13709 "wire pair: paired with {peer_handle}.\n trust: {} bilateral: yes (slot_token recorded)\n next: `wire send {peer_handle} \"<msg>\"`",
13710 if pinned_in_trust {
13711 "VERIFIED"
13712 } else {
13713 "MISSING (bug)"
13714 }
13715 );
13716 return Ok(());
13717 }
13718
13719 if std::time::Instant::now() >= deadline {
13720 bail!(
13727 "wire pair: timed out after {timeout_secs}s. \
13728 peer {peer_handle} never sent pair_drop_ack. \
13729 likely causes: (a) their daemon is down — ask them to run \
13730 `wire status` and `wire daemon &`; (b) their binary is older \
13731 than 0.5.x and doesn't understand pair_drop events — ask \
13732 them to `wire upgrade`; (c) network / relay blip — re-run \
13733 `wire pair {handle_arg}` to retry."
13734 );
13735 }
13736
13737 std::thread::sleep(poll_interval);
13738 }
13739}
13740
13741fn cmd_claim(
13742 nick: &str,
13743 relay_override: Option<&str>,
13744 public_url: Option<&str>,
13745 hidden: bool,
13746 as_json: bool,
13747) -> Result<()> {
13748 let (_did, relay_url, slot_id, slot_token) =
13751 crate::pair_invite::ensure_self_with_relay(relay_override)?;
13752 let card = config::read_agent_card()?;
13753
13754 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
13763 let canonical = crate::agent_card::display_handle_from_did(did).to_string();
13764 if !canonical.is_empty() && nick != canonical && !as_json {
13765 eprintln!(
13766 "wire claim: typed `{nick}` ignored — one-name rule. Claiming your persona `{canonical}`."
13767 );
13768 }
13769 let nick = if canonical.is_empty() {
13770 nick
13771 } else {
13772 canonical.as_str()
13773 };
13774 if !crate::pair_profile::is_valid_nick(nick) {
13775 bail!(
13776 "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
13777 );
13778 }
13779
13780 let client = crate::relay_client::RelayClient::new(&relay_url);
13781 let discoverable = if hidden { Some(false) } else { None };
13785 let resp =
13786 client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
13787
13788 if as_json {
13789 println!(
13790 "{}",
13791 serde_json::to_string(&json!({
13792 "nick": nick,
13793 "relay": relay_url,
13794 "response": resp,
13795 }))?
13796 );
13797 } else {
13798 let domain = public_url
13802 .unwrap_or(&relay_url)
13803 .trim_start_matches("https://")
13804 .trim_start_matches("http://")
13805 .trim_end_matches('/')
13806 .split('/')
13807 .next()
13808 .unwrap_or("<this-relay-domain>")
13809 .to_string();
13810 println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
13811 println!("verify with: wire whois {nick}@{domain}");
13812 }
13813 Ok(())
13814}
13815
13816fn cmd_profile(action: ProfileAction) -> Result<()> {
13817 match action {
13818 ProfileAction::Set { field, value, json } => {
13819 let parsed: Value =
13823 serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
13824 let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
13825 let published = republish_card_to_phonebook();
13826 if json {
13827 println!(
13828 "{}",
13829 serde_json::to_string(&json!({
13830 "field": field,
13831 "profile": new_profile,
13832 "published_to": published,
13833 }))?
13834 );
13835 } else {
13836 println!("profile.{field} set");
13837 print_profile_publish_result(&published);
13838 }
13839 }
13840 ProfileAction::Get { json } => return cmd_whois(None, json, None),
13841 ProfileAction::Clear { field, json } => {
13842 let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
13843 let published = republish_card_to_phonebook();
13844 if json {
13845 println!(
13846 "{}",
13847 serde_json::to_string(&json!({
13848 "field": field,
13849 "cleared": true,
13850 "profile": new_profile,
13851 "published_to": published,
13852 }))?
13853 );
13854 } else {
13855 println!("profile.{field} cleared");
13856 print_profile_publish_result(&published);
13857 }
13858 }
13859 }
13860 Ok(())
13861}
13862
13863fn republish_card_to_phonebook() -> Vec<String> {
13871 let Ok(card) = config::read_agent_card() else {
13872 return Vec::new();
13873 };
13874 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
13875 let persona = crate::agent_card::display_handle_from_did(did).to_string();
13876 if persona.is_empty() {
13877 return Vec::new();
13878 }
13879 let Ok(state) = config::read_relay_state() else {
13880 return Vec::new();
13881 };
13882 let mut published = Vec::new();
13883 for ep in crate::endpoints::self_endpoints(&state) {
13884 if ep.scope != crate::endpoints::EndpointScope::Federation
13885 || ep.slot_id.is_empty()
13886 || ep.slot_token.is_empty()
13887 {
13888 continue;
13889 }
13890 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
13891 if client
13892 .handle_claim_v2(&persona, &ep.slot_id, &ep.slot_token, None, &card, None)
13893 .is_ok()
13894 {
13895 published.push(ep.relay_url.clone());
13896 }
13897 }
13898 published
13899}
13900
13901fn print_profile_publish_result(published: &[String]) {
13902 if published.is_empty() {
13903 println!(
13904 " (local only — not bound to a federation relay; run `wire up` to publish to the phonebook)"
13905 );
13906 } else {
13907 println!(" published to phonebook: {}", published.join(", "));
13908 }
13909}
13910
13911fn cmd_setup(apply: bool) -> Result<()> {
13914 use std::path::PathBuf;
13915
13916 let entry = json!({
13926 "command": "wire",
13927 "args": ["mcp"],
13928 "env": {"WIRE_SESSION_ID": "${CLAUDE_CODE_SESSION_ID}"}
13929 });
13930 let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
13931
13932 let mut targets: Vec<(&str, PathBuf)> = Vec::new();
13935 if let Some(home) = dirs::home_dir() {
13936 targets.push(("Claude Code", home.join(".claude.json")));
13939 targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
13941 #[cfg(target_os = "macos")]
13943 targets.push((
13944 "Claude Desktop (macOS)",
13945 home.join("Library/Application Support/Claude/claude_desktop_config.json"),
13946 ));
13947 #[cfg(target_os = "windows")]
13949 if let Ok(appdata) = std::env::var("APPDATA") {
13950 targets.push((
13951 "Claude Desktop (Windows)",
13952 PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
13953 ));
13954 }
13955 targets.push(("Cursor", home.join(".cursor/mcp.json")));
13957
13958 #[cfg(target_os = "macos")]
13960 targets.push((
13961 "VS Code (GitHub Copilot)",
13962 home.join("Library/Application Support/Code/User/settings.json"),
13963 ));
13964 #[cfg(target_os = "linux")]
13965 targets.push((
13966 "VS Code (GitHub Copilot)",
13967 home.join(".config/Code/User/settings.json"),
13968 ));
13969 #[cfg(target_os = "windows")]
13970 if let Ok(appdata) = std::env::var("APPDATA") {
13971 targets.push((
13972 "VS Code (GitHub Copilot)",
13973 PathBuf::from(appdata).join("Code/User/settings.json"),
13974 ));
13975 }
13976
13977 #[cfg(target_os = "macos")]
13979 targets.push((
13980 "VS Code Insiders",
13981 home.join("Library/Application Support/Code - Insiders/User/settings.json"),
13982 ));
13983 #[cfg(target_os = "linux")]
13984 targets.push((
13985 "VS Code Insiders",
13986 home.join(".config/Code - Insiders/User/settings.json"),
13987 ));
13988 #[cfg(target_os = "windows")]
13989 if let Ok(appdata) = std::env::var("APPDATA") {
13990 targets.push((
13991 "VS Code Insiders",
13992 PathBuf::from(appdata).join("Code - Insiders/User/settings.json"),
13993 ));
13994 }
13995
13996 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
14001 targets.push((
14002 "GitHub Copilot CLI (XDG)",
14003 PathBuf::from(xdg).join("copilot/mcp-config.json"),
14004 ));
14005 }
14006 targets.push(("GitHub Copilot CLI", home.join(".copilot/mcp-config.json")));
14007 }
14008 targets.push((
14010 "VS Code (workspace)",
14011 PathBuf::from(".vscode/settings.json"),
14012 ));
14013 targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
14015
14016 println!("wire setup\n");
14017 println!("MCP server snippet (add this to your client's mcpServers):");
14018 println!();
14019 println!("{entry_pretty}");
14020 println!();
14021
14022 if !apply {
14023 println!("Probable MCP host config locations on this machine:");
14024 for (name, path) in &targets {
14025 let marker = if path.exists() {
14026 "✓ found"
14027 } else {
14028 " (would create)"
14029 };
14030 println!(" {marker:14} {name}: {}", path.display());
14031 }
14032 println!();
14033 println!("Run `wire setup --apply` to merge wire into each config above.");
14034 println!(
14035 "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
14036 );
14037 return Ok(());
14038 }
14039
14040 let mut modified: Vec<String> = Vec::new();
14041 let mut skipped: Vec<String> = Vec::new();
14042 for (name, path) in &targets {
14043 match upsert_mcp_entry(path, "wire", &entry) {
14044 Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
14045 Ok(false) => skipped.push(format!(" {name} ({}): already configured", path.display())),
14046 Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
14047 }
14048 }
14049 if !modified.is_empty() {
14050 println!("Modified:");
14051 for line in &modified {
14052 println!(" {line}");
14053 }
14054 println!();
14055 println!("Restart the app(s) above to load wire MCP.");
14056 }
14057 if !skipped.is_empty() {
14058 println!();
14059 println!("Skipped:");
14060 for line in &skipped {
14061 println!(" {line}");
14062 }
14063 }
14064 Ok(())
14065}
14066
14067fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
14074 let mut cfg: Value = if path.exists() {
14075 let body = std::fs::read_to_string(path).context("reading config")?;
14076 if body.trim().is_empty() {
14077 json!({})
14078 } else {
14079 serde_json::from_str(&body).with_context(|| {
14085 format!(
14086 "{} is not strict JSON (comments / trailing commas?); \
14087 add the wire MCP entry manually to avoid overwriting it",
14088 path.display()
14089 )
14090 })?
14091 }
14092 } else {
14093 json!({})
14094 };
14095 if !cfg.is_object() {
14096 cfg = json!({});
14097 }
14098
14099 let is_vscode = path.to_string_lossy().contains("Code/User/settings.json")
14101 || path.to_string_lossy().contains(".vscode/settings.json")
14102 || path.to_string_lossy().contains("Code - Insiders");
14103
14104 let root = cfg.as_object_mut().unwrap();
14105
14106 if is_vscode {
14107 let mcp = root.entry("mcp".to_string()).or_insert_with(|| json!({}));
14109 if !mcp.is_object() {
14110 *mcp = json!({});
14111 }
14112 let mcp_obj = mcp.as_object_mut().unwrap();
14113 let servers = mcp_obj
14114 .entry("servers".to_string())
14115 .or_insert_with(|| json!({}));
14116 if !servers.is_object() {
14117 *servers = json!({});
14118 }
14119 let map = servers.as_object_mut().unwrap();
14120 if map.get(server_name) == Some(entry) {
14121 return Ok(false);
14122 }
14123 map.insert(server_name.to_string(), entry.clone());
14124 } else {
14125 let servers = root
14127 .entry("mcpServers".to_string())
14128 .or_insert_with(|| json!({}));
14129 if !servers.is_object() {
14130 *servers = json!({});
14131 }
14132 let map = servers.as_object_mut().unwrap();
14133 if map.get(server_name) == Some(entry) {
14134 return Ok(false);
14135 }
14136 map.insert(server_name.to_string(), entry.clone());
14137 }
14138
14139 if let Some(parent) = path.parent()
14140 && !parent.as_os_str().is_empty()
14141 {
14142 std::fs::create_dir_all(parent).context("creating parent dir")?;
14143 }
14144 let out = serde_json::to_string_pretty(&cfg)? + "\n";
14145 std::fs::write(path, out).context("writing config")?;
14146 Ok(true)
14147}
14148
14149const STATUSLINE_RENDERER: &str = include_str!("../assets/wire-statusline.sh");
14155
14156fn cmd_setup_statusline(apply: bool, remove: bool) -> Result<()> {
14162 use std::path::PathBuf;
14163 let cfg_dir: PathBuf = std::env::var_os("CLAUDE_CONFIG_DIR")
14164 .map(PathBuf::from)
14165 .or_else(|| dirs::home_dir().map(|h| h.join(".claude")))
14166 .ok_or_else(|| anyhow!("cannot locate Claude config dir (set $CLAUDE_CONFIG_DIR)"))?;
14167 let settings_path = cfg_dir.join("settings.json");
14168 let script_path = cfg_dir.join("wire-statusline.sh");
14169 let (command, command_warn) = statusline_command(&script_path);
14174
14175 println!("wire setup --statusline\n");
14176 println!("Claude config dir: {}", cfg_dir.display());
14177 println!(" renderer: {}", script_path.display());
14178 println!(" settings: {}", settings_path.display());
14179 if let Some(w) = &command_warn {
14180 println!(" ⚠ {w}");
14181 }
14182 println!();
14183
14184 if remove {
14185 if !apply {
14186 println!("Would REMOVE the statusLine key from settings.json and delete the renderer.");
14187 println!("Run `wire setup --statusline --remove --apply` to do it.");
14188 return Ok(());
14189 }
14190 let dropped = remove_statusline_entry(&settings_path)?;
14191 let script_gone = if script_path.exists() {
14192 std::fs::remove_file(&script_path).is_ok()
14193 } else {
14194 false
14195 };
14196 println!(
14197 "Removed: statusLine key {} · renderer {}",
14198 if dropped { "dropped" } else { "absent" },
14199 if script_gone { "deleted" } else { "absent" }
14200 );
14201 return Ok(());
14202 }
14203
14204 if !apply {
14205 println!("Would write the renderer above and merge into settings.json:");
14206 println!();
14207 println!(" \"statusLine\": {{ \"type\": \"command\", \"command\": \"{command}\" }}");
14208 println!();
14209 println!("Resulting statusline: ● <emoji> <nickname> · <cwd>");
14210 println!("Run `wire setup --statusline --apply` to install.");
14211 println!("(Existing settings.json keys are preserved; an invalid settings.json aborts.)");
14212 return Ok(());
14213 }
14214
14215 if let Some(parent) = script_path.parent() {
14216 std::fs::create_dir_all(parent).context("creating Claude config dir")?;
14217 }
14218 std::fs::write(&script_path, STATUSLINE_RENDERER).context("writing renderer script")?;
14219 #[cfg(unix)]
14220 {
14221 use std::os::unix::fs::PermissionsExt;
14222 if let Ok(meta) = std::fs::metadata(&script_path) {
14223 let mut perms = meta.permissions();
14224 perms.set_mode(0o755);
14225 let _ = std::fs::set_permissions(&script_path, perms);
14226 }
14227 }
14228 let changed = upsert_statusline_entry(&settings_path, &command)?;
14229 println!("✓ renderer written: {}", script_path.display());
14230 if changed {
14231 println!("✓ merged statusLine into: {}", settings_path.display());
14232 } else {
14233 println!(
14234 " settings.json already configured: {}",
14235 settings_path.display()
14236 );
14237 }
14238 println!();
14239 println!("Restart Claude Code (or reopen the session) to see your persona in the statusline.");
14240 Ok(())
14241}
14242
14243fn upsert_statusline_entry(path: &std::path::Path, command: &str) -> Result<bool> {
14247 let mut cfg: Value = if path.exists() {
14248 let body = std::fs::read_to_string(path).context("reading settings.json")?;
14249 if body.trim().is_empty() {
14250 json!({})
14251 } else {
14252 serde_json::from_str(&body).context(
14253 "settings.json exists but is not valid JSON — refusing to clobber; fix or remove it first",
14254 )?
14255 }
14256 } else {
14257 json!({})
14258 };
14259 if !cfg.is_object() {
14260 bail!("settings.json root is not a JSON object — refusing to clobber");
14261 }
14262 let desired = json!({"type": "command", "command": command});
14263 let root = cfg.as_object_mut().unwrap();
14264 if root.get("statusLine") == Some(&desired) {
14265 return Ok(false);
14266 }
14267 root.insert("statusLine".to_string(), desired);
14268 if let Some(parent) = path.parent()
14269 && !parent.as_os_str().is_empty()
14270 {
14271 std::fs::create_dir_all(parent).context("creating parent dir")?;
14272 }
14273 let out = serde_json::to_string_pretty(&cfg)? + "\n";
14274 std::fs::write(path, out).context("writing settings.json")?;
14275 Ok(true)
14276}
14277
14278fn remove_statusline_entry(path: &std::path::Path) -> Result<bool> {
14281 if !path.exists() {
14282 return Ok(false);
14283 }
14284 let body = std::fs::read_to_string(path).context("reading settings.json")?;
14285 if body.trim().is_empty() {
14286 return Ok(false);
14287 }
14288 let mut cfg: Value = serde_json::from_str(&body)
14289 .context("settings.json is not valid JSON — refusing to edit")?;
14290 let Some(root) = cfg.as_object_mut() else {
14291 return Ok(false);
14292 };
14293 if root.remove("statusLine").is_none() {
14294 return Ok(false);
14295 }
14296 let out = serde_json::to_string_pretty(&cfg)? + "\n";
14297 std::fs::write(path, out).context("writing settings.json")?;
14298 Ok(true)
14299}
14300
14301fn statusline_command(script_path: &std::path::Path) -> (String, Option<String>) {
14304 #[cfg(windows)]
14305 {
14306 match resolve_git_bash() {
14307 Some(bash) => (format!("\"{}\" \"{}\"", bash, script_path.display()), None),
14308 None => (
14309 format!("bash \"{}\"", script_path.display()),
14310 Some(
14311 "could not locate git-bash; using bare `bash`. On Windows that may resolve to \
14312 WSL (System32\\bash.exe) and the statusline will be blank — install Git for \
14313 Windows or set statusLine.command to your git-bash bash.exe path."
14314 .to_string(),
14315 ),
14316 ),
14317 }
14318 }
14319 #[cfg(unix)]
14320 {
14321 (format!("bash \"{}\"", script_path.display()), None)
14322 }
14323}
14324
14325#[cfg(windows)]
14329fn resolve_git_bash() -> Option<String> {
14330 use std::path::PathBuf;
14331 if let Ok(out) = std::process::Command::new("where.exe").arg("bash").output()
14334 && out.status.success()
14335 {
14336 for line in String::from_utf8_lossy(&out.stdout).lines() {
14337 let p = line.trim();
14338 if !p.is_empty() && !p.to_lowercase().contains("\\system32\\") {
14339 return Some(p.to_string());
14340 }
14341 }
14342 }
14343 let candidates = [
14345 std::env::var("ProgramFiles")
14346 .ok()
14347 .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
14348 std::env::var("ProgramFiles(x86)")
14349 .ok()
14350 .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
14351 std::env::var("LocalAppData")
14352 .ok()
14353 .map(|p| format!("{p}\\Programs\\Git\\bin\\bash.exe")),
14354 ];
14355 candidates
14356 .into_iter()
14357 .flatten()
14358 .find(|c| PathBuf::from(c).exists())
14359}
14360
14361#[cfg(test)]
14362mod statusline_tests {
14363 use super::*;
14364
14365 #[test]
14366 fn statusline_merge_preserves_keys_and_is_idempotent() {
14367 let dir = tempfile::tempdir().unwrap();
14368 let path = dir.path().join("settings.json");
14369 std::fs::write(&path, r#"{"theme":"dark","model":"opus"}"#).unwrap();
14370 assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14372 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14373 assert_eq!(v["theme"], "dark");
14374 assert_eq!(v["model"], "opus");
14375 assert_eq!(v["statusLine"]["type"], "command");
14376 assert_eq!(v["statusLine"]["command"], "bash /x.sh");
14377 assert!(!upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14379 assert!(remove_statusline_entry(&path).unwrap());
14381 let v2: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14382 assert_eq!(v2["theme"], "dark");
14383 assert!(v2.get("statusLine").is_none());
14384 assert!(!remove_statusline_entry(&path).unwrap());
14386 }
14387
14388 #[test]
14389 fn statusline_merge_refuses_to_clobber_invalid_json() {
14390 let dir = tempfile::tempdir().unwrap();
14391 let path = dir.path().join("settings.json");
14392 std::fs::write(&path, "this is not json {").unwrap();
14393 let err = upsert_statusline_entry(&path, "bash /x.sh").unwrap_err();
14394 assert!(
14395 format!("{err:#}").contains("not valid JSON"),
14396 "err: {err:#}"
14397 );
14398 assert_eq!(
14400 std::fs::read_to_string(&path).unwrap(),
14401 "this is not json {"
14402 );
14403 }
14404
14405 #[test]
14406 fn statusline_creates_settings_when_absent() {
14407 let dir = tempfile::tempdir().unwrap();
14408 let path = dir.path().join("settings.json");
14409 assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14410 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14411 assert_eq!(v["statusLine"]["command"], "bash /x.sh");
14412 }
14413}
14414
14415fn cmd_notify(
14418 interval_secs: u64,
14419 peer_filter: Option<&str>,
14420 once: bool,
14421 as_json: bool,
14422) -> Result<()> {
14423 use crate::inbox_watch::InboxWatcher;
14424 let cursor_path = config::state_dir()?.join("notify.cursor");
14425 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
14426 if !once {
14430 crate::session::warn_on_identity_collision(std::process::id(), "notify");
14431 }
14432
14433 let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
14434 let events = watcher.poll()?;
14435 for ev in events {
14436 if let Some(p) = peer_filter
14437 && ev.peer != p
14438 {
14439 continue;
14440 }
14441 if as_json {
14442 println!("{}", serde_json::to_string(&ev)?);
14443 } else {
14444 os_notify_inbox_event(&ev);
14445 }
14446 }
14447 watcher.save_cursors(&cursor_path)?;
14448 Ok(())
14449 };
14450
14451 if once {
14452 return sweep(&mut watcher);
14453 }
14454
14455 let interval = std::time::Duration::from_secs(interval_secs.max(1));
14456 loop {
14457 if let Err(e) = sweep(&mut watcher) {
14458 eprintln!("wire notify: sweep error: {e}");
14459 }
14460 std::thread::sleep(interval);
14461 }
14462}
14463
14464fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
14465 let who = persona_label(&ev.peer);
14466 let title = if ev.verified {
14467 format!("wire ← {who}")
14468 } else {
14469 format!("wire ← {who} (UNVERIFIED)")
14470 };
14471 let body = format!("{}: {}", ev.kind, ev.body_preview);
14472 let id = if ev.event_id.is_empty() {
14478 ev.body_preview.as_str()
14479 } else {
14480 ev.event_id.as_str()
14481 };
14482 let dedup_key = format!("inbox:{}:{}", ev.peer, id);
14483 crate::os_notify::toast_dedup(&dedup_key, &title, &body);
14484}
14485
14486#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
14487fn os_toast(title: &str, body: &str) {
14488 eprintln!("[wire notify] {title}\n {body}");
14489}
14490
14491#[cfg(test)]
14494mod relay_url_tests {
14495 use super::*;
14496
14497 #[test]
14498 fn strip_relay_url_userinfo_strips_handle_and_returns_cleaned() {
14499 assert_eq!(
14511 strip_relay_url_userinfo("https://copilot-agent@wireup.net"),
14512 "https://wireup.net",
14513 "https URL with handle userinfo is stripped to the bare host"
14514 );
14515 assert_eq!(
14516 strip_relay_url_userinfo("http://copilot-agent@127.0.0.1:8771"),
14517 "http://127.0.0.1:8771",
14518 "http + port + userinfo is stripped, port preserved"
14519 );
14520 assert_eq!(strip_relay_url_userinfo("https://u:p@host"), "https://host");
14522 assert_eq!(
14524 strip_relay_url_userinfo("https://nick@host:8443"),
14525 "https://host:8443"
14526 );
14527 assert_eq!(strip_relay_url_userinfo("nick@wireup.net"), "wireup.net");
14531 assert_eq!(
14533 strip_relay_url_userinfo("https://nick@wireup.net/v1/events?x=1#frag"),
14534 "https://wireup.net/v1/events?x=1#frag"
14535 );
14536 }
14537
14538 #[test]
14539 fn strip_relay_url_userinfo_passes_clean_urls_through_unchanged() {
14540 for ok in [
14542 "https://wireup.net",
14543 "http://wireup.net",
14544 "http://127.0.0.1:8771",
14545 "https://relay.example.com:9443/v1/wire",
14546 "https://wireup.net/?env=prod",
14547 "https://wireup.net/users/me@example.com",
14549 "https://wireup.net/?to=me@example.com",
14550 "https://wireup.net/#contact@me",
14552 "http://[::1]:8771",
14554 "wireup.net",
14556 "wireup.net:8443",
14557 ] {
14558 assert_eq!(
14559 strip_relay_url_userinfo(ok),
14560 ok,
14561 "clean URL `{ok}` must pass through unchanged"
14562 );
14563 }
14564 }
14565
14566 #[test]
14567 fn assert_relay_url_clean_for_publish_blocks_userinfo_at_persist_site() {
14568 assert!(assert_relay_url_clean_for_publish("https://wireup.net").is_ok());
14574 assert!(assert_relay_url_clean_for_publish("http://127.0.0.1:8771").is_ok());
14575 assert!(
14576 assert_relay_url_clean_for_publish("https://wireup.net/?to=me@example.com").is_ok()
14577 );
14578
14579 let err = assert_relay_url_clean_for_publish("https://nick@wireup.net")
14580 .unwrap_err()
14581 .to_string();
14582 assert!(
14583 err.contains("invariant violated"),
14584 "persist-site failure must be flagged as an internal invariant violation, not user error: {err}"
14585 );
14586 assert!(
14587 err.contains("strip_relay_url_userinfo"),
14588 "error must name the upstream filter so the caller can audit the bypass: {err}"
14589 );
14590 assert!(assert_relay_url_clean_for_publish("https://u:p@host").is_err());
14592 assert!(assert_relay_url_clean_for_publish("https://nick@host:8443").is_err());
14594 }
14595
14596 #[test]
14597 fn strip_proto_no_longer_doubles_handle_after_userinfo_fix() {
14598 let after_strip = strip_relay_url_userinfo("https://nick@wireup.net");
14604 assert_eq!(after_strip, "https://wireup.net");
14605 assert_eq!(strip_proto(&after_strip), "wireup.net");
14606 assert!(
14608 strip_proto("https://nick@wireup.net").contains('@'),
14609 "strip_proto preserves userinfo by design; the userinfo guard upstream is what prevents the doubled echo"
14610 );
14611 }
14612}
14613
14614#[cfg(test)]
14615mod self_pair_guard_tests {
14616 use super::*;
14617
14618 #[test]
14619 fn reject_self_pair_after_resolution_blocks_matching_dids() {
14620 let err = reject_self_pair_after_resolution(
14627 "did:wire:winter-bay-4092b577",
14628 "did:wire:winter-bay-4092b577",
14629 )
14630 .unwrap_err()
14631 .to_string();
14632 assert!(
14633 err.contains("refusing to self-pair"),
14634 "must explicitly refuse, not silently bail: {err}"
14635 );
14636 assert!(
14637 err.contains("did:wire:winter-bay-4092b577"),
14638 "must include the colliding DID so the operator can grep their `wire whoami` output: {err}"
14639 );
14640 assert!(
14641 err.contains("issue #30") || err.contains("issue #29"),
14642 "must point at the tracking issue so historical context is one search away: {err}"
14643 );
14644 assert!(
14647 err.contains("WIRE_SESSION_ID"),
14648 "remediation must name the env var operators set: {err}"
14649 );
14650 assert!(
14651 err.contains("uuidgen") || err.contains("NewGuid"),
14652 "remediation must include a concrete command to mint a unique id: {err}"
14653 );
14654 }
14655
14656 #[test]
14657 fn reject_self_pair_after_resolution_allows_distinct_dids() {
14658 reject_self_pair_after_resolution(
14663 "did:wire:winter-bay-4092b577",
14664 "did:wire:cedar-bayou-0616dc6c",
14665 )
14666 .unwrap();
14667 reject_self_pair_after_resolution("did:wire:ed25519:abc123", "did:wire:ed25519:def456")
14668 .unwrap();
14669 reject_self_pair_after_resolution(
14673 "did:wire:noble-canyon-deadbeef",
14674 "did:wire:noble-canyon-cafef00d",
14675 )
14676 .unwrap();
14677 }
14678}
14679
14680#[cfg(test)]
14681mod slot_reresolve_tests {
14682 use super::*;
14683
14684 #[test]
14705 fn try_reresolve_skips_when_error_is_not_4xx_shape() {
14706 let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
14707 let already = std::collections::HashSet::new();
14708 let res =
14711 try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "post failed: 502", &already)
14712 .unwrap();
14713 assert!(!res, "502 must NOT trigger a re-resolve");
14714
14715 let res =
14716 try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "connection refused", &already)
14717 .unwrap();
14718 assert!(!res, "transport errors must NOT trigger a re-resolve");
14719
14720 let res = try_reresolve_peer_on_slot_4xx(
14721 &mut state,
14722 "some-peer",
14723 "post failed: 401 Unauthorized",
14724 &already,
14725 )
14726 .unwrap();
14727 assert!(
14728 !res,
14729 "401 (auth) is a token problem, not a slot rotation — must NOT trigger a re-resolve"
14730 );
14731 }
14732
14733 #[test]
14734 fn try_reresolve_rate_limits_one_attempt_per_peer_per_push() {
14735 let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
14740 let mut already = std::collections::HashSet::new();
14741 already.insert("some-peer".to_string());
14742 let res = try_reresolve_peer_on_slot_4xx(
14743 &mut state,
14744 "some-peer",
14745 "post failed: 410 Gone",
14746 &already,
14747 )
14748 .unwrap();
14749 assert!(
14750 !res,
14751 "peer already in `already_tried` must NOT trigger another re-resolve in the same push"
14752 );
14753 }
14754
14755 #[test]
14756 fn try_reresolve_errors_when_peer_missing_from_state() {
14757 let mut state = json!({"peers": {}});
14761 let already = std::collections::HashSet::new();
14762 let err = try_reresolve_peer_on_slot_4xx(
14763 &mut state,
14764 "missing-peer",
14765 "post failed: 410 Gone",
14766 &already,
14767 )
14768 .unwrap_err()
14769 .to_string();
14770 assert!(
14771 err.contains("missing-peer") && err.contains("not in relay_state"),
14772 "missing-peer error must name the peer + the failure: {err}"
14773 );
14774 }
14775
14776 #[test]
14777 fn try_reresolve_errors_when_peer_has_no_federation_endpoint() {
14778 let mut state = json!({
14785 "peers": {
14786 "local-only": {
14787 "endpoints": [
14788 {
14789 "scope": "Local",
14790 "relay_url": "http://127.0.0.1:8771",
14791 "slot_id": "loc",
14792 "slot_token": "tok"
14793 }
14794 ]
14795 }
14796 }
14797 });
14798 let already = std::collections::HashSet::new();
14799 let err = try_reresolve_peer_on_slot_4xx(
14800 &mut state,
14801 "local-only",
14802 "post failed: 410 Gone",
14803 &already,
14804 )
14805 .unwrap_err()
14806 .to_string();
14807 assert!(
14808 err.contains("federation endpoint"),
14809 "no-federation error must name the problem: {err}"
14810 );
14811 }
14812
14813 #[test]
14829 fn error_smells_like_slot_4xx_matches_reqwest_status_display_shape() {
14830 assert!(error_smells_like_slot_4xx(
14833 "post_event failed: 410 Gone: slot rotated by peer"
14834 ));
14835 assert!(error_smells_like_slot_4xx(
14836 "post_event failed: 404 Not Found: handle no longer claimed"
14837 ));
14838 }
14839
14840 #[test]
14841 fn error_smells_like_slot_4xx_matches_uds_bare_u16_shape() {
14842 assert!(error_smells_like_slot_4xx(
14846 "post_event (uds /tmp/wire-relay.sock) failed: 410: gone"
14847 ));
14848 assert!(error_smells_like_slot_4xx(
14849 "post_event (uds /tmp/wire-relay.sock) failed: 404: not found"
14850 ));
14851 }
14852
14853 #[test]
14854 fn error_smells_like_slot_4xx_rejects_substring_lookalikes() {
14855 let false_positives = [
14859 "push aborted: slot 4101 expired",
14860 "post_event failed: 502 Bad Gateway: request_id=410abc-deadbeef",
14861 "post_event failed: 500: received 4040 bytes, expected envelope",
14862 "post_event failed: 500: event 0x4104 malformed",
14863 "post_event failed: 503: backlog=4102 entries pending",
14864 "post_event failed: 500: tx_id=4044beef",
14866 "post_event failed: 500: hash=abc410def",
14868 ];
14869 for case in false_positives {
14870 assert!(
14871 !error_smells_like_slot_4xx(case),
14872 "must NOT trigger re-resolve on substring lookalike: {case:?}"
14873 );
14874 }
14875 }
14876
14877 #[test]
14878 fn error_smells_like_slot_4xx_handles_edge_positions() {
14879 assert!(error_smells_like_slot_4xx("410 Gone"));
14881 assert!(error_smells_like_slot_4xx("404 Not Found"));
14882 assert!(error_smells_like_slot_4xx("got 410"));
14884 assert!(error_smells_like_slot_4xx("got 404"));
14885 assert!(error_smells_like_slot_4xx("post_event failed:\t410\tGone"));
14887 assert!(error_smells_like_slot_4xx("post_event failed:\n410\nGone"));
14888 assert!(error_smells_like_slot_4xx("410"));
14890 assert!(error_smells_like_slot_4xx("404"));
14891 assert!(!error_smells_like_slot_4xx(""));
14893 assert!(!error_smells_like_slot_4xx("no relevant status"));
14894 assert!(!error_smells_like_slot_4xx(
14897 "post_event failed: 401 Unauthorized"
14898 ));
14899 assert!(!error_smells_like_slot_4xx(
14900 "post_event failed: 403 Forbidden"
14901 ));
14902 assert!(!error_smells_like_slot_4xx(
14903 "post_event failed: 411 Length Required"
14904 ));
14905 }
14906}