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)]
165 queue: bool,
166 #[arg(long)]
168 json: bool,
169 },
170 Dial {
189 name: String,
193 message: Option<String>,
197 #[arg(long)]
199 json: bool,
200 },
201 Tail {
209 peer: Option<String>,
211 #[arg(long)]
213 json: bool,
214 #[arg(long, default_value_t = 0)]
216 limit: usize,
217 #[arg(long)]
220 oldest: bool,
221 },
222 Monitor {
233 #[arg(long)]
235 peer: Option<String>,
236 #[arg(long)]
238 json: bool,
239 #[arg(long)]
242 include_handshake: bool,
243 #[arg(long, default_value_t = 500)]
245 interval_ms: u64,
246 #[arg(long, default_value_t = 0)]
248 replay: usize,
249 },
250 Verify {
252 path: String,
254 #[arg(long)]
256 json: bool,
257 },
258 Mcp,
262 RelayServer {
264 #[arg(long, default_value = "127.0.0.1:8770")]
266 bind: String,
267 #[arg(long)]
275 local_only: bool,
276 #[arg(long)]
282 uds: Option<std::path::PathBuf>,
283 },
284 BindRelay {
293 url: String,
295 #[arg(long)]
301 scope: Option<String>,
302 #[arg(long)]
308 replace: bool,
309 #[arg(long)]
315 migrate_pinned: bool,
316 #[arg(long)]
317 json: bool,
318 },
319 AddPeerSlot {
322 handle: String,
324 url: String,
326 slot_id: String,
328 slot_token: String,
330 #[arg(long)]
331 json: bool,
332 },
333 Push {
335 peer: Option<String>,
337 #[arg(long)]
338 json: bool,
339 },
340 Pull {
342 #[arg(long)]
343 json: bool,
344 },
345 Status {
348 #[arg(long)]
350 peer: Option<String>,
351 #[arg(long)]
352 json: bool,
353 },
354 Responder {
356 #[command(subcommand)]
357 command: ResponderCommand,
358 },
359 Pin {
362 card_file: String,
364 #[arg(long)]
365 json: bool,
366 },
367 RotateSlot {
378 #[arg(long)]
381 no_announce: bool,
382 #[arg(long)]
383 json: bool,
384 },
385 ForgetPeer {
389 handle: String,
391 #[arg(long)]
393 purge: bool,
394 #[arg(long)]
395 json: bool,
396 },
397 Supervisor {
405 #[arg(long)]
408 json: bool,
409 },
410 Daemon {
414 #[arg(long, default_value_t = 5)]
416 interval: u64,
417 #[arg(long)]
419 once: bool,
420 #[arg(long)]
430 all_sessions: bool,
431 #[arg(long)]
437 session: Option<String>,
438 #[arg(long)]
439 json: bool,
440 },
441 #[command(hide = true)] PairHost {
447 #[arg(long)]
449 relay: String,
450 #[arg(long)]
454 yes: bool,
455 #[arg(long, default_value_t = 300)]
457 timeout: u64,
458 #[arg(long)]
464 detach: bool,
465 #[arg(long)]
467 json: bool,
468 },
469 #[command(alias = "join")]
473 #[command(hide = true)] PairJoin {
475 code_phrase: String,
477 #[arg(long)]
479 relay: String,
480 #[arg(long)]
481 yes: bool,
482 #[arg(long, default_value_t = 300)]
483 timeout: u64,
484 #[arg(long)]
486 detach: bool,
487 #[arg(long)]
489 json: bool,
490 },
491 #[command(hide = true)] PairConfirm {
496 code_phrase: String,
498 digits: String,
500 #[arg(long)]
502 json: bool,
503 },
504 #[command(hide = true)] PairList {
507 #[arg(long)]
509 json: bool,
510 #[arg(long)]
514 watch: bool,
515 #[arg(long, default_value_t = 1)]
517 watch_interval: u64,
518 },
519 #[command(hide = true)] PairCancel {
522 code_phrase: String,
523 #[arg(long)]
524 json: bool,
525 },
526 #[command(hide = true)] PairWatch {
537 code_phrase: String,
538 #[arg(long, default_value = "sas_ready")]
540 status: String,
541 #[arg(long, default_value_t = 300)]
543 timeout: u64,
544 #[arg(long)]
546 json: bool,
547 },
548 #[command(hide = true)] Pair {
562 handle: String,
565 #[arg(long)]
568 code: Option<String>,
569 #[arg(long, default_value = "https://wireup.net")]
571 relay: String,
572 #[arg(long)]
574 yes: bool,
575 #[arg(long, default_value_t = 300)]
577 timeout: u64,
578 #[arg(long)]
581 no_setup: bool,
582 #[arg(long)]
587 detach: bool,
588 },
589 #[command(hide = true)] PairAbandon {
596 code_phrase: String,
598 #[arg(long, default_value = "https://wireup.net")]
600 relay: String,
601 },
602 #[command(hide = true)] PairAccept {
609 peer: String,
611 #[arg(long)]
613 json: bool,
614 },
615 #[command(hide = true)] PairReject {
623 peer: String,
625 #[arg(long)]
627 json: bool,
628 },
629 #[command(hide = true)] PairListInbound {
636 #[arg(long)]
638 json: bool,
639 },
640 #[command(subcommand)]
650 Session(SessionCommand),
651 Identity {
656 #[command(subcommand)]
657 cmd: IdentityCommand,
658 },
659 #[command(subcommand)]
664 Mesh(MeshCommand),
665 #[command(subcommand)]
669 Group(GroupCommand),
670 #[command(subcommand)]
673 Enroll(EnrollCommand),
674 Setup {
679 #[arg(long)]
681 apply: bool,
682 #[arg(long)]
688 statusline: bool,
689 #[arg(long)]
692 remove: bool,
693 },
694 Whois {
698 handle: Option<String>,
700 #[arg(long)]
701 json: bool,
702 #[arg(long)]
705 relay: Option<String>,
706 },
707 Add {
713 handle: String,
716 #[arg(long)]
718 relay: Option<String>,
719 #[arg(long)]
727 local_sister: bool,
728 #[arg(long)]
729 json: bool,
730 },
731 Up {
744 relay: Option<String>,
748 #[arg(long)]
751 name: Option<String>,
752 #[arg(long)]
757 with_local: Option<String>,
758 #[arg(long)]
760 no_local: bool,
761 #[arg(long)]
762 json: bool,
763 },
764 Doctor {
771 #[arg(long)]
773 json: bool,
774 #[arg(long, default_value_t = 5)]
776 recent_rejections: usize,
777 },
778 #[command(visible_alias = "update")]
790 Upgrade {
791 #[arg(long)]
793 check: bool,
794 #[arg(long)]
797 local: bool,
798 #[arg(long = "restart-mcp")]
804 restart_mcp: bool,
805 #[arg(long = "refresh-stale-children")]
815 refresh_stale_children: bool,
816 #[arg(long)]
817 json: bool,
818 },
819 Service {
824 #[command(subcommand)]
825 action: ServiceAction,
826 },
827 Diag {
832 #[command(subcommand)]
833 action: DiagAction,
834 },
835 #[command(hide = true)]
847 Claim {
848 nick: String,
850 #[arg(long)]
852 relay: Option<String>,
853 #[arg(long)]
855 public_url: Option<String>,
856 #[arg(long)]
864 hidden: bool,
865 #[arg(long)]
866 json: bool,
867 },
868 Profile {
878 #[command(subcommand)]
879 action: ProfileAction,
880 },
881 #[command(hide = true)] Invite {
886 #[arg(long, default_value = "https://wireup.net")]
888 relay: String,
889 #[arg(long, default_value_t = 86_400)]
891 ttl: u64,
892 #[arg(long, default_value_t = 1)]
895 uses: u32,
896 #[arg(long)]
900 share: bool,
901 #[arg(long)]
903 json: bool,
904 },
905 Accept {
915 target: String,
917 #[arg(long)]
919 json: bool,
920 },
921 #[command(alias = "invite-accept")]
929 AcceptInvite {
930 url: String,
932 #[arg(long)]
934 json: bool,
935 },
936 Reject {
939 peer: String,
941 #[arg(long)]
943 json: bool,
944 },
945 Notify {
950 #[arg(long, default_value_t = 2)]
952 interval: u64,
953 #[arg(long)]
955 peer: Option<String>,
956 #[arg(long)]
958 once: bool,
959 #[arg(long)]
963 json: bool,
964 },
965 Quiet {
972 #[command(subcommand)]
973 action: QuietAction,
974 },
975}
976
977#[derive(Subcommand, Debug)]
978pub enum QuietAction {
979 On,
982 Off,
985 Status {
988 #[arg(long)]
991 json: bool,
992 },
993}
994
995#[derive(Subcommand, Debug)]
996pub enum DiagAction {
997 Tail {
999 #[arg(long, default_value_t = 20)]
1000 limit: usize,
1001 #[arg(long)]
1002 json: bool,
1003 },
1004 Enable,
1007 Disable,
1009 Status {
1011 #[arg(long)]
1012 json: bool,
1013 },
1014}
1015
1016#[derive(Subcommand, Debug)]
1021pub enum EnrollCommand {
1022 Op {
1024 #[arg(long, default_value = "operator")]
1026 handle: String,
1027 #[arg(long)]
1028 json: bool,
1029 },
1030 OrgCreate {
1032 #[arg(long)]
1034 handle: String,
1035 #[arg(long)]
1036 json: bool,
1037 },
1038 OrgAddMember {
1042 op_did: String,
1044 #[arg(long)]
1046 org: String,
1047 #[arg(long)]
1048 json: bool,
1049 },
1050 Republish {
1058 #[arg(long)]
1059 json: bool,
1060 },
1061 AddMembership {
1077 #[arg(long)]
1080 bundle: Option<String>,
1081 #[arg(long)]
1083 org: Option<String>,
1084 #[arg(long = "org-pubkey")]
1086 org_pubkey: Option<String>,
1087 #[arg(long = "member-cert")]
1090 member_cert: Option<String>,
1091 #[arg(long)]
1092 json: bool,
1093 },
1094}
1095
1096#[derive(Subcommand, Debug)]
1097pub enum IdentityCommand {
1098 Show {
1101 #[arg(long)]
1102 json: bool,
1103 },
1104 List {
1109 #[arg(long)]
1110 json: bool,
1111 },
1112 #[command(hide = true)]
1120 Publish {
1121 nick: String,
1123 #[arg(long)]
1126 relay: Option<String>,
1127 #[arg(long, alias = "public")]
1130 public_url: Option<String>,
1131 #[arg(long)]
1135 hidden: bool,
1136 #[arg(long)]
1137 json: bool,
1138 },
1139 Destroy {
1143 name: String,
1145 #[arg(long)]
1147 force: bool,
1148 #[arg(long)]
1149 json: bool,
1150 },
1151 Create {
1163 #[arg(long)]
1166 name: Option<String>,
1167 #[arg(long, conflicts_with = "local")]
1170 anonymous: bool,
1171 #[arg(long)]
1174 local: bool,
1175 #[arg(long)]
1176 json: bool,
1177 },
1178 Persist {
1183 name: String,
1185 #[arg(long = "as", value_name = "NEW_NAME")]
1187 as_name: Option<String>,
1188 #[arg(long)]
1189 json: bool,
1190 },
1191 Demote {
1201 name: String,
1203 #[arg(long)]
1204 json: bool,
1205 },
1206}
1207
1208#[derive(Subcommand, Debug)]
1209pub enum SessionCommand {
1210 New {
1218 name: Option<String>,
1220 #[arg(long, default_value = "https://wireup.net")]
1222 relay: String,
1223 #[arg(long)]
1230 with_local: bool,
1231 #[arg(long, default_value = "http://127.0.0.1:8771")]
1235 local_relay: String,
1236 #[arg(long)]
1243 with_lan: bool,
1244 #[arg(long)]
1248 lan_relay: Option<String>,
1249 #[arg(long)]
1256 with_uds: bool,
1257 #[arg(long)]
1261 uds_socket: Option<std::path::PathBuf>,
1262 #[arg(long)]
1265 no_daemon: bool,
1266 #[arg(long)]
1274 local_only: bool,
1275 #[arg(long)]
1277 json: bool,
1278 },
1279 List {
1282 #[arg(long)]
1283 json: bool,
1284 },
1285 ListLocal {
1291 #[arg(long)]
1292 json: bool,
1293 },
1294 PairAllLocal {
1310 #[arg(long, default_value_t = 1)]
1315 settle_secs: u64,
1316 #[arg(long, default_value = "https://wireup.net")]
1321 federation_relay: String,
1322 #[arg(long)]
1323 json: bool,
1324 },
1325 MeshStatus {
1339 #[arg(long, default_value_t = 300)]
1344 stale_secs: u64,
1345 #[arg(long)]
1346 json: bool,
1347 },
1348 Env {
1352 name: Option<String>,
1354 #[arg(long)]
1355 json: bool,
1356 },
1357 Current {
1361 #[arg(long)]
1362 json: bool,
1363 },
1364 Bind {
1372 name: Option<String>,
1376 #[arg(long)]
1377 json: bool,
1378 },
1379 Destroy {
1383 name: String,
1384 #[arg(long)]
1386 force: bool,
1387 #[arg(long)]
1388 json: bool,
1389 },
1390}
1391
1392#[derive(Subcommand, Debug)]
1398pub enum GroupCommand {
1399 Create {
1401 name: String,
1403 #[arg(long)]
1404 json: bool,
1405 },
1406 Add {
1408 group: String,
1410 peer: String,
1412 #[arg(long)]
1413 json: bool,
1414 },
1415 Send {
1417 group: String,
1419 message: String,
1421 #[arg(long)]
1422 json: bool,
1423 },
1424 Tail {
1426 group: String,
1428 #[arg(long, default_value_t = 20)]
1430 limit: usize,
1431 #[arg(long)]
1432 json: bool,
1433 },
1434 List {
1436 #[arg(long)]
1437 json: bool,
1438 },
1439 Invite {
1444 group: String,
1446 #[arg(long)]
1447 json: bool,
1448 },
1449 Join {
1453 code: String,
1455 #[arg(long)]
1456 json: bool,
1457 },
1458}
1459
1460#[derive(Subcommand, Debug)]
1462pub enum MeshCommand {
1463 Status {
1466 #[arg(long, default_value_t = 300)]
1468 stale_secs: u64,
1469 #[arg(long)]
1470 json: bool,
1471 },
1472 Broadcast {
1491 #[arg(long, default_value = "claim")]
1494 kind: String,
1495 #[arg(long, default_value = "local")]
1497 scope: String,
1498 #[arg(long)]
1500 exclude: Vec<String>,
1501 #[arg(long)]
1505 noreply: bool,
1506 body: String,
1508 #[arg(long)]
1509 json: bool,
1510 },
1511 Role {
1520 #[command(subcommand)]
1521 action: MeshRoleAction,
1522 },
1523 Route {
1539 role: String,
1541 #[arg(long, default_value = "round-robin")]
1543 strategy: String,
1544 #[arg(long)]
1546 exclude: Vec<String>,
1547 #[arg(long, default_value = "claim")]
1550 kind: String,
1551 body: String,
1553 #[arg(long)]
1554 json: bool,
1555 },
1556}
1557
1558#[derive(Subcommand, Debug)]
1560pub enum MeshRoleAction {
1561 Set {
1566 role: String,
1567 #[arg(long)]
1568 json: bool,
1569 },
1570 Get {
1573 peer: Option<String>,
1574 #[arg(long)]
1575 json: bool,
1576 },
1577 List {
1580 #[arg(long)]
1581 json: bool,
1582 },
1583 Clear {
1586 #[arg(long)]
1587 json: bool,
1588 },
1589}
1590
1591#[derive(Subcommand, Debug)]
1592pub enum ServiceAction {
1593 Install {
1603 #[arg(long)]
1605 local_relay: bool,
1606 #[arg(long)]
1607 json: bool,
1608 },
1609 Uninstall {
1613 #[arg(long)]
1615 local_relay: bool,
1616 #[arg(long)]
1617 json: bool,
1618 },
1619 Status {
1621 #[arg(long)]
1623 local_relay: bool,
1624 #[arg(long)]
1625 json: bool,
1626 },
1627}
1628
1629#[derive(Subcommand, Debug)]
1630pub enum ResponderCommand {
1631 Set {
1633 status: String,
1635 #[arg(long)]
1637 reason: Option<String>,
1638 #[arg(long)]
1640 json: bool,
1641 },
1642 Get {
1644 peer: Option<String>,
1646 #[arg(long)]
1648 json: bool,
1649 },
1650}
1651
1652#[derive(Subcommand, Debug)]
1653pub enum ProfileAction {
1654 Set {
1658 field: String,
1659 value: String,
1660 #[arg(long)]
1661 json: bool,
1662 },
1663 Get {
1665 #[arg(long)]
1666 json: bool,
1667 },
1668 Clear {
1670 field: String,
1671 #[arg(long)]
1672 json: bool,
1673 },
1674}
1675
1676pub fn run() -> Result<()> {
1678 crate::session::maybe_adopt_session_wire_home("cli");
1689 let cli = Cli::parse();
1690 match cli.command {
1691 Command::Init {
1692 handle,
1693 name,
1694 relay,
1695 offline,
1696 json,
1697 } => cmd_init(
1698 Some(&handle),
1699 name.as_deref(),
1700 relay.as_deref(),
1701 offline,
1702 json,
1703 ),
1704 Command::Status { peer, json } => {
1705 if let Some(peer) = peer {
1706 cmd_status_peer(&peer, json)
1707 } else {
1708 cmd_status(json)
1709 }
1710 }
1711 Command::Whoami {
1712 json,
1713 short,
1714 colored,
1715 } => cmd_whoami(json_default(json), short, colored),
1716 Command::Peers { json } => cmd_peers(json_default(json)),
1717 Command::Here { json } => cmd_here(json_default(json)),
1718 Command::Completions { shell } => {
1719 use clap::CommandFactory;
1726 let mut cmd = Cli::command();
1727 clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1728 Ok(())
1729 }
1730 Command::Pending { json } => cmd_pair_list_inbound(json_default(json)),
1731 Command::Reject { peer, json } => cmd_pair_reject(&peer, json_default(json)),
1732 Command::Send {
1733 peer,
1734 kind_or_body,
1735 body,
1736 deadline,
1737 no_auto_pair,
1738 queue,
1739 json,
1740 } => {
1741 let (kind, body) = match body {
1744 Some(real_body) => (kind_or_body, real_body),
1745 None => ("claim".to_string(), kind_or_body),
1746 };
1747 cmd_send(
1748 &peer,
1749 &kind,
1750 &body,
1751 deadline.as_deref(),
1752 no_auto_pair,
1753 queue,
1754 json_default(json),
1755 )
1756 }
1757 Command::Dial {
1758 name,
1759 message,
1760 json,
1761 } => cmd_dial(&name, message.as_deref(), json_default(json)),
1762 Command::Tail {
1763 peer,
1764 json,
1765 limit,
1766 oldest,
1767 } => cmd_tail(peer.as_deref(), json, limit, oldest),
1768 Command::Monitor {
1769 peer,
1770 json,
1771 include_handshake,
1772 interval_ms,
1773 replay,
1774 } => cmd_monitor(
1775 peer.as_deref(),
1776 json,
1777 include_handshake,
1778 interval_ms,
1779 replay,
1780 ),
1781 Command::Verify { path, json } => cmd_verify(&path, json),
1782 Command::Responder { command } => match command {
1783 ResponderCommand::Set {
1784 status,
1785 reason,
1786 json,
1787 } => cmd_responder_set(&status, reason.as_deref(), json),
1788 ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1789 },
1790 Command::Mcp => cmd_mcp(),
1791 Command::RelayServer {
1792 bind,
1793 local_only,
1794 uds,
1795 } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1796 Command::BindRelay {
1797 url,
1798 scope,
1799 replace,
1800 migrate_pinned,
1801 json,
1802 } => cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1803 Command::AddPeerSlot {
1804 handle,
1805 url,
1806 slot_id,
1807 slot_token,
1808 json,
1809 } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1810 Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1811 Command::Pull { json } => cmd_pull(json),
1812 Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1813 Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1814 Command::ForgetPeer {
1815 handle,
1816 purge,
1817 json,
1818 } => cmd_forget_peer(&handle, purge, json),
1819 Command::Supervisor { json } => cmd_supervisor(json),
1820 Command::Daemon {
1821 interval,
1822 once,
1823 all_sessions,
1824 session,
1825 json,
1826 } => cmd_daemon(interval, once, all_sessions, session, json),
1827 Command::PairHost {
1828 relay,
1829 yes,
1830 timeout,
1831 detach,
1832 json,
1833 } => {
1834 if detach {
1835 cmd_pair_host_detach(&relay, json)
1836 } else {
1837 cmd_pair_host(&relay, yes, timeout)
1838 }
1839 }
1840 Command::PairJoin {
1841 code_phrase,
1842 relay,
1843 yes,
1844 timeout,
1845 detach,
1846 json,
1847 } => {
1848 if detach {
1849 cmd_pair_join_detach(&code_phrase, &relay, json)
1850 } else {
1851 cmd_pair_join(&code_phrase, &relay, yes, timeout)
1852 }
1853 }
1854 Command::PairConfirm {
1855 code_phrase,
1856 digits,
1857 json,
1858 } => cmd_pair_confirm(&code_phrase, &digits, json),
1859 Command::PairList {
1860 json,
1861 watch,
1862 watch_interval,
1863 } => cmd_pair_list(json, watch, watch_interval),
1864 Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1865 Command::PairWatch {
1866 code_phrase,
1867 status,
1868 timeout,
1869 json,
1870 } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1871 Command::Pair {
1872 handle,
1873 code,
1874 relay,
1875 yes,
1876 timeout,
1877 no_setup,
1878 detach,
1879 } => {
1880 if handle.contains('@') && code.is_none() {
1887 cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1888 } else if detach {
1889 cmd_pair_detach(&handle, code.as_deref(), &relay)
1890 } else {
1891 cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1892 }
1893 }
1894 Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1895 Command::PairAccept { peer, json } => {
1896 let j = json_default(json);
1897 deprecation_warn("pair-accept", &format!("accept {peer}"), j);
1898 cmd_pair_accept(&peer, j)
1899 }
1900 Command::PairReject { peer, json } => {
1901 let j = json_default(json);
1902 deprecation_warn("pair-reject", &format!("reject {peer}"), j);
1903 cmd_pair_reject(&peer, j)
1904 }
1905 Command::PairListInbound { json } => {
1906 let j = json_default(json);
1907 deprecation_warn("pair-list-inbound", "pending", j);
1908 cmd_pair_list_inbound(j)
1909 }
1910 Command::Session(cmd) => cmd_session(cmd),
1911 Command::Identity { cmd } => cmd_identity(cmd),
1912 Command::Mesh(cmd) => cmd_mesh(cmd),
1913 Command::Group(cmd) => cmd_group(cmd),
1914 Command::Enroll(cmd) => cmd_enroll(cmd),
1915 Command::Invite {
1916 relay,
1917 ttl,
1918 uses,
1919 share,
1920 json,
1921 } => cmd_invite(&relay, ttl, uses, share, json),
1922 Command::Accept { target, json } => {
1923 let j = json_default(json);
1929 if target.starts_with("wire://pair?") {
1930 deprecation_warn("accept-url", "accept-invite <url>", j);
1931 cmd_accept(&target, j)
1932 } else {
1933 cmd_pair_accept(&target, j)
1934 }
1935 }
1936 Command::AcceptInvite { url, json } => cmd_accept(&url, json_default(json)),
1937 Command::Whois {
1938 handle,
1939 json,
1940 relay,
1941 } => {
1942 match handle.as_deref() {
1951 Some(h) if !h.contains('@') => cmd_whois_local(h, json),
1952 other => cmd_whois(other, json, relay.as_deref()),
1953 }
1954 }
1955 Command::Add {
1956 handle,
1957 relay,
1958 local_sister,
1959 json,
1960 } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1961 Command::Up {
1962 relay,
1963 name,
1964 with_local,
1965 no_local,
1966 json,
1967 } => cmd_up(
1968 relay.as_deref(),
1969 name.as_deref(),
1970 with_local.as_deref(),
1971 no_local,
1972 json,
1973 ),
1974 Command::Doctor {
1975 json,
1976 recent_rejections,
1977 } => cmd_doctor(json, recent_rejections),
1978 Command::Upgrade {
1979 check,
1980 local,
1981 restart_mcp,
1982 refresh_stale_children,
1983 json,
1984 } => cmd_upgrade(check, local, restart_mcp, refresh_stale_children, json),
1985 Command::Service { action } => cmd_service(action),
1986 Command::Diag { action } => cmd_diag(action),
1987 Command::Claim {
1988 nick,
1989 relay,
1990 public_url,
1991 hidden,
1992 json,
1993 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1994 Command::Profile { action } => cmd_profile(action),
1995 Command::Setup {
1996 apply,
1997 statusline,
1998 remove,
1999 } => {
2000 if statusline {
2001 cmd_setup_statusline(apply, remove)
2002 } else {
2003 cmd_setup(apply)
2004 }
2005 }
2006 Command::Notify {
2007 interval,
2008 peer,
2009 once,
2010 json,
2011 } => cmd_notify(interval, peer.as_deref(), once, json),
2012 Command::Quiet { action } => cmd_quiet(action),
2013 }
2014}
2015
2016fn quiet_flag_path() -> Result<std::path::PathBuf> {
2023 Ok(config::config_dir()?.join("quiet"))
2024}
2025
2026fn cmd_quiet(action: QuietAction) -> Result<()> {
2027 match action {
2028 QuietAction::On => {
2029 let path = quiet_flag_path()?;
2030 if let Some(parent) = path.parent() {
2031 std::fs::create_dir_all(parent).with_context(|| {
2032 format!("creating config dir for quiet flag: {}", parent.display())
2033 })?;
2034 }
2035 std::fs::OpenOptions::new()
2037 .create(true)
2038 .truncate(true)
2039 .write(true)
2040 .open(&path)
2041 .with_context(|| format!("writing {}", path.display()))?;
2042 println!(
2043 "wire quiet: ON (toasts silenced — file at {})",
2044 path.display()
2045 );
2046 Ok(())
2047 }
2048 QuietAction::Off => {
2049 let path = quiet_flag_path()?;
2050 match std::fs::remove_file(&path) {
2051 Ok(()) => println!("wire quiet: OFF (toasts re-enabled)"),
2052 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
2053 println!("wire quiet: OFF (was already off)")
2054 }
2055 Err(e) => return Err(anyhow!("removing {}: {e}", path.display())),
2056 }
2057 if std::env::var("WIRE_NO_TOASTS").is_ok_and(|v| !v.is_empty() && v != "0") {
2059 println!(
2060 " note: WIRE_NO_TOASTS={} is still set in env — toasts stay silenced for this process / daemon until `launchctl unsetenv WIRE_NO_TOASTS` (or unset in your shell).",
2061 std::env::var("WIRE_NO_TOASTS").unwrap_or_default()
2062 );
2063 }
2064 Ok(())
2065 }
2066 QuietAction::Status { json } => {
2067 let env_set = std::env::var("WIRE_NO_TOASTS").is_ok_and(|v| !v.is_empty() && v != "0");
2068 let file_present = quiet_flag_path()?.exists();
2069 let (state, via) = match (env_set, file_present) {
2070 (true, _) => ("on", "env"),
2071 (false, true) => ("on", "file"),
2072 (false, false) => ("off", "none"),
2073 };
2074 if json {
2075 println!(
2076 "{}",
2077 serde_json::to_string(&json!({
2078 "state": state,
2079 "via": via,
2080 "file": quiet_flag_path()?.display().to_string(),
2081 "env_WIRE_NO_TOASTS": std::env::var("WIRE_NO_TOASTS").ok(),
2082 }))?
2083 );
2084 } else {
2085 match (env_set, file_present) {
2086 (true, _) => println!(
2087 "wire quiet: ON (via WIRE_NO_TOASTS={} in env)",
2088 std::env::var("WIRE_NO_TOASTS").unwrap_or_default()
2089 ),
2090 (false, true) => println!(
2091 "wire quiet: ON (via file at {})",
2092 quiet_flag_path()?.display()
2093 ),
2094 (false, false) => println!("wire quiet: OFF"),
2095 }
2096 }
2097 Ok(())
2098 }
2099 }
2100}
2101
2102fn cmd_init(
2105 handle: Option<&str>,
2106 name: Option<&str>,
2107 relay: Option<&str>,
2108 offline: bool,
2109 as_json: bool,
2110) -> Result<()> {
2111 if let Some(h) = handle
2117 && !h
2118 .chars()
2119 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
2120 {
2121 bail!("handle must be ASCII alphanumeric / '-' / '_' (got {h:?})");
2122 }
2123 if config::is_initialized()? {
2124 bail!(
2125 "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
2126 config::config_dir()?
2127 );
2128 }
2129 let mut resolved_relay: Option<String> = relay.map(str::to_string);
2143 if resolved_relay.is_none() && !offline {
2144 let default_local = "http://127.0.0.1:8771";
2145 let client = crate::relay_client::RelayClient::new(default_local);
2146 if client.check_healthz().is_ok() {
2147 eprintln!(
2148 "wire init: local relay at {default_local} reachable — auto-attaching. \
2149 Use --relay <url> to pick a different relay, --offline to skip."
2150 );
2151 resolved_relay = Some(default_local.to_string());
2152 } else {
2153 use std::io::{BufRead, IsTerminal, Write};
2159 let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
2160 if interactive && std::env::var("WIRE_NO_INTERACTIVE").is_err() {
2161 eprintln!("wire init: no local relay reachable at {default_local}.");
2162 eprint!(
2163 " Bind to public federation relay https://wireup.net instead? \
2164 [Y/n/offline/url]: "
2165 );
2166 let _ = std::io::stderr().flush();
2167 let mut input = String::new();
2168 let _ = std::io::stdin().lock().read_line(&mut input);
2169 let answer = input.trim();
2170 match answer {
2171 "" | "y" | "Y" | "yes" | "YES" => {
2172 eprintln!("wire init: binding to https://wireup.net");
2173 resolved_relay = Some("https://wireup.net".to_string());
2174 }
2175 "n" | "N" | "no" | "NO" => {
2176 bail!(
2177 "wire init: declined federation default; re-run with --relay <url> or --offline."
2178 );
2179 }
2180 "offline" | "OFFLINE" => {
2181 eprintln!(
2182 "wire init: proceeding offline. \
2183 Run `wire bind-relay <url>` before pairing."
2184 );
2185 }
2191 url if url.starts_with("http://") || url.starts_with("https://") => {
2192 eprintln!("wire init: binding to {url}");
2193 resolved_relay = Some(url.to_string());
2194 }
2195 other => {
2196 bail!(
2197 "wire init: unrecognized answer `{other}` — \
2198 expected Y/n/offline/<url>. Re-run with --relay or --offline."
2199 );
2200 }
2201 }
2202 } else {
2203 bail!(
2204 "wire init: no relay specified and no local relay reachable at \
2205 http://127.0.0.1:8771.\n\
2206 Pick one (or just run `wire up`):\n\
2207 • `wire service install --local-relay` — start the local relay, then re-run\n\
2208 • `wire up @wireup.net` — bind to public federation in one command\n\
2209 • `wire init --offline` — generate keypair only \
2210 (peers cannot reach you until you `wire bind-relay <url>` later)"
2211 );
2212 }
2213 }
2214 }
2215 let relay = resolved_relay.as_deref();
2216
2217 config::ensure_dirs()?;
2218 let (sk_seed, pk_bytes) = generate_keypair();
2219 config::write_private_key(&sk_seed)?;
2220
2221 let seed = handle.unwrap_or("agent");
2239 let synth_did = crate::agent_card::did_for_with_key(seed, &pk_bytes);
2240 let character = crate::character::Character::from_did(&synth_did);
2241 let canonical_handle: &str = &character.nickname;
2242 if let Some(typed) = handle
2243 && typed != canonical_handle
2244 {
2245 eprintln!(
2246 "wire init: one-name rule — typed `{typed}` ignored in favor of \
2247 DID-derived persona `{canonical_handle}`. Peers will reach you as `{canonical_handle}`."
2248 );
2249 }
2250
2251 let card = build_agent_card(canonical_handle, &pk_bytes, name, None, None);
2252 let card = crate::enroll::with_op_claims_if_enrolled(card)?;
2255 let signed = sign_agent_card(&card, &sk_seed);
2256 config::write_agent_card(&signed)?;
2257
2258 let mut trust = empty_trust();
2259 add_self_to_trust(&mut trust, canonical_handle, &pk_bytes);
2260 config::write_trust(&trust)?;
2261
2262 let fp = fingerprint(&pk_bytes);
2263 let key_id = make_key_id(canonical_handle, &pk_bytes);
2264 let handle = canonical_handle;
2267
2268 let mut relay_info: Option<(String, String)> = None;
2270 if let Some(url) = relay {
2271 let normalized = url.trim_end_matches('/');
2272 let client = crate::relay_client::RelayClient::new(normalized);
2273 client.check_healthz()?;
2274 let alloc = client.allocate_slot(Some(handle))?;
2275 let mut state = config::read_relay_state()?;
2276 state["self"] = json!({
2277 "relay_url": normalized,
2278 "slot_id": alloc.slot_id.clone(),
2279 "slot_token": alloc.slot_token,
2280 });
2281 config::write_relay_state(&state)?;
2282 relay_info = Some((normalized.to_string(), alloc.slot_id));
2283 }
2284
2285 let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
2286 if as_json {
2287 let mut out = json!({
2288 "did": did_str.clone(),
2289 "fingerprint": fp,
2290 "key_id": key_id,
2291 "config_dir": config::config_dir()?.to_string_lossy(),
2292 });
2293 if let Some((url, slot_id)) = &relay_info {
2294 out["relay_url"] = json!(url);
2295 out["slot_id"] = json!(slot_id);
2296 }
2297 println!("{}", serde_json::to_string(&out)?);
2298 } else {
2299 println!("generated {did_str} (ed25519:{key_id})");
2300 println!(
2301 "config written to {}",
2302 config::config_dir()?.to_string_lossy()
2303 );
2304 if let Some((url, slot_id)) = &relay_info {
2305 println!("bound to relay {url} (slot {slot_id})");
2306 println!();
2307 println!(
2308 "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
2309 );
2310 } else {
2311 println!();
2312 println!(
2313 "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
2314 );
2315 }
2316 }
2317 Ok(())
2318}
2319
2320fn cmd_status(as_json: bool) -> Result<()> {
2323 let initialized = config::is_initialized()?;
2324
2325 let mut summary = json!({
2326 "initialized": initialized,
2327 });
2328
2329 if initialized {
2330 let card = config::read_agent_card()?;
2331 let did = card
2332 .get("did")
2333 .and_then(Value::as_str)
2334 .unwrap_or("")
2335 .to_string();
2336 let handle = card
2340 .get("handle")
2341 .and_then(Value::as_str)
2342 .map(str::to_string)
2343 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2344 let pk_b64 = card
2345 .get("verify_keys")
2346 .and_then(Value::as_object)
2347 .and_then(|m| m.values().next())
2348 .and_then(|v| v.get("key"))
2349 .and_then(Value::as_str)
2350 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2351 let pk_bytes = crate::signing::b64decode(pk_b64)?;
2352 summary["did"] = json!(did);
2353 summary["handle"] = json!(handle);
2354 summary["fingerprint"] = json!(fingerprint(&pk_bytes));
2355 summary["capabilities"] = card
2356 .get("capabilities")
2357 .cloned()
2358 .unwrap_or_else(|| json!([]));
2359
2360 let trust = config::read_trust()?;
2361 let relay_state_for_tier =
2362 config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2363 let mut peers = Vec::new();
2364 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
2365 for (peer_handle, _agent) in agents {
2366 if peer_handle == &handle {
2367 continue; }
2369 peers.push(json!({
2374 "handle": peer_handle,
2375 "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
2376 }));
2377 }
2378 }
2379 summary["peers"] = json!(peers);
2380
2381 let relay_state = config::read_relay_state()?;
2382 summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
2383 if !summary["self_relay"].is_null() {
2384 if let Some(obj) = summary["self_relay"].as_object_mut() {
2386 obj.remove("slot_token");
2387 }
2388 }
2389 summary["peer_slots_count"] = json!(
2390 relay_state
2391 .get("peers")
2392 .and_then(Value::as_object)
2393 .map(|m| m.len())
2394 .unwrap_or(0)
2395 );
2396
2397 let outbox = config::outbox_dir()?;
2399 let inbox = config::inbox_dir()?;
2400 summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
2401 summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
2402
2403 let snap = crate::ensure_up::daemon_liveness();
2409 let mut daemon = json!({
2410 "running": snap.pidfile_alive,
2411 "pid": snap.pidfile_pid,
2412 "all_running_pids": snap.pgrep_pids,
2413 "orphans": snap.orphan_pids,
2414 });
2415 if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
2416 daemon["version"] = json!(d.version);
2417 daemon["bin_path"] = json!(d.bin_path);
2418 daemon["did"] = json!(d.did);
2419 daemon["relay_url"] = json!(d.relay_url);
2420 daemon["started_at"] = json!(d.started_at);
2421 daemon["schema"] = json!(d.schema);
2422 if d.version != env!("CARGO_PKG_VERSION") {
2423 daemon["version_mismatch"] = json!({
2424 "daemon": d.version.clone(),
2425 "cli": env!("CARGO_PKG_VERSION"),
2426 });
2427 }
2428 } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
2429 daemon["pidfile_form"] = json!("legacy-int");
2430 daemon["version_mismatch"] = json!({
2431 "daemon": "<pre-0.5.11>",
2432 "cli": env!("CARGO_PKG_VERSION"),
2433 });
2434 }
2435 let last_sync_age = crate::ensure_up::last_sync_age_seconds();
2441 if let Some(rec) = crate::ensure_up::read_last_sync_record() {
2442 daemon["last_sync_at"] = json!(rec.ts);
2443 daemon["last_sync_age_seconds"] = json!(last_sync_age);
2444 daemon["last_sync_push_n"] = json!(rec.push_n);
2445 daemon["last_sync_pull_n"] = json!(rec.pull_n);
2446 daemon["last_sync_rejected_n"] = json!(rec.rejected_n);
2447 } else {
2448 daemon["last_sync_at"] = Value::Null;
2449 daemon["last_sync_age_seconds"] = Value::Null;
2450 }
2451 let pending_breakdown = config::compute_pending_push_breakdown();
2464 let pending_total: u64 = pending_breakdown.iter().map(|p| p.count).sum();
2465 daemon["pending_push_count"] = json!(pending_total);
2466 daemon["pending_push_breakdown"] = json!(pending_breakdown);
2467 daemon["stale_sync"] = json!(config::stale_sync(last_sync_age));
2468 daemon["stream_state"] = config::read_stream_state();
2469 let pid_session_map = crate::session::pid_to_session_map();
2486 let orphans_detail: Vec<Value> = snap
2487 .orphan_pids
2488 .iter()
2489 .map(|pid| {
2490 let cmdline = crate::platform::pid_cmdline(*pid);
2491 let session = pid_session_map.get(pid).cloned().or_else(|| {
2492 cmdline
2493 .as_deref()
2494 .and_then(crate::platform::parse_session_arg)
2495 .map(str::to_string)
2496 });
2497 json!({
2498 "pid": pid,
2499 "cmdline": cmdline,
2500 "session": session,
2501 })
2502 })
2503 .collect();
2504 daemon["orphans_detail"] = json!(orphans_detail);
2505 summary["daemon"] = daemon;
2506
2507 let pending = crate::pending_pair::list_pending().unwrap_or_default();
2509 let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
2510 for p in &pending {
2511 *counts.entry(p.status.clone()).or_default() += 1;
2512 }
2513 let pinned_verified_handles: std::collections::HashSet<String> =
2526 crate::config::read_trust()
2527 .ok()
2528 .and_then(|t| t.get("agents").and_then(Value::as_object).cloned())
2529 .map(|agents| {
2530 agents
2531 .into_iter()
2532 .filter_map(|(handle, agent)| {
2533 let tier = agent.get("tier").and_then(Value::as_str).unwrap_or("");
2534 if matches!(tier, "VERIFIED" | "ORG_VERIFIED") {
2535 Some(handle)
2536 } else {
2537 None
2538 }
2539 })
2540 .collect()
2541 })
2542 .unwrap_or_default();
2543 let raw_pending_inbound =
2544 crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
2545 let stale_inbound_handles: Vec<&str> = raw_pending_inbound
2546 .iter()
2547 .filter(|p| pinned_verified_handles.contains(&p.peer_handle))
2548 .map(|p| p.peer_handle.as_str())
2549 .collect();
2550 let pending_inbound: Vec<_> = raw_pending_inbound
2551 .iter()
2552 .filter(|p| !pinned_verified_handles.contains(&p.peer_handle))
2553 .collect();
2554 let inbound_handles: Vec<&str> = pending_inbound
2555 .iter()
2556 .map(|p| p.peer_handle.as_str())
2557 .collect();
2558 summary["pending_pairs"] = json!({
2559 "total": pending.len(),
2560 "by_status": counts,
2561 "inbound_count": pending_inbound.len(),
2562 "inbound_handles": inbound_handles,
2563 "stale_inbound_count": stale_inbound_handles.len(),
2567 "stale_inbound_handles": stale_inbound_handles,
2568 });
2569 }
2570
2571 if as_json {
2572 println!("{}", serde_json::to_string(&summary)?);
2573 } else if !initialized {
2574 println!("not initialized — run `wire init <handle>` first");
2575 } else {
2576 println!("did: {}", summary["did"].as_str().unwrap_or("?"));
2577 println!(
2578 "fingerprint: {}",
2579 summary["fingerprint"].as_str().unwrap_or("?")
2580 );
2581 println!("capabilities: {}", summary["capabilities"]);
2582 if !summary["self_relay"].is_null() {
2583 println!(
2584 "self relay: {} (slot {})",
2585 summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
2586 summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
2587 );
2588 } else {
2589 println!("self relay: (not bound — run `wire pair-host --relay <url>` to bind)");
2590 }
2591 println!(
2592 "peers: {}",
2593 summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
2594 );
2595 for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
2596 println!(
2597 " - {:<20} tier={}",
2598 p["handle"].as_str().unwrap_or(""),
2599 p["tier"].as_str().unwrap_or("?")
2600 );
2601 }
2602 println!(
2603 "outbox: {} file(s), {} event(s) queued",
2604 summary["outbox"]["files"].as_u64().unwrap_or(0),
2605 summary["outbox"]["events"].as_u64().unwrap_or(0)
2606 );
2607 println!(
2608 "inbox: {} file(s), {} event(s) received",
2609 summary["inbox"]["files"].as_u64().unwrap_or(0),
2610 summary["inbox"]["events"].as_u64().unwrap_or(0)
2611 );
2612 let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
2613 let daemon_pid = summary["daemon"]["pid"]
2614 .as_u64()
2615 .map(|p| p.to_string())
2616 .unwrap_or_else(|| "—".to_string());
2617 let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
2618 let version_suffix = if !daemon_version.is_empty() {
2619 format!(" v{daemon_version}")
2620 } else {
2621 String::new()
2622 };
2623 println!(
2624 "daemon: {} (pid {}{})",
2625 if daemon_running { "running" } else { "DOWN" },
2626 daemon_pid,
2627 version_suffix,
2628 );
2629 if let Some(mm) = summary["daemon"].get("version_mismatch") {
2631 println!(
2632 " !! version mismatch: daemon={} CLI={}. \
2633 run `wire upgrade` to swap atomically.",
2634 mm["daemon"].as_str().unwrap_or("?"),
2635 mm["cli"].as_str().unwrap_or("?"),
2636 );
2637 }
2638 if let Some(orphans) = summary["daemon"]["orphans"].as_array()
2639 && !orphans.is_empty()
2640 {
2641 let pids: Vec<String> = orphans
2642 .iter()
2643 .filter_map(|v| v.as_u64().map(|p| p.to_string()))
2644 .collect();
2645 println!(
2646 " !! orphan daemon process(es): pids {}. \
2647 pgrep saw them but pidfile didn't — likely stale process from \
2648 prior install. Multiple daemons race the relay cursor.",
2649 pids.join(", ")
2650 );
2651 if let Some(details) = summary["daemon"]["orphans_detail"].as_array() {
2658 for d in details {
2659 let pid = d["pid"].as_u64().unwrap_or(0);
2660 let session = d["session"].as_str();
2661 let cmdline = d["cmdline"].as_str();
2662 let is_supervisor = cmdline
2670 .map(|c| c.contains("--all-sessions"))
2671 .unwrap_or(false);
2672 match (session, cmdline, is_supervisor) {
2673 (Some(s), _, _) => {
2674 println!(" pid {pid}: serving session '{s}'")
2675 }
2676 (None, Some(c), true) if !c.is_empty() => println!(
2677 " pid {pid}: supervisor — orchestrates one daemon per session, doesn't sync directly (cmdline={c})"
2678 ),
2679 (None, Some(c), false) if !c.is_empty() => println!(
2680 " pid {pid}: (no --session — serving default WIRE_HOME) cmdline={c}"
2681 ),
2682 _ => println!(
2683 " pid {pid}: (cmdline unavailable — pid may have just exited)"
2684 ),
2685 }
2686 }
2687 }
2688 }
2689 let last_sync_age = summary["daemon"]["last_sync_age_seconds"].as_u64();
2695 let last_sync_at = summary["daemon"]["last_sync_at"].as_str();
2696 match (last_sync_at, last_sync_age) {
2697 (Some(ts), Some(age)) => {
2698 let stale = summary["daemon"]["stale_sync"].as_bool().unwrap_or(false);
2699 let stale_tag = if stale { " !! STALE (>60s)" } else { "" };
2700 let p = summary["daemon"]["last_sync_push_n"].as_u64().unwrap_or(0);
2701 let pl = summary["daemon"]["last_sync_pull_n"].as_u64().unwrap_or(0);
2702 let r = summary["daemon"]["last_sync_rejected_n"]
2703 .as_u64()
2704 .unwrap_or(0);
2705 println!(
2706 "last sync: {ts} ({age}s ago) push={p} pull={pl} rejected={r}{stale_tag}"
2707 );
2708 }
2709 _ => {
2710 println!(
2711 "last sync: (none recorded) — daemon hasn't completed a cycle in this WIRE_HOME"
2712 );
2713 }
2714 }
2715 let pending_push = summary["daemon"]["pending_push_count"]
2716 .as_u64()
2717 .unwrap_or(0);
2718 if pending_push > 0 {
2719 println!(
2720 "pending push: {pending_push} event(s) queued but not yet pushed to relay — \
2721 if stale_sync, this is the silent-send class (#162 fix #2)"
2722 );
2723 if let Some(breakdown) = summary["daemon"]["pending_push_breakdown"].as_array() {
2729 for entry in breakdown {
2730 let peer = entry.get("peer").and_then(Value::as_str).unwrap_or("?");
2731 let tier = entry
2732 .get("tier")
2733 .and_then(Value::as_str)
2734 .unwrap_or("UNKNOWN");
2735 let count = entry.get("count").and_then(Value::as_u64).unwrap_or(0);
2736 let hint = match tier {
2744 "PENDING_ACK" => {
2745 " — pair never completed; daemon won't push until accept/reject"
2746 }
2747 "UNTRUSTED" => " — peer not pinned; daemon won't push to UNTRUSTED",
2748 _ => "",
2749 };
2750 println!(" {count:>4} → {peer} ({tier}){hint}");
2751 }
2752 }
2753 } else {
2754 println!("pending push: 0");
2755 }
2756 match summary["daemon"]["stream_state"]
2757 .get("state")
2758 .and_then(Value::as_str)
2759 {
2760 Some(s) => {
2761 let last_evt = summary["daemon"]["stream_state"]
2762 .get("last_event_at")
2763 .and_then(Value::as_str)
2764 .unwrap_or("never");
2765 let reconnects = summary["daemon"]["stream_state"]
2766 .get("reconnect_count")
2767 .and_then(Value::as_u64)
2768 .unwrap_or(0);
2769 println!("stream: {s} (last event {last_evt}, reconnects {reconnects})");
2770 }
2771 None => {
2772 println!(
2773 "stream: (no stream_state.json) — daemon predates #168 or hasn't \
2774 subscribed yet; live monitor will fall back to polling cadence"
2775 );
2776 }
2777 }
2778 let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
2779 let inbound_count = summary["pending_pairs"]["inbound_count"]
2780 .as_u64()
2781 .unwrap_or(0);
2782 if pending_total > 0 {
2783 print!("pending pairs: {pending_total}");
2784 if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
2785 let parts: Vec<String> = obj
2786 .iter()
2787 .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
2788 .collect();
2789 if !parts.is_empty() {
2790 print!(" ({})", parts.join(", "));
2791 }
2792 }
2793 println!();
2794 } else if inbound_count == 0 {
2795 println!("pending pairs: none");
2796 }
2797 if inbound_count > 0 {
2801 let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
2802 .as_array()
2803 .map(|a| {
2804 a.iter()
2805 .filter_map(|v| v.as_str().map(str::to_string))
2806 .collect()
2807 })
2808 .unwrap_or_default();
2809 println!(
2810 "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
2811 handles.join(", "),
2812 );
2813 }
2814 }
2815 Ok(())
2816}
2817
2818pub(crate) fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
2819 if !dir.exists() {
2820 return Ok(json!({"files": 0, "events": 0}));
2821 }
2822 let mut files = 0usize;
2823 let mut events = 0usize;
2824 for entry in std::fs::read_dir(dir)? {
2825 let path = entry?.path();
2826 if path.extension().map(|x| x == "jsonl").unwrap_or(false)
2836 && !path
2837 .file_name()
2838 .and_then(|s| s.to_str())
2839 .map(|n| n.ends_with(".pushed.jsonl"))
2840 .unwrap_or(false)
2841 {
2842 files += 1;
2843 if let Ok(body) = std::fs::read_to_string(&path) {
2844 events += body.lines().filter(|l| !l.trim().is_empty()).count();
2845 }
2846 }
2847 }
2848 Ok(json!({"files": files, "events": events}))
2849}
2850
2851fn responder_status_allowed(status: &str) -> bool {
2854 matches!(
2855 status,
2856 "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
2857 )
2858}
2859
2860fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
2861 let state = config::read_relay_state()?;
2862 let (label, slot_info) = match peer {
2863 Some(peer) => (
2864 peer.to_string(),
2865 state
2866 .get("peers")
2867 .and_then(|p| p.get(peer))
2868 .ok_or_else(|| {
2869 anyhow!(
2870 "unknown peer {peer:?} in relay state — pair with them first:\n \
2871 wire add {peer}@wireup.net (or {peer}@<their-relay>)\n\
2872 (`wire peers` lists who you've already paired with.)"
2873 )
2874 })?,
2875 ),
2876 None => (
2877 "self".to_string(),
2878 state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
2879 anyhow!("self slot not bound — run `wire bind-relay <url>` first")
2880 })?,
2881 ),
2882 };
2883 let relay_url = slot_info["relay_url"]
2884 .as_str()
2885 .ok_or_else(|| anyhow!("{label} relay_url missing"))?
2886 .to_string();
2887 let slot_id = slot_info["slot_id"]
2888 .as_str()
2889 .ok_or_else(|| anyhow!("{label} slot_id missing"))?
2890 .to_string();
2891 let slot_token = slot_info["slot_token"]
2892 .as_str()
2893 .ok_or_else(|| anyhow!("{label} slot_token missing"))?
2894 .to_string();
2895 Ok((label, relay_url, slot_id, slot_token))
2896}
2897
2898fn cmd_supervisor(as_json: bool) -> Result<()> {
2905 let state = crate::daemon_supervisor::read_supervisor_state()?;
2906 if as_json {
2907 println!("{}", serde_json::to_string(&state)?);
2908 return Ok(());
2909 }
2910 let pid_label = state
2911 .supervisor_pid
2912 .map(|p| p.to_string())
2913 .unwrap_or_else(|| "—".to_string());
2914 println!(
2915 "supervisor: {} (pid {pid_label})",
2916 if state.supervisor_alive {
2917 "running"
2918 } else {
2919 "DOWN"
2920 },
2921 );
2922 let sessions_total = state.sessions.len();
2923 let sessions_with_daemon = state.sessions.iter().filter(|s| s.daemon_alive).count();
2924 println!(
2925 "sessions: {sessions_total} initialized, {sessions_with_daemon} with live daemon"
2926 );
2927 let mut shown = 0usize;
2932 for s in &state.sessions {
2933 if s.daemon_pid.is_none() {
2936 continue;
2937 }
2938 let recent = matches!(s.last_sync_age_seconds, Some(age) if age <= 60);
2941 if s.daemon_alive && recent {
2942 continue;
2943 }
2944 shown += 1;
2945 let age = s
2946 .last_sync_age_seconds
2947 .map(|a| format!("{a}s"))
2948 .unwrap_or_else(|| "?".to_string());
2949 let pid = s
2950 .daemon_pid
2951 .map(|p| p.to_string())
2952 .unwrap_or_else(|| "—".to_string());
2953 let liveness = if s.daemon_alive { "running" } else { "DOWN" };
2954 println!(
2955 " {:<24} pid {:<7} {} last_sync {}",
2956 s.name, pid, liveness, age
2957 );
2958 }
2959 if shown == 0 && sessions_with_daemon > 0 {
2960 println!(
2961 " (every session with a daemon is alive + synced within 60s — pass --json for full per-session detail)"
2962 );
2963 }
2964 if !state.unmanaged_pids.is_empty() {
2965 let pids: Vec<String> = state.unmanaged_pids.iter().map(u32::to_string).collect();
2966 println!(
2967 "unmanaged: {} pid(s) — {} — `wire daemon` processes not mapped to any session's pidfile.",
2968 state.unmanaged_pids.len(),
2969 pids.join(", ")
2970 );
2971 for pid in &state.unmanaged_pids {
2974 let cmdline = crate::platform::pid_cmdline(*pid);
2975 let session = cmdline
2976 .as_deref()
2977 .and_then(crate::platform::parse_session_arg);
2978 match (session, cmdline.as_deref()) {
2979 (Some(s), _) => println!(" pid {pid}: --session '{s}'"),
2980 (None, Some(c)) if !c.is_empty() => println!(" pid {pid}: cmdline={c}"),
2981 _ => println!(" pid {pid}: cmdline unavailable"),
2982 }
2983 }
2984 }
2985 if !state.stale_binary_sessions.is_empty() {
2992 let our_version = env!("CARGO_PKG_VERSION");
2993 println!(
2994 "stale binary: {} session(s) running daemons older than this CLI (v{our_version}). Supervisor won't respawn them until they exit.",
2995 state.stale_binary_sessions.len()
2996 );
2997 for name in &state.stale_binary_sessions {
2998 let session = state.sessions.iter().find(|s| &s.name == name);
3002 let ver = session
3003 .and_then(|s| s.daemon_version.clone())
3004 .unwrap_or_else(|| "?".to_string());
3005 let pid = session
3006 .and_then(|s| s.daemon_pid)
3007 .map(|p| p.to_string())
3008 .unwrap_or_else(|| "?".to_string());
3009 println!(" {name:<24} running v{ver} (pid {pid})");
3010 }
3011 }
3012 Ok(())
3013}
3014
3015fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
3016 if !responder_status_allowed(status) {
3017 bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
3018 }
3019 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
3020 let now = time::OffsetDateTime::now_utc()
3021 .format(&time::format_description::well_known::Rfc3339)
3022 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
3023 let mut record = json!({
3024 "status": status,
3025 "set_at": now,
3026 });
3027 if let Some(reason) = reason {
3028 record["reason"] = json!(reason);
3029 }
3030 if status == "online" {
3031 record["last_success_at"] = json!(now);
3032 }
3033 let client = crate::relay_client::RelayClient::new(&relay_url);
3034 let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
3035 if as_json {
3036 println!("{}", serde_json::to_string(&saved)?);
3037 } else {
3038 let reason = saved
3039 .get("reason")
3040 .and_then(Value::as_str)
3041 .map(|r| format!(" — {r}"))
3042 .unwrap_or_default();
3043 println!(
3044 "responder {}{}",
3045 saved
3046 .get("status")
3047 .and_then(Value::as_str)
3048 .unwrap_or(status),
3049 reason
3050 );
3051 }
3052 Ok(())
3053}
3054
3055fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
3056 let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
3057 let client = crate::relay_client::RelayClient::new(&relay_url);
3058 let health = client.responder_health_get(&slot_id, &slot_token)?;
3059 if as_json {
3060 println!(
3061 "{}",
3062 serde_json::to_string(&json!({
3063 "target": label,
3064 "responder_health": health,
3065 }))?
3066 );
3067 } else if health.is_null() {
3068 println!("{label}: responder health not reported");
3069 } else {
3070 let status = health
3071 .get("status")
3072 .and_then(Value::as_str)
3073 .unwrap_or("unknown");
3074 let reason = health
3075 .get("reason")
3076 .and_then(Value::as_str)
3077 .map(|r| format!(" — {r}"))
3078 .unwrap_or_default();
3079 let last_success = health
3080 .get("last_success_at")
3081 .and_then(Value::as_str)
3082 .map(|t| format!(" (last_success: {t})"))
3083 .unwrap_or_default();
3084 println!("{label}: {status}{reason}{last_success}");
3085 }
3086 Ok(())
3087}
3088
3089fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
3090 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
3091 let client = crate::relay_client::RelayClient::new(&relay_url);
3092
3093 let started = std::time::Instant::now();
3094 let transport_ok = client.healthz().unwrap_or(false);
3095 let latency_ms = started.elapsed().as_millis() as u64;
3096
3097 let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
3098 let now = std::time::SystemTime::now()
3099 .duration_since(std::time::UNIX_EPOCH)
3100 .map(|d| d.as_secs())
3101 .unwrap_or(0);
3102 let attention = match last_pull_at_unix {
3103 Some(last) if now.saturating_sub(last) <= 300 => json!({
3104 "status": "ok",
3105 "last_pull_at_unix": last,
3106 "age_seconds": now.saturating_sub(last),
3107 "event_count": event_count,
3108 }),
3109 Some(last) => json!({
3110 "status": "stale",
3111 "last_pull_at_unix": last,
3112 "age_seconds": now.saturating_sub(last),
3113 "event_count": event_count,
3114 }),
3115 None => json!({
3116 "status": "never_pulled",
3117 "last_pull_at_unix": Value::Null,
3118 "event_count": event_count,
3119 }),
3120 };
3121
3122 let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
3123 let responder = if responder_health.is_null() {
3124 json!({"status": "not_reported", "record": Value::Null})
3125 } else {
3126 json!({
3127 "status": responder_health
3128 .get("status")
3129 .and_then(Value::as_str)
3130 .unwrap_or("unknown"),
3131 "record": responder_health,
3132 })
3133 };
3134
3135 let report = json!({
3136 "peer": peer,
3137 "transport": {
3138 "status": if transport_ok { "ok" } else { "error" },
3139 "relay_url": relay_url,
3140 "latency_ms": latency_ms,
3141 },
3142 "attention": attention,
3143 "responder": responder,
3144 });
3145
3146 if as_json {
3147 println!("{}", serde_json::to_string(&report)?);
3148 } else {
3149 let transport_line = if transport_ok {
3150 format!("ok relay reachable ({latency_ms}ms)")
3151 } else {
3152 "error relay unreachable".to_string()
3153 };
3154 println!("transport {transport_line}");
3155 match report["attention"]["status"].as_str().unwrap_or("unknown") {
3156 "ok" => println!(
3157 "attention ok last pull {}s ago",
3158 report["attention"]["age_seconds"].as_u64().unwrap_or(0)
3159 ),
3160 "stale" => println!(
3161 "attention stale last pull {}m ago",
3162 report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
3163 ),
3164 "never_pulled" => println!("attention never pulled since relay reset"),
3165 other => println!("attention {other}"),
3166 }
3167 if report["responder"]["status"] == "not_reported" {
3168 println!("auto-responder not reported");
3169 } else {
3170 let record = &report["responder"]["record"];
3171 let status = record
3172 .get("status")
3173 .and_then(Value::as_str)
3174 .unwrap_or("unknown");
3175 let reason = record
3176 .get("reason")
3177 .and_then(Value::as_str)
3178 .map(|r| format!(" — {r}"))
3179 .unwrap_or_default();
3180 println!("auto-responder {status}{reason}");
3181 }
3182 }
3183 Ok(())
3184}
3185
3186fn current_cwd_display() -> String {
3194 let cwd = match std::env::current_dir() {
3195 Ok(c) => c,
3196 Err(_) => return String::from("?"),
3197 };
3198 if let Some(home) = dirs::home_dir()
3199 && let Ok(rel) = cwd.strip_prefix(&home)
3200 {
3201 let rel_str = rel.to_string_lossy();
3203 if rel_str.is_empty() {
3204 return String::from("~");
3205 }
3206 return format!("~/{rel_str}");
3207 }
3208 cwd.to_string_lossy().into_owned()
3209}
3210
3211pub(crate) fn op_claims_from_card(card: &Value) -> serde_json::Map<String, Value> {
3225 let mut out = serde_json::Map::new();
3226 for key in [
3227 "op_did",
3228 "op_pubkey",
3229 "op_cert",
3230 "org_memberships",
3231 "schema_version",
3232 ] {
3233 if let Some(v) = card.get(key)
3234 && !v.is_null()
3235 {
3236 out.insert(key.to_string(), v.clone());
3237 }
3238 }
3239 out
3240}
3241
3242fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
3243 if !config::is_initialized()? {
3244 if short {
3257 println!("(uninitialized) · {}", current_cwd_display());
3258 return Ok(());
3259 }
3260 if colored {
3261 println!(
3262 "\x1b[2m(uninitialized)\x1b[0m \x1b[2m·\x1b[0m {}",
3263 current_cwd_display()
3264 );
3265 return Ok(());
3266 }
3267 if as_json {
3268 println!(
3269 "{}",
3270 serde_json::to_string(&json!({
3271 "initialized": false,
3272 "cwd": current_cwd_display(),
3273 }))?
3274 );
3275 return Ok(());
3276 }
3277 bail!("not initialized — run `wire init <handle>` first");
3278 }
3279 let card = config::read_agent_card()?;
3280 let did = card
3281 .get("did")
3282 .and_then(Value::as_str)
3283 .unwrap_or("")
3284 .to_string();
3285 let handle = card
3286 .get("handle")
3287 .and_then(Value::as_str)
3288 .map(str::to_string)
3289 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
3290 let character = crate::character::Character::from_did(&did);
3294
3295 let cwd_display = current_cwd_display();
3301
3302 if short {
3305 println!("{} · {}", character.short(), cwd_display);
3306 return Ok(());
3307 }
3308 if colored {
3309 println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
3310 return Ok(());
3311 }
3312
3313 let pk_b64 = card
3314 .get("verify_keys")
3315 .and_then(Value::as_object)
3316 .and_then(|m| m.values().next())
3317 .and_then(|v| v.get("key"))
3318 .and_then(Value::as_str)
3319 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
3320 let pk_bytes = crate::signing::b64decode(pk_b64)?;
3321 let fp = fingerprint(&pk_bytes);
3322 let key_id = make_key_id(&handle, &pk_bytes);
3323 let capabilities = card
3324 .get("capabilities")
3325 .cloned()
3326 .unwrap_or_else(|| json!(["wire/v3.1"]));
3327
3328 if as_json {
3329 let has_override = false;
3333 let mut payload = serde_json::Map::new();
3334 payload.insert("initialized".into(), json!(true));
3337 payload.insert("did".into(), json!(did));
3338 payload.insert("handle".into(), json!(handle));
3339 payload.insert("fingerprint".into(), json!(fp));
3340 payload.insert("key_id".into(), json!(key_id));
3341 payload.insert("public_key_b64".into(), json!(pk_b64));
3342 payload.insert("capabilities".into(), capabilities);
3343 payload.insert(
3344 "config_dir".into(),
3345 json!(config::config_dir()?.to_string_lossy()),
3346 );
3347 payload.insert("persona".into(), serde_json::to_value(&character)?);
3348 payload.insert("persona_override".into(), json!(has_override));
3349 for (k, v) in op_claims_from_card(&card) {
3353 payload.insert(k, v);
3354 }
3355 println!("{}", serde_json::to_string(&payload)?);
3356 } else {
3357 println!("{}", character.colored());
3358 println!("{did} (ed25519:{key_id})");
3359 println!("fingerprint: {fp}");
3360 println!("capabilities: {capabilities}");
3361 if let Some(op_did) = card.get("op_did").and_then(Value::as_str) {
3366 let memberships = card
3367 .get("org_memberships")
3368 .and_then(Value::as_array)
3369 .map(|a| a.len())
3370 .unwrap_or(0);
3371 let plural = if memberships == 1 { "" } else { "s" };
3372 println!("enrolled: {op_did} ({memberships} org membership{plural})");
3373 }
3374 }
3375 Ok(())
3376}
3377
3378fn cmd_enroll(cmd: EnrollCommand) -> Result<()> {
3381 match cmd {
3382 EnrollCommand::Op { handle, json } => {
3383 let (sk, pk) = crate::signing::generate_keypair();
3384 crate::config::write_op_key(&sk)?;
3385 crate::config::write_op_handle(&handle)?;
3386 let op_did = crate::agent_card::did_for_op(&handle, &pk);
3387 let op_pubkey = crate::signing::b64encode(&pk);
3388 if json {
3389 println!(
3390 "{}",
3391 serde_json::to_string(&json!({"op_did": op_did, "op_pubkey": op_pubkey}))?
3392 );
3393 } else {
3394 println!(
3395 "→ operator enrolled\n op_did: {op_did}\n op_pubkey: {op_pubkey}\n key saved 0600 at {:?}",
3396 crate::config::op_key_path()?
3397 );
3398 }
3399 Ok(())
3400 }
3401 EnrollCommand::OrgCreate { handle, json } => {
3402 let (sk, pk) = crate::signing::generate_keypair();
3403 let org_did = crate::agent_card::did_for_org(&handle, &pk);
3404 crate::config::write_org_key(&org_did, &sk)?;
3405 let org_pubkey = crate::signing::b64encode(&pk);
3406 if json {
3407 println!(
3408 "{}",
3409 serde_json::to_string(&json!({"org_did": org_did, "org_pubkey": org_pubkey}))?
3410 );
3411 } else {
3412 println!(
3413 "→ organization created\n org_did: {org_did}\n org_pubkey: {org_pubkey}\n key saved 0600 at {:?}",
3414 crate::config::org_key_path(&org_did)?
3415 );
3416 }
3417 Ok(())
3418 }
3419 EnrollCommand::OrgAddMember { op_did, org, json } => {
3420 if !crate::agent_card::is_op_did(&op_did) {
3421 bail!("not a valid operator DID (did:wire:op:<handle>-<32hex>): {op_did}");
3422 }
3423 let org_sk = crate::config::read_org_key(&org).with_context(|| {
3424 format!("no stored key for org {org} — run `wire enroll org-create` first")
3425 })?;
3426 let org_pk = ed25519_dalek::SigningKey::from_bytes(&org_sk)
3427 .verifying_key()
3428 .to_bytes();
3429 let member_cert = crate::enroll::issue_member_cert(&org_sk, &op_did)?;
3430 let org_pubkey = crate::signing::b64encode(&org_pk);
3431 crate::config::add_membership(&org, &org_pubkey, &member_cert)?;
3434 if json {
3435 println!(
3436 "{}",
3437 serde_json::to_string(&json!({
3438 "org_did": org, "org_pubkey": org_pubkey, "member_cert": member_cert
3439 }))?
3440 );
3441 } else {
3442 println!(
3443 "→ 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}\"}}"
3444 );
3445 }
3446 Ok(())
3447 }
3448 EnrollCommand::AddMembership {
3449 bundle,
3450 org,
3451 org_pubkey,
3452 member_cert,
3453 json,
3454 } => cmd_enroll_add_membership(bundle, org, org_pubkey, member_cert, json),
3455 EnrollCommand::Republish { json } => {
3456 let card = crate::enroll::rebuild_card_with_current_claims()?;
3460 let published = republish_card_to_phonebook();
3461 let op_did = card
3462 .get("op_did")
3463 .and_then(Value::as_str)
3464 .map(str::to_string);
3465 let n_memberships = card
3466 .get("org_memberships")
3467 .and_then(Value::as_array)
3468 .map(Vec::len)
3469 .unwrap_or(0);
3470 if json {
3471 println!(
3472 "{}",
3473 serde_json::to_string(&json!({
3474 "op_did": op_did,
3475 "org_memberships": n_memberships,
3476 "published": published,
3477 }))?
3478 );
3479 } else {
3480 match op_did {
3481 Some(did) => println!(
3482 "→ card rebuilt with current enrollment\n op_did: {did}\n memberships: {n_memberships}"
3483 ),
3484 None => println!(
3485 "→ card rebuilt — no operator enrolled (claims stripped if previously present)"
3486 ),
3487 }
3488 print_profile_publish_result(&published);
3489 }
3490 Ok(())
3491 }
3492 }
3493}
3494
3495fn cmd_enroll_add_membership(
3503 bundle: Option<String>,
3504 org: Option<String>,
3505 org_pubkey: Option<String>,
3506 member_cert: Option<String>,
3507 as_json: bool,
3508) -> Result<()> {
3509 let (org_did, org_pk_b64, cert_b64) = if let Some(b) = bundle {
3511 let v: Value = serde_json::from_str(&b).with_context(|| "parsing --bundle as JSON")?;
3512 let o = v
3513 .get("org_did")
3514 .and_then(Value::as_str)
3515 .ok_or_else(|| anyhow!("--bundle missing 'org_did'"))?
3516 .to_string();
3517 let p = v
3518 .get("org_pubkey")
3519 .and_then(Value::as_str)
3520 .ok_or_else(|| anyhow!("--bundle missing 'org_pubkey'"))?
3521 .to_string();
3522 let c = v
3523 .get("member_cert")
3524 .and_then(Value::as_str)
3525 .ok_or_else(|| anyhow!("--bundle missing 'member_cert'"))?
3526 .to_string();
3527 (o, p, c)
3528 } else {
3529 let o = org.ok_or_else(|| anyhow!("--org is required when --bundle is not set"))?;
3530 let p = org_pubkey
3531 .ok_or_else(|| anyhow!("--org-pubkey is required when --bundle is not set"))?;
3532 let c = member_cert
3533 .ok_or_else(|| anyhow!("--member-cert is required when --bundle is not set"))?;
3534 (o, p, c)
3535 };
3536
3537 if !crate::agent_card::is_org_did(&org_did) {
3539 bail!("not a valid organization DID (did:wire:org:<handle>-<32hex>): {org_did}");
3540 }
3541
3542 let op_sk = crate::config::read_op_key().with_context(
3547 || "this operator is not enrolled — run `wire enroll op` first to mint op_did",
3548 )?;
3549 let op_handle = crate::config::read_op_handle()
3550 .ok()
3551 .flatten()
3552 .unwrap_or_else(|| "operator".to_string());
3553 let op_pk = ed25519_dalek::SigningKey::from_bytes(&op_sk)
3554 .verifying_key()
3555 .to_bytes();
3556 let op_did = crate::agent_card::did_for_op(&op_handle, &op_pk);
3557
3558 let org_pk_bytes =
3562 crate::signing::b64decode(&org_pk_b64).with_context(|| "decoding --org-pubkey (base64)")?;
3563 crate::identity::verify_member_cert(&org_pk_bytes, &cert_b64, &op_did)
3564 .map_err(|e| anyhow!("member_cert verification failed: {e:?} — bundle is not valid for this operator (op_did={op_did})"))?;
3565
3566 crate::config::add_membership(&org_did, &org_pk_b64, &cert_b64)?;
3570
3571 if as_json {
3572 println!(
3573 "{}",
3574 serde_json::to_string(&json!({
3575 "stored": true,
3576 "org_did": org_did,
3577 "op_did": op_did,
3578 "note": "run `wire enroll republish` to attach the claim to your agent card and republish",
3579 }))?
3580 );
3581 } else {
3582 println!(
3583 "→ membership stored\n org_did: {org_did}\n op_did: {op_did}\n next: `wire enroll republish` to attach + publish"
3584 );
3585 }
3586 Ok(())
3587}
3588
3589fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
3590 match cmd {
3591 IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
3598 IdentityCommand::List { json } => cmd_session_list(json),
3599 IdentityCommand::Publish {
3600 nick,
3601 relay,
3602 public_url,
3603 hidden,
3604 json,
3605 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
3606 IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
3607 IdentityCommand::Create {
3608 name,
3609 anonymous,
3610 local: _,
3611 json,
3612 } => cmd_identity_create(name.as_deref(), anonymous, json),
3613 IdentityCommand::Persist {
3614 name,
3615 as_name,
3616 json,
3617 } => cmd_identity_persist(&name, as_name.as_deref(), json),
3618 IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
3619 }
3620}
3621
3622fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
3627 if anonymous {
3628 let rand_suffix = format!("{:08x}", rand::random::<u32>());
3630 let anon_name = name
3631 .map(crate::session::sanitize_name)
3632 .unwrap_or_else(|| format!("anon-{rand_suffix}"));
3633 let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
3634 std::fs::create_dir_all(&anon_root)
3635 .with_context(|| format!("creating anon root {anon_root:?}"))?;
3636 let session_home = anon_root.join("sessions").join(&anon_name);
3638 std::fs::create_dir_all(&session_home)?;
3639 let status = run_wire_with_home(&session_home, &["init", &anon_name, "--offline"])?;
3640 if !status.success() {
3641 bail!("anonymous identity init failed: {status}");
3642 }
3643 let marker = anon_root.join("anon-marker.json");
3646 std::fs::write(
3647 &marker,
3648 serde_json::to_vec_pretty(&serde_json::json!({
3649 "name": anon_name,
3650 "session_home": session_home.to_string_lossy(),
3651 "created_at": time::OffsetDateTime::now_utc()
3652 .format(&time::format_description::well_known::Rfc3339)
3653 .unwrap_or_default(),
3654 "kind": "anonymous",
3655 }))?,
3656 )?;
3657 let card = serde_json::from_slice::<Value>(&std::fs::read(
3658 session_home
3659 .join("config")
3660 .join("wire")
3661 .join("agent-card.json"),
3662 )?)?;
3663 let did = card
3664 .get("did")
3665 .and_then(Value::as_str)
3666 .unwrap_or("")
3667 .to_string();
3668 if as_json {
3669 println!(
3670 "{}",
3671 serde_json::to_string(&json!({
3672 "kind": "anonymous",
3673 "name": anon_name,
3674 "did": did,
3675 "session_home": session_home.to_string_lossy(),
3676 "anon_root": anon_root.to_string_lossy(),
3677 }))?
3678 );
3679 } else {
3680 println!("created anonymous identity `{anon_name}` ({did})");
3681 println!(
3682 " session_home: {} (dies on reboot — /tmp)",
3683 session_home.display()
3684 );
3685 println!();
3686 println!("activate in this shell:");
3687 println!(" export WIRE_HOME={}", session_home.display());
3688 println!();
3689 println!("promote to persistent later with:");
3690 println!(" wire identity persist {anon_name}");
3691 }
3692 return Ok(());
3693 }
3694 let name_arg = name.map(|s| s.to_string());
3696 cmd_session_new(
3697 name_arg.as_deref(),
3698 "https://wireup.net",
3699 false,
3700 "http://127.0.0.1:8771",
3701 false,
3702 None,
3703 false,
3704 None,
3705 true, true, as_json,
3708 )
3709}
3710
3711fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
3714 let temp = std::env::temp_dir();
3716 let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
3717 for entry in std::fs::read_dir(&temp)?.flatten() {
3718 let path = entry.path();
3719 if !path
3720 .file_name()
3721 .and_then(|s| s.to_str())
3722 .map(|s| s.starts_with("wire-anon-"))
3723 .unwrap_or(false)
3724 {
3725 continue;
3726 }
3727 let marker = path.join("anon-marker.json");
3728 if let Ok(bytes) = std::fs::read(&marker)
3729 && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
3730 && json.get("name").and_then(Value::as_str) == Some(name)
3731 {
3732 let session_home = json
3733 .get("session_home")
3734 .and_then(Value::as_str)
3735 .map(std::path::PathBuf::from)
3736 .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
3737 found = Some((path, session_home));
3738 break;
3739 }
3740 }
3741 let (anon_root, anon_session_home) = found.ok_or_else(|| {
3742 anyhow!(
3743 "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
3744 run `wire identity list` to see available identities"
3745 )
3746 })?;
3747
3748 let new_name = as_name.unwrap_or(name);
3749 let new_session_home = crate::session::session_dir(new_name)?;
3750 if new_session_home.exists() {
3751 bail!(
3752 "target session `{new_name}` already exists at {new_session_home:?} — \
3753 pick a different name with --as <new-name>"
3754 );
3755 }
3756
3757 if let Some(parent) = new_session_home.parent() {
3759 std::fs::create_dir_all(parent)?;
3760 }
3761 std::fs::rename(&anon_session_home, &new_session_home)
3762 .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
3763
3764 let _ = std::fs::remove_dir_all(&anon_root);
3766
3767 let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
3770 let cwd_key = crate::session::normalize_cwd_key(&cwd);
3771 let new_name_for_reg = new_name.to_string();
3772 if let Err(e) = crate::session::update_registry(|reg| {
3773 reg.by_cwd.insert(cwd_key, new_name_for_reg);
3774 Ok(())
3775 }) {
3776 eprintln!("wire identity persist: failed to update registry: {e:#}");
3777 }
3778
3779 if as_json {
3780 println!(
3781 "{}",
3782 serde_json::to_string(&json!({
3783 "kind": "persisted",
3784 "from_name": name,
3785 "to_name": new_name,
3786 "session_home": new_session_home.to_string_lossy(),
3787 }))?
3788 );
3789 } else {
3790 println!("persisted anonymous identity `{name}` → local session `{new_name}`");
3791 println!(
3792 " session_home: {} (survives reboot)",
3793 new_session_home.display()
3794 );
3795 println!(" registered cwd: {}", cwd.display());
3796 }
3797 Ok(())
3798}
3799
3800fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
3806 let sessions = crate::session::list_sessions()?;
3807 let session = sessions
3808 .iter()
3809 .find(|s| s.name == name)
3810 .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
3811 let relay_state_path = session
3812 .home_dir
3813 .join("config")
3814 .join("wire")
3815 .join("relay.json");
3816 if !relay_state_path.exists() {
3817 bail!("session `{name}` has no relay state — already demoted?");
3818 }
3819 let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
3820 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
3821 let had_fed = self_obj
3822 .get("relay_url")
3823 .and_then(Value::as_str)
3824 .map(|u| {
3825 u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
3826 })
3827 .unwrap_or(false);
3828 if !had_fed {
3829 if as_json {
3830 println!(
3831 "{}",
3832 serde_json::to_string(
3833 &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
3834 )?
3835 );
3836 } else {
3837 println!("session `{name}` has no federation slot — nothing to demote");
3838 }
3839 return Ok(());
3840 }
3841 if let Some(self_mut) = state
3844 .as_object_mut()
3845 .and_then(|m| m.get_mut("self"))
3846 .and_then(|s| s.as_object_mut())
3847 {
3848 self_mut.remove("relay_url");
3849 self_mut.remove("slot_id");
3850 self_mut.remove("slot_token");
3851 if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
3852 eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
3853 }
3854 }
3855 std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
3856
3857 if as_json {
3858 println!(
3859 "{}",
3860 serde_json::to_string(
3861 &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
3862 )?
3863 );
3864 } else {
3865 println!("demoted `{name}` from federation → local");
3866 println!(" relay slot binding removed; keypair + agent-card retained");
3867 println!(" re-publish with `wire identity publish <nick>`");
3868 }
3869 Ok(())
3870}
3871
3872fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
3877 crate::trust::effective_tier(trust, relay_state, handle)
3878}
3879
3880fn cmd_peers(as_json: bool) -> Result<()> {
3881 let trust = config::read_trust()?;
3882 let agents = trust
3883 .get("agents")
3884 .and_then(Value::as_object)
3885 .cloned()
3886 .unwrap_or_default();
3887 let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
3888
3889 let mut self_did: Option<String> = None;
3890 if let Ok(card) = config::read_agent_card() {
3891 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
3892 }
3893
3894 let mut peers = Vec::new();
3895 for (handle, agent) in agents.iter() {
3896 let did = agent
3897 .get("did")
3898 .and_then(Value::as_str)
3899 .unwrap_or("")
3900 .to_string();
3901 if Some(did.as_str()) == self_did.as_deref() {
3902 continue; }
3904 let tier = effective_peer_tier(&trust, &relay_state, handle);
3905 let capabilities = agent
3906 .get("card")
3907 .and_then(|c| c.get("capabilities"))
3908 .cloned()
3909 .unwrap_or_else(|| json!([]));
3910 let character = if did.is_empty() {
3915 None
3916 } else {
3917 let card_obj = agent.get("card");
3918 Some(match card_obj {
3919 Some(card) => crate::character::Character::from_card(card),
3920 None => crate::character::Character::from_did(&did),
3921 })
3922 };
3923 let peer_op_claims = agent
3927 .get("card")
3928 .map(op_claims_from_card)
3929 .unwrap_or_default();
3930 let mut row = serde_json::Map::new();
3931 row.insert("handle".into(), json!(handle));
3932 row.insert("did".into(), json!(did));
3933 row.insert("tier".into(), json!(tier));
3934 row.insert("capabilities".into(), capabilities);
3935 row.insert("persona".into(), serde_json::to_value(&character)?);
3936 for (k, v) in peer_op_claims {
3937 row.insert(k, v);
3938 }
3939 peers.push(Value::Object(row));
3940 }
3941
3942 if as_json {
3943 println!("{}", serde_json::to_string(&peers)?);
3944 } else if peers.is_empty() {
3945 println!("no peers pinned (run `wire join <code>` to pair)");
3946 } else {
3947 for p in &peers {
3953 let char_json = &p["persona"];
3954 let (colored_char, plain_len): (String, usize) = match char_json {
3955 serde_json::Value::Null => ("?".to_string(), 1),
3956 v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
3957 Ok(c) => {
3958 let plain = c.short().chars().count() + 1; (c.colored(), plain)
3960 }
3961 Err(_) => ("?".to_string(), 1),
3962 },
3963 };
3964 let pad = 22usize.saturating_sub(plain_len);
3965 println!(
3966 "{}{} {:<20} {:<10} {}",
3967 colored_char,
3968 " ".repeat(pad),
3969 p["handle"].as_str().unwrap_or(""),
3970 p["tier"].as_str().unwrap_or(""),
3971 p["did"].as_str().unwrap_or(""),
3972 );
3973 }
3974 }
3975 Ok(())
3976}
3977
3978fn maybe_warn_peer_attentiveness(peer: &str) {
3988 let state = match config::read_relay_state() {
3989 Ok(s) => s,
3990 Err(_) => return,
3991 };
3992 let p = state.get("peers").and_then(|p| p.get(peer));
3993 let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
3994 Some(s) if !s.is_empty() => s,
3995 _ => return,
3996 };
3997 let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
3998 Some(s) if !s.is_empty() => s,
3999 _ => return,
4000 };
4001 let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
4002 Some(s) if !s.is_empty() => s.to_string(),
4003 _ => match state
4004 .get("self")
4005 .and_then(|s| s.get("relay_url"))
4006 .and_then(Value::as_str)
4007 {
4008 Some(s) if !s.is_empty() => s.to_string(),
4009 _ => return,
4010 },
4011 };
4012 let client = crate::relay_client::RelayClient::new(&relay_url);
4013 let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
4014 Ok(t) => t,
4015 Err(_) => return,
4016 };
4017 let now = std::time::SystemTime::now()
4018 .duration_since(std::time::UNIX_EPOCH)
4019 .map(|d| d.as_secs())
4020 .unwrap_or(0);
4021 match last_pull {
4022 None => {
4023 eprintln!(
4024 "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
4025 );
4026 }
4027 Some(t) if now.saturating_sub(t) > 300 => {
4028 let mins = now.saturating_sub(t) / 60;
4029 eprintln!(
4030 "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
4031 );
4032 }
4033 _ => {}
4034 }
4035}
4036
4037pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
4038 let trimmed = input.trim();
4039 if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
4040 {
4041 return Ok(trimmed.to_string());
4042 }
4043 let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
4044 let n: i64 = amount
4045 .parse()
4046 .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
4047 if n <= 0 {
4048 bail!("deadline duration must be positive: {input:?}");
4049 }
4050 let duration = match unit {
4051 "m" => time::Duration::minutes(n),
4052 "h" => time::Duration::hours(n),
4053 "d" => time::Duration::days(n),
4054 _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
4055 };
4056 Ok((time::OffsetDateTime::now_utc() + duration)
4057 .format(&time::format_description::well_known::Rfc3339)
4058 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
4059}
4060
4061fn cmd_send(
4062 peer: &str,
4063 kind: &str,
4064 body_arg: &str,
4065 deadline: Option<&str>,
4066 no_auto_pair: bool,
4070 queue: bool,
4074 as_json: bool,
4075) -> Result<()> {
4076 if !config::is_initialized()? {
4077 bail!("not initialized — run `wire init <handle>` first");
4078 }
4079 let peer_in = crate::agent_card::bare_handle(peer).to_string();
4080 let peer = match resolve_peer_handle(&peer_in) {
4088 Ok(Some(resolved)) if resolved != peer_in => {
4089 eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
4090 resolved
4091 }
4092 Ok(Some(canonical)) => canonical, Ok(None) => peer_in, Err(ResolveError::Ambiguous(candidates)) => bail!(
4095 "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
4096 Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
4097 candidates.len(),
4098 candidates.join(", ")
4099 ),
4100 Err(ResolveError::NotFound) => peer_in, };
4102
4103 let peer_is_pinned = config::read_relay_state()
4110 .ok()
4111 .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
4112 .map(|peers| peers.contains_key(&peer))
4113 .unwrap_or(false);
4114 if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) {
4115 if no_auto_pair {
4116 bail!(
4117 "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \
4118 and --no-auto-pair was passed. Run `wire dial {peer}` first, \
4119 then re-run send."
4120 );
4121 }
4122 eprintln!(
4123 "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \
4124 Pass --no-auto-pair to refuse implicit dialing."
4125 );
4126 cmd_add_local_sister(&sister_name, true).map_err(|e| {
4127 anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}")
4128 })?;
4129 }
4130
4131 let peer = peer.as_str();
4132 let sk_seed = config::read_private_key()?;
4133 let card = config::read_agent_card()?;
4134 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4135 let handle = crate::agent_card::display_handle_from_did(did).to_string();
4136 let pk_b64 = card
4137 .get("verify_keys")
4138 .and_then(Value::as_object)
4139 .and_then(|m| m.values().next())
4140 .and_then(|v| v.get("key"))
4141 .and_then(Value::as_str)
4142 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
4143 let pk_bytes = crate::signing::b64decode(pk_b64)?;
4144
4145 let body_value: Value = if body_arg == "-" {
4150 use std::io::Read;
4151 let mut raw = String::new();
4152 std::io::stdin()
4153 .read_to_string(&mut raw)
4154 .with_context(|| "reading body from stdin")?;
4155 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
4158 } else if let Some(path) = body_arg.strip_prefix('@') {
4159 let raw =
4160 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
4161 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
4162 } else {
4163 Value::String(body_arg.to_string())
4164 };
4165
4166 let kind_id = parse_kind(kind)?;
4167
4168 let now = time::OffsetDateTime::now_utc()
4169 .format(&time::format_description::well_known::Rfc3339)
4170 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
4171
4172 let trust_for_did = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
4179 let to_did = crate::trust::resolve_peer_did(&trust_for_did, peer);
4180 let mut event = json!({
4181 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4182 "timestamp": now,
4183 "from": did,
4184 "to": to_did,
4185 "type": kind,
4186 "kind": kind_id,
4187 "body": body_value,
4188 });
4189 if let Some(deadline) = deadline {
4190 event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
4191 }
4192 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
4193 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
4194
4195 maybe_warn_peer_attentiveness(peer);
4201
4202 if !queue {
4209 let outcome = crate::send::attempt_deliver(peer, &signed)?;
4210 if as_json {
4211 println!(
4212 "{}",
4213 serde_json::to_string(&crate::send::delivery_json(&outcome, peer))?
4214 );
4215 } else {
4216 use crate::send::SyncDelivery;
4217 match &outcome {
4218 SyncDelivery::Delivered {
4219 event_id,
4220 relay_url,
4221 slot_id,
4222 } => println!("delivered {event_id} → {peer} (relay {relay_url} slot {slot_id})"),
4223 SyncDelivery::Duplicate {
4224 event_id,
4225 relay_url,
4226 slot_id,
4227 } => println!(
4228 "duplicate {event_id} → {peer} (already on relay {relay_url} slot {slot_id} — change the body to send a distinct event)"
4229 ),
4230 SyncDelivery::PeerUnknown { event_id } => println!(
4231 "FAILED {event_id} → {peer}: peer not pinned. Run `wire dial {peer}` to pair, or `wire send --queue {peer} ...` to write to outbox for the daemon to retry later."
4232 ),
4233 SyncDelivery::SlotStale {
4234 event_id, detail, ..
4235 } => println!(
4236 "FAILED {event_id} → {peer}: relay says slot is stale ({detail}). Run `wire dial {peer}` to re-pair."
4237 ),
4238 SyncDelivery::TransportError {
4239 event_id, detail, ..
4240 } => println!(
4241 "FAILED {event_id} → {peer}: transport error ({detail}). Retry, or pass --queue to outbox the event for daemon retry."
4242 ),
4243 }
4244 }
4245 if !outcome.reached_relay() {
4249 std::process::exit(2);
4250 }
4251 return Ok(());
4252 }
4253
4254 let peer_pinned_in_trust = trust_for_did
4268 .get("agents")
4269 .and_then(Value::as_object)
4270 .map(|a| a.contains_key(peer))
4271 .unwrap_or(false);
4272 if !peer_pinned_in_trust && !peer_is_pinned {
4273 let pending_outbound = crate::pending_pair::list_pending()
4278 .ok()
4279 .map(|v| {
4280 v.iter().any(|p| {
4281 p.peer_did
4282 .as_deref()
4283 .map(|d| {
4284 crate::agent_card::display_handle_from_did(d)
4285 .to_string()
4286 .eq(peer)
4287 })
4288 .unwrap_or(false)
4289 })
4290 })
4291 .unwrap_or(false);
4292 let pending_inbound = crate::pending_inbound_pair::list_pending_inbound()
4293 .ok()
4294 .map(|v| v.iter().any(|p| p.peer_handle == peer))
4295 .unwrap_or(false);
4296 if !pending_outbound && !pending_inbound {
4297 eprintln!(
4298 "wire send: WARN — `{peer}` is not pinned and has no pending pair. \
4299 The event will sit in outbox forever unless you pair first \
4300 (`wire dial {peer}` or accept an inbound invite)."
4301 );
4302 }
4303 }
4304 let line = serde_json::to_vec(&signed)?;
4305 let outbox = config::append_outbox_record(peer, &line)?;
4306 if as_json {
4307 println!(
4308 "{}",
4309 serde_json::to_string(&json!({
4310 "event_id": event_id,
4311 "status": "queued",
4312 "peer": peer,
4313 "outbox": outbox.to_string_lossy(),
4314 }))?
4315 );
4316 } else {
4317 println!(
4318 "queued event {event_id} → {peer} (outbox: {}; daemon will push)",
4319 outbox.display()
4320 );
4321 }
4322 Ok(())
4323}
4324
4325fn parse_kind(s: &str) -> Result<u32> {
4326 if let Ok(n) = s.parse::<u32>() {
4327 return Ok(n);
4328 }
4329 for (id, name) in crate::signing::kinds() {
4330 if *name == s {
4331 return Ok(*id);
4332 }
4333 }
4334 Ok(1)
4336}
4337
4338fn cmd_here(as_json: bool) -> Result<()> {
4344 let initialized = config::is_initialized().unwrap_or(false);
4345
4346 let (self_did, self_handle, self_character) = if initialized {
4348 let card = config::read_agent_card().ok();
4349 let did = card
4350 .as_ref()
4351 .and_then(|c| c.get("did").and_then(Value::as_str))
4352 .unwrap_or("")
4353 .to_string();
4354 let handle = if did.is_empty() {
4355 String::new()
4356 } else {
4357 crate::agent_card::display_handle_from_did(&did).to_string()
4358 };
4359 let character = if did.is_empty() {
4360 None
4361 } else {
4362 Some(crate::character::Character::from_did(&did))
4364 };
4365 (did, handle, character)
4366 } else {
4367 (String::new(), String::new(), None)
4368 };
4369
4370 let cwd = std::env::current_dir()
4371 .map(|p| p.to_string_lossy().into_owned())
4372 .unwrap_or_default();
4373 let wire_home = std::env::var("WIRE_HOME").unwrap_or_default();
4374
4375 let mut sisters: Vec<Value> = Vec::new();
4377 if let Ok(listing) = crate::session::list_local_sessions() {
4378 for group in listing.local.values() {
4379 for s in group {
4380 if s.handle.as_deref() == Some(self_handle.as_str()) {
4381 continue; }
4383 let ch = s.did.as_deref().map(crate::character::Character::from_did);
4384 sisters.push(json!({
4385 "session": s.name,
4386 "handle": s.handle,
4387 "persona": ch,
4388 }));
4389 }
4390 }
4391 }
4392
4393 let mut peers: Vec<Value> = Vec::new();
4395 if initialized
4396 && let Ok(trust) = config::read_trust()
4397 && let Some(agents) = trust.get("agents").and_then(Value::as_object)
4398 {
4399 let relay_state =
4403 config::read_relay_state().unwrap_or_else(|_| json!({"self": null, "peers": {}}));
4404 for (handle, agent) in agents {
4405 if handle == &self_handle {
4406 continue; }
4408 let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
4409 let ch = if did.is_empty() {
4410 None
4411 } else {
4412 Some(crate::character::Character::from_did(did))
4413 };
4414 peers.push(json!({
4423 "handle": handle,
4424 "did": did,
4425 "tier": crate::trust::effective_tier(&trust, &relay_state, handle),
4426 "persona": ch,
4427 }));
4428 }
4429 }
4430
4431 if as_json {
4432 println!(
4433 "{}",
4434 serde_json::to_string(&json!({
4435 "self": {
4436 "handle": self_handle,
4437 "did": self_did,
4438 "persona": self_character,
4439 "cwd": cwd,
4440 "wire_home": wire_home,
4441 },
4442 "sister_sessions": sisters,
4443 "pinned_peers": peers,
4444 }))?
4445 );
4446 return Ok(());
4447 }
4448
4449 if !initialized {
4451 println!("not initialized — run `wire init <handle>` to bootstrap.");
4452 return Ok(());
4453 }
4454 let glyph = self_character
4455 .as_ref()
4456 .map(crate::character::emoji_with_fallback)
4457 .unwrap_or_else(|| "?".to_string());
4458 let nick = self_character
4459 .as_ref()
4460 .map(|c| c.nickname.clone())
4461 .unwrap_or_default();
4462 println!("you are {glyph} {nick} ({self_handle})");
4463 if !cwd.is_empty() {
4464 println!(" cwd: {cwd}");
4465 }
4466 let render_glyph = |character: &Value| -> String {
4471 let emoji = character
4472 .get("emoji")
4473 .and_then(Value::as_str)
4474 .unwrap_or("?");
4475 let nickname = character
4476 .get("nickname")
4477 .and_then(Value::as_str)
4478 .unwrap_or("?");
4479 if crate::character::terminal_supports_emoji() {
4480 return emoji.to_string();
4481 }
4482 let synth = crate::character::Character {
4485 nickname: nickname.to_string(),
4486 emoji: emoji.to_string(),
4487 palette: crate::character::Palette {
4488 primary_hex: String::new(),
4489 accent_hex: String::new(),
4490 ansi256_primary: 0,
4491 ansi256_accent: 0,
4492 },
4493 };
4494 crate::character::emoji_with_fallback(&synth)
4495 };
4496 if !sisters.is_empty() {
4497 println!();
4498 println!("sister sessions on this machine:");
4499 for s in &sisters {
4500 let session = s["session"].as_str().unwrap_or("?");
4501 let ch_nick = s["persona"]["nickname"].as_str().unwrap_or("?");
4502 let glyph = render_glyph(&s["persona"]);
4503 println!(" {glyph} {ch_nick} ({session})");
4504 }
4505 }
4506 if !peers.is_empty() {
4507 println!();
4508 println!("pinned peers:");
4509 for p in &peers {
4510 let handle = p["handle"].as_str().unwrap_or("?");
4511 let tier = p["tier"].as_str().unwrap_or("");
4512 let ch_nick = p["persona"]["nickname"].as_str().unwrap_or("?");
4513 let glyph = render_glyph(&p["persona"]);
4514 println!(" {glyph} {ch_nick} ({handle}) [{tier}]");
4515 }
4516 }
4517 if sisters.is_empty() && peers.is_empty() {
4518 println!();
4519 println!(
4520 "no neighbors yet — `wire session new` to add a sister, or `wire dial <peer>` to reach out."
4521 );
4522 }
4523 Ok(())
4524}
4525
4526fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
4538 if name.contains('@') {
4539 cmd_add(name, None, false, true)
4545 .map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
4546 if let Some(msg) = message {
4547 let bare = name.split('@').next().unwrap_or(name);
4549 cmd_send(bare, "claim", msg, None, false, false, as_json)?;
4550 }
4551 return Ok(());
4552 }
4553
4554 let resolution = match resolve_name_to_target(name) {
4559 Ok(r) => r,
4560 Err(e) if as_json => {
4561 let pool = known_local_names();
4562 let suggestions = closest_candidates(name, &pool, 3, 3);
4563 println!(
4564 "{}",
4565 serde_json::to_string(&json!({
4566 "name_input": name,
4567 "found": false,
4568 "candidates": suggestions,
4569 "error": format!("{e:#}"),
4570 }))?
4571 );
4572 return Ok(());
4573 }
4574 Err(e) => return Err(e),
4575 };
4576 let mut steps: Vec<Value> = Vec::new();
4577
4578 match &resolution {
4579 DialTarget::PinnedPeer { handle, .. } => {
4580 steps.push(json!({
4581 "step": "resolved",
4582 "kind": "already_pinned",
4583 "handle": handle,
4584 }));
4585 }
4586 DialTarget::LocalSister { session_name, .. } => {
4587 steps.push(json!({
4588 "step": "resolved",
4589 "kind": "local_sister",
4590 "session": session_name,
4591 }));
4592 cmd_add_local_sister(session_name, true).map_err(|e| {
4598 anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
4599 })?;
4600 steps.push(json!({
4601 "step": "paired",
4602 "via": "local_sister",
4603 }));
4604 }
4605 }
4606
4607 let send_handle = match &resolution {
4608 DialTarget::PinnedPeer { handle, .. } => handle.clone(),
4609 DialTarget::LocalSister { handle, .. } => handle.clone(),
4610 };
4611
4612 let send_result = if let Some(msg) = message {
4613 let r = cmd_send(&send_handle, "claim", msg, None, false, false, true);
4614 match &r {
4615 Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
4616 Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
4617 }
4618 Some(r)
4619 } else {
4620 None
4621 };
4622
4623 if as_json {
4624 println!(
4625 "{}",
4626 serde_json::to_string(&json!({
4627 "name_input": name,
4628 "resolved_handle": send_handle,
4629 "steps": steps,
4630 }))?
4631 );
4632 } else {
4633 println!("wire dial: resolved `{name}` → handle `{send_handle}`");
4634 for s in &steps {
4635 let step = s.get("step").and_then(Value::as_str).unwrap_or("?");
4636 println!(" - {step}");
4637 }
4638 if message.is_some() {
4639 println!(" (use `wire tail {send_handle}` to read replies)");
4640 }
4641 }
4642 if let Some(Err(e)) = send_result {
4643 return Err(e);
4644 }
4645 Ok(())
4646}
4647
4648fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
4654 let resolution = match resolve_name_to_target(name) {
4660 Ok(r) => r,
4661 Err(e) if as_json => {
4662 let pool = known_local_names();
4663 let suggestions = closest_candidates(name, &pool, 3, 3);
4664 println!(
4665 "{}",
4666 serde_json::to_string(&json!({
4667 "name_input": name,
4668 "found": false,
4669 "candidates": suggestions,
4670 "error": format!("{e:#}"),
4671 }))?
4672 );
4673 return Ok(());
4674 }
4675 Err(e) => return Err(e),
4676 };
4677 match resolution {
4678 DialTarget::PinnedPeer {
4679 handle,
4680 did,
4681 nickname,
4682 emoji,
4683 tier,
4684 } => {
4685 let op_claims = config::read_trust()
4689 .ok()
4690 .and_then(|t| {
4691 t.get("agents")
4692 .and_then(Value::as_object)
4693 .and_then(|m| m.get(&handle))
4694 .and_then(|a| a.get("card").cloned())
4695 })
4696 .map(|c| op_claims_from_card(&c))
4697 .unwrap_or_default();
4698
4699 if as_json {
4700 let mut payload = serde_json::Map::new();
4701 payload.insert("kind".into(), json!("pinned_peer"));
4702 payload.insert("handle".into(), json!(handle));
4703 payload.insert("did".into(), json!(did));
4704 payload.insert("nickname".into(), json!(nickname));
4705 payload.insert("emoji".into(), json!(emoji));
4706 payload.insert("tier".into(), json!(tier));
4707 for (k, v) in &op_claims {
4708 payload.insert(k.clone(), v.clone());
4709 }
4710 println!("{}", serde_json::to_string(&payload)?);
4711 } else {
4712 let n = nickname.as_deref().unwrap_or("(no character)");
4713 let e = emoji.as_deref().unwrap_or("?");
4714 println!("{e} {n}");
4715 println!(" handle: {handle}");
4716 println!(" did: {did}");
4717 println!(" tier: {tier}");
4718 if let Some(op_did) = op_claims.get("op_did").and_then(Value::as_str) {
4721 println!(" op_did: {op_did}");
4722 }
4723 println!(" reach: pinned peer (already in trust ring + slot pinned)");
4724 }
4725 }
4726 DialTarget::LocalSister {
4727 session_name,
4728 handle,
4729 did,
4730 nickname,
4731 emoji,
4732 } => {
4733 if as_json {
4734 println!(
4735 "{}",
4736 serde_json::to_string(&json!({
4737 "kind": "local_sister",
4738 "session_name": session_name,
4739 "handle": handle,
4740 "did": did,
4741 "nickname": nickname,
4742 "emoji": emoji,
4743 }))?
4744 );
4745 } else {
4746 let n = nickname.as_deref().unwrap_or("(no character)");
4747 let e = emoji.as_deref().unwrap_or("?");
4748 println!("{e} {n}");
4749 println!(" session: {session_name}");
4750 println!(" handle: {handle}");
4751 println!(
4752 " did: {}",
4753 did.as_deref().unwrap_or("(card unreadable)")
4754 );
4755 println!(" reach: local sister on this machine — `wire dial {n}` pairs us");
4756 }
4757 }
4758 }
4759 Ok(())
4760}
4761
4762pub(crate) enum DialTarget {
4763 PinnedPeer {
4764 handle: String,
4765 did: String,
4766 nickname: Option<String>,
4767 emoji: Option<String>,
4768 tier: String,
4769 },
4770 LocalSister {
4771 session_name: String,
4772 handle: String,
4773 did: Option<String>,
4774 nickname: Option<String>,
4775 emoji: Option<String>,
4776 },
4777}
4778
4779pub(crate) fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
4789 let needle = name.trim();
4790 if needle.is_empty() {
4791 bail!("empty name");
4792 }
4793
4794 if config::is_initialized().unwrap_or(false) {
4797 let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
4798 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
4799 for (handle_key, agent) in agents {
4800 let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
4801 if did.is_empty() {
4802 continue;
4803 }
4804 let handle = handle_key.clone();
4805 let character = crate::character::Character::from_did(did);
4806 let tier = agent
4807 .get("tier")
4808 .and_then(Value::as_str)
4809 .unwrap_or("UNKNOWN")
4810 .to_string();
4811 let matches = handle.eq_ignore_ascii_case(needle)
4812 || did.eq_ignore_ascii_case(needle)
4813 || character.nickname.eq_ignore_ascii_case(needle);
4814 if matches {
4815 return Ok(DialTarget::PinnedPeer {
4816 handle,
4817 did: did.to_string(),
4818 nickname: Some(character.nickname),
4819 emoji: Some(character.emoji.to_string()),
4820 tier,
4821 });
4822 }
4823 }
4824 }
4825 }
4826
4827 if let Some(session_name) = crate::session::resolve_local_sister(needle) {
4829 let sessions = crate::session::list_sessions().unwrap_or_default();
4830 let s = sessions.iter().find(|s| s.name == session_name);
4831 if let Some(s) = s {
4832 return Ok(DialTarget::LocalSister {
4833 session_name: s.name.clone(),
4834 handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
4835 did: s.did.clone(),
4836 nickname: s.character.as_ref().map(|c| c.nickname.clone()),
4837 emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
4838 });
4839 }
4840 }
4841
4842 let pool = known_local_names();
4847 let suggestions = closest_candidates(name, &pool, 3, 3);
4848 if suggestions.is_empty() {
4849 bail!(
4850 "no peer matched `{name}`.\n\
4851 Tried: pinned peers (`wire peers`) + local sister sessions \
4852 (`wire session list-local`).\n\
4853 For cross-machine federation: `wire dial <handle>@<relay-domain>`."
4854 );
4855 }
4856 bail!(
4857 "no peer matched `{name}`.\n\
4858 Did you mean: {}?\n\
4859 List all: `wire peers`, `wire session list-local`.",
4860 suggestions
4861 .iter()
4862 .map(|s| format!("`{s}`"))
4863 .collect::<Vec<_>>()
4864 .join(", ")
4865 );
4866}
4867
4868fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize, oldest: bool) -> Result<()> {
4884 let inbox = config::inbox_dir()?;
4885 if !inbox.exists() {
4886 if !as_json {
4887 eprintln!("no inbox yet — daemon hasn't run, or no events received");
4888 }
4889 return Ok(());
4890 }
4891 let trust = config::read_trust()?;
4892
4893 let entries: Vec<_> = std::fs::read_dir(&inbox)?
4894 .filter_map(|e| e.ok())
4895 .map(|e| e.path())
4896 .filter(|p| {
4897 p.extension().map(|x| x == "jsonl").unwrap_or(false)
4898 && match peer {
4899 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
4900 None => true,
4901 }
4902 })
4903 .collect();
4904
4905 let mut events: Vec<(String, usize, Value)> = Vec::new();
4911 for path in &entries {
4912 let body = std::fs::read_to_string(path)?;
4913 for (idx, line) in body.lines().enumerate() {
4914 let event: Value = match serde_json::from_str(line) {
4915 Ok(v) => v,
4916 Err(_) => continue,
4917 };
4918 let ts = event
4919 .get("timestamp")
4920 .and_then(Value::as_str)
4921 .unwrap_or("")
4922 .to_string();
4923 events.push((ts, idx, event));
4924 }
4925 }
4926 events.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
4927
4928 let total = events.len();
4930 let window: &[(String, usize, Value)] = if limit == 0 {
4931 &events[..]
4932 } else if oldest {
4933 &events[..limit.min(total)]
4934 } else {
4935 let start = total.saturating_sub(limit);
4936 &events[start..]
4937 };
4938
4939 for (_, _, event) in window {
4940 let verified = verify_message_v31(event, &trust).is_ok();
4941 if as_json {
4942 let mut event_with_meta = event.clone();
4943 if let Some(obj) = event_with_meta.as_object_mut() {
4944 obj.insert("verified".into(), json!(verified));
4945 }
4946 println!("{}", serde_json::to_string(&event_with_meta)?);
4947 } else {
4948 let ts = event
4949 .get("timestamp")
4950 .and_then(Value::as_str)
4951 .unwrap_or("?");
4952 let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
4953 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
4954 let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
4955 let summary = event
4956 .get("body")
4957 .map(|b| match b {
4958 Value::String(s) => s.clone(),
4959 _ => b.to_string(),
4960 })
4961 .unwrap_or_default();
4962 let mark = if verified { "✓" } else { "✗" };
4963 let deadline = event
4964 .get("time_sensitive_until")
4965 .and_then(Value::as_str)
4966 .map(|d| format!(" deadline: {d}"))
4967 .unwrap_or_default();
4968 println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
4969 }
4970 }
4971 Ok(())
4972}
4973
4974fn monitor_is_noise_kind(kind: &str) -> bool {
4980 matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
4981}
4982
4983fn resolve_persona(peer_handle: &str) -> Option<crate::character::Character> {
4987 let trust = config::read_trust().ok()?;
4988 let agent = trust.get("agents").and_then(|a| a.get(peer_handle))?;
4989 if let Some(card) = agent.get("card") {
4990 Some(crate::character::Character::from_card(card))
4991 } else {
4992 let did = agent.get("did").and_then(Value::as_str)?;
4993 Some(crate::character::Character::from_did(did))
4994 }
4995}
4996
4997fn persona_label(peer_handle: &str) -> String {
4999 match resolve_persona(peer_handle) {
5000 Some(ch) => format!("{} {}", ch.emoji, ch.nickname),
5001 None => peer_handle.to_string(),
5002 }
5003}
5004
5005fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
5013 if as_json {
5014 Ok(serde_json::to_string(e)?)
5015 } else {
5016 let eid_short: String = e.event_id.chars().take(12).collect();
5017 let body = e.body_preview.replace('\n', " ");
5018 let ts: String = e.timestamp.chars().take(19).collect();
5019 Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
5020 }
5021}
5022
5023fn cmd_monitor(
5039 peer_filter: Option<&str>,
5040 as_json: bool,
5041 include_handshake: bool,
5042 interval_ms: u64,
5043 replay: usize,
5044) -> Result<()> {
5045 let inbox_dir = config::inbox_dir()?;
5046 if !inbox_dir.exists() && !as_json {
5047 eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
5048 }
5049 crate::session::warn_on_identity_collision(std::process::id(), "monitor");
5054 if replay > 0 && inbox_dir.exists() {
5060 let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
5061 for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
5062 let path = entry.path();
5063 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
5064 continue;
5065 }
5066 let peer = match path.file_stem().and_then(|s| s.to_str()) {
5067 Some(s) => s.to_string(),
5068 None => continue,
5069 };
5070 if let Some(filter) = peer_filter
5071 && peer != filter
5072 {
5073 continue;
5074 }
5075 let body = std::fs::read_to_string(&path).unwrap_or_default();
5076 for line in body.lines() {
5077 let line = line.trim();
5078 if line.is_empty() {
5079 continue;
5080 }
5081 let signed: Value = match serde_json::from_str(line) {
5082 Ok(v) => v,
5083 Err(_) => continue,
5084 };
5085 let ev = crate::inbox_watch::InboxEvent::from_signed(
5086 &peer, signed, true,
5087 );
5088 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
5089 continue;
5090 }
5091 all.push(ev);
5092 }
5093 }
5094 all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
5097 let start = all.len().saturating_sub(replay);
5098 for ev in &all[start..] {
5099 println!("{}", monitor_render(ev, as_json)?);
5100 }
5101 use std::io::Write;
5102 std::io::stdout().flush().ok();
5103 }
5104
5105 let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
5108 let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
5109
5110 loop {
5111 let events = match w.poll() {
5118 Ok(evs) => evs,
5119 Err(e) => {
5120 eprintln!("wire monitor: poll error (continuing to watch): {e:#}");
5121 std::thread::sleep(sleep_dur);
5122 continue;
5123 }
5124 };
5125 let mut wrote = false;
5126 for ev in events {
5127 if let Some(filter) = peer_filter
5128 && ev.peer != filter
5129 {
5130 continue;
5131 }
5132 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
5133 continue;
5134 }
5135 println!("{}", monitor_render(&ev, as_json)?);
5136 wrote = true;
5137 }
5138 if wrote {
5139 use std::io::Write;
5140 std::io::stdout().flush().ok();
5141 }
5142 std::thread::sleep(sleep_dur);
5143 }
5144}
5145
5146#[cfg(test)]
5147mod tier_tests {
5148 use super::*;
5149 use serde_json::json;
5150
5151 fn trust_with(handle: &str, tier: &str) -> Value {
5152 json!({
5153 "version": 1,
5154 "agents": {
5155 handle: {
5156 "tier": tier,
5157 "did": format!("did:wire:{handle}"),
5158 "card": {"capabilities": ["wire/v3.1"]}
5159 }
5160 }
5161 })
5162 }
5163
5164 #[test]
5165 fn pending_ack_when_verified_but_no_slot_token() {
5166 let trust = trust_with("willard", "VERIFIED");
5170 let relay_state = json!({
5171 "peers": {
5172 "willard": {
5173 "relay_url": "https://relay",
5174 "slot_id": "abc",
5175 "slot_token": "",
5176 }
5177 }
5178 });
5179 assert_eq!(
5180 effective_peer_tier(&trust, &relay_state, "willard"),
5181 "PENDING_ACK"
5182 );
5183 }
5184
5185 #[test]
5186 fn verified_when_slot_token_present() {
5187 let trust = trust_with("willard", "VERIFIED");
5188 let relay_state = json!({
5189 "peers": {
5190 "willard": {
5191 "relay_url": "https://relay",
5192 "slot_id": "abc",
5193 "slot_token": "tok123",
5194 }
5195 }
5196 });
5197 assert_eq!(
5198 effective_peer_tier(&trust, &relay_state, "willard"),
5199 "VERIFIED"
5200 );
5201 }
5202
5203 #[test]
5204 fn raw_tier_passes_through_for_non_verified() {
5205 let trust = trust_with("willard", "UNTRUSTED");
5208 let relay_state = json!({
5209 "peers": {"willard": {"slot_token": ""}}
5210 });
5211 assert_eq!(
5212 effective_peer_tier(&trust, &relay_state, "willard"),
5213 "UNTRUSTED"
5214 );
5215 }
5216
5217 #[test]
5218 fn pending_ack_when_relay_state_missing_peer() {
5219 let trust = trust_with("willard", "VERIFIED");
5223 let relay_state = json!({"peers": {}});
5224 assert_eq!(
5225 effective_peer_tier(&trust, &relay_state, "willard"),
5226 "PENDING_ACK"
5227 );
5228 }
5229}
5230
5231#[cfg(test)]
5232mod monitor_tests {
5233 use super::*;
5234 use crate::inbox_watch::InboxEvent;
5235 use serde_json::Value;
5236
5237 fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
5238 InboxEvent {
5239 peer: peer.to_string(),
5240 event_id: "abcd1234567890ef".to_string(),
5241 kind: kind.to_string(),
5242 body_preview: body.to_string(),
5243 verified: true,
5244 timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
5245 raw: Value::Null,
5246 }
5247 }
5248
5249 #[test]
5250 fn monitor_filter_drops_handshake_kinds_by_default() {
5251 assert!(monitor_is_noise_kind("pair_drop"));
5256 assert!(monitor_is_noise_kind("pair_drop_ack"));
5257 assert!(monitor_is_noise_kind("heartbeat"));
5258
5259 assert!(!monitor_is_noise_kind("claim"));
5261 assert!(!monitor_is_noise_kind("decision"));
5262 assert!(!monitor_is_noise_kind("ack"));
5263 assert!(!monitor_is_noise_kind("request"));
5264 assert!(!monitor_is_noise_kind("note"));
5265 assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
5269 }
5270
5271 #[test]
5272 fn monitor_render_plain_is_one_short_line() {
5273 let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
5274 let line = monitor_render(&e, false).unwrap();
5275 assert!(!line.contains('\n'), "render must be one line: {line}");
5277 assert!(line.contains("willard"));
5279 assert!(line.contains("claim"));
5280 assert!(line.contains("real v8 train"));
5281 assert!(line.contains("abcd12345678"));
5283 assert!(
5284 !line.contains("abcd1234567890ef"),
5285 "should truncate full id"
5286 );
5287 assert!(line.contains("2026-05-15T23:14:07"));
5289 }
5290
5291 #[test]
5292 fn monitor_render_strips_newlines_from_body() {
5293 let e = ev("spark", "claim", "line one\nline two\nline three");
5298 let line = monitor_render(&e, false).unwrap();
5299 assert!(!line.contains('\n'), "newlines must be stripped: {line}");
5300 assert!(line.contains("line one line two line three"));
5301 }
5302
5303 #[test]
5304 fn monitor_render_json_is_valid_jsonl() {
5305 let e = ev("spark", "claim", "hi");
5306 let line = monitor_render(&e, true).unwrap();
5307 assert!(!line.contains('\n'));
5308 let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
5309 assert_eq!(parsed["peer"], "spark");
5310 assert_eq!(parsed["kind"], "claim");
5311 assert_eq!(parsed["body_preview"], "hi");
5312 }
5313
5314 #[test]
5315 fn monitor_does_not_drop_on_verified_null() {
5316 let mut e = ev("spark", "claim", "from disk with verified=null");
5327 e.verified = false; let line = monitor_render(&e, false).unwrap();
5329 assert!(line.contains("from disk with verified=null"));
5330 assert!(!monitor_is_noise_kind("claim"));
5332 }
5333}
5334
5335fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
5338 let body = if path == "-" {
5339 let mut buf = String::new();
5340 use std::io::Read;
5341 std::io::stdin().read_to_string(&mut buf)?;
5342 buf
5343 } else {
5344 std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
5345 };
5346 let event: Value = serde_json::from_str(&body)?;
5347 let trust = config::read_trust()?;
5348 match verify_message_v31(&event, &trust) {
5349 Ok(()) => {
5350 if as_json {
5351 println!("{}", serde_json::to_string(&json!({"verified": true}))?);
5352 } else {
5353 println!("verified ✓");
5354 }
5355 Ok(())
5356 }
5357 Err(e) => {
5358 let reason = e.to_string();
5359 if as_json {
5360 println!(
5361 "{}",
5362 serde_json::to_string(&json!({"verified": false, "reason": reason}))?
5363 );
5364 } else {
5365 eprintln!("FAILED: {reason}");
5366 }
5367 std::process::exit(1);
5368 }
5369 }
5370}
5371
5372fn cmd_mcp() -> Result<()> {
5375 crate::mcp::run()
5376}
5377
5378fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
5379 if let Some(socket_path) = uds {
5384 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
5385 std::path::PathBuf::from(home)
5386 .join("state")
5387 .join("wire-relay")
5388 .join("uds")
5389 } else {
5390 dirs::state_dir()
5391 .or_else(dirs::data_local_dir)
5392 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
5393 .join("wire-relay")
5394 .join("uds")
5395 };
5396 let runtime = tokio::runtime::Builder::new_multi_thread()
5397 .enable_all()
5398 .build()?;
5399 return runtime.block_on(crate::relay_server::serve_uds(
5400 socket_path.to_path_buf(),
5401 base,
5402 ));
5403 }
5404 if local_only {
5408 validate_loopback_bind(bind)?;
5409 }
5410 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
5416 std::path::PathBuf::from(home)
5417 .join("state")
5418 .join("wire-relay")
5419 } else {
5420 dirs::state_dir()
5421 .or_else(dirs::data_local_dir)
5422 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
5423 .join("wire-relay")
5424 };
5425 let state_dir = if local_only { base.join("local") } else { base };
5426 let runtime = tokio::runtime::Builder::new_multi_thread()
5427 .enable_all()
5428 .build()?;
5429 runtime.block_on(crate::relay_server::serve_with_mode(
5430 bind,
5431 state_dir,
5432 crate::relay_server::ServerMode { local_only },
5433 ))
5434}
5435
5436fn validate_loopback_bind(bind: &str) -> Result<()> {
5454 let host = if let Some(stripped) = bind.strip_prefix('[') {
5456 let close = stripped
5457 .find(']')
5458 .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
5459 stripped[..close].to_string()
5460 } else {
5461 bind.rsplit_once(':')
5462 .map(|(h, _)| h.to_string())
5463 .unwrap_or_else(|| bind.to_string())
5464 };
5465 use std::net::{IpAddr, ToSocketAddrs};
5466 let probe = format!("{host}:0");
5467 let resolved: Vec<_> = probe
5468 .to_socket_addrs()
5469 .with_context(|| format!("resolving bind host {host:?}"))?
5470 .collect();
5471 if resolved.is_empty() {
5472 bail!("--local-only: bind host {host:?} resolved to no addresses");
5473 }
5474 for addr in &resolved {
5475 let ip = addr.ip();
5476 let is_acceptable = match ip {
5477 IpAddr::V4(v4) => {
5478 v4.is_loopback() || v4.is_private() || {
5479 let octets = v4.octets();
5481 octets[0] == 100 && (64..=127).contains(&octets[1])
5482 }
5483 }
5484 IpAddr::V6(v6) => v6.is_loopback(), };
5486 if !is_acceptable {
5487 bail!(
5488 "--local-only refuses non-private bind: {host:?} resolves to {ip} \
5489 which is not loopback (127/8, ::1), RFC 1918 private \
5490 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
5491 (100.64.0.0/10). Remove --local-only to bind publicly."
5492 );
5493 }
5494 }
5495 Ok(())
5496}
5497
5498fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
5501 use crate::endpoints::EndpointScope;
5502 match s.to_lowercase().as_str() {
5503 "federation" | "fed" => Ok(EndpointScope::Federation),
5504 "local" => Ok(EndpointScope::Local),
5505 "lan" => Ok(EndpointScope::Lan),
5506 "uds" => Ok(EndpointScope::Uds),
5507 other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
5508 }
5509}
5510
5511fn cmd_bind_relay(
5517 url: &str,
5518 scope: Option<&str>,
5519 replace: bool,
5520 migrate_pinned: bool,
5521 as_json: bool,
5522) -> Result<()> {
5523 use crate::endpoints::{Endpoint, self_endpoints};
5524
5525 if !config::is_initialized()? {
5526 bail!("not initialized — run `wire init <handle>` first");
5527 }
5528 let card = config::read_agent_card()?;
5529 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
5530 let handle = crate::agent_card::display_handle_from_did(did).to_string();
5531
5532 let normalized_raw = url.trim_end_matches('/');
5533 let normalized_owned = strip_relay_url_userinfo(normalized_raw);
5537 let normalized = normalized_owned.as_str();
5538 assert_relay_url_clean_for_publish(normalized)?;
5542 let new_scope = match scope {
5543 Some(s) => parse_scope(s)?,
5544 None => crate::endpoints::infer_scope_from_url(normalized),
5545 };
5546
5547 let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
5548 let pinned: Vec<String> = existing
5549 .get("peers")
5550 .and_then(|p| p.as_object())
5551 .map(|o| o.keys().cloned().collect())
5552 .unwrap_or_default();
5553
5554 let existing_eps = self_endpoints(&existing);
5555 let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
5556
5557 let destructive = replace || is_rebind_same;
5564 if destructive && !pinned.is_empty() && !migrate_pinned {
5565 let list = pinned.join(", ");
5566 let why = if replace {
5567 "`--replace` drops your other slot(s)"
5568 } else {
5569 "re-binding the same relay rotates its slot"
5570 };
5571 bail!(
5572 "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
5573 pinned to your CURRENT slot and would keep pushing to a slot you no longer \
5574 read.\n\n\
5575 SAFE PATHS:\n\
5576 • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
5577 slots — no black-hole.\n\
5578 • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
5579 • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
5580 peer out-of-band.\n\n\
5581 Issue #7 (silent black-hole on relay change) caught this.",
5582 n = pinned.len(),
5583 );
5584 }
5585
5586 let client = crate::relay_client::RelayClient::new(normalized);
5587 client.check_healthz()?;
5588 let alloc = client.allocate_slot(Some(&handle))?;
5589
5590 if destructive && !pinned.is_empty() {
5591 eprintln!(
5592 "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
5593 until they re-pin: {peers}",
5594 mode = if replace { "replacing" } else { "rotating" },
5595 n = pinned.len(),
5596 peers = pinned.join(", "),
5597 );
5598 }
5599
5600 let mut state = existing;
5604 if replace {
5605 state["self"] = Value::Null;
5606 }
5607 crate::endpoints::upsert_self_endpoint(
5608 &mut state,
5609 Endpoint {
5610 relay_url: normalized.to_string(),
5611 slot_id: alloc.slot_id.clone(),
5612 slot_token: alloc.slot_token.clone(),
5613 scope: new_scope,
5614 },
5615 );
5616 config::write_relay_state(&state)?;
5617 let eps = self_endpoints(&state);
5618
5619 let scope_str = format!("{new_scope:?}").to_lowercase();
5620 if as_json {
5621 println!(
5622 "{}",
5623 serde_json::to_string(&json!({
5624 "relay_url": normalized,
5625 "slot_id": alloc.slot_id,
5626 "scope": scope_str,
5627 "endpoints": eps.len(),
5628 "additive": !replace,
5629 "slot_token_present": true,
5630 }))?
5631 );
5632 } else {
5633 println!(
5634 "bound {scope_str} slot on {normalized} (slot {})",
5635 alloc.slot_id
5636 );
5637 println!(
5638 "self now has {n} endpoint(s): {list}",
5639 n = eps.len(),
5640 list = eps
5641 .iter()
5642 .map(|e| format!("{}({:?})", e.relay_url, e.scope))
5643 .collect::<Vec<_>>()
5644 .join(", "),
5645 );
5646 }
5647 Ok(())
5648}
5649
5650fn cmd_add_peer_slot(
5653 handle: &str,
5654 url: &str,
5655 slot_id: &str,
5656 slot_token: &str,
5657 as_json: bool,
5658) -> Result<()> {
5659 use crate::endpoints::{Endpoint, infer_scope_from_url, pin_peer_endpoints};
5660 let mut state = config::read_relay_state()?;
5661
5662 let new_ep = Endpoint {
5669 relay_url: url.to_string(),
5670 slot_id: slot_id.to_string(),
5671 slot_token: slot_token.to_string(),
5672 scope: infer_scope_from_url(url),
5673 };
5674 let mut endpoints: Vec<Endpoint> = state
5675 .get("peers")
5676 .and_then(|p| p.get(handle))
5677 .and_then(|e| e.get("endpoints"))
5678 .and_then(|a| serde_json::from_value::<Vec<Endpoint>>(a.clone()).ok())
5679 .unwrap_or_default();
5680 if endpoints.is_empty()
5682 && let Some(peer) = state.get("peers").and_then(|p| p.get(handle))
5683 && let (Some(ru), Some(si), Some(st)) = (
5684 peer.get("relay_url").and_then(Value::as_str),
5685 peer.get("slot_id").and_then(Value::as_str),
5686 peer.get("slot_token").and_then(Value::as_str),
5687 )
5688 {
5689 endpoints.push(Endpoint {
5690 relay_url: ru.to_string(),
5691 slot_id: si.to_string(),
5692 slot_token: st.to_string(),
5693 scope: infer_scope_from_url(ru),
5694 });
5695 }
5696 if let Some(existing) = endpoints
5698 .iter_mut()
5699 .find(|e| e.relay_url == new_ep.relay_url)
5700 {
5701 *existing = new_ep;
5702 } else {
5703 endpoints.push(new_ep);
5704 }
5705 let n = endpoints.len();
5706 pin_peer_endpoints(&mut state, handle, &endpoints)?;
5707 config::write_relay_state(&state)?;
5708 if as_json {
5709 println!(
5710 "{}",
5711 serde_json::to_string(&json!({
5712 "handle": handle,
5713 "relay_url": url,
5714 "slot_id": slot_id,
5715 "added": true,
5716 "endpoint_count": n,
5717 }))?
5718 );
5719 } else {
5720 println!(
5721 "pinned peer slot for {handle} at {url} ({slot_id}) — peer now has {n} endpoint(s)"
5722 );
5723 }
5724 Ok(())
5725}
5726
5727fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
5730 let mut state = config::read_relay_state()?;
5731 let peers = state["peers"].as_object().cloned().unwrap_or_default();
5732 if peers.is_empty() {
5733 bail!(
5734 "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
5735 );
5736 }
5737 let outbox_dir = config::outbox_dir()?;
5738 if outbox_dir.exists() {
5743 let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
5744 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
5745 let path = entry.path();
5746 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
5747 continue;
5748 }
5749 let stem = match path.file_stem().and_then(|s| s.to_str()) {
5750 Some(s) => s.to_string(),
5751 None => continue,
5752 };
5753 if pinned.contains(&stem) {
5754 continue;
5755 }
5756 let bare = crate::agent_card::bare_handle(&stem);
5759 if pinned.contains(bare) {
5760 eprintln!(
5761 "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
5762 Merge with: `cat {} >> {}` then delete the FQDN file.",
5763 stem,
5764 path.display(),
5765 outbox_dir.join(format!("{bare}.jsonl")).display(),
5766 );
5767 }
5768 }
5769 }
5770 if !outbox_dir.exists() {
5771 if as_json {
5772 println!(
5773 "{}",
5774 serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
5775 );
5776 } else {
5777 println!("phyllis: nothing to dial out — write a message first with `wire send`");
5778 }
5779 return Ok(());
5780 }
5781
5782 let mut pushed = Vec::new();
5783 let mut skipped = Vec::new();
5784
5785 let mut rotated_this_push: std::collections::HashSet<String> = std::collections::HashSet::new();
5790 let mut state_dirty = false;
5793
5794 for (peer_handle, _) in peers.iter() {
5800 if let Some(want) = peer_filter
5801 && peer_handle != want
5802 {
5803 continue;
5804 }
5805 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5806 if !outbox.exists() {
5807 continue;
5808 }
5809 let mut ordered_endpoints =
5810 crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
5811 if ordered_endpoints.is_empty() {
5812 for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
5816 let event: Value = match serde_json::from_str(line) {
5817 Ok(v) => v,
5818 Err(_) => continue,
5819 };
5820 let event_id = event
5821 .get("event_id")
5822 .and_then(Value::as_str)
5823 .unwrap_or("")
5824 .to_string();
5825 skipped.push(json!({
5826 "peer": peer_handle,
5827 "event_id": event_id,
5828 "reason": "no reachable endpoint pinned for peer",
5829 }));
5830 }
5831 continue;
5832 }
5833 let body = std::fs::read_to_string(&outbox)?;
5834 for line in body.lines() {
5835 let event: Value = match serde_json::from_str(line) {
5836 Ok(v) => v,
5837 Err(_) => continue,
5838 };
5839 let event_id = event
5840 .get("event_id")
5841 .and_then(Value::as_str)
5842 .unwrap_or("")
5843 .to_string();
5844
5845 let last_err: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
5854 match crate::relay_client::try_post_event_with_failover(
5855 &ordered_endpoints,
5856 &event,
5857 |endpoint, ev| {
5858 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
5859 match client.post_event(&endpoint.slot_id, &endpoint.slot_token, ev) {
5860 Ok(resp) => Ok(resp),
5861 Err(e) => {
5862 *last_err.borrow_mut() =
5863 Some(crate::relay_client::format_transport_error(&e));
5864 Err(e)
5865 }
5866 }
5867 },
5868 ) {
5869 Ok((endpoint, resp)) => {
5870 if resp.status == "duplicate" {
5871 skipped.push(json!({
5872 "peer": peer_handle,
5873 "event_id": event_id,
5874 "reason": "duplicate",
5875 "endpoint": endpoint.relay_url,
5876 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5877 }));
5878 } else {
5879 pushed.push(json!({
5880 "peer": peer_handle,
5881 "event_id": event_id,
5882 "endpoint": endpoint.relay_url,
5883 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5884 }));
5885 }
5886 }
5887 Err(_) => {
5888 let last_err_text = last_err.borrow().clone().unwrap_or_default();
5898 let mut delivered_via_retry: Option<(crate::endpoints::Endpoint, _)> = None;
5899 match try_reresolve_peer_on_slot_4xx(
5900 &mut state,
5901 peer_handle,
5902 &last_err_text,
5903 &rotated_this_push,
5904 ) {
5905 Ok(true) => {
5906 rotated_this_push.insert(peer_handle.clone());
5908 state_dirty = true;
5909 ordered_endpoints = crate::endpoints::peer_endpoints_in_priority_order(
5914 &state,
5915 peer_handle,
5916 );
5917 *last_err.borrow_mut() = None;
5918 if let Ok((endpoint, resp)) =
5919 crate::relay_client::try_post_event_with_failover(
5920 &ordered_endpoints,
5921 &event,
5922 |endpoint, ev| {
5923 let client = crate::relay_client::RelayClient::new(
5924 &endpoint.relay_url,
5925 );
5926 match client.post_event(
5927 &endpoint.slot_id,
5928 &endpoint.slot_token,
5929 ev,
5930 ) {
5931 Ok(resp) => Ok(resp),
5932 Err(e) => {
5933 *last_err.borrow_mut() = Some(
5934 crate::relay_client::format_transport_error(&e),
5935 );
5936 Err(e)
5937 }
5938 }
5939 },
5940 )
5941 {
5942 delivered_via_retry = Some((endpoint, resp));
5943 }
5944 }
5945 Ok(false) => {
5946 }
5950 Err(e) => {
5951 *last_err.borrow_mut() = Some(format!(
5956 "{}; re-resolve also failed: {e:#}",
5957 last_err.borrow().clone().unwrap_or_default()
5958 ));
5959 rotated_this_push.insert(peer_handle.clone());
5961 }
5962 }
5963 if let Some((endpoint, resp)) = delivered_via_retry {
5964 if resp.status == "duplicate" {
5965 skipped.push(json!({
5966 "peer": peer_handle,
5967 "event_id": event_id,
5968 "reason": "duplicate",
5969 "endpoint": endpoint.relay_url,
5970 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5971 "via": "slot_reresolve_retry",
5972 }));
5973 } else {
5974 pushed.push(json!({
5975 "peer": peer_handle,
5976 "event_id": event_id,
5977 "endpoint": endpoint.relay_url,
5978 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5979 "via": "slot_reresolve_retry",
5980 }));
5981 }
5982 } else {
5983 skipped.push(json!({
5988 "peer": peer_handle,
5989 "event_id": event_id,
5990 "reason": last_err
5991 .borrow()
5992 .clone()
5993 .unwrap_or_else(|| "all endpoints failed".to_string()),
5994 }));
5995 }
5996 }
5997 }
5998 }
5999 }
6000
6001 if state_dirty && let Err(e) = config::write_relay_state(&state) {
6006 eprintln!(
6007 "wire push: WARN failed to persist rotated peer slots: {e:#}. \
6008 Slot rotation will be re-attempted on next push."
6009 );
6010 }
6011
6012 if as_json {
6013 println!(
6014 "{}",
6015 serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
6016 );
6017 } else {
6018 println!(
6019 "pushed {} event(s); skipped {} ({})",
6020 pushed.len(),
6021 skipped.len(),
6022 if skipped.is_empty() {
6023 "none"
6024 } else {
6025 "see --json for detail"
6026 }
6027 );
6028 }
6029 Ok(())
6030}
6031
6032fn cmd_pull(as_json: bool) -> Result<()> {
6035 let state = config::read_relay_state()?;
6036 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
6037 if self_state.is_null() {
6038 bail!("self slot not bound — run `wire bind-relay <url>` first");
6039 }
6040
6041 let endpoints = crate::endpoints::self_endpoints(&state);
6050 if endpoints.is_empty() {
6051 bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
6052 }
6053
6054 let inbox_dir = config::inbox_dir()?;
6055 config::ensure_dirs()?;
6056
6057 let mut total_seen = 0usize;
6058 let mut all_written: Vec<Value> = Vec::new();
6059 let mut all_rejected: Vec<Value> = Vec::new();
6060 let mut all_blocked = false;
6061 let mut all_advance_cursor_to: Option<String> = None;
6062
6063 for endpoint in &endpoints {
6064 let cursor_key = endpoint_cursor_key(endpoint.scope);
6065 let last_event_id = self_state
6066 .get(&cursor_key)
6067 .and_then(Value::as_str)
6068 .map(str::to_string);
6069 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
6070 let events = match client.list_events(
6071 &endpoint.slot_id,
6072 &endpoint.slot_token,
6073 last_event_id.as_deref(),
6074 Some(1000),
6075 ) {
6076 Ok(ev) => ev,
6077 Err(e) => {
6078 eprintln!(
6082 "wire pull: endpoint {} ({:?}) errored: {}; continuing",
6083 endpoint.relay_url,
6084 endpoint.scope,
6085 crate::relay_client::format_transport_error(&e),
6086 );
6087 continue;
6088 }
6089 };
6090 total_seen += events.len();
6091 let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
6092 all_written.extend(result.written.iter().cloned());
6093 all_rejected.extend(result.rejected.iter().cloned());
6094 if result.blocked {
6095 all_blocked = true;
6096 }
6097 if let Some(eid) = result.advance_cursor_to.clone() {
6100 if endpoint.scope == crate::endpoints::EndpointScope::Federation {
6101 all_advance_cursor_to = Some(eid.clone());
6102 }
6103 let key = cursor_key.clone();
6104 config::update_relay_state(|state| {
6105 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
6106 self_obj.insert(key, Value::String(eid));
6107 }
6108 Ok(())
6109 })?;
6110 }
6111 }
6112
6113 let result = crate::pull::PullResult {
6118 written: all_written,
6119 rejected: all_rejected,
6120 blocked: all_blocked,
6121 advance_cursor_to: all_advance_cursor_to,
6122 };
6123 let events_len = total_seen;
6124
6125 if as_json {
6129 println!(
6130 "{}",
6131 serde_json::to_string(&json!({
6132 "written": result.written,
6133 "rejected": result.rejected,
6134 "total_seen": events_len,
6135 "cursor_blocked": result.blocked,
6136 "cursor_advanced_to": result.advance_cursor_to,
6137 }))?
6138 );
6139 } else {
6140 let blocking = result
6141 .rejected
6142 .iter()
6143 .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
6144 .count();
6145 if blocking > 0 {
6146 println!(
6147 "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
6148 events_len,
6149 result.written.len(),
6150 result.rejected.len(),
6151 blocking,
6152 );
6153 } else {
6154 println!(
6155 "pulled {} event(s); wrote {}; rejected {}",
6156 events_len,
6157 result.written.len(),
6158 result.rejected.len(),
6159 );
6160 }
6161 }
6162 Ok(())
6163}
6164
6165fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
6170 match scope {
6171 crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
6172 crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
6173 crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
6174 crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
6175 }
6176}
6177
6178fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
6181 if !config::is_initialized()? {
6182 bail!("not initialized — run `wire init <handle>` first");
6183 }
6184 let mut state = config::read_relay_state()?;
6185 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
6186 if self_state.is_null() {
6187 bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
6188 }
6189 let primary = crate::endpoints::self_primary_endpoint(&state)
6193 .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
6194 let url = primary.relay_url.clone();
6195 let old_slot_id = primary.slot_id.clone();
6196 let old_slot_token = primary.slot_token.clone();
6197
6198 let card = config::read_agent_card()?;
6200 let did = card
6201 .get("did")
6202 .and_then(Value::as_str)
6203 .unwrap_or("")
6204 .to_string();
6205 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
6206 let pk_b64 = card
6207 .get("verify_keys")
6208 .and_then(Value::as_object)
6209 .and_then(|m| m.values().next())
6210 .and_then(|v| v.get("key"))
6211 .and_then(Value::as_str)
6212 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
6213 .to_string();
6214 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
6215 let sk_seed = config::read_private_key()?;
6216
6217 let normalized = url.trim_end_matches('/').to_string();
6219 let client = crate::relay_client::RelayClient::new(&normalized);
6220 client
6221 .check_healthz()
6222 .context("aborting rotation; old slot still valid")?;
6223 let alloc = client.allocate_slot(Some(&handle))?;
6224 let new_slot_id = alloc.slot_id.clone();
6225 let new_slot_token = alloc.slot_token.clone();
6226
6227 let mut announced: Vec<String> = Vec::new();
6234 if !no_announce {
6235 let now = time::OffsetDateTime::now_utc()
6236 .format(&time::format_description::well_known::Rfc3339)
6237 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
6238 let body = json!({
6239 "reason": "operator-initiated slot rotation",
6240 "new_relay_url": url,
6241 "new_slot_id": new_slot_id,
6242 });
6246 let peers = state["peers"].as_object().cloned().unwrap_or_default();
6247 for (peer_handle, _peer_info) in peers.iter() {
6248 let event = json!({
6249 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6250 "timestamp": now.clone(),
6251 "from": did,
6252 "to": format!("did:wire:{peer_handle}"),
6253 "type": "wire_close",
6254 "kind": 1201,
6255 "body": body.clone(),
6256 });
6257 let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
6258 Ok(s) => s,
6259 Err(e) => {
6260 eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
6261 continue;
6262 }
6263 };
6264 let peer_info = match state["peers"].get(peer_handle) {
6269 Some(p) => p.clone(),
6270 None => continue,
6271 };
6272 let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
6273 let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
6274 let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
6275 if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
6276 continue;
6277 }
6278 let peer_client = if peer_url == url {
6279 client.clone()
6280 } else {
6281 crate::relay_client::RelayClient::new(peer_url)
6282 };
6283 match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
6284 Ok(_) => announced.push(peer_handle.clone()),
6285 Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
6286 }
6287 }
6288 }
6289
6290 state["self"] = json!({
6292 "relay_url": url,
6293 "slot_id": new_slot_id,
6294 "slot_token": new_slot_token,
6295 });
6296 config::write_relay_state(&state)?;
6297
6298 if as_json {
6299 println!(
6300 "{}",
6301 serde_json::to_string(&json!({
6302 "rotated": true,
6303 "old_slot_id": old_slot_id,
6304 "new_slot_id": new_slot_id,
6305 "relay_url": url,
6306 "announced_to": announced,
6307 }))?
6308 );
6309 } else {
6310 println!("rotated slot on {url}");
6311 println!(
6312 " old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
6313 );
6314 println!(" new slot_id: {new_slot_id}");
6315 if !announced.is_empty() {
6316 println!(
6317 " announced wire_close (kind=1201) to: {}",
6318 announced.join(", ")
6319 );
6320 }
6321 println!();
6322 println!("next steps:");
6323 println!(" - peers see the wire_close event in their next `wire pull`");
6324 println!(
6325 " - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
6326 );
6327 println!(" (or full re-pair via `wire pair-host`/`wire join`)");
6328 println!(" - until they do, you'll receive but they won't be able to reach you");
6329 let _ = old_slot_token;
6331 }
6332 Ok(())
6333}
6334
6335fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
6338 let mut trust = config::read_trust()?;
6339 let mut removed_from_trust = false;
6340 if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
6341 && agents.remove(handle).is_some()
6342 {
6343 removed_from_trust = true;
6344 }
6345 config::write_trust(&trust)?;
6346
6347 let mut state = config::read_relay_state()?;
6348 let mut removed_from_relay = false;
6349 if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
6350 && peers.remove(handle).is_some()
6351 {
6352 removed_from_relay = true;
6353 }
6354 config::write_relay_state(&state)?;
6355
6356 let mut purged: Vec<String> = Vec::new();
6357 if purge {
6358 for dir in [config::inbox_dir()?, config::outbox_dir()?] {
6359 let path = dir.join(format!("{handle}.jsonl"));
6360 if path.exists() {
6361 std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
6362 purged.push(path.to_string_lossy().into());
6363 }
6364 }
6365 }
6366
6367 if !removed_from_trust && !removed_from_relay {
6368 if as_json {
6369 println!(
6370 "{}",
6371 serde_json::to_string(&json!({
6372 "removed": false,
6373 "reason": format!("peer {handle:?} not pinned"),
6374 }))?
6375 );
6376 } else {
6377 eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
6378 }
6379 return Ok(());
6380 }
6381
6382 if as_json {
6383 println!(
6384 "{}",
6385 serde_json::to_string(&json!({
6386 "handle": handle,
6387 "removed_from_trust": removed_from_trust,
6388 "removed_from_relay_state": removed_from_relay,
6389 "purged_files": purged,
6390 }))?
6391 );
6392 } else {
6393 println!("forgot peer {handle:?}");
6394 if removed_from_trust {
6395 println!(" - removed from trust.json");
6396 }
6397 if removed_from_relay {
6398 println!(" - removed from relay.json");
6399 }
6400 if !purged.is_empty() {
6401 for p in &purged {
6402 println!(" - deleted {p}");
6403 }
6404 } else if !purge {
6405 println!(" (inbox/outbox files preserved; pass --purge to delete them)");
6406 }
6407 }
6408 Ok(())
6409}
6410
6411fn cmd_daemon(
6414 interval_secs: u64,
6415 once: bool,
6416 all_sessions: bool,
6417 session: Option<String>,
6418 as_json: bool,
6419) -> Result<()> {
6420 if all_sessions {
6425 if once {
6426 bail!("--all-sessions and --once are mutually exclusive (supervisor runs forever)");
6427 }
6428 if session.is_some() {
6429 bail!(
6430 "--all-sessions and --session are mutually exclusive (supervisor manages every session, not a single named one)"
6431 );
6432 }
6433 return crate::daemon_supervisor::run_supervisor(interval_secs, as_json);
6434 }
6435 if let Some(ref name) = session {
6440 let home = crate::session::find_session_home_by_name(name)
6449 .with_context(|| format!("resolving session home for --session {name}"))?
6450 .ok_or_else(|| {
6451 anyhow!(
6452 "session '{name}' not found — run `wire session list` to see initialized sessions"
6453 )
6454 })?;
6455 unsafe {
6458 std::env::set_var("WIRE_HOME", &home);
6459 }
6460 if !as_json {
6461 eprintln!(
6462 "wire daemon: pinned to session '{name}' (WIRE_HOME={})",
6463 home.display()
6464 );
6465 }
6466 }
6467 if !config::is_initialized()? {
6468 bail!("not initialized — run `wire init <handle>` first");
6469 }
6470 let _pid_guard = if !once && std::env::var("WIRE_DAEMON_NO_SINGLETON").is_err() {
6480 if let Some(holder_pid) = crate::ensure_up::daemon_singleton_holder() {
6481 if as_json {
6482 println!(
6483 "{}",
6484 serde_json::to_string(&json!({
6485 "status": "skipped",
6486 "reason": "daemon already running",
6487 "holder_pid": holder_pid,
6488 }))?
6489 );
6490 } else {
6491 eprintln!(
6492 "wire daemon: another daemon is already running (pid {holder_pid}); not starting a second polling loop. Set WIRE_DAEMON_NO_SINGLETON=1 to override."
6493 );
6494 }
6495 return Ok(());
6496 }
6497 Some(crate::ensure_up::claim_daemon_singleton()?)
6498 } else {
6499 None
6500 };
6501 if !once {
6506 crate::session::warn_on_identity_collision(std::process::id(), "daemon");
6507 }
6508 let interval = std::time::Duration::from_secs(interval_secs.max(1));
6509
6510 if !as_json {
6511 if once {
6512 eprintln!("wire daemon: single sync cycle, then exit");
6513 } else {
6514 eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
6515 }
6516 }
6517
6518 if let Err(e) = crate::pending_pair::cleanup_on_startup() {
6522 eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
6523 }
6524
6525 let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
6531 if !once {
6532 crate::daemon_stream::spawn_stream_subscriber(wake_tx);
6533 }
6534
6535 loop {
6536 let pushed = run_sync_push().unwrap_or_else(|e| {
6537 eprintln!("daemon: push error: {e:#}");
6538 json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
6539 });
6540 let pulled = run_sync_pull().unwrap_or_else(|e| {
6541 eprintln!("daemon: pull error: {e:#}");
6542 json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
6543 });
6544 let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
6545 eprintln!("daemon: pending-pair tick error: {e:#}");
6546 json!({"transitions": []})
6547 });
6548
6549 let cycle_push_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
6556 let cycle_pull_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
6557 let cycle_rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
6558 crate::ensure_up::write_last_sync_record(cycle_push_n, cycle_pull_n, cycle_rejected_n);
6559
6560 if as_json {
6561 println!(
6562 "{}",
6563 serde_json::to_string(&json!({
6564 "ts": time::OffsetDateTime::now_utc()
6565 .format(&time::format_description::well_known::Rfc3339)
6566 .unwrap_or_default(),
6567 "push": pushed,
6568 "pull": pulled,
6569 "pairs": pairs,
6570 }))?
6571 );
6572 } else {
6573 let pair_transitions = pairs["transitions"]
6574 .as_array()
6575 .map(|a| a.len())
6576 .unwrap_or(0);
6577 if cycle_push_n > 0 || cycle_pull_n > 0 || cycle_rejected_n > 0 || pair_transitions > 0
6578 {
6579 eprintln!(
6580 "daemon: pushed={cycle_push_n} pulled={cycle_pull_n} rejected={cycle_rejected_n} pair-transitions={pair_transitions}"
6581 );
6582 }
6583 if let Some(arr) = pairs["transitions"].as_array() {
6585 for t in arr {
6586 eprintln!(
6587 " pair {} : {} → {}",
6588 t.get("code").and_then(Value::as_str).unwrap_or("?"),
6589 t.get("from").and_then(Value::as_str).unwrap_or("?"),
6590 t.get("to").and_then(Value::as_str).unwrap_or("?")
6591 );
6592 if let Some(sas) = t.get("sas").and_then(Value::as_str)
6593 && t.get("to").and_then(Value::as_str) == Some("sas_ready")
6594 {
6595 eprintln!(" SAS digits: {}-{}", &sas[..3], &sas[3..]);
6596 eprintln!(
6597 " Run: wire pair-confirm {} {}",
6598 t.get("code").and_then(Value::as_str).unwrap_or("?"),
6599 sas
6600 );
6601 }
6602 }
6603 }
6604 }
6605
6606 if once {
6607 return Ok(());
6608 }
6609 match wake_rx.recv_timeout(interval) {
6622 Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
6623 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
6624 std::thread::sleep(interval);
6625 }
6626 }
6627 while wake_rx.try_recv().is_ok() {}
6628 }
6629}
6630
6631fn run_sync_push() -> Result<Value> {
6634 let state = config::read_relay_state()?;
6635 let peers = state["peers"].as_object().cloned().unwrap_or_default();
6636 if peers.is_empty() {
6637 return Ok(json!({"pushed": [], "skipped": []}));
6638 }
6639 let outbox_dir = config::outbox_dir()?;
6640 if !outbox_dir.exists() {
6641 return Ok(json!({"pushed": [], "skipped": []}));
6642 }
6643 let mut pushed = Vec::new();
6644 let mut skipped = Vec::new();
6645 for (peer_handle, slot_info) in peers.iter() {
6646 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
6647 if !outbox.exists() {
6648 continue;
6649 }
6650 let url = slot_info["relay_url"].as_str().unwrap_or("");
6651 let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
6652 let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
6653 if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
6654 continue;
6655 }
6656 let client = crate::relay_client::RelayClient::new(url);
6657 let body = std::fs::read_to_string(&outbox)?;
6658 for line in body.lines() {
6659 let event: Value = match serde_json::from_str(line) {
6660 Ok(v) => v,
6661 Err(_) => continue,
6662 };
6663 let event_id = event
6664 .get("event_id")
6665 .and_then(Value::as_str)
6666 .unwrap_or("")
6667 .to_string();
6668 match client.post_event(slot_id, slot_token, &event) {
6669 Ok(resp) => {
6670 let now = time::OffsetDateTime::now_utc()
6679 .format(&time::format_description::well_known::Rfc3339)
6680 .unwrap_or_default();
6681 if let Err(e) = config::append_pushed_log(peer_handle, &event_id, &now) {
6682 eprintln!(
6683 "daemon: pushed-log append for {peer_handle}/{event_id} failed (non-fatal): {e:#}"
6684 );
6685 }
6686 if resp.status == "duplicate" {
6687 skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
6688 } else {
6689 pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
6690 }
6691 }
6692 Err(e) => {
6693 let reason = crate::relay_client::format_transport_error(&e);
6697 skipped
6698 .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
6699 }
6700 }
6701 }
6702 }
6703 Ok(json!({"pushed": pushed, "skipped": skipped}))
6704}
6705
6706pub fn run_sync_pull() -> Result<Value> {
6714 let state = config::read_relay_state()?;
6715 if state.get("self").map(Value::is_null).unwrap_or(true) {
6716 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
6717 }
6718 let endpoints = crate::endpoints::self_endpoints(&state);
6725 if endpoints.is_empty() {
6726 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
6727 }
6728 let inbox_dir = config::inbox_dir()?;
6729 config::ensure_dirs()?;
6730
6731 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
6736 let legacy_cursor = self_obj
6737 .get("last_pulled_event_id")
6738 .and_then(Value::as_str)
6739 .map(str::to_string);
6740 let primary_slot = crate::endpoints::self_primary_endpoint(&state).map(|e| e.slot_id);
6741 let mut cursors: serde_json::Map<String, Value> = self_obj
6742 .get("cursors")
6743 .and_then(Value::as_object)
6744 .cloned()
6745 .unwrap_or_default();
6746
6747 let mut all_written: Vec<Value> = Vec::new();
6748 let mut all_rejected: Vec<Value> = Vec::new();
6749 let mut total_seen = 0usize;
6750 let mut blocked_any = false;
6751
6752 for ep in &endpoints {
6753 if ep.relay_url.is_empty() {
6754 continue;
6755 }
6756 let cursor = cursors
6757 .get(&ep.slot_id)
6758 .and_then(Value::as_str)
6759 .map(str::to_string)
6760 .or_else(|| {
6761 if Some(&ep.slot_id) == primary_slot.as_ref() {
6762 legacy_cursor.clone()
6763 } else {
6764 None
6765 }
6766 });
6767 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
6768 let events =
6771 match client.list_events(&ep.slot_id, &ep.slot_token, cursor.as_deref(), Some(1000)) {
6772 Ok(e) => e,
6773 Err(e) => {
6774 eprintln!(
6775 "daemon: pull error on {} slot {} (continuing): {e:#}",
6776 ep.relay_url, ep.slot_id
6777 );
6778 continue;
6779 }
6780 };
6781 total_seen += events.len();
6782 let result = crate::pull::process_events(&events, cursor, &inbox_dir)?;
6785 if let Some(eid) = &result.advance_cursor_to {
6786 cursors.insert(ep.slot_id.clone(), Value::String(eid.clone()));
6787 }
6788 blocked_any |= result.blocked;
6789 all_written.extend(result.written);
6790 all_rejected.extend(result.rejected);
6791 }
6792
6793 let primary_cursor = primary_slot
6797 .as_ref()
6798 .and_then(|s| cursors.get(s))
6799 .and_then(Value::as_str)
6800 .map(str::to_string);
6801 let mut latest_inbound: std::collections::HashMap<String, String> =
6809 std::collections::HashMap::new();
6810 for w in &all_written {
6811 let from = match w.get("from").and_then(Value::as_str) {
6812 Some(s) => s.to_string(),
6813 None => continue,
6814 };
6815 let ts = match w.get("timestamp").and_then(Value::as_str) {
6816 Some(s) if !s.is_empty() => s.to_string(),
6817 _ => continue,
6818 };
6819 latest_inbound
6820 .entry(from)
6821 .and_modify(|existing| {
6822 if ts > *existing {
6823 *existing = ts.clone();
6824 }
6825 })
6826 .or_insert(ts);
6827 }
6828 config::update_relay_state(|state| {
6829 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
6830 self_obj.insert("cursors".into(), Value::Object(cursors.clone()));
6831 if let Some(pc) = &primary_cursor {
6832 self_obj.insert("last_pulled_event_id".into(), Value::String(pc.clone()));
6833 }
6834 }
6835 if !latest_inbound.is_empty()
6836 && let Some(peers_obj) = state.get_mut("peers").and_then(Value::as_object_mut)
6837 {
6838 for (handle, ts) in &latest_inbound {
6839 let entry = peers_obj.entry(handle.clone()).or_insert_with(|| json!({}));
6840 if let Some(obj) = entry.as_object_mut() {
6841 obj.insert("last_inbound_event_at".into(), Value::String(ts.clone()));
6842 }
6843 }
6844 }
6845 Ok(())
6846 })?;
6847
6848 Ok(json!({
6849 "written": all_written,
6850 "rejected": all_rejected,
6851 "total_seen": total_seen,
6852 "cursor_blocked": blocked_any,
6853 "endpoints_pulled": endpoints.len(),
6854 }))
6855}
6856
6857fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
6860 let body =
6861 std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
6862 let card: Value =
6863 serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
6864 crate::agent_card::verify_agent_card(&card)
6865 .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
6866
6867 let mut trust = config::read_trust()?;
6868 crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
6869
6870 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
6871 let handle = crate::agent_card::display_handle_from_did(did).to_string();
6872 config::write_trust(&trust)?;
6873
6874 if as_json {
6875 println!(
6876 "{}",
6877 serde_json::to_string(&json!({
6878 "handle": handle,
6879 "did": did,
6880 "tier": "VERIFIED",
6881 "pinned": true,
6882 }))?
6883 );
6884 } else {
6885 println!("pinned {handle} ({did}) at tier VERIFIED");
6886 }
6887 Ok(())
6888}
6889
6890fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
6893 pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
6894}
6895
6896fn cmd_pair_join(
6897 code_phrase: &str,
6898 relay_url: &str,
6899 auto_yes: bool,
6900 timeout_secs: u64,
6901) -> Result<()> {
6902 pair_orchestrate(
6903 relay_url,
6904 Some(code_phrase),
6905 "guest",
6906 auto_yes,
6907 timeout_secs,
6908 )
6909}
6910
6911fn pair_orchestrate(
6917 relay_url: &str,
6918 code_in: Option<&str>,
6919 role: &str,
6920 auto_yes: bool,
6921 timeout_secs: u64,
6922) -> Result<()> {
6923 use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
6924
6925 let mut s = pair_session_open(role, relay_url, code_in)?;
6926
6927 if role == "host" {
6928 eprintln!();
6929 eprintln!("share this code phrase with your peer:");
6930 eprintln!();
6931 eprintln!(" {}", s.code);
6932 eprintln!();
6933 eprintln!(
6934 "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
6935 s.code
6936 );
6937 } else {
6938 eprintln!();
6939 eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
6940 }
6941
6942 const HEARTBEAT_SECS: u64 = 10;
6947 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
6948 let started = std::time::Instant::now();
6949 let mut last_heartbeat = started;
6950 let formatted = loop {
6951 if let Some(sas) = pair_session_try_sas(&mut s)? {
6952 break sas;
6953 }
6954 let now = std::time::Instant::now();
6955 if now >= deadline {
6956 return Err(anyhow!(
6957 "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
6958 ));
6959 }
6960 if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
6961 let elapsed = now.duration_since(started).as_secs();
6962 eprintln!(" ... still waiting ({elapsed}s / {timeout_secs}s)");
6963 last_heartbeat = now;
6964 }
6965 std::thread::sleep(std::time::Duration::from_millis(250));
6966 };
6967
6968 eprintln!();
6969 eprintln!("SAS digits (must match peer's terminal):");
6970 eprintln!();
6971 eprintln!(" {formatted}");
6972 eprintln!();
6973
6974 if !auto_yes {
6977 eprint!("does this match your peer's terminal? [y/N]: ");
6978 use std::io::Write;
6979 std::io::stderr().flush().ok();
6980 let mut input = String::new();
6981 std::io::stdin().read_line(&mut input)?;
6982 let trimmed = input.trim().to_lowercase();
6983 if trimmed != "y" && trimmed != "yes" {
6984 bail!("SAS confirmation declined — aborting pairing");
6985 }
6986 }
6987 s.sas_confirmed = true;
6988
6989 let result = pair_session_finalize(&mut s, timeout_secs)?;
6991
6992 let peer_did = result["paired_with"].as_str().unwrap_or("");
6993 let peer_role = if role == "host" { "guest" } else { "host" };
6994 eprintln!("paired with {peer_did} (peer role: {peer_role})");
6995 eprintln!("peer card pinned at tier VERIFIED");
6996 eprintln!(
6997 "peer relay slot saved to {}",
6998 config::relay_state_path()?.display()
6999 );
7000
7001 println!("{}", serde_json::to_string(&result)?);
7002 Ok(())
7003}
7004
7005fn cmd_pair(
7011 handle: &str,
7012 code: Option<&str>,
7013 relay: &str,
7014 auto_yes: bool,
7015 timeout_secs: u64,
7016 no_setup: bool,
7017) -> Result<()> {
7018 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
7021 let did = init_result
7022 .get("did")
7023 .and_then(|v| v.as_str())
7024 .unwrap_or("(unknown)")
7025 .to_string();
7026 let already = init_result
7027 .get("already_initialized")
7028 .and_then(|v| v.as_bool())
7029 .unwrap_or(false);
7030 if already {
7031 println!("(identity {did} already initialized — reusing)");
7032 } else {
7033 println!("initialized {did}");
7034 }
7035 println!();
7036
7037 match code {
7039 None => {
7040 println!("hosting pair on {relay} (no code = host) ...");
7041 cmd_pair_host(relay, auto_yes, timeout_secs)?;
7042 }
7043 Some(c) => {
7044 println!("joining pair with code {c} on {relay} ...");
7045 cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
7046 }
7047 }
7048
7049 if !no_setup {
7051 println!();
7052 println!("registering wire as MCP server in detected client configs ...");
7053 if let Err(e) = cmd_setup(true) {
7054 eprintln!("warn: setup --apply failed: {e}");
7056 eprintln!(" pair succeeded; you can re-run `wire setup --apply` manually.");
7057 }
7058 }
7059
7060 println!();
7061 println!("pair complete. Next steps:");
7062 println!(" wire daemon start # background sync of inbox/outbox vs relay");
7063 println!(" wire send <peer> claim <msg> # send your peer something");
7064 println!(" wire tail # watch incoming events");
7065 Ok(())
7066}
7067
7068fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
7074 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
7075 let did = init_result
7076 .get("did")
7077 .and_then(|v| v.as_str())
7078 .unwrap_or("(unknown)")
7079 .to_string();
7080 let already = init_result
7081 .get("already_initialized")
7082 .and_then(|v| v.as_bool())
7083 .unwrap_or(false);
7084 if already {
7085 println!("(identity {did} already initialized — reusing)");
7086 } else {
7087 println!("initialized {did}");
7088 }
7089 println!();
7090 match code {
7091 None => cmd_pair_host_detach(relay, false),
7092 Some(c) => cmd_pair_join_detach(c, relay, false),
7093 }
7094}
7095
7096fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
7097 if !config::is_initialized()? {
7098 bail!("not initialized — run `wire init <handle>` first");
7099 }
7100 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
7101 Ok(b) => b,
7102 Err(e) => {
7103 if !as_json {
7104 eprintln!(
7105 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
7106 );
7107 }
7108 false
7109 }
7110 };
7111 let code = crate::sas::generate_code_phrase();
7112 let code_hash = crate::pair_session::derive_code_hash(&code);
7113 let now = time::OffsetDateTime::now_utc()
7114 .format(&time::format_description::well_known::Rfc3339)
7115 .unwrap_or_default();
7116 let p = crate::pending_pair::PendingPair {
7117 code: code.clone(),
7118 code_hash,
7119 role: "host".to_string(),
7120 relay_url: relay_url.to_string(),
7121 status: "request_host".to_string(),
7122 sas: None,
7123 peer_did: None,
7124 created_at: now,
7125 last_error: None,
7126 pair_id: None,
7127 our_slot_id: None,
7128 our_slot_token: None,
7129 spake2_seed_b64: None,
7130 };
7131 crate::pending_pair::write_pending(&p)?;
7132 if as_json {
7133 println!(
7134 "{}",
7135 serde_json::to_string(&json!({
7136 "state": "queued",
7137 "code_phrase": code,
7138 "relay_url": relay_url,
7139 "role": "host",
7140 "daemon_spawned": daemon_spawned,
7141 }))?
7142 );
7143 } else {
7144 if daemon_spawned {
7145 println!("(started wire daemon in background)");
7146 }
7147 println!("detached pair-host queued. Share this code with your peer:\n");
7148 println!(" {code}\n");
7149 println!("Next steps:");
7150 println!(" wire pair-list # check status");
7151 println!(" wire pair-confirm {code} <digits> # when SAS shows up");
7152 println!(" wire pair-cancel {code} # to abort");
7153 }
7154 Ok(())
7155}
7156
7157fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
7158 if !config::is_initialized()? {
7159 bail!("not initialized — run `wire init <handle>` first");
7160 }
7161 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
7162 Ok(b) => b,
7163 Err(e) => {
7164 if !as_json {
7165 eprintln!(
7166 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
7167 );
7168 }
7169 false
7170 }
7171 };
7172 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
7173 let code_hash = crate::pair_session::derive_code_hash(&code);
7174 let now = time::OffsetDateTime::now_utc()
7175 .format(&time::format_description::well_known::Rfc3339)
7176 .unwrap_or_default();
7177 let p = crate::pending_pair::PendingPair {
7178 code: code.clone(),
7179 code_hash,
7180 role: "guest".to_string(),
7181 relay_url: relay_url.to_string(),
7182 status: "request_guest".to_string(),
7183 sas: None,
7184 peer_did: None,
7185 created_at: now,
7186 last_error: None,
7187 pair_id: None,
7188 our_slot_id: None,
7189 our_slot_token: None,
7190 spake2_seed_b64: None,
7191 };
7192 crate::pending_pair::write_pending(&p)?;
7193 if as_json {
7194 println!(
7195 "{}",
7196 serde_json::to_string(&json!({
7197 "state": "queued",
7198 "code_phrase": code,
7199 "relay_url": relay_url,
7200 "role": "guest",
7201 "daemon_spawned": daemon_spawned,
7202 }))?
7203 );
7204 } else {
7205 if daemon_spawned {
7206 println!("(started wire daemon in background)");
7207 }
7208 println!("detached pair-join queued for code {code}.");
7209 println!(
7210 "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
7211 );
7212 }
7213 Ok(())
7214}
7215
7216fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
7217 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
7218 let typed: String = typed_digits
7219 .chars()
7220 .filter(|c| c.is_ascii_digit())
7221 .collect();
7222 if typed.len() != 6 {
7223 bail!(
7224 "expected 6 digits (got {} after stripping non-digits)",
7225 typed.len()
7226 );
7227 }
7228 let mut p = crate::pending_pair::read_pending(&code)?
7229 .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
7230 if p.status != "sas_ready" {
7231 bail!(
7232 "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
7233 p.status
7234 );
7235 }
7236 let stored = p
7237 .sas
7238 .as_ref()
7239 .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
7240 .clone();
7241 if stored == typed {
7242 p.status = "confirmed".to_string();
7243 crate::pending_pair::write_pending(&p)?;
7244 if as_json {
7245 println!(
7246 "{}",
7247 serde_json::to_string(&json!({
7248 "state": "confirmed",
7249 "code_phrase": code,
7250 }))?
7251 );
7252 } else {
7253 println!("digits match. Daemon will finalize the handshake on its next tick.");
7254 println!("Run `wire peers` after a few seconds to confirm.");
7255 }
7256 } else {
7257 p.status = "aborted".to_string();
7258 p.last_error = Some(format!(
7259 "SAS digit mismatch (typed {typed}, expected {stored})"
7260 ));
7261 let client = crate::relay_client::RelayClient::new(&p.relay_url);
7262 let _ = client.pair_abandon(&p.code_hash);
7263 crate::pending_pair::write_pending(&p)?;
7264 crate::os_notify::toast(
7265 &format!("wire — pair aborted ({})", p.code),
7266 p.last_error.as_deref().unwrap_or("digits mismatch"),
7267 );
7268 if as_json {
7269 println!(
7270 "{}",
7271 serde_json::to_string(&json!({
7272 "state": "aborted",
7273 "code_phrase": code,
7274 "error": "digits mismatch",
7275 }))?
7276 );
7277 }
7278 bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
7279 }
7280 Ok(())
7281}
7282
7283fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
7284 if watch {
7285 return cmd_pair_list_watch(watch_interval_secs);
7286 }
7287 let spake2_items = crate::pending_pair::list_pending()?;
7288 let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
7289 if as_json {
7290 println!("{}", serde_json::to_string(&spake2_items)?);
7295 return Ok(());
7296 }
7297 if spake2_items.is_empty() && inbound_items.is_empty() {
7298 println!("no pending pair sessions.");
7299 return Ok(());
7300 }
7301 if !inbound_items.is_empty() {
7304 println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
7305 println!(
7306 "{:<20} {:<35} {:<25} NEXT STEP",
7307 "PEER", "RELAY", "RECEIVED"
7308 );
7309 for p in &inbound_items {
7310 println!(
7311 "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
7312 p.peer_handle,
7313 p.peer_relay_url,
7314 p.received_at,
7315 peer = p.peer_handle,
7316 );
7317 }
7318 println!();
7319 }
7320 if !spake2_items.is_empty() {
7321 println!("SPAKE2 SESSIONS");
7322 println!(
7323 "{:<15} {:<8} {:<18} {:<10} NOTE",
7324 "CODE", "ROLE", "STATUS", "SAS"
7325 );
7326 for p in spake2_items {
7327 let sas = p
7328 .sas
7329 .as_ref()
7330 .map(|d| format!("{}-{}", &d[..3], &d[3..]))
7331 .unwrap_or_else(|| "—".to_string());
7332 let note = p
7333 .last_error
7334 .as_deref()
7335 .or(p.peer_did.as_deref())
7336 .unwrap_or("");
7337 println!(
7338 "{:<15} {:<8} {:<18} {:<10} {}",
7339 p.code, p.role, p.status, sas, note
7340 );
7341 }
7342 }
7343 Ok(())
7344}
7345
7346fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
7358 use std::collections::HashMap;
7359 use std::io::Write;
7360 let interval = std::time::Duration::from_secs(interval_secs.max(1));
7361 let mut prev: HashMap<String, String> = HashMap::new();
7364 {
7365 let items = crate::pending_pair::list_pending()?;
7366 for p in &items {
7367 println!("{}", serde_json::to_string(&p)?);
7368 prev.insert(p.code.clone(), p.status.clone());
7369 }
7370 let _ = std::io::stdout().flush();
7372 }
7373 loop {
7374 std::thread::sleep(interval);
7375 let items = match crate::pending_pair::list_pending() {
7376 Ok(v) => v,
7377 Err(_) => continue,
7378 };
7379 let mut cur: HashMap<String, String> = HashMap::new();
7380 for p in &items {
7381 cur.insert(p.code.clone(), p.status.clone());
7382 match prev.get(&p.code) {
7383 None => {
7384 println!("{}", serde_json::to_string(&p)?);
7386 }
7387 Some(prev_status) if prev_status != &p.status => {
7388 println!("{}", serde_json::to_string(&p)?);
7390 }
7391 _ => {}
7392 }
7393 }
7394 for code in prev.keys() {
7395 if !cur.contains_key(code) {
7396 println!(
7399 "{}",
7400 serde_json::to_string(&json!({
7401 "code": code,
7402 "status": "removed",
7403 "_synthetic": true,
7404 }))?
7405 );
7406 }
7407 }
7408 let _ = std::io::stdout().flush();
7409 prev = cur;
7410 }
7411}
7412
7413fn cmd_pair_watch(
7417 code_phrase: &str,
7418 target_status: &str,
7419 timeout_secs: u64,
7420 as_json: bool,
7421) -> Result<()> {
7422 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
7423 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
7424 let mut last_seen_status: Option<String> = None;
7425 loop {
7426 let p_opt = crate::pending_pair::read_pending(&code)?;
7427 let now = std::time::Instant::now();
7428 match p_opt {
7429 None => {
7430 if last_seen_status.is_some() {
7434 if as_json {
7435 println!(
7436 "{}",
7437 serde_json::to_string(&json!({"state": "finalized", "code": code}))?
7438 );
7439 } else {
7440 println!("pair {code} finalized (file removed)");
7441 }
7442 return Ok(());
7443 } else {
7444 if as_json {
7445 println!(
7446 "{}",
7447 serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
7448 );
7449 }
7450 std::process::exit(1);
7451 }
7452 }
7453 Some(p) => {
7454 let cur = p.status.clone();
7455 if Some(cur.clone()) != last_seen_status {
7456 if as_json {
7457 println!("{}", serde_json::to_string(&p)?);
7459 }
7460 last_seen_status = Some(cur.clone());
7461 }
7462 if cur == target_status {
7463 if !as_json {
7464 let sas_str = p
7465 .sas
7466 .as_ref()
7467 .map(|s| format!("{}-{}", &s[..3], &s[3..]))
7468 .unwrap_or_else(|| "—".to_string());
7469 println!("pair {code} reached {target_status} (SAS: {sas_str})");
7470 }
7471 return Ok(());
7472 }
7473 if cur == "aborted" || cur == "aborted_restart" {
7474 if !as_json {
7475 let err = p.last_error.as_deref().unwrap_or("(no detail)");
7476 eprintln!("pair {code} {cur}: {err}");
7477 }
7478 std::process::exit(1);
7479 }
7480 }
7481 }
7482 if now >= deadline {
7483 if !as_json {
7484 eprintln!(
7485 "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
7486 );
7487 }
7488 std::process::exit(2);
7489 }
7490 std::thread::sleep(std::time::Duration::from_millis(250));
7491 }
7492}
7493
7494fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
7495 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
7496 let p = crate::pending_pair::read_pending(&code)?
7497 .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
7498 let client = crate::relay_client::RelayClient::new(&p.relay_url);
7499 let _ = client.pair_abandon(&p.code_hash);
7500 crate::pending_pair::delete_pending(&code)?;
7501 if as_json {
7502 println!(
7503 "{}",
7504 serde_json::to_string(&json!({
7505 "state": "cancelled",
7506 "code_phrase": code,
7507 }))?
7508 );
7509 } else {
7510 println!("cancelled pending pair {code} (relay slot released, file removed).");
7511 }
7512 Ok(())
7513}
7514
7515fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
7518 let code = crate::sas::parse_code_phrase(code_phrase)?;
7521 let code_hash = crate::pair_session::derive_code_hash(code);
7522 let client = crate::relay_client::RelayClient::new(relay_url);
7523 client.pair_abandon(&code_hash)?;
7524 println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
7525 println!("host can now issue a fresh code; guest can re-join.");
7526 Ok(())
7527}
7528
7529fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
7532 let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
7533
7534 let share_payload: Option<Value> = if share {
7537 let client = reqwest::blocking::Client::new();
7538 let single_use = if uses == 1 { Some(1u32) } else { None };
7539 let body = json!({
7540 "invite_url": url,
7541 "ttl_seconds": ttl,
7542 "uses": single_use,
7543 });
7544 let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
7545 let resp = client.post(&endpoint).json(&body).send()?;
7546 if !resp.status().is_success() {
7547 let code = resp.status();
7548 let txt = resp.text().unwrap_or_default();
7549 bail!("relay {code} on /v1/invite/register: {txt}");
7550 }
7551 let parsed: Value = resp.json()?;
7552 let token = parsed
7553 .get("token")
7554 .and_then(Value::as_str)
7555 .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
7556 .to_string();
7557 let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
7558 let curl_line = format!("curl -fsSL {share_url} | sh");
7559 Some(json!({
7560 "token": token,
7561 "share_url": share_url,
7562 "curl": curl_line,
7563 "expires_unix": parsed.get("expires_unix"),
7564 }))
7565 } else {
7566 None
7567 };
7568
7569 if as_json {
7570 let mut out = json!({
7571 "invite_url": url,
7572 "ttl_secs": ttl,
7573 "uses": uses,
7574 "relay": relay,
7575 });
7576 if let Some(s) = &share_payload {
7577 out["share"] = s.clone();
7578 }
7579 println!("{}", serde_json::to_string(&out)?);
7580 } else if let Some(s) = share_payload {
7581 let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
7582 eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
7583 eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
7584 println!("{curl}");
7585 } else {
7586 eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
7587 eprintln!("# TTL: {ttl}s. Uses: {uses}.");
7588 println!("{url}");
7589 }
7590 Ok(())
7591}
7592
7593fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
7594 let resolved = if url.starts_with("http://") || url.starts_with("https://") {
7598 let sep = if url.contains('?') { '&' } else { '?' };
7599 let resolve_url = format!("{url}{sep}format=url");
7600 let client = reqwest::blocking::Client::new();
7601 let resp = client
7602 .get(&resolve_url)
7603 .send()
7604 .with_context(|| format!("GET {resolve_url}"))?;
7605 if !resp.status().is_success() {
7606 bail!("could not resolve short URL {url} (HTTP {})", resp.status());
7607 }
7608 let body = resp.text().unwrap_or_default().trim().to_string();
7609 if !body.starts_with("wire://pair?") {
7610 bail!(
7611 "short URL {url} did not resolve to a wire:// invite. \
7612 (got: {}{})",
7613 body.chars().take(80).collect::<String>(),
7614 if body.chars().count() > 80 { "…" } else { "" }
7615 );
7616 }
7617 body
7618 } else {
7619 url.to_string()
7620 };
7621
7622 let result = crate::pair_invite::accept_invite(&resolved)?;
7623 if as_json {
7624 println!("{}", serde_json::to_string(&result)?);
7625 } else {
7626 let did = result
7627 .get("paired_with")
7628 .and_then(Value::as_str)
7629 .unwrap_or("?");
7630 println!("paired with {did}");
7631 println!(
7632 "you can now: wire send {} <kind> <body>",
7633 crate::agent_card::display_handle_from_did(did)
7634 );
7635 }
7636 Ok(())
7637}
7638
7639fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
7642 if let Some(h) = handle {
7643 let parsed = crate::pair_profile::parse_handle(h)?;
7644 if config::is_initialized()? {
7647 let card = config::read_agent_card()?;
7648 let local_handle = card
7649 .get("profile")
7650 .and_then(|p| p.get("handle"))
7651 .and_then(Value::as_str)
7652 .map(str::to_string);
7653 if local_handle.as_deref() == Some(h) {
7654 return cmd_whois(None, as_json, None);
7655 }
7656 }
7657 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
7659 if as_json {
7660 println!("{}", serde_json::to_string(&resolved)?);
7661 } else {
7662 print_resolved_profile(&resolved);
7663 }
7664 return Ok(());
7665 }
7666 let card = config::read_agent_card()?;
7667 if as_json {
7668 let profile = card.get("profile").cloned().unwrap_or(Value::Null);
7669 let mut payload = serde_json::Map::new();
7670 payload.insert(
7671 "did".into(),
7672 card.get("did").cloned().unwrap_or(Value::Null),
7673 );
7674 payload.insert("profile".into(), profile);
7675 for (k, v) in op_claims_from_card(&card) {
7679 payload.insert(k, v);
7680 }
7681 println!("{}", serde_json::to_string(&payload)?);
7682 } else {
7683 print!("{}", crate::pair_profile::render_self_summary()?);
7684 }
7685 Ok(())
7686}
7687
7688fn print_resolved_profile(resolved: &Value) {
7689 let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
7690 let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
7691 let relay = resolved
7692 .get("relay_url")
7693 .and_then(Value::as_str)
7694 .unwrap_or("");
7695 let slot = resolved
7696 .get("slot_id")
7697 .and_then(Value::as_str)
7698 .unwrap_or("");
7699 let profile = resolved
7700 .get("card")
7701 .and_then(|c| c.get("profile"))
7702 .cloned()
7703 .unwrap_or(Value::Null);
7704 println!("{did}");
7705 println!(" nick: {nick}");
7706 if !relay.is_empty() {
7707 println!(" relay_url: {relay}");
7708 }
7709 if !slot.is_empty() {
7710 println!(" slot_id: {slot}");
7711 }
7712 let pick =
7713 |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
7714 if let Some(s) = pick("display_name") {
7715 println!(" display_name: {s}");
7716 }
7717 if let Some(s) = pick("emoji") {
7718 println!(" emoji: {s}");
7719 }
7720 if let Some(s) = pick("motto") {
7721 println!(" motto: {s}");
7722 }
7723 if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
7724 let joined: Vec<String> = arr
7725 .iter()
7726 .filter_map(|v| v.as_str().map(str::to_string))
7727 .collect();
7728 println!(" vibe: {}", joined.join(", "));
7729 }
7730 if let Some(s) = pick("pronouns") {
7731 println!(" pronouns: {s}");
7732 }
7733}
7734
7735fn host_of_url(url: &str) -> String {
7743 let no_scheme = url
7744 .trim_start_matches("https://")
7745 .trim_start_matches("http://");
7746 no_scheme
7747 .split('/')
7748 .next()
7749 .unwrap_or("")
7750 .split(':')
7751 .next()
7752 .unwrap_or("")
7753 .to_string()
7754}
7755
7756fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
7760 const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
7762 let peer_domain = peer_domain.trim().to_ascii_lowercase();
7763 if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
7764 return true;
7765 }
7766 let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
7769 if !our_host.is_empty() && our_host == peer_domain {
7770 return true;
7771 }
7772 false
7773}
7774
7775fn resolve_local_session<'a>(
7793 sessions: &'a [crate::session::SessionInfo],
7794 input: &str,
7795) -> Result<&'a crate::session::SessionInfo, ResolveError> {
7796 if let Some(s) = sessions.iter().find(|s| s.name == input) {
7799 return Ok(s);
7800 }
7801 let nick_matches: Vec<&crate::session::SessionInfo> = sessions
7802 .iter()
7803 .filter(|s| {
7804 s.character
7805 .as_ref()
7806 .map(|c| c.nickname == input)
7807 .unwrap_or(false)
7808 })
7809 .collect();
7810 match nick_matches.len() {
7811 0 => Err(ResolveError::NotFound),
7812 1 => Ok(nick_matches[0]),
7813 _ => Err(ResolveError::Ambiguous(
7814 nick_matches.iter().map(|s| s.name.clone()).collect(),
7815 )),
7816 }
7817}
7818
7819#[derive(Debug)]
7820enum ResolveError {
7821 NotFound,
7822 Ambiguous(Vec<String>),
7823}
7824
7825fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
7841 let trust = match config::read_trust() {
7842 Ok(t) => t,
7843 Err(_) => return Ok(None),
7844 };
7845 let agents = match trust.get("agents").and_then(|a| a.as_object()) {
7846 Some(a) => a,
7847 None => return Ok(None),
7848 };
7849 if agents.contains_key(input) {
7850 return Ok(Some(input.to_string()));
7851 }
7852 let mut nick_matches: Vec<String> = Vec::new();
7853 for (handle, agent) in agents.iter() {
7854 let character = match agent.get("card") {
7858 Some(card) => crate::character::Character::from_card(card),
7859 None => match agent.get("did").and_then(Value::as_str) {
7860 Some(did) => crate::character::Character::from_did(did),
7861 None => continue,
7862 },
7863 };
7864 if character.nickname == input {
7865 nick_matches.push(handle.clone());
7866 }
7867 }
7868 match nick_matches.len() {
7869 0 => Ok(None),
7870 1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
7871 _ => Err(ResolveError::Ambiguous(nick_matches)),
7872 }
7873}
7874
7875fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
7876 let sessions = crate::session::list_sessions()?;
7878 let sister = match resolve_local_session(&sessions, sister_name) {
7879 Ok(s) => s,
7880 Err(ResolveError::NotFound) => bail!(
7881 "no sister session named `{sister_name}` (matched by session name or character nickname). \
7882 Run `wire session list` to see what's available."
7883 ),
7884 Err(ResolveError::Ambiguous(candidates)) => bail!(
7885 "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
7886 Disambiguate by passing the session name (one of those listed) instead of the nickname.",
7887 candidates.len(),
7888 candidates.join(", ")
7889 ),
7890 };
7891 if sister.name != sister_name {
7894 eprintln!(
7895 "wire add: resolved nickname `{sister_name}` → session `{}`",
7896 sister.name
7897 );
7898 }
7899
7900 let our_card = config::read_agent_card()
7903 .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
7904 let our_did = our_card
7905 .get("did")
7906 .and_then(Value::as_str)
7907 .ok_or_else(|| anyhow!("agent-card missing did"))?
7908 .to_string();
7909 if let Some(sister_did) = sister.did.as_deref()
7910 && sister_did == our_did
7911 {
7912 bail!("refusing to add self (`{sister_name}` is this very session)");
7913 }
7914
7915 let sister_card_path = sister
7917 .home_dir
7918 .join("config")
7919 .join("wire")
7920 .join("agent-card.json");
7921 let sister_card: Value = serde_json::from_slice(
7922 &std::fs::read(&sister_card_path)
7923 .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
7924 )
7925 .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
7926 let sister_relay_state: Value = std::fs::read(
7927 sister
7928 .home_dir
7929 .join("config")
7930 .join("wire")
7931 .join("relay.json"),
7932 )
7933 .ok()
7934 .and_then(|b| serde_json::from_slice(&b).ok())
7935 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
7936
7937 let sister_did = sister_card
7938 .get("did")
7939 .and_then(Value::as_str)
7940 .ok_or_else(|| anyhow!("sister card missing did"))?
7941 .to_string();
7942 let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
7943
7944 let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
7948 if sister_endpoints.is_empty() {
7949 bail!(
7950 "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
7951 );
7952 }
7953 let sister_local = sister_endpoints
7954 .iter()
7955 .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
7956 let delivery_endpoint = match sister_local {
7957 Some(e) => e.clone(),
7958 None => sister_endpoints[0].clone(),
7959 };
7960
7961 let our_relay_state = config::read_relay_state()?;
7967 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
7968 if our_endpoints.is_empty() {
7969 bail!(
7970 "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
7971 );
7972 }
7973 let our_advertised = our_endpoints
7974 .iter()
7975 .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
7976 .cloned()
7977 .unwrap_or_else(|| our_endpoints[0].clone());
7978
7979 let mut trust = config::read_trust()?;
7983 crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
7984 config::write_trust(&trust)?;
7985 let mut relay_state = config::read_relay_state()?;
7986 crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
7987 config::write_relay_state(&relay_state)?;
7988
7989 let sk_seed = config::read_private_key()?;
7992 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
7993 let pk_b64 = our_card
7994 .get("verify_keys")
7995 .and_then(Value::as_object)
7996 .and_then(|m| m.values().next())
7997 .and_then(|v| v.get("key"))
7998 .and_then(Value::as_str)
7999 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
8000 let pk_bytes = crate::signing::b64decode(pk_b64)?;
8001 let now = time::OffsetDateTime::now_utc()
8002 .format(&time::format_description::well_known::Rfc3339)
8003 .unwrap_or_default();
8004 let mut body = json!({
8005 "card": our_card,
8006 "relay_url": our_advertised.relay_url,
8007 "slot_id": our_advertised.slot_id,
8008 "slot_token": our_advertised.slot_token,
8009 });
8010 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
8011 let event = json!({
8012 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8013 "timestamp": now,
8014 "from": our_did,
8015 "to": sister_did,
8016 "type": "pair_drop",
8017 "kind": 1100u32,
8018 "body": body,
8019 });
8020 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
8021 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
8022
8023 let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
8027 client
8028 .post_event(
8029 &delivery_endpoint.slot_id,
8030 &delivery_endpoint.slot_token,
8031 &signed,
8032 )
8033 .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
8034
8035 if as_json {
8036 println!(
8037 "{}",
8038 serde_json::to_string(&json!({
8039 "handle": sister_name,
8040 "paired_with": sister_did,
8041 "peer_handle": sister_handle,
8042 "event_id": event_id,
8043 "delivered_via": match delivery_endpoint.scope {
8044 crate::endpoints::EndpointScope::Local => "local",
8045 crate::endpoints::EndpointScope::Lan => "lan",
8046 crate::endpoints::EndpointScope::Uds => "uds",
8047 crate::endpoints::EndpointScope::Federation => "federation",
8048 },
8049 "status": "drop_sent",
8050 }))?
8051 );
8052 } else {
8053 let scope = match delivery_endpoint.scope {
8054 crate::endpoints::EndpointScope::Local => "local",
8055 crate::endpoints::EndpointScope::Lan => "lan",
8056 crate::endpoints::EndpointScope::Uds => "uds",
8057 crate::endpoints::EndpointScope::Federation => "federation",
8058 };
8059 println!(
8060 "→ 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.",
8061 delivery_endpoint.relay_url
8062 );
8063 }
8064 Ok(())
8065}
8066
8067fn cmd_add(
8068 handle_arg: &str,
8069 relay_override: Option<&str>,
8070 local_sister: bool,
8071 as_json: bool,
8072) -> Result<()> {
8073 if local_sister {
8081 let resolved = crate::session::resolve_local_sister(handle_arg)
8082 .unwrap_or_else(|| handle_arg.to_string());
8083 return cmd_add_local_sister(&resolved, as_json);
8084 }
8085 if !handle_arg.contains('@')
8086 && let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
8087 {
8088 eprintln!(
8089 "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
8090 — routing via --local-sister (disk-read card, no relay lookup)."
8091 );
8092 return cmd_add_local_sister(&resolved, as_json);
8093 }
8094 if !handle_arg.contains('@') {
8095 bail!(
8096 "`{handle_arg}` doesn't match any local sister session and has no \
8097 @<relay> suffix for federation.\n\
8098 — Local sisters: `wire session list-local` (operator types name OR \
8099 character nickname)\n\
8100 — Federation: `wire add <handle>@<relay-domain>` (e.g. \
8101 `wire add alice@wireup.net`)"
8102 );
8103 }
8104 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
8105
8106 let (our_did, our_relay, our_slot_id, our_slot_token) =
8108 crate::pair_invite::ensure_self_with_relay(relay_override)?;
8109 if our_did == format!("did:wire:{}", parsed.nick) {
8110 bail!("refusing to add self (handle matches own DID)");
8112 }
8113
8114 if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
8124 return cmd_add_accept_pending(
8125 handle_arg,
8126 &parsed.nick,
8127 &pending,
8128 &our_relay,
8129 &our_slot_id,
8130 &our_slot_token,
8131 as_json,
8132 );
8133 }
8134
8135 if !is_known_relay_domain(&parsed.domain, &our_relay) {
8152 eprintln!(
8153 "wire add: WARN unfamiliar relay domain `{}`.",
8154 parsed.domain
8155 );
8156 eprintln!(
8157 " This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
8158 host_of_url(&our_relay)
8159 );
8160 eprintln!(
8161 " and not on the known-good list. If you meant `{}@wireup.net`, ",
8162 parsed.nick
8163 );
8164 eprintln!(
8165 " run `wire add {}@wireup.net` instead. Otherwise verify with your",
8166 parsed.nick
8167 );
8168 eprintln!(" peer out-of-band that they actually run a relay at this domain");
8169 eprintln!(" before relying on the pair. (See issue #9.4.)");
8170 }
8171
8172 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
8174 let peer_card = resolved
8175 .get("card")
8176 .cloned()
8177 .ok_or_else(|| anyhow!("resolved missing card"))?;
8178 let peer_did = resolved
8179 .get("did")
8180 .and_then(Value::as_str)
8181 .ok_or_else(|| anyhow!("resolved missing did"))?
8182 .to_string();
8183 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
8184
8185 reject_self_pair_after_resolution(&our_did, &peer_did)?;
8190
8191 let peer_slot_id = resolved
8192 .get("slot_id")
8193 .and_then(Value::as_str)
8194 .ok_or_else(|| anyhow!("resolved missing slot_id"))?
8195 .to_string();
8196 let peer_relay = resolved
8197 .get("relay_url")
8198 .and_then(Value::as_str)
8199 .map(str::to_string)
8200 .or_else(|| relay_override.map(str::to_string))
8201 .unwrap_or_else(|| format!("https://{}", parsed.domain));
8202
8203 let mut trust = config::read_trust()?;
8205 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
8206 config::write_trust(&trust)?;
8207 let mut relay_state = config::read_relay_state()?;
8208 let mut endpoints: Vec<crate::endpoints::Endpoint> = relay_state
8221 .get("peers")
8222 .and_then(|p| p.get(&peer_handle))
8223 .and_then(|e| e.get("endpoints"))
8224 .and_then(|a| serde_json::from_value::<Vec<crate::endpoints::Endpoint>>(a.clone()).ok())
8225 .unwrap_or_default();
8226 let fed_token = endpoints
8227 .iter()
8228 .find(|e| {
8229 e.relay_url == peer_relay && e.scope == crate::endpoints::EndpointScope::Federation
8230 })
8231 .map(|e| e.slot_token.clone())
8232 .unwrap_or_default();
8233 let fed_ep = crate::endpoints::Endpoint {
8234 relay_url: peer_relay.clone(),
8235 slot_id: peer_slot_id.clone(),
8236 slot_token: fed_token, scope: crate::endpoints::EndpointScope::Federation,
8238 };
8239 if let Some(existing) = endpoints
8240 .iter_mut()
8241 .find(|e| e.relay_url == fed_ep.relay_url)
8242 {
8243 *existing = fed_ep;
8244 } else {
8245 endpoints.push(fed_ep);
8246 }
8247 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints)?;
8248 config::write_relay_state(&relay_state)?;
8249
8250 let our_card = config::read_agent_card()?;
8253 let sk_seed = config::read_private_key()?;
8254 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
8255 let pk_b64 = our_card
8256 .get("verify_keys")
8257 .and_then(Value::as_object)
8258 .and_then(|m| m.values().next())
8259 .and_then(|v| v.get("key"))
8260 .and_then(Value::as_str)
8261 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
8262 let pk_bytes = crate::signing::b64decode(pk_b64)?;
8263 let now = time::OffsetDateTime::now_utc()
8264 .format(&time::format_description::well_known::Rfc3339)
8265 .unwrap_or_default();
8266 let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
8271 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
8272 let mut body = json!({
8273 "card": our_card,
8274 "relay_url": our_relay,
8275 "slot_id": our_slot_id,
8276 "slot_token": our_slot_token,
8277 });
8278 if !our_endpoints.is_empty() {
8279 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
8280 }
8281 let event = json!({
8282 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8283 "timestamp": now,
8284 "from": our_did,
8285 "to": peer_did,
8286 "type": "pair_drop",
8287 "kind": 1100u32,
8288 "body": body,
8289 });
8290 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
8291
8292 let client = crate::relay_client::RelayClient::new(&peer_relay);
8294 let resp = client.handle_intro(&parsed.nick, &signed)?;
8295 let event_id = signed
8296 .get("event_id")
8297 .and_then(Value::as_str)
8298 .unwrap_or("")
8299 .to_string();
8300
8301 if as_json {
8302 println!(
8303 "{}",
8304 serde_json::to_string(&json!({
8305 "handle": handle_arg,
8306 "paired_with": peer_did,
8307 "peer_handle": peer_handle,
8308 "event_id": event_id,
8309 "drop_response": resp,
8310 "status": "drop_sent",
8311 }))?
8312 );
8313 } else {
8314 println!(
8315 "→ 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."
8316 );
8317 }
8318 Ok(())
8319}
8320
8321fn cmd_add_accept_pending(
8328 handle_arg: &str,
8329 peer_nick: &str,
8330 pending: &crate::pending_inbound_pair::PendingInboundPair,
8331 _our_relay: &str,
8332 _our_slot_id: &str,
8333 _our_slot_token: &str,
8334 as_json: bool,
8335) -> Result<()> {
8336 let mut trust = config::read_trust()?;
8339 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
8340 config::write_trust(&trust)?;
8341
8342 let mut relay_state = config::read_relay_state()?;
8348 let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
8349 vec![crate::endpoints::Endpoint::federation(
8350 pending.peer_relay_url.clone(),
8351 pending.peer_slot_id.clone(),
8352 pending.peer_slot_token.clone(),
8353 )]
8354 } else {
8355 pending.peer_endpoints.clone()
8356 };
8357 crate::endpoints::pin_peer_endpoints(
8358 &mut relay_state,
8359 &pending.peer_handle,
8360 &endpoints_to_pin,
8361 )?;
8362 config::write_relay_state(&relay_state)?;
8363
8364 crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &endpoints_to_pin).with_context(
8369 || {
8370 format!(
8371 "pair_drop_ack send to {} (across {} endpoint(s)) failed",
8372 pending.peer_handle,
8373 endpoints_to_pin.len()
8374 )
8375 },
8376 )?;
8377
8378 crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
8380
8381 if as_json {
8382 println!(
8383 "{}",
8384 serde_json::to_string(&json!({
8385 "handle": handle_arg,
8386 "paired_with": pending.peer_did,
8387 "peer_handle": pending.peer_handle,
8388 "status": "bilateral_accepted",
8389 "via": "pending_inbound",
8390 }))?
8391 );
8392 } else {
8393 println!(
8394 "→ 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} \"...\"`.",
8395 peer = pending.peer_handle,
8396 );
8397 }
8398 Ok(())
8399}
8400
8401fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
8408 let nick = crate::agent_card::bare_handle(peer_nick);
8409 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
8410 anyhow!(
8411 "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
8412 or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
8413 )
8414 })?;
8415 let (_our_did, our_relay, our_slot_id, our_slot_token) =
8416 crate::pair_invite::ensure_self_with_relay(None)?;
8417 let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
8418 cmd_add_accept_pending(
8419 &handle_arg,
8420 nick,
8421 &pending,
8422 &our_relay,
8423 &our_slot_id,
8424 &our_slot_token,
8425 as_json,
8426 )
8427}
8428
8429fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
8432 let items = crate::pending_inbound_pair::list_pending_inbound()?;
8433 if as_json {
8434 println!("{}", serde_json::to_string(&items)?);
8435 return Ok(());
8436 }
8437 if items.is_empty() {
8438 println!("no pending pair requests — your inbox is clear.");
8439 return Ok(());
8440 }
8441 let plural = if items.len() == 1 { "" } else { "s" };
8448 println!("{} pending pair request{plural}:\n", items.len());
8449 for p in &items {
8450 let ch = crate::character::Character::from_did(&p.peer_did);
8451 let glyph = crate::character::emoji_with_fallback(&ch);
8452 println!(
8455 " {glyph} {nick} ({handle}) wants to pair with you",
8456 nick = ch.nickname,
8457 handle = p.peer_handle,
8458 );
8459 }
8460 println!();
8461 println!(
8462 "→ to accept any: `wire accept <name>` (e.g. `wire accept {first}`)",
8463 first = items
8464 .first()
8465 .map(|p| {
8466 let ch = crate::character::Character::from_did(&p.peer_did);
8467 ch.nickname
8468 })
8469 .unwrap_or_else(|| "<name>".to_string())
8470 );
8471 println!("→ to refuse: `wire reject <name>`");
8472 Ok(())
8473}
8474
8475fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
8479 let nick = crate::agent_card::bare_handle(peer_nick);
8480 let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
8481 crate::pending_inbound_pair::consume_pending_inbound(nick)?;
8482
8483 if as_json {
8484 println!(
8485 "{}",
8486 serde_json::to_string(&json!({
8487 "peer": nick,
8488 "rejected": existed.is_some(),
8489 "had_pending": existed.is_some(),
8490 }))?
8491 );
8492 } else if existed.is_some() {
8493 println!(
8494 "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
8495 );
8496 } else {
8497 println!("no pending pair from {nick} — nothing to reject");
8498 }
8499 Ok(())
8500}
8501
8502fn cmd_group(cmd: GroupCommand) -> Result<()> {
8513 match cmd {
8514 GroupCommand::Create { name, json } => cmd_group_create(&name, json),
8515 GroupCommand::Add { group, peer, json } => cmd_group_add(&group, &peer, json),
8516 GroupCommand::Send {
8517 group,
8518 message,
8519 json,
8520 } => cmd_group_send(&group, &message, json),
8521 GroupCommand::Tail { group, limit, json } => cmd_group_tail(&group, limit, json),
8522 GroupCommand::List { json } => cmd_group_list(json),
8523 GroupCommand::Invite { group, json } => cmd_group_invite(&group, json),
8524 GroupCommand::Join { code, json } => cmd_group_join(&code, json),
8525 }
8526}
8527
8528fn group_self() -> Result<(String, String, String, String)> {
8531 let card = config::read_agent_card()?;
8532 let did = card
8533 .get("did")
8534 .and_then(Value::as_str)
8535 .ok_or_else(|| anyhow!("agent-card missing did — run `wire up` first"))?
8536 .to_string();
8537 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
8538 let pk_b64 = card
8539 .get("verify_keys")
8540 .and_then(Value::as_object)
8541 .and_then(|m| m.values().next())
8542 .and_then(|v| v.get("key"))
8543 .and_then(Value::as_str)
8544 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
8545 .to_string();
8546 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
8547 let key_id = make_key_id(&handle, &pk_bytes);
8548 Ok((did, handle, key_id, pk_b64))
8549}
8550
8551fn group_room_relay_url() -> Result<String> {
8554 use crate::endpoints::EndpointScope;
8555 let state = config::read_relay_state()?;
8556 let eps = crate::endpoints::self_endpoints(&state);
8557 let pick = eps
8558 .iter()
8559 .find(|e| e.scope == EndpointScope::Federation)
8560 .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Lan))
8561 .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Local))
8562 .or_else(|| eps.first());
8563 match pick {
8564 Some(e) if !e.relay_url.is_empty() => Ok(e.relay_url.clone()),
8565 _ => bail!("no relay endpoint on this identity — run `wire up --relay <url>` first"),
8566 }
8567}
8568
8569fn distribute_group_invite(group: &crate::group::Group, self_did: &str) -> Result<usize> {
8573 let (_, self_handle, _, pk_b64) = group_self()?;
8574 let sk_seed = config::read_private_key()?;
8575 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
8576 let now_iso = time::OffsetDateTime::now_utc()
8577 .format(&time::format_description::well_known::Rfc3339)
8578 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8579 let group_json = serde_json::to_value(group)?;
8580 let mut delivered = 0usize;
8581 for handle in group.other_member_handles(self_did) {
8582 let event = json!({
8583 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8584 "timestamp": now_iso,
8585 "from": self_did,
8586 "to": format!("did:wire:{handle}"),
8587 "type": "group_invite",
8588 "kind": parse_kind("group_invite")?,
8589 "body": group_json,
8590 });
8591 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
8592 .map_err(|e| anyhow!("signing group_invite for `{handle}`: {e:?}"))?;
8593 let line = serde_json::to_vec(&signed)?;
8594 if config::append_outbox_record(&handle, &line).is_ok() {
8595 delivered += 1;
8596 }
8597 }
8598 Ok(delivered)
8599}
8600
8601fn introduce_pin(
8608 trust: &mut Value,
8609 handle: &str,
8610 did: &str,
8611 key_id: &str,
8612 key: &str,
8613 group_id: &str,
8614) -> bool {
8615 let now = time::OffsetDateTime::now_utc()
8616 .format(&time::format_description::well_known::Rfc3339)
8617 .unwrap_or_default();
8618 let agents = trust
8619 .as_object_mut()
8620 .expect("trust is an object")
8621 .entry("agents")
8622 .or_insert_with(|| json!({}));
8623 let key_rec = json!({"key_id": key_id, "key": key, "added_at": now, "active": true});
8624 match agents.get_mut(handle) {
8625 Some(existing) => {
8626 let keys = existing
8629 .as_object_mut()
8630 .and_then(|o| o.get_mut("public_keys"))
8631 .and_then(Value::as_array_mut);
8632 if let Some(keys) = keys {
8633 let have = keys
8634 .iter()
8635 .any(|k| k.get("key_id").and_then(Value::as_str) == Some(key_id));
8636 if !have {
8637 keys.push(key_rec);
8638 return true;
8639 }
8640 }
8641 false
8642 }
8643 None => {
8644 agents[handle] = json!({
8646 "tier": "UNTRUSTED",
8647 "did": did,
8648 "public_keys": [key_rec],
8649 "introduced_via": group_id,
8650 "pinned_at": now,
8651 });
8652 true
8653 }
8654 }
8655}
8656
8657fn ingest_group_invites() -> Result<()> {
8663 let inbox = config::inbox_dir()?;
8664 if !inbox.exists() {
8665 return Ok(());
8666 }
8667 let (self_did, ..) = group_self()?;
8668 let trust_now = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
8669 let mut best: std::collections::HashMap<String, crate::group::Group> =
8671 std::collections::HashMap::new();
8672
8673 for entry in std::fs::read_dir(&inbox)?.flatten() {
8674 let path = entry.path();
8675 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
8676 continue;
8677 }
8678 for line in std::fs::read_to_string(&path).unwrap_or_default().lines() {
8679 let event: Value = match serde_json::from_str(line) {
8680 Ok(v) => v,
8681 Err(_) => continue,
8682 };
8683 if event.get("type").and_then(Value::as_str) != Some("group_invite") {
8684 continue;
8685 }
8686 if verify_message_v31(&event, &trust_now).is_err() {
8689 continue;
8690 }
8691 let Some(body) = event.get("body") else {
8692 continue;
8693 };
8694 let group: crate::group::Group = match serde_json::from_value(body.clone()) {
8695 Ok(g) => g,
8696 Err(_) => continue,
8697 };
8698 if group.creator_did == self_did {
8699 continue; }
8701 let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
8703 if from_did != group.creator_did {
8704 continue;
8705 }
8706 let creator_handle = crate::agent_card::display_handle_from_did(&group.creator_did);
8709 let creator_key = trust_now
8710 .get("agents")
8711 .and_then(|a| a.get(creator_handle))
8712 .and_then(|a| a.get("public_keys"))
8713 .and_then(Value::as_array)
8714 .and_then(|ks| ks.first())
8715 .and_then(|k| k.get("key"))
8716 .and_then(Value::as_str)
8717 .and_then(|b| crate::signing::b64decode(b).ok());
8718 let Some(creator_key) = creator_key else {
8719 continue;
8720 };
8721 if !group.verify(&creator_key) {
8722 continue;
8723 }
8724 match best.get(&group.id) {
8725 Some(prev) if prev.epoch >= group.epoch => {}
8726 _ => {
8727 best.insert(group.id.clone(), group);
8728 }
8729 }
8730 }
8731 }
8732
8733 if best.is_empty() {
8734 return Ok(());
8735 }
8736 let mut trust = config::read_trust()?;
8737 for group in best.values() {
8738 if let Ok(local) = crate::group::load_group(&group.id)
8740 && local.epoch >= group.epoch
8741 {
8742 continue;
8743 }
8744 crate::group::save_group(group)?;
8745 for m in &group.members {
8746 if m.did == self_did || m.key.is_empty() {
8747 continue;
8748 }
8749 introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
8750 }
8751 }
8752 config::write_trust(&trust)?;
8753 Ok(())
8754}
8755
8756fn cmd_group_create(name: &str, as_json: bool) -> Result<()> {
8757 if !config::is_initialized()? {
8758 bail!("not initialized — run `wire up` first");
8759 }
8760 let (did, handle, key_id, pk_b64) = group_self()?;
8761 let relay_url = group_room_relay_url()?;
8762 let client = crate::relay_client::RelayClient::new(&relay_url);
8764 let room = client
8765 .allocate_slot(Some(&format!("group:{name}")))
8766 .with_context(|| format!("allocating group room on {relay_url}"))?;
8767 let id = format!("g{:016x}", rand::random::<u64>());
8768 let mut group = crate::group::Group::new(id.clone(), name.to_string(), handle, did.clone());
8769 group.set_room(relay_url, room.slot_id, room.slot_token);
8770 group.set_member_keys(&did, key_id, pk_b64)?;
8771 let sk = config::read_private_key()?;
8772 group.sign(&sk)?;
8773 crate::group::save_group(&group)?;
8774 if as_json {
8775 println!(
8776 "{}",
8777 serde_json::to_string(&json!({
8778 "id": id, "name": name, "members": 1, "relay_url": group.relay_url
8779 }))?
8780 );
8781 } else {
8782 println!(
8783 "created group `{name}` (id {id}) — room on {}. You are the creator.",
8784 group.relay_url
8785 );
8786 println!(" add peers: `wire group add {id} <peer>` talk: `wire group send {id} \"hi\"`");
8787 }
8788 Ok(())
8789}
8790
8791fn cmd_group_add(group_ref: &str, peer: &str, as_json: bool) -> Result<()> {
8792 let (self_did, ..) = group_self()?;
8793 let mut group = crate::group::resolve_group(group_ref)?;
8794 if group.creator_did != self_did {
8795 bail!("only the group creator can add members (the creator signs the roster)");
8796 }
8797 let bare = crate::agent_card::bare_handle(peer).to_string();
8799 let trust = config::read_trust()?;
8800 let agent = trust
8801 .get("agents")
8802 .and_then(|a| a.get(&bare))
8803 .ok_or_else(|| {
8804 anyhow!("`{bare}` is not a pinned peer — pair first (`wire dial {bare}@<relay>`)")
8805 })?;
8806 let tier = agent
8807 .get("tier")
8808 .and_then(Value::as_str)
8809 .unwrap_or("UNTRUSTED");
8810 if tier != "VERIFIED" {
8811 bail!(
8812 "`{bare}` is {tier}, not VERIFIED — only verified peers can be added as Members (T22 consent)"
8813 );
8814 }
8815 let peer_did = agent
8816 .get("did")
8817 .and_then(Value::as_str)
8818 .ok_or_else(|| anyhow!("trust entry for `{bare}` is missing a did"))?
8819 .to_string();
8820 let key = agent
8823 .get("public_keys")
8824 .and_then(Value::as_array)
8825 .and_then(|ks| {
8826 ks.iter()
8827 .find(|k| k.get("active").and_then(Value::as_bool).unwrap_or(true))
8828 })
8829 .ok_or_else(|| anyhow!("no active pinned key for `{bare}` in trust"))?;
8830 let peer_key_id = key
8831 .get("key_id")
8832 .and_then(Value::as_str)
8833 .unwrap_or_default()
8834 .to_string();
8835 let peer_pk = key
8836 .get("key")
8837 .and_then(Value::as_str)
8838 .unwrap_or_default()
8839 .to_string();
8840
8841 group.add_member(
8842 bare.clone(),
8843 peer_did.clone(),
8844 crate::group::GroupTier::Member,
8845 )?;
8846 group.set_member_keys(&peer_did, peer_key_id, peer_pk)?;
8847 let sk = config::read_private_key()?;
8848 group.sign(&sk)?;
8849 crate::group::save_group(&group)?;
8850 let delivered = distribute_group_invite(&group, &self_did).unwrap_or(0);
8853 if as_json {
8854 println!(
8855 "{}",
8856 serde_json::to_string(&json!({
8857 "group": group.id, "added": bare, "epoch": group.epoch,
8858 "members": group.members.len(), "invites_queued": delivered
8859 }))?
8860 );
8861 } else {
8862 println!(
8863 "added `{bare}` to `{}` — now {} member(s), epoch {} ({delivered} invite(s) queued; run `wire push`)",
8864 group.name,
8865 group.members.len(),
8866 group.epoch
8867 );
8868 }
8869 Ok(())
8870}
8871
8872fn cmd_group_send(group_ref: &str, message: &str, as_json: bool) -> Result<()> {
8873 if !config::is_initialized()? {
8874 bail!("not initialized — run `wire up` first");
8875 }
8876 ingest_group_invites()?;
8877 let (self_did, self_handle, _, pk_b64) = group_self()?;
8878 let group = crate::group::resolve_group(group_ref)?;
8879 if group.slot_id.is_empty() || group.relay_url.is_empty() {
8884 bail!(
8885 "group `{}` has no room slot (legacy/partial group)",
8886 group.name
8887 );
8888 }
8889 let sk_seed = config::read_private_key()?;
8890 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
8891 let now_iso = time::OffsetDateTime::now_utc()
8892 .format(&time::format_description::well_known::Rfc3339)
8893 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8894 let event = json!({
8895 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8896 "timestamp": now_iso,
8897 "from": self_did,
8898 "to": format!("did:wire:group:{}", group.id),
8899 "type": "group_msg",
8900 "kind": parse_kind("group_msg")?,
8901 "body": {
8902 "group_id": group.id,
8903 "group_name": group.name,
8904 "epoch": group.epoch,
8905 "text": message,
8906 },
8907 });
8908 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
8909 .map_err(|e| anyhow!("signing group_msg: {e:?}"))?;
8910 let client = crate::relay_client::RelayClient::new(&group.relay_url);
8912 client
8913 .post_event(&group.slot_id, &group.slot_token, &signed)
8914 .with_context(|| {
8915 format!(
8916 "posting to group room {} on {}",
8917 group.slot_id, group.relay_url
8918 )
8919 })?;
8920 if as_json {
8921 println!(
8922 "{}",
8923 serde_json::to_string(&json!({
8924 "group": group.id, "epoch": group.epoch, "status": "posted",
8925 "members": group.members.len()
8926 }))?
8927 );
8928 } else {
8929 println!(
8930 "group `{}`: posted to the room ({} member(s))",
8931 group.name,
8932 group.members.len()
8933 );
8934 }
8935 Ok(())
8936}
8937
8938fn cmd_group_tail(group_ref: &str, limit: usize, as_json: bool) -> Result<()> {
8939 ingest_group_invites()?;
8940 let group = crate::group::resolve_group(group_ref)?;
8941 if group.slot_id.is_empty() || group.relay_url.is_empty() {
8942 bail!(
8943 "group `{}` has no room slot (legacy/partial group)",
8944 group.name
8945 );
8946 }
8947 let mut trust = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
8948 let client = crate::relay_client::RelayClient::new(&group.relay_url);
8949 let fetch = if limit == 0 {
8951 1000
8952 } else {
8953 (limit * 4).min(1000)
8954 };
8955 let events = client
8956 .list_events(&group.slot_id, &group.slot_token, None, Some(fetch))
8957 .with_context(|| {
8958 format!(
8959 "pulling group room {} on {}",
8960 group.slot_id, group.relay_url
8961 )
8962 })?;
8963
8964 let mut trust_changed = false;
8970 for event in &events {
8971 if event.get("type").and_then(Value::as_str) != Some("group_join") {
8972 continue;
8973 }
8974 if let Some((h, did, kid, key)) = group_join_pin_material(event)
8975 && introduce_pin(&mut trust, &h, &did, &kid, &key, &group.id)
8976 {
8977 trust_changed = true;
8978 }
8979 }
8980 if trust_changed {
8981 let _ = config::write_trust(&trust);
8982 }
8983
8984 enum Line {
8987 Msg {
8988 from: String,
8989 text: String,
8990 verified: bool,
8991 },
8992 Join {
8993 who: String,
8994 },
8995 }
8996 let mut timeline: Vec<(String, Line)> = Vec::new();
8997 for event in &events {
8998 let ty = event.get("type").and_then(Value::as_str).unwrap_or("");
8999 let body = match event.get("body") {
9000 Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok(),
9001 Some(v) => Some(v.clone()),
9002 None => None,
9003 };
9004 let Some(body) = body else { continue };
9005 if body.get("group_id").and_then(Value::as_str) != Some(group.id.as_str()) {
9006 continue;
9007 }
9008 let ts = event
9009 .get("timestamp")
9010 .and_then(Value::as_str)
9011 .unwrap_or("")
9012 .to_string();
9013 let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
9014 let from_handle = crate::agent_card::display_handle_from_did(from_did).to_string();
9015 match ty {
9016 "group_msg" => {
9017 let text = body
9018 .get("text")
9019 .and_then(Value::as_str)
9020 .unwrap_or("")
9021 .to_string();
9022 let verified = verify_message_v31(event, &trust).is_ok();
9023 timeline.push((
9024 ts,
9025 Line::Msg {
9026 from: from_handle,
9027 text,
9028 verified,
9029 },
9030 ));
9031 }
9032 "group_join" => timeline.push((ts, Line::Join { who: from_handle })),
9033 _ => {}
9034 }
9035 }
9036 timeline.sort_by(|a, b| a.0.cmp(&b.0));
9037 let start = if limit > 0 {
9038 timeline.len().saturating_sub(limit)
9039 } else {
9040 0
9041 };
9042 let recent = &timeline[start..];
9043 if as_json {
9044 let arr: Vec<Value> = recent
9045 .iter()
9046 .map(|(ts, l)| match l {
9047 Line::Msg {
9048 from,
9049 text,
9050 verified,
9051 } => {
9052 json!({"ts": ts, "type": "msg", "from": from, "text": text, "verified": verified})
9053 }
9054 Line::Join { who } => json!({"ts": ts, "type": "join", "from": who}),
9055 })
9056 .collect();
9057 println!(
9058 "{}",
9059 serde_json::to_string(
9060 &json!({"group": group.id, "name": group.name, "messages": arr})
9061 )?
9062 );
9063 } else if recent.is_empty() {
9064 println!("group `{}`: no messages yet", group.name);
9065 } else {
9066 for (ts, l) in recent {
9067 let short_ts: String = ts.chars().take(19).collect();
9068 match l {
9069 Line::Msg {
9070 from,
9071 text,
9072 verified,
9073 } => {
9074 let mark = if *verified { "✓" } else { "✗" };
9075 println!("[{short_ts}] {} {mark}: {text}", persona_label(from));
9076 }
9077 Line::Join { who } => println!("[{short_ts}] {} joined", persona_label(who)),
9078 }
9079 }
9080 }
9081 Ok(())
9082}
9083
9084fn group_join_pin_material(event: &Value) -> Option<(String, String, String, String)> {
9090 let body = match event.get("body") {
9091 Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok()?,
9092 Some(v) => v.clone(),
9093 None => return None,
9094 };
9095 let card = body.get("joiner_card")?;
9096 let mut tmp = json!({"agents": {}});
9098 crate::trust::add_agent_card_pin(&mut tmp, card, Some("UNTRUSTED"));
9099 if verify_message_v31(event, &tmp).is_err() {
9100 return None;
9101 }
9102 let did = card.get("did").and_then(Value::as_str)?.to_string();
9103 let handle = card
9104 .get("handle")
9105 .and_then(Value::as_str)
9106 .map(str::to_string)
9107 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
9108 let (kid_full, krec) = card
9109 .get("verify_keys")
9110 .and_then(Value::as_object)
9111 .and_then(|m| m.iter().next())?;
9112 let key_id = kid_full
9113 .strip_prefix("ed25519:")
9114 .unwrap_or(kid_full)
9115 .to_string();
9116 let key = krec.get("key").and_then(Value::as_str)?.to_string();
9117 Some((handle, did, key_id, key))
9118}
9119
9120fn cmd_group_invite(group_ref: &str, as_json: bool) -> Result<()> {
9123 let group = crate::group::resolve_group(group_ref)?;
9124 if group.slot_id.is_empty() || group.relay_url.is_empty() {
9125 bail!(
9126 "group `{}` has no room slot — nothing to invite into",
9127 group.name
9128 );
9129 }
9130 if group.creator_sig.is_empty() {
9131 bail!(
9132 "group `{}` roster is unsigned — add a member or recreate before inviting",
9133 group.name
9134 );
9135 }
9136 let payload = serde_json::to_vec(&group)?;
9137 let code = format!("wire-group:{}", crate::signing::b64encode(&payload));
9138 if as_json {
9139 println!(
9140 "{}",
9141 serde_json::to_string(&json!({"group": group.id, "name": group.name, "code": code}))?
9142 );
9143 } else {
9144 println!(
9145 "join code for `{}` — share ONLY with people you want in the room (it IS the room key):\n",
9146 group.name
9147 );
9148 println!("{code}\n");
9149 println!("they run: wire group join <code>");
9150 }
9151 Ok(())
9152}
9153
9154fn cmd_group_join(code: &str, as_json: bool) -> Result<()> {
9158 if !config::is_initialized()? {
9159 bail!("not initialized — run `wire up` first");
9160 }
9161 let raw = code.trim();
9162 let b64 = raw.strip_prefix("wire-group:").unwrap_or(raw);
9163 let payload =
9164 crate::signing::b64decode(b64).map_err(|_| anyhow!("invalid join code (not base64)"))?;
9165 let group: crate::group::Group = serde_json::from_slice(&payload)
9166 .map_err(|_| anyhow!("invalid join code (not a group payload)"))?;
9167 if group.slot_id.is_empty() || group.relay_url.is_empty() {
9168 bail!("join code carries no room coords");
9169 }
9170 let creator_key = group
9173 .members
9174 .iter()
9175 .find(|m| m.did == group.creator_did)
9176 .map(|m| m.key.clone())
9177 .filter(|k| !k.is_empty())
9178 .and_then(|k| crate::signing::b64decode(&k).ok())
9179 .ok_or_else(|| anyhow!("join code is missing the creator's key"))?;
9180 if !group.verify(&creator_key) {
9181 bail!("join code failed its signature check (tampered or corrupt)");
9182 }
9183 let (self_did, self_handle, _, _) = group_self()?;
9184 if group.creator_did == self_did {
9185 bail!("you created group `{}` — you're already in it", group.name);
9186 }
9187
9188 crate::group::save_group(&group)?;
9190 let mut trust = config::read_trust()?;
9191 for m in &group.members {
9192 if m.did == self_did || m.key.is_empty() {
9193 continue;
9194 }
9195 introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
9196 }
9197 config::write_trust(&trust)?;
9198
9199 let card = config::read_agent_card()?;
9201 let sk_seed = config::read_private_key()?;
9202 let pk_b64 = card
9203 .get("verify_keys")
9204 .and_then(Value::as_object)
9205 .and_then(|m| m.values().next())
9206 .and_then(|v| v.get("key"))
9207 .and_then(Value::as_str)
9208 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
9209 let pk_bytes = crate::signing::b64decode(pk_b64)?;
9210 let now_iso = time::OffsetDateTime::now_utc()
9211 .format(&time::format_description::well_known::Rfc3339)
9212 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
9213 let event = json!({
9214 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
9215 "timestamp": now_iso,
9216 "from": self_did,
9217 "to": format!("did:wire:group:{}", group.id),
9218 "type": "group_join",
9219 "kind": parse_kind("group_join")?,
9220 "body": {
9221 "group_id": group.id,
9222 "group_name": group.name,
9223 "epoch": group.epoch,
9224 "joiner_card": card,
9225 "text": "joined",
9226 },
9227 });
9228 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
9229 .map_err(|e| anyhow!("signing group_join: {e:?}"))?;
9230 let client = crate::relay_client::RelayClient::new(&group.relay_url);
9231 let announced = client
9232 .post_event(&group.slot_id, &group.slot_token, &signed)
9233 .is_ok();
9234
9235 if as_json {
9236 println!(
9237 "{}",
9238 serde_json::to_string(&json!({
9239 "group": group.id, "name": group.name, "joined": true,
9240 "members": group.members.len(), "announced": announced
9241 }))?
9242 );
9243 } else {
9244 println!(
9245 "joined group `{}` ({} member(s)) at Introduced tier.",
9246 group.name,
9247 group.members.len()
9248 );
9249 if announced {
9250 println!(" announced to the room — members will verify your messages.");
9251 } else {
9252 println!(
9253 " ⚠ couldn't reach the room relay to announce; retry a `wire group send` so members can verify you."
9254 );
9255 }
9256 println!(
9257 " read: `wire group tail {}` talk: `wire group send {} \"hi\"`",
9258 group.id, group.id
9259 );
9260 }
9261 Ok(())
9262}
9263
9264fn cmd_group_list(as_json: bool) -> Result<()> {
9265 let groups = crate::group::list_groups()?;
9266 if as_json {
9267 let arr: Vec<Value> = groups
9268 .iter()
9269 .map(|g| {
9270 json!({
9271 "id": g.id,
9272 "name": g.name,
9273 "epoch": g.epoch,
9274 "members": g.members.iter().map(|m| json!({"handle": m.handle, "tier": m.tier.as_str()})).collect::<Vec<_>>(),
9275 })
9276 })
9277 .collect();
9278 println!("{}", serde_json::to_string(&json!({"groups": arr}))?);
9279 } else if groups.is_empty() {
9280 println!("no groups yet — create one with `wire group create <name>`");
9281 } else {
9282 for g in &groups {
9283 println!(
9284 "{} ({}) — {} member(s), epoch {}",
9285 g.name,
9286 g.id,
9287 g.members.len(),
9288 g.epoch
9289 );
9290 for m in &g.members {
9291 println!(" {} [{}]", m.handle, m.tier.as_str());
9292 }
9293 }
9294 }
9295 Ok(())
9296}
9297
9298fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
9301 match cmd {
9302 MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
9303 MeshCommand::Broadcast {
9304 kind,
9305 scope,
9306 exclude,
9307 noreply,
9308 body,
9309 json,
9310 } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
9311 MeshCommand::Role { action } => cmd_mesh_role(action),
9312 MeshCommand::Route {
9313 role,
9314 strategy,
9315 exclude,
9316 kind,
9317 body,
9318 json,
9319 } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
9320 }
9321}
9322
9323fn cmd_mesh_route(
9328 role: &str,
9329 strategy: &str,
9330 exclude: &[String],
9331 kind: &str,
9332 body_arg: &str,
9333 as_json: bool,
9334) -> Result<()> {
9335 use std::time::Instant;
9336
9337 if !config::is_initialized()? {
9338 bail!("not initialized — run `wire init <handle>` first");
9339 }
9340 let strategy = strategy.to_ascii_lowercase();
9341 if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
9342 bail!("unknown strategy `{strategy}` — use round-robin | first | random");
9343 }
9344
9345 let state = config::read_relay_state()?;
9348 let pinned: std::collections::BTreeSet<String> = state["peers"]
9349 .as_object()
9350 .map(|m| m.keys().cloned().collect())
9351 .unwrap_or_default();
9352
9353 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
9354
9355 let sessions = crate::session::list_sessions()?;
9360 let mut candidates: Vec<(String, Option<String>)> = Vec::new(); for s in &sessions {
9362 let handle = match s.handle.as_ref() {
9363 Some(h) => h.clone(),
9364 None => continue,
9365 };
9366 if exclude_set.contains(handle.as_str()) {
9367 continue;
9368 }
9369 if !pinned.contains(&handle) {
9370 continue;
9371 }
9372 let card_path = s
9373 .home_dir
9374 .join("config")
9375 .join("wire")
9376 .join("agent-card.json");
9377 let card_role = std::fs::read(&card_path)
9378 .ok()
9379 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
9380 .and_then(|c| {
9381 c.get("profile")
9382 .and_then(|p| p.get("role"))
9383 .and_then(Value::as_str)
9384 .map(str::to_string)
9385 });
9386 if card_role.as_deref() == Some(role) {
9387 candidates.push((handle, s.did.clone()));
9388 }
9389 }
9390
9391 candidates.sort_by(|a, b| a.0.cmp(&b.0));
9392 candidates.dedup_by(|a, b| a.0 == b.0);
9393
9394 if candidates.is_empty() {
9395 bail!(
9396 "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
9397 );
9398 }
9399
9400 let chosen = match strategy.as_str() {
9401 "first" => candidates[0].clone(),
9402 "random" => {
9403 use rand::Rng;
9404 let idx = rand::thread_rng().gen_range(0..candidates.len());
9405 candidates[idx].clone()
9406 }
9407 "round-robin" => {
9408 let cursor_path = mesh_route_cursor_path()?;
9413 let mut cursors: std::collections::BTreeMap<String, String> =
9414 read_mesh_route_cursors(&cursor_path);
9415 let last = cursors.get(role).cloned();
9416 let pick = match last {
9417 None => candidates[0].clone(),
9418 Some(last_h) => candidates
9419 .iter()
9420 .find(|(h, _)| h.as_str() > last_h.as_str())
9421 .cloned()
9422 .unwrap_or_else(|| candidates[0].clone()),
9423 };
9424 cursors.insert(role.to_string(), pick.0.clone());
9425 write_mesh_route_cursors(&cursor_path, &cursors)?;
9426 pick
9427 }
9428 _ => unreachable!(),
9429 };
9430
9431 let (chosen_handle, _chosen_did) = chosen;
9432
9433 let body_value: Value = if body_arg == "-" {
9435 use std::io::Read;
9436 let mut raw = String::new();
9437 std::io::stdin()
9438 .read_to_string(&mut raw)
9439 .with_context(|| "reading body from stdin")?;
9440 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
9441 } else if let Some(path) = body_arg.strip_prefix('@') {
9442 let raw =
9443 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
9444 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
9445 } else {
9446 Value::String(body_arg.to_string())
9447 };
9448
9449 let sk_seed = config::read_private_key()?;
9450 let card = config::read_agent_card()?;
9451 let did = card
9452 .get("did")
9453 .and_then(Value::as_str)
9454 .ok_or_else(|| anyhow!("agent-card missing did"))?
9455 .to_string();
9456 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
9457 let pk_b64 = card
9458 .get("verify_keys")
9459 .and_then(Value::as_object)
9460 .and_then(|m| m.values().next())
9461 .and_then(|v| v.get("key"))
9462 .and_then(Value::as_str)
9463 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
9464 let pk_bytes = crate::signing::b64decode(pk_b64)?;
9465
9466 let kind_id = parse_kind(kind)?;
9467 let now_iso = time::OffsetDateTime::now_utc()
9468 .format(&time::format_description::well_known::Rfc3339)
9469 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
9470
9471 let event = json!({
9472 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
9473 "timestamp": now_iso,
9474 "from": did,
9475 "to": format!("did:wire:{chosen_handle}"),
9476 "type": kind,
9477 "kind": kind_id,
9478 "body": json!({
9479 "content": body_value,
9480 "routed_via": {
9481 "role": role,
9482 "strategy": strategy,
9483 },
9484 }),
9485 });
9486 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
9487 .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
9488 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
9489
9490 let line = serde_json::to_vec(&signed)?;
9491 config::append_outbox_record(&chosen_handle, &line)?;
9492
9493 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
9494 if endpoints.is_empty() {
9495 bail!(
9496 "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
9497 );
9498 }
9499 let start = Instant::now();
9500 let mut delivered = false;
9501 let mut last_err: Option<String> = None;
9502 let mut via_scope: Option<String> = None;
9503 for ep in &endpoints {
9504 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
9509 Ok(_) => {
9510 delivered = true;
9511 via_scope = Some(
9512 match ep.scope {
9513 crate::endpoints::EndpointScope::Local => "local",
9514 crate::endpoints::EndpointScope::Lan => "lan",
9515 crate::endpoints::EndpointScope::Uds => "uds",
9516 crate::endpoints::EndpointScope::Federation => "federation",
9517 }
9518 .to_string(),
9519 );
9520 break;
9521 }
9522 Err(e) => last_err = Some(format!("{e:#}")),
9523 }
9524 }
9525 let rtt_ms = start.elapsed().as_millis() as u64;
9526
9527 let summary = json!({
9528 "role": role,
9529 "strategy": strategy,
9530 "routed_to": chosen_handle,
9531 "event_id": event_id,
9532 "delivered": delivered,
9533 "delivered_via": via_scope,
9534 "rtt_ms": rtt_ms,
9535 "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
9536 "error": last_err,
9537 });
9538
9539 if as_json {
9540 println!("{}", serde_json::to_string(&summary)?);
9541 } else if delivered {
9542 let via = via_scope.as_deref().unwrap_or("?");
9543 println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
9544 } else {
9545 let err = last_err.as_deref().unwrap_or("no endpoints reachable");
9546 bail!("delivery to `{chosen_handle}` failed: {err}");
9547 }
9548 Ok(())
9549}
9550
9551fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
9552 Ok(config::state_dir()?.join("mesh-route-cursor.json"))
9553}
9554
9555fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
9556 std::fs::read(path)
9557 .ok()
9558 .and_then(|b| serde_json::from_slice(&b).ok())
9559 .unwrap_or_default()
9560}
9561
9562fn write_mesh_route_cursors(
9563 path: &std::path::Path,
9564 cursors: &std::collections::BTreeMap<String, String>,
9565) -> Result<()> {
9566 if let Some(parent) = path.parent() {
9567 std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
9568 }
9569 let body = serde_json::to_vec_pretty(cursors)?;
9570 std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
9571 Ok(())
9572}
9573
9574fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
9579 match action {
9580 MeshRoleAction::Set { role, json } => {
9581 validate_role_tag(&role)?;
9582 let new_profile =
9583 crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
9584 if json {
9585 println!(
9586 "{}",
9587 serde_json::to_string(&json!({
9588 "role": role,
9589 "profile": new_profile,
9590 }))?
9591 );
9592 } else {
9593 println!("self role = {role} (signed into agent-card)");
9594 }
9595 }
9596 MeshRoleAction::Get { peer, json } => {
9597 let (who, role) = match peer.as_deref() {
9598 None => {
9599 let card = config::read_agent_card()?;
9600 let role = card
9601 .get("profile")
9602 .and_then(|p| p.get("role"))
9603 .and_then(Value::as_str)
9604 .map(str::to_string);
9605 let who = card
9606 .get("did")
9607 .and_then(Value::as_str)
9608 .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
9609 .unwrap_or_else(|| "self".to_string());
9610 (who, role)
9611 }
9612 Some(handle) => {
9613 let bare = crate::agent_card::bare_handle(handle).to_string();
9614 let trust = config::read_trust()?;
9615 let role = trust
9616 .get("agents")
9617 .and_then(|a| a.get(&bare))
9618 .and_then(|a| a.get("card"))
9619 .and_then(|c| c.get("profile"))
9620 .and_then(|p| p.get("role"))
9621 .and_then(Value::as_str)
9622 .map(str::to_string);
9623 (bare, role)
9624 }
9625 };
9626 if json {
9627 println!(
9628 "{}",
9629 serde_json::to_string(&json!({
9630 "handle": who,
9631 "role": role,
9632 }))?
9633 );
9634 } else {
9635 match role {
9636 Some(r) => println!("{who}: {r}"),
9637 None => println!("{who}: (unset)"),
9638 }
9639 }
9640 }
9641 MeshRoleAction::List { json } => {
9642 let mut self_did: Option<String> = None;
9643 if let Ok(card) = config::read_agent_card() {
9644 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
9645 }
9646 let sessions = crate::session::list_sessions()?;
9647 let mut rows: Vec<Value> = Vec::new();
9648 for s in &sessions {
9649 let card_path = s
9650 .home_dir
9651 .join("config")
9652 .join("wire")
9653 .join("agent-card.json");
9654 let role = std::fs::read(&card_path)
9655 .ok()
9656 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
9657 .and_then(|c| {
9658 c.get("profile")
9659 .and_then(|p| p.get("role"))
9660 .and_then(Value::as_str)
9661 .map(str::to_string)
9662 });
9663 let is_self = match (&self_did, &s.did) {
9664 (Some(a), Some(b)) => a == b,
9665 _ => false,
9666 };
9667 rows.push(json!({
9668 "name": s.name,
9669 "handle": s.handle,
9670 "role": role,
9671 "self": is_self,
9672 }));
9673 }
9674 rows.sort_by(|a, b| {
9675 a["name"]
9676 .as_str()
9677 .unwrap_or("")
9678 .cmp(b["name"].as_str().unwrap_or(""))
9679 });
9680 if json {
9681 println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
9682 } else if rows.is_empty() {
9683 println!("no sister sessions on this machine.");
9684 } else {
9685 println!("SISTER ROLES (this machine):");
9686 for r in &rows {
9687 let name = r["name"].as_str().unwrap_or("?");
9688 let role = r["role"].as_str().unwrap_or("(unset)");
9689 let marker = if r["self"].as_bool().unwrap_or(false) {
9690 " ← you"
9691 } else {
9692 ""
9693 };
9694 println!(" {name:<24} {role}{marker}");
9695 }
9696 }
9697 }
9698 MeshRoleAction::Clear { json } => {
9699 let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
9700 if json {
9701 println!(
9702 "{}",
9703 serde_json::to_string(&json!({
9704 "cleared": true,
9705 "profile": new_profile,
9706 }))?
9707 );
9708 } else {
9709 println!("self role cleared");
9710 }
9711 }
9712 }
9713 Ok(())
9714}
9715
9716fn validate_role_tag(role: &str) -> Result<()> {
9721 if role.is_empty() {
9722 bail!("role must not be empty (use `wire mesh role --clear` to unset)");
9723 }
9724 if role.len() > 32 {
9725 bail!("role too long ({} chars; max 32)", role.len());
9726 }
9727 for c in role.chars() {
9728 if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
9729 bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
9730 }
9731 }
9732 Ok(())
9733}
9734
9735fn cmd_mesh_broadcast(
9755 kind: &str,
9756 scope_str: &str,
9757 exclude: &[String],
9758 _noreply: bool,
9759 body_arg: &str,
9760 as_json: bool,
9761) -> Result<()> {
9762 use std::time::Instant;
9763
9764 if !config::is_initialized()? {
9765 bail!("not initialized — run `wire init <handle>` first");
9766 }
9767
9768 let scope = match scope_str {
9769 "local" => crate::endpoints::EndpointScope::Local,
9770 "federation" => crate::endpoints::EndpointScope::Federation,
9771 "both" => {
9772 crate::endpoints::EndpointScope::Local
9776 }
9777 other => bail!("unknown scope `{other}` — use local | federation | both"),
9778 };
9779 let any_scope = scope_str == "both";
9780
9781 let state = config::read_relay_state()?;
9782 let peers = state["peers"].as_object().cloned().unwrap_or_default();
9783 if peers.is_empty() {
9784 bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
9785 }
9786
9787 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
9788
9789 struct Target {
9793 handle: String,
9794 endpoints: Vec<crate::endpoints::Endpoint>,
9795 }
9796 let mut targets: Vec<Target> = Vec::new();
9797 let mut skipped_wrong_scope: Vec<String> = Vec::new();
9798 let mut skipped_excluded: Vec<String> = Vec::new();
9799 for handle in peers.keys() {
9800 if exclude_set.contains(handle.as_str()) {
9801 skipped_excluded.push(handle.clone());
9802 continue;
9803 }
9804 let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
9805 let filtered: Vec<crate::endpoints::Endpoint> = ordered
9806 .into_iter()
9807 .filter(|ep| any_scope || ep.scope == scope)
9808 .collect();
9809 if filtered.is_empty() {
9810 skipped_wrong_scope.push(handle.clone());
9811 continue;
9812 }
9813 targets.push(Target {
9814 handle: handle.clone(),
9815 endpoints: filtered,
9816 });
9817 }
9818
9819 if targets.is_empty() {
9820 bail!(
9821 "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
9822 skipped_excluded.len(),
9823 skipped_wrong_scope.len()
9824 );
9825 }
9826
9827 let sk_seed = config::read_private_key()?;
9829 let card = config::read_agent_card()?;
9830 let did = card
9831 .get("did")
9832 .and_then(Value::as_str)
9833 .ok_or_else(|| anyhow!("agent-card missing did"))?
9834 .to_string();
9835 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
9836 let pk_b64 = card
9837 .get("verify_keys")
9838 .and_then(Value::as_object)
9839 .and_then(|m| m.values().next())
9840 .and_then(|v| v.get("key"))
9841 .and_then(Value::as_str)
9842 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
9843 let pk_bytes = crate::signing::b64decode(pk_b64)?;
9844
9845 let body_value: Value = if body_arg == "-" {
9846 use std::io::Read;
9847 let mut raw = String::new();
9848 std::io::stdin()
9849 .read_to_string(&mut raw)
9850 .with_context(|| "reading body from stdin")?;
9851 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
9852 } else if let Some(path) = body_arg.strip_prefix('@') {
9853 let raw =
9854 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
9855 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
9856 } else {
9857 Value::String(body_arg.to_string())
9858 };
9859
9860 let kind_id = parse_kind(kind)?;
9861 let now_iso = time::OffsetDateTime::now_utc()
9862 .format(&time::format_description::well_known::Rfc3339)
9863 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
9864
9865 let broadcast_id = generate_broadcast_id();
9866 let target_count = targets.len();
9867
9868 let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
9872 Vec::with_capacity(targets.len());
9873 for t in &targets {
9874 let body = json!({
9875 "content": body_value,
9876 "broadcast_id": broadcast_id,
9877 "broadcast_target_count": target_count,
9878 });
9879 let event = json!({
9880 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
9881 "timestamp": now_iso,
9882 "from": did,
9883 "to": format!("did:wire:{}", t.handle),
9884 "type": kind,
9885 "kind": kind_id,
9886 "body": body,
9887 });
9888 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
9889 .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
9890 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
9891 signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
9892 }
9893
9894 for (peer, _, signed, _) in &signed_per_peer {
9898 let line = serde_json::to_vec(signed)?;
9899 config::append_outbox_record(peer, &line)?;
9900 }
9901
9902 use std::sync::mpsc;
9906 let (tx, rx) = mpsc::channel::<Value>();
9907 std::thread::scope(|s| {
9908 for (peer, endpoints, signed, event_id) in &signed_per_peer {
9909 let tx = tx.clone();
9910 let peer = peer.clone();
9911 let event_id = event_id.clone();
9912 let endpoints = endpoints.clone();
9913 let signed = signed.clone();
9914 s.spawn(move || {
9915 let start = Instant::now();
9916 let mut delivered = false;
9917 let mut last_err: Option<String> = None;
9918 let mut delivered_via: Option<String> = None;
9919 for ep in &endpoints {
9920 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
9925 Ok(_) => {
9926 delivered = true;
9927 delivered_via = Some(
9928 match ep.scope {
9929 crate::endpoints::EndpointScope::Local => "local",
9930 crate::endpoints::EndpointScope::Lan => "lan",
9931 crate::endpoints::EndpointScope::Uds => "uds",
9932 crate::endpoints::EndpointScope::Federation => "federation",
9933 }
9934 .to_string(),
9935 );
9936 break;
9937 }
9938 Err(e) => last_err = Some(format!("{e:#}")),
9939 }
9940 }
9941 let rtt_ms = start.elapsed().as_millis() as u64;
9942 let _ = tx.send(json!({
9943 "peer": peer,
9944 "event_id": event_id,
9945 "delivered": delivered,
9946 "delivered_via": delivered_via,
9947 "rtt_ms": rtt_ms,
9948 "error": last_err,
9949 }));
9950 });
9951 }
9952 });
9953 drop(tx);
9954
9955 let mut results: Vec<Value> = rx.iter().collect();
9956 results.sort_by(|a, b| {
9957 a["peer"]
9958 .as_str()
9959 .unwrap_or("")
9960 .cmp(b["peer"].as_str().unwrap_or(""))
9961 });
9962
9963 let delivered = results
9964 .iter()
9965 .filter(|r| r["delivered"].as_bool().unwrap_or(false))
9966 .count();
9967 let failed = results.len() - delivered;
9968
9969 let summary = json!({
9970 "broadcast_id": broadcast_id,
9971 "kind": kind,
9972 "scope": scope_str,
9973 "target_count": target_count,
9974 "delivered": delivered,
9975 "failed": failed,
9976 "skipped_excluded": skipped_excluded,
9977 "skipped_wrong_scope": skipped_wrong_scope,
9978 "results": results,
9979 });
9980
9981 if as_json {
9982 println!("{}", serde_json::to_string(&summary)?);
9983 return Ok(());
9984 }
9985
9986 println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
9987 for r in &results {
9988 let peer = r["peer"].as_str().unwrap_or("?");
9989 let delivered = r["delivered"].as_bool().unwrap_or(false);
9990 let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
9991 let via = r["delivered_via"].as_str().unwrap_or("");
9992 if delivered {
9993 println!(" {peer:<24} ✓ delivered ({rtt}ms, {via})");
9994 } else {
9995 let err = r["error"].as_str().unwrap_or("?");
9996 println!(" {peer:<24} ✗ failed — {err}");
9997 }
9998 }
9999 if !skipped_excluded.is_empty() {
10000 println!(" excluded: {}", skipped_excluded.join(", "));
10001 }
10002 if !skipped_wrong_scope.is_empty() {
10003 println!(
10004 " skipped (wrong scope): {}",
10005 skipped_wrong_scope.join(", ")
10006 );
10007 }
10008 println!("broadcast_id: {broadcast_id}");
10009 Ok(())
10010}
10011
10012fn generate_broadcast_id() -> String {
10016 use rand::RngCore;
10017 let mut buf = [0u8; 16];
10018 rand::thread_rng().fill_bytes(&mut buf);
10019 let h = hex::encode(buf);
10020 format!(
10021 "{}-{}-{}-{}-{}",
10022 &h[0..8],
10023 &h[8..12],
10024 &h[12..16],
10025 &h[16..20],
10026 &h[20..32],
10027 )
10028}
10029
10030fn cmd_session(cmd: SessionCommand) -> Result<()> {
10031 match cmd {
10032 SessionCommand::New {
10033 name,
10034 relay,
10035 with_local,
10036 local_relay,
10037 with_lan,
10038 lan_relay,
10039 with_uds,
10040 uds_socket,
10041 no_daemon,
10042 local_only,
10043 json,
10044 } => cmd_session_new(
10045 name.as_deref(),
10046 &relay,
10047 with_local,
10048 &local_relay,
10049 with_lan,
10050 lan_relay.as_deref(),
10051 with_uds,
10052 uds_socket.as_deref(),
10053 no_daemon,
10054 local_only,
10055 json,
10056 ),
10057 SessionCommand::List { json } => cmd_session_list(json),
10058 SessionCommand::ListLocal { json } => cmd_session_list_local(json),
10059 SessionCommand::PairAllLocal {
10060 settle_secs,
10061 federation_relay,
10062 json,
10063 } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
10064 SessionCommand::MeshStatus { stale_secs, json } => {
10065 cmd_session_mesh_status(stale_secs, json)
10066 }
10067 SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
10068 SessionCommand::Current { json } => cmd_session_current(json),
10069 SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
10070 SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
10071 }
10072}
10073
10074fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
10075 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
10076 let cwd_str = crate::session::normalize_cwd_key(&cwd);
10077
10078 let resolved_name = match name_arg {
10079 Some(n) => crate::session::sanitize_name(n),
10080 None => crate::session::sanitize_name(
10081 cwd.file_name()
10082 .and_then(|s| s.to_str())
10083 .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
10084 ),
10085 };
10086
10087 let session_home = crate::session::session_dir(&resolved_name)?;
10088 if !session_home.exists() {
10089 bail!(
10090 "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
10091 session_home.display()
10092 );
10093 }
10094
10095 let prior = crate::session::read_registry()
10096 .ok()
10097 .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
10098 if prior.as_deref() == Some(resolved_name.as_str()) {
10099 if json {
10100 println!(
10101 "{}",
10102 serde_json::to_string(&json!({
10103 "cwd": cwd_str,
10104 "session": resolved_name,
10105 "changed": false,
10106 }))?
10107 );
10108 } else {
10109 println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
10110 }
10111 return Ok(());
10112 }
10113 if let Some(prior_name) = &prior {
10114 eprintln!(
10115 "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
10116 );
10117 }
10118
10119 crate::session::update_registry(|reg| {
10120 reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
10121 Ok(())
10122 })?;
10123
10124 if json {
10125 println!(
10126 "{}",
10127 serde_json::to_string(&json!({
10128 "cwd": cwd_str,
10129 "session": resolved_name,
10130 "changed": true,
10131 "previous": prior,
10132 }))?
10133 );
10134 } else {
10135 println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
10136 println!("(next `wire` invocation from this cwd will auto-detect into this session)");
10137 }
10138 Ok(())
10139}
10140
10141fn resolve_session_name(name: Option<&str>) -> Result<String> {
10142 if let Some(n) = name {
10143 return Ok(crate::session::sanitize_name(n));
10144 }
10145 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
10146 let registry = crate::session::read_registry().unwrap_or_default();
10147 Ok(crate::session::derive_name_from_cwd(&cwd, ®istry))
10148}
10149
10150#[allow(clippy::too_many_arguments)] fn cmd_session_new(
10154 name_arg: Option<&str>,
10155 relay: &str,
10156 with_local: bool,
10157 local_relay: &str,
10158 with_lan: bool,
10159 lan_relay: Option<&str>,
10160 with_uds: bool,
10161 uds_socket: Option<&std::path::Path>,
10162 no_daemon: bool,
10163 local_only: bool,
10164 as_json: bool,
10165) -> Result<()> {
10166 let with_local = with_local || local_only;
10169 if with_lan && lan_relay.is_none() {
10171 bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
10172 }
10173 if with_uds && uds_socket.is_none() {
10175 bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
10176 }
10177 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
10178 let mut registry = crate::session::read_registry().unwrap_or_default();
10179 let name = match name_arg {
10180 Some(n) => crate::session::sanitize_name(n),
10181 None => crate::session::derive_name_from_cwd(&cwd, ®istry),
10182 };
10183 let session_home = crate::session::session_dir(&name)?;
10184
10185 let already_exists = session_home.exists()
10186 && session_home
10187 .join("config")
10188 .join("wire")
10189 .join("agent-card.json")
10190 .exists();
10191 if already_exists {
10192 registry
10196 .by_cwd
10197 .insert(cwd.to_string_lossy().into_owned(), name.clone());
10198 crate::session::write_registry(®istry)?;
10199 let info = render_session_info(&name, &session_home, &cwd)?;
10200 emit_session_new_result(&info, "already_exists", as_json)?;
10201 if !no_daemon {
10202 ensure_session_daemon(&session_home)?;
10203 }
10204 return Ok(());
10205 }
10206
10207 std::fs::create_dir_all(&session_home)
10208 .with_context(|| format!("creating session dir {session_home:?}"))?;
10209
10210 let init_args: Vec<&str> = if local_only {
10219 vec!["init", &name, "--offline"]
10220 } else {
10221 vec!["init", &name, "--relay", relay]
10222 };
10223 let init_status = run_wire_with_home(&session_home, &init_args)?;
10224 if !init_status.success() {
10225 let how = if local_only {
10226 format!("`wire init {name}` (local-only)")
10227 } else {
10228 format!("`wire init {name} --relay {relay}`")
10229 };
10230 bail!("{how} failed inside session dir {session_home:?}");
10231 }
10232
10233 let effective_handle = if local_only {
10238 name.clone()
10239 } else {
10240 let mut claim_attempt = 0u32;
10241 let mut effective = name.clone();
10242 loop {
10243 claim_attempt += 1;
10244 let status =
10245 run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
10246 if status.success() {
10247 break;
10248 }
10249 if claim_attempt >= 5 {
10250 bail!(
10251 "5 failed attempts to claim a handle on {relay} for session {name}. \
10252 Try `wire session destroy {name} --force` and re-run with a different name, \
10253 or use `--local-only` if you don't need a federation address."
10254 );
10255 }
10256 let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
10257 let suffix = crate::session::derive_name_from_cwd(&attempt_path, ®istry);
10258 let token = suffix
10259 .rsplit('-')
10260 .next()
10261 .filter(|t| t.len() == 4)
10262 .map(str::to_string)
10263 .unwrap_or_else(|| format!("{claim_attempt}"));
10264 effective = format!("{name}-{token}");
10265 }
10266 effective
10267 };
10268
10269 registry
10272 .by_cwd
10273 .insert(cwd.to_string_lossy().into_owned(), name.clone());
10274 crate::session::write_registry(®istry)?;
10275
10276 if with_local {
10287 try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
10288 if local_only {
10289 let relay_state_path = session_home.join("config").join("wire").join("relay.json");
10294 let state: Value = std::fs::read(&relay_state_path)
10295 .ok()
10296 .and_then(|b| serde_json::from_slice(&b).ok())
10297 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
10298 let endpoints = crate::endpoints::self_endpoints(&state);
10299 let has_local = endpoints
10300 .iter()
10301 .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
10302 if !has_local {
10303 bail!(
10304 "--local-only requested but local-relay probe at {local_relay} failed — \
10305 ensure the local relay is running (`wire service install --local-relay`), \
10306 then re-run `wire session new {name} --local-only`."
10307 );
10308 }
10309 }
10310 }
10311
10312 if with_lan && let Some(lan_url) = lan_relay {
10316 try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
10317 }
10318 if with_uds && let Some(socket_path) = uds_socket {
10320 try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
10321 }
10322
10323 if !no_daemon {
10324 ensure_session_daemon(&session_home)?;
10325 }
10326
10327 let info = render_session_info(&name, &session_home, &cwd)?;
10328 emit_session_new_result(&info, "created", as_json)
10329}
10330
10331#[cfg(unix)]
10341fn try_allocate_uds_slot(
10342 session_home: &std::path::Path,
10343 handle: &str,
10344 uds_socket: &std::path::Path,
10345) {
10346 let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
10349 Ok((200, _)) => true,
10350 Ok((status, body)) => {
10351 eprintln!(
10352 "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
10353 String::from_utf8_lossy(&body)
10354 );
10355 return;
10356 }
10357 Err(e) => {
10358 eprintln!(
10359 "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
10360 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
10361 );
10362 return;
10363 }
10364 };
10365 if !healthz {
10366 return;
10367 }
10368
10369 let alloc_body = serde_json::json!({"handle": handle}).to_string();
10371 let (status, body) = match crate::relay_client::uds_request(
10372 uds_socket,
10373 "POST",
10374 "/v1/slot/allocate",
10375 &[("Content-Type", "application/json")],
10376 alloc_body.as_bytes(),
10377 ) {
10378 Ok(r) => r,
10379 Err(e) => {
10380 eprintln!(
10381 "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
10382 );
10383 return;
10384 }
10385 };
10386 if status >= 300 {
10387 eprintln!(
10388 "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
10389 String::from_utf8_lossy(&body)
10390 );
10391 return;
10392 }
10393 let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
10394 Ok(a) => a,
10395 Err(e) => {
10396 eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
10397 return;
10398 }
10399 };
10400
10401 let state_path = session_home.join("config").join("wire").join("relay.json");
10402 let mut state: serde_json::Value = std::fs::read(&state_path)
10403 .ok()
10404 .and_then(|b| serde_json::from_slice(&b).ok())
10405 .unwrap_or_else(|| serde_json::json!({}));
10406
10407 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
10408 .get("self")
10409 .and_then(|s| s.get("endpoints"))
10410 .and_then(|e| e.as_array())
10411 .map(|arr| {
10412 arr.iter()
10413 .filter_map(|v| {
10414 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
10415 })
10416 .collect()
10417 })
10418 .unwrap_or_default();
10419 endpoints.push(crate::endpoints::Endpoint::uds(
10420 format!("unix://{}", uds_socket.display()),
10421 alloc.slot_id.clone(),
10422 alloc.slot_token.clone(),
10423 ));
10424
10425 let self_obj = state
10426 .as_object_mut()
10427 .expect("relay_state root is an object")
10428 .entry("self")
10429 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
10430 if !self_obj.is_object() {
10431 *self_obj = serde_json::Value::Object(serde_json::Map::new());
10432 }
10433 if let Some(obj) = self_obj.as_object_mut() {
10434 obj.insert(
10435 "endpoints".into(),
10436 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
10437 );
10438 }
10439 if let Err(e) = std::fs::write(
10440 &state_path,
10441 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
10442 ) {
10443 eprintln!("wire session new: failed to write {state_path:?}: {e}");
10444 return;
10445 }
10446 eprintln!(
10447 "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
10448 uds_socket.display(),
10449 alloc.slot_id
10450 );
10451}
10452
10453#[cfg(not(unix))]
10454fn try_allocate_uds_slot(
10455 _session_home: &std::path::Path,
10456 _handle: &str,
10457 _uds_socket: &std::path::Path,
10458) {
10459 eprintln!(
10460 "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
10461 );
10462}
10463
10464fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
10474 let probe = match crate::relay_client::build_blocking_client(Some(
10475 std::time::Duration::from_millis(500),
10476 )) {
10477 Ok(c) => c,
10478 Err(e) => {
10479 eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
10480 return;
10481 }
10482 };
10483 let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
10484 match probe.get(&healthz_url).send() {
10485 Ok(resp) if resp.status().is_success() => {}
10486 Ok(resp) => {
10487 eprintln!(
10488 "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
10489 resp.status()
10490 );
10491 return;
10492 }
10493 Err(e) => {
10494 eprintln!(
10495 "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
10496 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
10497 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
10498 );
10499 return;
10500 }
10501 };
10502
10503 let lan_client = crate::relay_client::RelayClient::new(lan_relay);
10504 let alloc = match lan_client.allocate_slot(Some(handle)) {
10505 Ok(a) => a,
10506 Err(e) => {
10507 eprintln!(
10508 "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
10509 );
10510 return;
10511 }
10512 };
10513
10514 let state_path = session_home.join("config").join("wire").join("relay.json");
10515 let mut state: serde_json::Value = std::fs::read(&state_path)
10516 .ok()
10517 .and_then(|b| serde_json::from_slice(&b).ok())
10518 .unwrap_or_else(|| serde_json::json!({}));
10519
10520 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
10523 .get("self")
10524 .and_then(|s| s.get("endpoints"))
10525 .and_then(|e| e.as_array())
10526 .map(|arr| {
10527 arr.iter()
10528 .filter_map(|v| {
10529 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
10530 })
10531 .collect()
10532 })
10533 .unwrap_or_default();
10534 endpoints.push(crate::endpoints::Endpoint::lan(
10535 lan_relay.trim_end_matches('/').to_string(),
10536 alloc.slot_id.clone(),
10537 alloc.slot_token.clone(),
10538 ));
10539
10540 let self_obj = state
10541 .as_object_mut()
10542 .expect("relay_state root is an object")
10543 .entry("self")
10544 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
10545 if !self_obj.is_object() {
10546 *self_obj = serde_json::Value::Object(serde_json::Map::new());
10547 }
10548 if let Some(obj) = self_obj.as_object_mut() {
10549 obj.insert(
10550 "endpoints".into(),
10551 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
10552 );
10553 }
10554 if let Err(e) = std::fs::write(
10555 &state_path,
10556 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
10557 ) {
10558 eprintln!("wire session new: failed to write {state_path:?}: {e}");
10559 return;
10560 }
10561 eprintln!(
10562 "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
10563 alloc.slot_id
10564 );
10565}
10566
10567fn try_allocate_local_slot(
10575 session_home: &std::path::Path,
10576 handle: &str,
10577 _federation_relay: &str,
10578 local_relay: &str,
10579) {
10580 let probe = match crate::relay_client::build_blocking_client(Some(
10583 std::time::Duration::from_millis(500),
10584 )) {
10585 Ok(c) => c,
10586 Err(e) => {
10587 eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
10588 return;
10589 }
10590 };
10591 let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
10592 match probe.get(&healthz_url).send() {
10593 Ok(resp) if resp.status().is_success() => {}
10594 Ok(resp) => {
10595 eprintln!(
10596 "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
10597 resp.status()
10598 );
10599 return;
10600 }
10601 Err(e) => {
10602 eprintln!(
10603 "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
10604 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
10605 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
10606 );
10607 return;
10608 }
10609 };
10610
10611 let local_client = crate::relay_client::RelayClient::new(local_relay);
10613 let alloc = match local_client.allocate_slot(Some(handle)) {
10614 Ok(a) => a,
10615 Err(e) => {
10616 eprintln!(
10617 "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
10618 );
10619 return;
10620 }
10621 };
10622
10623 let state_path = session_home.join("config").join("wire").join("relay.json");
10638 let mut state: serde_json::Value = std::fs::read(&state_path)
10639 .ok()
10640 .and_then(|b| serde_json::from_slice(&b).ok())
10641 .unwrap_or_else(|| serde_json::json!({}));
10642 let fed_endpoint = state.get("self").and_then(|s| {
10645 let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
10646 let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
10647 let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
10648 Some(crate::endpoints::Endpoint::federation(
10649 url.to_string(),
10650 slot_id.to_string(),
10651 slot_token.to_string(),
10652 ))
10653 });
10654
10655 let local_endpoint = crate::endpoints::Endpoint::local(
10656 local_relay.trim_end_matches('/').to_string(),
10657 alloc.slot_id.clone(),
10658 alloc.slot_token.clone(),
10659 );
10660
10661 let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
10662 if let Some(f) = fed_endpoint.clone() {
10663 endpoints.push(f);
10664 }
10665 endpoints.push(local_endpoint);
10666
10667 let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
10677 Some(f) => (f.relay_url, f.slot_id, f.slot_token),
10678 None => (
10679 local_relay.trim_end_matches('/').to_string(),
10680 alloc.slot_id.clone(),
10681 alloc.slot_token.clone(),
10682 ),
10683 };
10684 let self_obj = state
10685 .as_object_mut()
10686 .expect("relay_state root is an object")
10687 .entry("self")
10688 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
10689 if !self_obj.is_object() {
10692 *self_obj = serde_json::Value::Object(serde_json::Map::new());
10693 }
10694 if let Some(obj) = self_obj.as_object_mut() {
10695 obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
10696 obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
10697 obj.insert(
10698 "slot_token".into(),
10699 serde_json::Value::String(legacy_slot_token),
10700 );
10701 obj.insert(
10702 "endpoints".into(),
10703 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
10704 );
10705 }
10706
10707 if let Err(e) = std::fs::write(
10708 &state_path,
10709 serde_json::to_vec_pretty(&state).unwrap_or_default(),
10710 ) {
10711 eprintln!(
10712 "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
10713 );
10714 return;
10715 }
10716 eprintln!(
10717 "wire session new: local slot allocated on {local_relay} (slot_id={})",
10718 alloc.slot_id
10719 );
10720}
10721
10722fn render_session_info(
10723 name: &str,
10724 session_home: &std::path::Path,
10725 cwd: &std::path::Path,
10726) -> Result<serde_json::Value> {
10727 let card_path = session_home
10728 .join("config")
10729 .join("wire")
10730 .join("agent-card.json");
10731 let (did, handle) = if card_path.exists() {
10732 let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
10733 let did = card
10734 .get("did")
10735 .and_then(Value::as_str)
10736 .unwrap_or("")
10737 .to_string();
10738 let handle = card
10739 .get("handle")
10740 .and_then(Value::as_str)
10741 .map(str::to_string)
10742 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
10743 (did, handle)
10744 } else {
10745 (String::new(), String::new())
10746 };
10747 Ok(json!({
10748 "name": name,
10749 "home_dir": session_home.to_string_lossy(),
10750 "cwd": cwd.to_string_lossy(),
10751 "did": did,
10752 "handle": handle,
10753 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
10754 }))
10755}
10756
10757fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
10758 if as_json {
10759 let mut obj = info.clone();
10760 obj["status"] = json!(status);
10761 println!("{}", serde_json::to_string(&obj)?);
10762 } else {
10763 let name = info["name"].as_str().unwrap_or("?");
10764 let handle = info["handle"].as_str().unwrap_or("?");
10765 let home = info["home_dir"].as_str().unwrap_or("?");
10766 let did = info["did"].as_str().unwrap_or("?");
10767 let export = info["export"].as_str().unwrap_or("?");
10768 let prefix = if status == "already_exists" {
10769 "session already exists (re-registered cwd)"
10770 } else {
10771 "session created"
10772 };
10773 println!(
10774 "{prefix}\n name: {name}\n handle: {handle}\n did: {did}\n home: {home}\n\nactivate with:\n {export}"
10775 );
10776 }
10777 Ok(())
10778}
10779
10780fn run_wire_with_home(
10781 session_home: &std::path::Path,
10782 args: &[&str],
10783) -> Result<std::process::ExitStatus> {
10784 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
10785 let status = std::process::Command::new(&bin)
10786 .env("WIRE_HOME", session_home)
10787 .env_remove("RUST_LOG")
10788 .env("WIRE_AUTO_INIT", "0")
10791 .args(args)
10792 .status()
10793 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
10794 Ok(status)
10795}
10796
10797pub fn maybe_auto_init_cwd_session(label: &str) {
10816 if std::env::var("WIRE_HOME").is_ok() {
10817 return; }
10819 if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
10820 return; }
10822 let cwd = match std::env::current_dir() {
10823 Ok(c) => c,
10824 Err(_) => return,
10825 };
10826 if crate::session::detect_session_wire_home(&cwd).is_some() {
10829 return;
10830 }
10831
10832 use fs2::FileExt;
10849 let sessions_root = match crate::session::sessions_root() {
10850 Ok(r) => r,
10851 Err(_) => return,
10852 };
10853 if let Err(e) = std::fs::create_dir_all(&sessions_root) {
10854 eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
10855 return;
10856 }
10857 let lock_path = sessions_root.join(".auto-init.lock");
10858 let lock_file = match std::fs::OpenOptions::new()
10859 .create(true)
10860 .truncate(false)
10861 .read(true)
10862 .write(true)
10863 .open(&lock_path)
10864 {
10865 Ok(f) => f,
10866 Err(e) => {
10867 eprintln!(
10868 "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
10869 );
10870 return;
10871 }
10872 };
10873 if let Err(e) = lock_file.lock_exclusive() {
10874 eprintln!(
10875 "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
10876 );
10877 return;
10878 }
10879 let registry = crate::session::read_registry().unwrap_or_default();
10884 let name = crate::session::derive_name_from_cwd(&cwd, ®istry);
10885 let session_home = match crate::session::session_dir(&name) {
10886 Ok(h) => h,
10887 Err(_) => {
10888 let _ = fs2::FileExt::unlock(&lock_file);
10889 return;
10890 }
10891 };
10892 let agent_card_path = session_home
10893 .join("config")
10894 .join("wire")
10895 .join("agent-card.json");
10896 let needs_init = !agent_card_path.exists();
10897
10898 if needs_init {
10899 if let Err(e) = std::fs::create_dir_all(&session_home) {
10900 eprintln!(
10901 "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
10902 );
10903 let _ = fs2::FileExt::unlock(&lock_file);
10904 return;
10905 }
10906 match run_wire_with_home(&session_home, &["init", &name, "--offline"]) {
10911 Ok(status) if status.success() => {}
10912 Ok(status) => {
10913 eprintln!(
10914 "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
10915 );
10916 let _ = fs2::FileExt::unlock(&lock_file);
10917 return;
10918 }
10919 Err(e) => {
10920 eprintln!(
10921 "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
10922 );
10923 let _ = fs2::FileExt::unlock(&lock_file);
10924 return;
10925 }
10926 }
10927 try_allocate_local_slot(
10934 &session_home,
10935 &name,
10936 "https://wireup.net",
10937 "http://127.0.0.1:8771",
10938 );
10939 } else {
10940 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
10944 eprintln!(
10945 "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
10946 );
10947 }
10948 }
10949 let cwd_key = crate::session::normalize_cwd_key(&cwd);
10959 let name_for_reg = name.clone();
10960 if let Err(e) = crate::session::update_registry(|reg| {
10961 reg.by_cwd.insert(cwd_key, name_for_reg);
10962 Ok(())
10963 }) {
10964 eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
10965 }
10967 let _ = fs2::FileExt::unlock(&lock_file);
10970
10971 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
10972 eprintln!(
10973 "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
10974 cwd.display(),
10975 session_home.display()
10976 );
10977 }
10978 unsafe {
10981 std::env::set_var("WIRE_HOME", &session_home);
10982 }
10983}
10984
10985fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
10986 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
10989 if pidfile.exists() {
10990 let bytes = std::fs::read(&pidfile).unwrap_or_default();
10991 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
10992 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
10993 } else {
10994 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
10995 };
10996 if let Some(p) = pid {
10997 let alive = {
10998 #[cfg(target_os = "linux")]
10999 {
11000 std::path::Path::new(&format!("/proc/{p}")).exists()
11001 }
11002 #[cfg(not(target_os = "linux"))]
11003 {
11004 std::process::Command::new("kill")
11005 .args(["-0", &p.to_string()])
11006 .output()
11007 .map(|o| o.status.success())
11008 .unwrap_or(false)
11009 }
11010 };
11011 if alive {
11012 return Ok(());
11013 }
11014 }
11015 }
11016
11017 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
11020 let log_path = session_home.join("state").join("wire").join("daemon.log");
11021 if let Some(parent) = log_path.parent() {
11022 std::fs::create_dir_all(parent).ok();
11023 }
11024 let log_file = std::fs::OpenOptions::new()
11025 .create(true)
11026 .append(true)
11027 .open(&log_path)
11028 .with_context(|| format!("opening daemon log {log_path:?}"))?;
11029 let log_err = log_file.try_clone()?;
11030 std::process::Command::new(&bin)
11031 .env("WIRE_HOME", session_home)
11032 .env_remove("RUST_LOG")
11033 .args(["daemon", "--interval", "5"])
11034 .stdout(log_file)
11035 .stderr(log_err)
11036 .stdin(std::process::Stdio::null())
11037 .spawn()
11038 .with_context(|| "spawning session-local `wire daemon`")?;
11039 Ok(())
11040}
11041
11042fn cmd_session_list(as_json: bool) -> Result<()> {
11043 let items = crate::session::list_sessions()?;
11044 if as_json {
11045 println!("{}", serde_json::to_string(&items)?);
11046 return Ok(());
11047 }
11048 if items.is_empty() {
11049 println!("no sessions on this machine. `wire session new` to create one.");
11050 return Ok(());
11051 }
11052 println!(
11053 "{:<22} {:<24} {:<24} {:<10} CWD",
11054 "PERSONA", "NAME", "HANDLE", "DAEMON"
11055 );
11056 for s in items {
11057 let plain = s
11061 .character
11062 .as_ref()
11063 .map(|c| c.short())
11064 .unwrap_or_else(|| "?".to_string());
11065 let colored = s
11066 .character
11067 .as_ref()
11068 .map(|c| c.colored())
11069 .unwrap_or_else(|| "?".to_string());
11070 let displayed_width = plain.chars().count() + 1; let pad = 22usize.saturating_sub(displayed_width);
11075 println!(
11076 "{}{} {:<24} {:<24} {:<10} {}",
11077 colored,
11078 " ".repeat(pad),
11079 s.name,
11080 s.handle.as_deref().unwrap_or("?"),
11081 if s.daemon_running { "running" } else { "down" },
11082 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
11083 );
11084 }
11085 Ok(())
11086}
11087
11088fn cmd_session_list_local(as_json: bool) -> Result<()> {
11100 let listing = crate::session::list_local_sessions()?;
11101 if as_json {
11102 println!("{}", serde_json::to_string(&listing)?);
11103 return Ok(());
11104 }
11105
11106 if listing.local.is_empty() && listing.federation_only.is_empty() {
11107 println!(
11108 "no sessions on this machine. `wire session new --with-local` to create one \
11109 with a local-relay endpoint (start the relay first: \
11110 `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
11111 );
11112 return Ok(());
11113 }
11114
11115 if listing.local.is_empty() {
11116 println!(
11117 "no sister sessions reachable via a local relay. \
11118 Re-run `wire session new --with-local` to add a Local endpoint, or \
11119 start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
11120 );
11121 } else {
11122 let mut keys: Vec<&String> = listing.local.keys().collect();
11124 keys.sort();
11125 for relay_url in keys {
11126 let group = &listing.local[relay_url];
11127 println!("LOCAL RELAY: {relay_url}");
11128 println!(" {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
11129 for s in group {
11130 println!(
11131 " {:<24} {:<32} {:<10} {}",
11132 s.name,
11133 s.handle.as_deref().unwrap_or("?"),
11134 if s.daemon_running { "running" } else { "down" },
11135 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
11136 );
11137 }
11138 println!();
11139 }
11140 }
11141
11142 if !listing.federation_only.is_empty() {
11143 println!("federation-only (no local endpoint):");
11144 for s in &listing.federation_only {
11145 println!(
11146 " {:<24} {:<32} {}",
11147 s.name,
11148 s.handle.as_deref().unwrap_or("?"),
11149 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
11150 );
11151 }
11152 }
11153 Ok(())
11154}
11155
11156fn cmd_session_pair_all_local(
11175 settle_secs: u64,
11176 federation_relay: &str,
11177 as_json: bool,
11178) -> Result<()> {
11179 use std::collections::BTreeSet;
11180 use std::time::Duration;
11181
11182 let listing = crate::session::list_local_sessions()?;
11183 let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
11187 Default::default();
11188 for group in listing.local.into_values() {
11189 for s in group {
11190 by_name.entry(s.name.clone()).or_insert(s);
11191 }
11192 }
11193 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
11194
11195 if sessions.len() < 2 {
11196 let msg = format!(
11197 "{} sister session(s) with a local endpoint — need at least 2 to pair.",
11198 sessions.len()
11199 );
11200 if as_json {
11201 println!(
11202 "{}",
11203 serde_json::to_string(&json!({
11204 "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
11205 "pairs_attempted": 0,
11206 "pairs_succeeded": 0,
11207 "pairs_skipped_already_paired": 0,
11208 "pairs_failed": 0,
11209 "note": msg,
11210 }))?
11211 );
11212 } else {
11213 println!("{msg}");
11214 if let Some(s) = sessions.first() {
11215 println!(" - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
11216 }
11217 println!("Use `wire session new --with-local` to add more.");
11218 }
11219 return Ok(());
11220 }
11221
11222 let fed_host = host_of_url(federation_relay);
11223 if fed_host.is_empty() {
11224 bail!(
11225 "federation_relay `{federation_relay}` has no parseable host — \
11226 pass a full URL like `https://wireup.net`."
11227 );
11228 }
11229
11230 let mut attempted = 0u32;
11232 let mut succeeded = 0u32;
11233 let mut skipped_already = 0u32;
11234 let mut failed = 0u32;
11235 let mut per_pair: Vec<Value> = Vec::new();
11236
11237 for i in 0..sessions.len() {
11238 for j in (i + 1)..sessions.len() {
11239 let a = &sessions[i];
11240 let b = &sessions[j];
11241 attempted += 1;
11242
11243 let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
11249 let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
11250 let a_pinned_b = session_has_peer(&a.home_dir, b_handle);
11251 let b_pinned_a = session_has_peer(&b.home_dir, a_handle);
11252 if a_pinned_b && b_pinned_a {
11253 skipped_already += 1;
11254 per_pair.push(json!({
11255 "from": a.name,
11256 "to": b.name,
11257 "status": "already_paired",
11258 }));
11259 continue;
11260 }
11261
11262 let pair_result = drive_bilateral_pair(
11263 &a.home_dir,
11264 &a.name,
11265 &b.home_dir,
11266 &b.name,
11267 &fed_host,
11268 federation_relay,
11269 settle_secs,
11270 );
11271
11272 match pair_result {
11273 Ok(()) => {
11274 succeeded += 1;
11275 per_pair.push(json!({
11276 "from": a.name,
11277 "to": b.name,
11278 "status": "paired",
11279 }));
11280 }
11281 Err(e) => {
11282 failed += 1;
11283 let detail = format!("{e:#}");
11284 per_pair.push(json!({
11285 "from": a.name,
11286 "to": b.name,
11287 "status": "failed",
11288 "error": detail,
11289 }));
11290 }
11291 }
11292
11293 std::thread::sleep(Duration::from_millis(200));
11296 }
11297 }
11298
11299 let _ = BTreeSet::<String>::new(); let summary = json!({
11301 "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
11302 "pairs_attempted": attempted,
11303 "pairs_succeeded": succeeded,
11304 "pairs_skipped_already_paired": skipped_already,
11305 "pairs_failed": failed,
11306 "results": per_pair,
11307 });
11308 if as_json {
11309 println!("{}", serde_json::to_string(&summary)?);
11310 } else {
11311 println!(
11312 "wire session pair-all-local: {} session(s), {} pair(s) attempted",
11313 sessions.len(),
11314 attempted
11315 );
11316 println!(" paired: {succeeded}");
11317 println!(" skipped (already pinned): {skipped_already}");
11318 println!(" failed: {failed}");
11319 for entry in summary["results"].as_array().unwrap_or(&vec![]) {
11320 let from = entry["from"].as_str().unwrap_or("?");
11321 let to = entry["to"].as_str().unwrap_or("?");
11322 let status = entry["status"].as_str().unwrap_or("?");
11323 let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
11324 if err.is_empty() {
11325 println!(" {from:<24} ↔ {to:<24} {status}");
11326 } else {
11327 println!(" {from:<24} ↔ {to:<24} {status} — {err}");
11328 }
11329 }
11330 }
11331 Ok(())
11332}
11333
11334fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
11337 val_session_relay_state(session_home)
11338 .and_then(|v| v.get("peers").cloned())
11339 .and_then(|p| p.get(peer_name).cloned())
11340 .is_some()
11341}
11342
11343fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
11348 let path = session_home.join("config").join("wire").join("relay.json");
11349 let bytes = std::fs::read(&path).ok()?;
11350 serde_json::from_slice(&bytes).ok()
11351}
11352
11353fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
11357 use std::collections::BTreeMap;
11358
11359 let listing = crate::session::list_local_sessions()?;
11362 let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
11363 for group in listing.local.into_values() {
11364 for s in group {
11365 by_name.entry(s.name.clone()).or_insert(s);
11366 }
11367 }
11368 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
11369 let federation_only = listing.federation_only;
11370
11371 if sessions.is_empty() {
11372 let msg = "no sister sessions with a local endpoint on this machine.".to_string();
11373 if as_json {
11374 println!(
11375 "{}",
11376 serde_json::to_string(&json!({
11377 "sessions": [],
11378 "edges": [],
11379 "local_relay": null,
11380 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
11381 "summary": {
11382 "session_count": 0,
11383 "edge_count": 0,
11384 "healthy": 0,
11385 "stale": 0,
11386 "asymmetric": 0,
11387 },
11388 "note": msg,
11389 }))?
11390 );
11391 } else {
11392 println!("{msg}");
11393 println!("Use `wire session new --with-local` to create one.");
11394 }
11395 return Ok(());
11396 }
11397
11398 struct SessionState {
11400 view: crate::session::LocalSessionView,
11401 relay_state: Value,
11402 local_relay_url: Option<String>,
11403 }
11404 let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
11405 for s in sessions {
11406 let relay_state = val_session_relay_state(&s.home_dir)
11407 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
11408 let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
11409 sstates.push(SessionState {
11410 view: s,
11411 relay_state,
11412 local_relay_url,
11413 });
11414 }
11415
11416 let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
11419 for s in &sstates {
11420 if let Some(url) = &s.local_relay_url
11421 && !local_relays.contains_key(url)
11422 {
11423 let healthy = probe_relay_healthz(url);
11424 local_relays.insert(url.clone(), healthy);
11425 }
11426 }
11427
11428 let now = std::time::SystemTime::now()
11429 .duration_since(std::time::UNIX_EPOCH)
11430 .map(|d| d.as_secs())
11431 .unwrap_or(0);
11432
11433 let mut edges: Vec<Value> = Vec::new();
11437 let mut healthy_count = 0u32;
11438 let mut stale_count = 0u32;
11439 let mut asymmetric_count = 0u32;
11440
11441 for i in 0..sstates.len() {
11442 for j in (i + 1)..sstates.len() {
11443 let a = &sstates[i];
11444 let b = &sstates[j];
11445 let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
11450 let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
11451 let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
11452 let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
11453
11454 let bilateral = a_to_b.pinned && b_to_a.pinned;
11455 let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
11459 (Some("local"), _) | (_, Some("local")) => "local",
11460 (Some("federation"), _) | (_, Some("federation")) => "federation",
11461 _ => "unknown",
11462 };
11463
11464 let mut status = if bilateral { "healthy" } else { "asymmetric" };
11467 if bilateral {
11468 let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
11469 Some(s) => s > stale_secs,
11470 None => d.probed,
11471 });
11472 if either_stale {
11473 status = "stale";
11474 }
11475 }
11476
11477 match status {
11478 "healthy" => healthy_count += 1,
11479 "stale" => stale_count += 1,
11480 "asymmetric" => asymmetric_count += 1,
11481 _ => {}
11482 }
11483
11484 edges.push(json!({
11485 "from": a.view.name,
11486 "to": b.view.name,
11487 "bilateral": bilateral,
11488 "scope": scope,
11489 "status": status,
11490 "directions": {
11491 a.view.name.clone(): direction_summary(&a_to_b),
11492 b.view.name.clone(): direction_summary(&b_to_a),
11493 },
11494 }));
11495 }
11496 }
11497
11498 let summary = json!({
11499 "sessions": sstates.iter().map(|s| json!({
11500 "name": s.view.name,
11501 "handle": s.view.handle,
11502 "cwd": s.view.cwd,
11503 "daemon_running": s.view.daemon_running,
11504 "local_relay": s.local_relay_url,
11505 })).collect::<Vec<_>>(),
11506 "edges": edges,
11507 "local_relays": local_relays.iter().map(|(url, healthy)| json!({
11508 "url": url,
11509 "healthy": healthy,
11510 })).collect::<Vec<_>>(),
11511 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
11512 "summary": {
11513 "session_count": sstates.len(),
11514 "edge_count": edges.len(),
11515 "healthy": healthy_count,
11516 "stale": stale_count,
11517 "asymmetric": asymmetric_count,
11518 "stale_threshold_secs": stale_secs,
11519 },
11520 });
11521
11522 if as_json {
11523 println!("{}", serde_json::to_string(&summary)?);
11524 return Ok(());
11525 }
11526
11527 println!(
11528 "wire mesh: {} session(s), {} edge(s)",
11529 sstates.len(),
11530 edges.len()
11531 );
11532 for (url, healthy) in &local_relays {
11533 let tick = if *healthy { "✓" } else { "✗" };
11534 println!(" local-relay {url} {tick}");
11535 }
11536 if !federation_only.is_empty() {
11537 print!(" federation-only sessions:");
11538 for f in &federation_only {
11539 print!(" {}", f.name);
11540 }
11541 println!();
11542 }
11543
11544 let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
11546 let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
11547 print!("\n{:>col_w$}", "", col_w = col_w);
11548 for n in &names {
11549 print!("{n:>col_w$}");
11550 }
11551 println!();
11552 for (i, row) in names.iter().enumerate() {
11553 print!("{row:>col_w$}");
11554 for (j, col) in names.iter().enumerate() {
11555 let cell = if i == j {
11556 "self".to_string()
11557 } else {
11558 let d = probe_directed_edge(&sstates[i].relay_state, col, now);
11559 match d.scope.as_deref() {
11560 Some("local") => "local".to_string(),
11561 Some("federation") => "fed".to_string(),
11562 _ => "—".to_string(),
11563 }
11564 };
11565 print!("{cell:>col_w$}");
11566 }
11567 println!();
11568 }
11569
11570 println!("\nHealth (stale threshold: {stale_secs}s):");
11571 for e in &edges {
11572 let from = e["from"].as_str().unwrap_or("?");
11573 let to = e["to"].as_str().unwrap_or("?");
11574 let scope = e["scope"].as_str().unwrap_or("?");
11575 let status = e["status"].as_str().unwrap_or("?");
11576 let mark = match status {
11577 "healthy" => "✓",
11578 "stale" => "⚠",
11579 "asymmetric" => "!",
11580 _ => "?",
11581 };
11582 let dirs = e["directions"].as_object().cloned().unwrap_or_default();
11583 let mut details: Vec<String> = Vec::new();
11584 for (who, d) in &dirs {
11585 let silent = d.get("silent_secs").and_then(Value::as_u64);
11586 let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
11587 let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
11588 let label = match (pinned, probed, silent) {
11589 (false, _, _) => format!("{who} has not pinned"),
11590 (true, false, _) => format!("{who} pinned but no endpoint to probe"),
11591 (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
11592 (true, true, Some(s)) => format!("{who} silent {s}s"),
11593 (true, true, None) => format!("{who} never pulled"),
11594 };
11595 details.push(label);
11596 }
11597 println!(
11598 " {mark} {from} ↔ {to} scope={scope} {status:>10} [{}]",
11599 details.join(" | ")
11600 );
11601 }
11602 Ok(())
11603}
11604
11605#[derive(Default)]
11606struct DirectedEdge {
11607 pinned: bool,
11608 scope: Option<String>,
11609 last_pull_at_unix: Option<u64>,
11610 silent_secs: Option<u64>,
11611 probed: bool,
11612 event_count: usize,
11613}
11614
11615fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
11621 let pinned = from_state
11622 .get("peers")
11623 .and_then(|p| p.get(to_name))
11624 .is_some();
11625 if !pinned {
11626 return DirectedEdge::default();
11627 }
11628 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
11629 let ep = match endpoints.into_iter().next() {
11630 Some(e) => e,
11631 None => {
11632 return DirectedEdge {
11633 pinned: true,
11634 ..Default::default()
11635 };
11636 }
11637 };
11638 let scope = Some(
11639 match ep.scope {
11640 crate::endpoints::EndpointScope::Local => "local",
11641 crate::endpoints::EndpointScope::Lan => "lan",
11642 crate::endpoints::EndpointScope::Uds => "uds",
11643 crate::endpoints::EndpointScope::Federation => "federation",
11644 }
11645 .to_string(),
11646 );
11647 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
11648 let (count, last) = client
11649 .slot_state(&ep.slot_id, &ep.slot_token)
11650 .unwrap_or((0, None));
11651 let silent = last.map(|t| now.saturating_sub(t));
11652 DirectedEdge {
11653 pinned: true,
11654 scope,
11655 last_pull_at_unix: last,
11656 silent_secs: silent,
11657 probed: true,
11658 event_count: count,
11659 }
11660}
11661
11662fn direction_summary(d: &DirectedEdge) -> Value {
11663 json!({
11664 "pinned": d.pinned,
11665 "scope": d.scope,
11666 "probed": d.probed,
11667 "last_pull_at_unix": d.last_pull_at_unix,
11668 "silent_secs": d.silent_secs,
11669 "event_count": d.event_count,
11670 })
11671}
11672
11673fn probe_relay_healthz(url: &str) -> bool {
11675 let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
11676 let client = match reqwest::blocking::Client::builder()
11677 .timeout(std::time::Duration::from_millis(500))
11678 .build()
11679 {
11680 Ok(c) => c,
11681 Err(_) => return false,
11682 };
11683 match client.get(&probe_url).send() {
11684 Ok(r) => r.status().is_success(),
11685 Err(_) => false,
11686 }
11687}
11688
11689fn drive_bilateral_pair(
11704 a_home: &std::path::Path,
11705 a_name: &str,
11706 b_home: &std::path::Path,
11707 b_name: &str,
11708 _fed_host: &str,
11709 _federation_relay: &str,
11710 settle_secs: u64,
11711) -> Result<()> {
11712 use std::time::Duration;
11713 let bin = std::env::current_exe().context("locating self exe")?;
11714
11715 let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
11716 let out = std::process::Command::new(&bin)
11717 .env("WIRE_HOME", home)
11718 .env_remove("RUST_LOG")
11719 .args(args)
11720 .output()
11721 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
11722 if !out.status.success() {
11723 bail!(
11724 "`wire {}` failed: stderr={}",
11725 args.join(" "),
11726 String::from_utf8_lossy(&out.stderr).trim()
11727 );
11728 }
11729 Ok(())
11730 };
11731
11732 let read_card_handle = |home: &std::path::Path| -> Result<String> {
11737 let card_path = home.join("config").join("wire").join("agent-card.json");
11738 let bytes = std::fs::read(&card_path)
11739 .with_context(|| format!("reading agent-card at {card_path:?}"))?;
11740 let card: Value = serde_json::from_slice(&bytes)?;
11741 card.get("handle")
11742 .and_then(Value::as_str)
11743 .map(str::to_string)
11744 .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
11745 };
11746 let a_handle = read_card_handle(a_home)
11747 .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
11748 let b_handle = read_card_handle(b_home)
11749 .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
11750
11751 run(a_home, &["add", b_name, "--local-sister", "--json"])
11755 .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
11756
11757 std::thread::sleep(Duration::from_secs(settle_secs));
11759
11760 run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
11763 run(b_home, &["pair-accept", &a_handle, "--json"]).with_context(|| {
11764 format!("step 5/8: {b_name} `wire pair-accept {a_handle}` (a session={a_name})")
11765 })?;
11766 run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
11767
11768 std::thread::sleep(Duration::from_secs(settle_secs));
11770
11771 run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
11773 let _ = &b_handle;
11775
11776 Ok(())
11777}
11778
11779fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
11780 let name = resolve_session_name(name_arg)?;
11781 let session_home = crate::session::session_dir(&name)?;
11782 if !session_home.exists() {
11783 bail!(
11784 "no session named {name:?} on this machine. `wire session list` to enumerate, \
11785 `wire session new {name}` to create."
11786 );
11787 }
11788 if as_json {
11789 println!(
11790 "{}",
11791 serde_json::to_string(&json!({
11792 "name": name,
11793 "home_dir": session_home.to_string_lossy(),
11794 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
11795 }))?
11796 );
11797 } else {
11798 println!("export WIRE_HOME={}", session_home.to_string_lossy());
11799 }
11800 Ok(())
11801}
11802
11803fn cmd_session_current(as_json: bool) -> Result<()> {
11804 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
11805 let registry = crate::session::read_registry().unwrap_or_default();
11806 let cwd_key = crate::session::normalize_cwd_key(&cwd);
11807 let name = registry
11812 .by_cwd
11813 .get(&cwd_key)
11814 .or_else(|| {
11815 registry
11816 .by_cwd
11817 .iter()
11818 .find(|(k, _)| {
11819 crate::session::normalize_cwd_key(std::path::Path::new(k)) == cwd_key
11820 })
11821 .map(|(_, v)| v)
11822 })
11823 .cloned();
11824 if as_json {
11825 println!(
11826 "{}",
11827 serde_json::to_string(&json!({
11828 "cwd": cwd_key,
11829 "session": name,
11830 }))?
11831 );
11832 } else if let Some(n) = name {
11833 println!("{n}");
11834 } else {
11835 println!("(no session registered for this cwd)");
11836 }
11837 Ok(())
11838}
11839
11840fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
11841 let name = crate::session::sanitize_name(name_arg);
11842 let session_home = crate::session::session_dir(&name)?;
11843 if !session_home.exists() {
11844 if as_json {
11845 println!(
11846 "{}",
11847 serde_json::to_string(&json!({
11848 "name": name,
11849 "destroyed": false,
11850 "reason": "no such session",
11851 }))?
11852 );
11853 } else {
11854 println!("no session named {name:?} — nothing to destroy.");
11855 }
11856 return Ok(());
11857 }
11858 if !force {
11859 bail!(
11860 "destroying session {name:?} would delete its keypair + state irrecoverably. \
11861 Pass --force to confirm."
11862 );
11863 }
11864
11865 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
11867 if let Ok(bytes) = std::fs::read(&pidfile) {
11868 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
11869 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
11870 } else {
11871 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
11872 };
11873 if let Some(p) = pid {
11874 let _ = std::process::Command::new("kill")
11875 .args(["-TERM", &p.to_string()])
11876 .output();
11877 }
11878 }
11879
11880 std::fs::remove_dir_all(&session_home)
11881 .with_context(|| format!("removing session dir {session_home:?}"))?;
11882
11883 let mut registry = crate::session::read_registry().unwrap_or_default();
11885 registry.by_cwd.retain(|_, v| v != &name);
11886 crate::session::write_registry(®istry)?;
11887
11888 if as_json {
11889 println!(
11890 "{}",
11891 serde_json::to_string(&json!({
11892 "name": name,
11893 "destroyed": true,
11894 }))?
11895 );
11896 } else {
11897 println!("destroyed session {name:?}.");
11898 }
11899 Ok(())
11900}
11901
11902fn cmd_diag(action: DiagAction) -> Result<()> {
11905 let state = config::state_dir()?;
11906 let knob = state.join("diag.enabled");
11907 let log_path = state.join("diag.jsonl");
11908 match action {
11909 DiagAction::Tail { limit, json } => {
11910 let entries = crate::diag::tail(limit);
11911 if json {
11912 for e in entries {
11913 println!("{}", serde_json::to_string(&e)?);
11914 }
11915 } else if entries.is_empty() {
11916 println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
11917 } else {
11918 for e in entries {
11919 let ts = e["ts"].as_u64().unwrap_or(0);
11920 let ty = e["type"].as_str().unwrap_or("?");
11921 let pid = e["pid"].as_u64().unwrap_or(0);
11922 let payload = e["payload"].to_string();
11923 println!("[{ts}] pid={pid} {ty} {payload}");
11924 }
11925 }
11926 }
11927 DiagAction::Enable => {
11928 config::ensure_dirs()?;
11929 std::fs::write(&knob, "1")?;
11930 println!("wire diag: enabled at {knob:?}");
11931 }
11932 DiagAction::Disable => {
11933 if knob.exists() {
11934 std::fs::remove_file(&knob)?;
11935 }
11936 println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
11937 }
11938 DiagAction::Status { json } => {
11939 let enabled = crate::diag::is_enabled();
11940 let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
11941 if json {
11942 println!(
11943 "{}",
11944 serde_json::to_string(&serde_json::json!({
11945 "enabled": enabled,
11946 "log_path": log_path,
11947 "log_size_bytes": size,
11948 }))?
11949 );
11950 } else {
11951 println!("wire diag status");
11952 println!(" enabled: {enabled}");
11953 println!(" log: {log_path:?}");
11954 println!(" log size: {size} bytes");
11955 }
11956 }
11957 }
11958 Ok(())
11959}
11960
11961fn cmd_service(action: ServiceAction) -> Result<()> {
11964 let kind = |local_relay: bool| {
11965 if local_relay {
11966 crate::service::ServiceKind::LocalRelay
11967 } else {
11968 crate::service::ServiceKind::Daemon
11969 }
11970 };
11971 let (report, as_json) = match action {
11972 ServiceAction::Install { local_relay, json } => {
11973 (crate::service::install_kind(kind(local_relay))?, json)
11974 }
11975 ServiceAction::Uninstall { local_relay, json } => {
11976 (crate::service::uninstall_kind(kind(local_relay))?, json)
11977 }
11978 ServiceAction::Status { local_relay, json } => {
11979 (crate::service::status_kind(kind(local_relay))?, json)
11980 }
11981 };
11982 if as_json {
11983 println!("{}", serde_json::to_string(&report)?);
11984 } else {
11985 println!("wire service {}", report.action);
11986 println!(" platform: {}", report.platform);
11987 println!(" unit: {}", report.unit_path);
11988 println!(" status: {}", report.status);
11989 println!(" detail: {}", report.detail);
11990 }
11991 Ok(())
11992}
11993
11994const CRATE_NAME: &str = "slancha-wire";
11997
11998fn release_asset_triple() -> Option<(&'static str, &'static str)> {
12002 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
12003 {
12004 return Some(("x86_64-pc-windows-msvc", ".exe"));
12005 }
12006 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
12007 {
12008 return Some(("aarch64-apple-darwin", ""));
12009 }
12010 #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
12011 {
12012 return Some(("x86_64-apple-darwin", ""));
12013 }
12014 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
12015 {
12016 return Some(("x86_64-unknown-linux-musl", ""));
12017 }
12018 #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
12019 {
12020 return Some(("aarch64-unknown-linux-musl", ""));
12021 }
12022 #[allow(unreachable_code)]
12023 None
12024}
12025
12026fn fetch_latest_published_version() -> Result<String> {
12028 let url = format!("https://crates.io/api/v1/crates/{CRATE_NAME}");
12029 let client = reqwest::blocking::Client::builder()
12030 .timeout(std::time::Duration::from_secs(20))
12031 .build()?;
12032 let resp = client
12033 .get(&url)
12034 .header(
12036 "User-Agent",
12037 format!("wire/{} (self-update)", env!("CARGO_PKG_VERSION")),
12038 )
12039 .send()?;
12040 if !resp.status().is_success() {
12041 bail!("crates.io returned {} for {CRATE_NAME}", resp.status());
12042 }
12043 let v: Value = resp.json()?;
12044 v.get("crate")
12045 .and_then(|c| {
12046 c.get("max_stable_version")
12047 .or_else(|| c.get("newest_version"))
12048 })
12049 .and_then(Value::as_str)
12050 .map(str::to_string)
12051 .ok_or_else(|| anyhow!("crates.io response missing crate.max_stable_version"))
12052}
12053
12054fn version_is_newer(latest: &str, current: &str) -> bool {
12057 let parse = |s: &str| -> (u64, u64, u64) {
12058 let core = s.split('-').next().unwrap_or(s);
12059 let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
12060 (
12061 it.next().unwrap_or(0),
12062 it.next().unwrap_or(0),
12063 it.next().unwrap_or(0),
12064 )
12065 };
12066 parse(latest) > parse(current)
12067}
12068
12069fn cargo_on_path() -> bool {
12070 std::process::Command::new("cargo")
12071 .arg("--version")
12072 .stdout(std::process::Stdio::null())
12073 .stderr(std::process::Stdio::null())
12074 .status()
12075 .map(|s| s.success())
12076 .unwrap_or(false)
12077}
12078
12079fn self_update_from_release(latest: &str) -> Result<()> {
12082 let (triple, ext) = release_asset_triple().ok_or_else(|| {
12083 anyhow!(
12084 "no prebuilt release binary for this platform — install a Rust toolchain and re-run, \
12085 or `cargo install {CRATE_NAME}`"
12086 )
12087 })?;
12088 let base =
12089 format!("https://github.com/SlanchaAi/wire/releases/download/v{latest}/wire-{triple}{ext}");
12090 let client = reqwest::blocking::Client::builder()
12091 .timeout(std::time::Duration::from_secs(120))
12092 .build()?;
12093 let resp = client
12094 .get(&base)
12095 .header("User-Agent", "wire-self-update")
12096 .send()?;
12097 if !resp.status().is_success() {
12098 bail!("downloading {base} returned {}", resp.status());
12099 }
12100 let bytes = resp.bytes()?;
12101
12102 if let Ok(sha) = client
12104 .get(format!("{base}.sha256"))
12105 .header("User-Agent", "wire-self-update")
12106 .send()
12107 && sha.status().is_success()
12108 {
12109 let expected = sha
12110 .text()?
12111 .split_whitespace()
12112 .next()
12113 .unwrap_or("")
12114 .to_string();
12115 if !expected.is_empty() {
12116 use sha2::{Digest, Sha256};
12117 let mut h = Sha256::new();
12118 h.update(&bytes);
12119 let actual = hex::encode(h.finalize());
12120 if expected != actual {
12121 bail!(
12122 "SHA-256 mismatch — expected {expected}, got {actual} (aborting, binary NOT replaced)"
12123 );
12124 }
12125 }
12126 }
12127
12128 let exe = std::env::current_exe().context("locating current exe")?;
12129 let dir = exe
12130 .parent()
12131 .ok_or_else(|| anyhow!("current exe has no parent dir"))?;
12132 let tmp = dir.join(format!(".wire-update-{}", std::process::id()));
12133 std::fs::write(&tmp, &bytes).with_context(|| format!("writing {tmp:?}"))?;
12134 #[cfg(unix)]
12135 {
12136 use std::os::unix::fs::PermissionsExt;
12137 let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
12138 std::fs::rename(&tmp, &exe).with_context(|| format!("replacing {exe:?}"))?;
12141 }
12142 #[cfg(windows)]
12143 {
12144 let old = exe.with_extension("old");
12147 let _ = std::fs::remove_file(&old);
12148 std::fs::rename(&exe, &old)
12149 .with_context(|| format!("renaming running exe {exe:?} aside"))?;
12150 std::fs::rename(&tmp, &exe).with_context(|| format!("installing new exe at {exe:?}"))?;
12151 }
12152 Ok(())
12153}
12154
12155struct UpdateOutcome {
12157 current: String,
12158 latest: String,
12159 available: bool,
12161 installed: bool,
12163 via: Option<&'static str>,
12165}
12166
12167fn self_update_step(install: bool) -> Result<UpdateOutcome> {
12171 let current = env!("CARGO_PKG_VERSION").to_string();
12172 let latest = fetch_latest_published_version().context("checking crates.io for latest wire")?;
12173 let available = version_is_newer(&latest, ¤t);
12174 if !install || !available {
12175 return Ok(UpdateOutcome {
12176 current,
12177 latest,
12178 available,
12179 installed: false,
12180 via: None,
12181 });
12182 }
12183 let via = if cargo_on_path() {
12184 eprintln!(
12185 "wire upgrade: {current} → {latest} — installing via `cargo install {CRATE_NAME}` …"
12186 );
12187 let status = std::process::Command::new("cargo")
12188 .args([
12189 "install",
12190 CRATE_NAME,
12191 "--version",
12192 &latest,
12193 "--force",
12194 "--locked",
12195 ])
12196 .status()
12197 .context("running cargo install")?;
12198 if !status.success() {
12199 bail!("`cargo install {CRATE_NAME}` failed");
12200 }
12201 "cargo install"
12202 } else {
12203 eprintln!(
12204 "wire upgrade: {current} → {latest} — no `cargo` on PATH, downloading the prebuilt release binary …"
12205 );
12206 self_update_from_release(&latest)?;
12207 "prebuilt release binary"
12208 };
12209 Ok(UpdateOutcome {
12210 current,
12211 latest,
12212 available,
12213 installed: true,
12214 via: Some(via),
12215 })
12216}
12217
12218fn upgrade_kill_set(
12239 my_pid: Option<u32>,
12240 found_daemon_pids: &[u32],
12241 owned_session_pids: &std::collections::HashSet<u32>,
12242) -> Vec<u32> {
12243 let mut k: Vec<u32> = Vec::new();
12244 if let Some(p) = my_pid {
12245 k.push(p);
12246 }
12247 for &p in found_daemon_pids {
12248 if !owned_session_pids.contains(&p) && Some(p) != my_pid {
12249 k.push(p); }
12251 }
12252 k.sort_unstable();
12253 k.dedup();
12254 k
12255}
12256
12257#[derive(Debug, Clone)]
12264struct PathWireBinary {
12265 path: std::path::PathBuf,
12268 canonical: std::path::PathBuf,
12272 sha256: Option<String>,
12275 mtime: Option<std::time::SystemTime>,
12277 path_index: usize,
12280 is_current_exe: bool,
12286}
12287
12288impl PathWireBinary {
12289 fn is_active(&self) -> bool {
12291 self.path_index == 0
12292 }
12293 fn sha256_short(&self) -> String {
12296 self.sha256
12297 .as_deref()
12298 .map(|s| s[..s.len().min(8)].to_string())
12299 .unwrap_or_else(|| "????????".to_string())
12300 }
12301 fn mtime_display(&self) -> String {
12303 let Some(ts) = self.mtime else {
12304 return "?".to_string();
12305 };
12306 let secs = match ts.duration_since(std::time::UNIX_EPOCH) {
12307 Ok(d) => d.as_secs() as i64,
12308 Err(_) => return "?".to_string(),
12309 };
12310 time::OffsetDateTime::from_unix_timestamp(secs)
12311 .ok()
12312 .and_then(|dt| {
12313 dt.format(&time::format_description::well_known::Rfc3339)
12314 .ok()
12315 })
12316 .unwrap_or_else(|| "?".to_string())
12317 }
12318}
12319
12320fn sha256_file(p: &std::path::Path) -> Result<String> {
12322 use sha2::{Digest, Sha256};
12323 let mut f = std::fs::File::open(p).with_context(|| format!("opening {}", p.display()))?;
12324 let mut h = Sha256::new();
12325 std::io::copy(&mut f, &mut h).with_context(|| format!("hashing {}", p.display()))?;
12326 Ok(hex::encode(h.finalize()))
12327}
12328
12329fn enumerate_path_wire_binaries() -> Vec<PathWireBinary> {
12343 let path = std::env::var("PATH").unwrap_or_default();
12344 let current_exe_canon: Option<std::path::PathBuf> = std::env::current_exe()
12345 .ok()
12346 .and_then(|p| p.canonicalize().ok());
12347 enumerate_path_wire_binaries_from(&path, current_exe_canon.as_deref())
12348}
12349
12350fn enumerate_path_wire_binaries_from(
12355 path: &str,
12356 current_exe_canon: Option<&std::path::Path>,
12357) -> Vec<PathWireBinary> {
12358 if path.is_empty() {
12359 return Vec::new();
12360 }
12361 let separator = if cfg!(windows) { ';' } else { ':' };
12366 let names: &[&str] = if cfg!(windows) {
12367 &["wire.exe", "wire"]
12371 } else {
12372 &["wire"]
12373 };
12374
12375 let mut seen: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
12376 let mut out: Vec<PathWireBinary> = Vec::new();
12377 for dir in path.split(separator) {
12378 if dir.is_empty() {
12379 continue;
12380 }
12381 for name in names {
12382 let candidate = std::path::PathBuf::from(dir).join(name);
12383 if !candidate.is_file() {
12386 continue;
12387 }
12388 let canon = candidate
12389 .canonicalize()
12390 .unwrap_or_else(|_| candidate.clone());
12391 if !seen.insert(canon.clone()) {
12392 break;
12395 }
12396 let meta = std::fs::metadata(&canon).ok();
12397 let mtime = meta.as_ref().and_then(|m| m.modified().ok());
12398 let sha256 = sha256_file(&canon).ok();
12399 let is_current_exe = current_exe_canon
12400 .map(|c| c == canon.as_path())
12401 .unwrap_or(false);
12402 let path_index = out.len();
12403 out.push(PathWireBinary {
12404 path: candidate,
12405 canonical: canon,
12406 sha256,
12407 mtime,
12408 path_index,
12409 is_current_exe,
12410 });
12411 break;
12414 }
12415 }
12416 out
12417}
12418
12419fn path_shadow_warning(bins: &[PathWireBinary]) -> Option<String> {
12431 let any_current = bins.iter().any(|b| b.is_current_exe);
12432 let multi = bins.len() >= 2;
12433 let off_path = !bins.is_empty() && !any_current;
12434 let none_on_path = bins.is_empty();
12435 if !multi && !off_path && !none_on_path {
12436 return None;
12437 }
12438 let mut out = String::new();
12439 if multi {
12440 out.push_str(&format!(
12441 "WARN: {} distinct `wire` binaries on PATH — older entries can shadow your fresh install:\n",
12442 bins.len()
12443 ));
12444 for b in bins {
12445 let mut tags: Vec<&str> = Vec::new();
12446 if b.is_active() {
12447 tags.push("ACTIVE (bare `wire` resolves here)");
12448 }
12449 if b.is_current_exe {
12450 tags.push("THIS upgrade ran against this binary");
12451 }
12452 let tag_str = if tags.is_empty() {
12453 String::new()
12454 } else {
12455 format!(" ← {}", tags.join("; "))
12456 };
12457 out.push_str(&format!(
12458 " [{}] {} (sha256:{} mtime:{}){}\n",
12459 b.path_index,
12460 b.path.display(),
12461 b.sha256_short(),
12462 b.mtime_display(),
12463 tag_str,
12464 ));
12465 }
12466 if !any_current {
12467 out.push_str(
12468 " NOTE: none of the PATH-resident binaries is the one running this `wire upgrade`.\n",
12469 );
12470 out.push_str(
12471 " Your upgrade will NOT affect bare `wire` calls in shells, scripts, or peer agents.\n",
12472 );
12473 } else if !bins[0].is_current_exe {
12474 out.push_str(
12475 " Bare `wire` calls (shells, scripts, daemons, peer agents) will use the\n",
12476 );
12477 out.push_str(
12478 " ACTIVE binary [0], NOT the one you just upgraded. Recommended fixes:\n",
12479 );
12480 out.push_str(&format!(
12481 " - rm {} (or symlink it to the upgraded binary)\n",
12482 bins[0].path.display(),
12483 ));
12484 out.push_str(
12485 " - or reorder PATH so the upgraded binary's directory precedes the active one\n",
12486 );
12487 out.push_str(" Verify with: which -a wire\n");
12488 }
12489 } else if off_path {
12490 let active = &bins[0];
12492 out.push_str("WARN: this `wire upgrade` is running against an off-PATH binary;\n");
12493 out.push_str(&format!(
12494 " bare `wire` resolves to {} (sha256:{}),\n",
12495 active.path.display(),
12496 active.sha256_short(),
12497 ));
12498 out.push_str(
12499 " which was NOT touched by this upgrade. Shells, scripts, and peer agents\n",
12500 );
12501 out.push_str(" will continue to invoke the old binary.\n");
12502 } else if none_on_path {
12503 out.push_str("WARN: no `wire` binary on PATH; bare `wire` will fail in future shells.\n");
12504 out.push_str(" This upgrade ran against an absolute-path invocation only.\n");
12505 }
12506 Some(out.trim_end().to_string())
12507}
12508
12509#[cfg(test)]
12510mod upgrade_tests {
12511 use super::*;
12512 use std::collections::HashSet;
12513
12514 #[test]
12515 fn upgrade_kill_set_is_session_scoped() {
12516 let owned: HashSet<u32> = [100, 200].into_iter().collect();
12518 let k = upgrade_kill_set(Some(100), &[100, 200, 999], &owned);
12520 assert!(k.contains(&100), "must kill my own daemon (to replace it)");
12521 assert!(k.contains(&999), "must sweep a true orphan");
12522 assert!(!k.contains(&200), "must SPARE a sibling session's daemon");
12523
12524 assert_eq!(
12528 upgrade_kill_set(Some(100), &[], &owned),
12529 vec![100],
12530 "own daemon killed even when the process scan is empty"
12531 );
12532
12533 assert_eq!(upgrade_kill_set(None, &[999], &HashSet::new()), vec![999]);
12535 }
12536
12537 fn write_fake_wire(dir: &std::path::Path, body: &[u8]) -> std::path::PathBuf {
12545 use std::io::Write;
12546 let p = dir.join("wire");
12547 let mut f = std::fs::File::create(&p).expect("create fake wire");
12548 f.write_all(body).expect("write fake wire");
12549 drop(f);
12550 #[cfg(unix)]
12551 {
12552 use std::os::unix::fs::PermissionsExt;
12553 let mut perm = std::fs::metadata(&p).unwrap().permissions();
12554 perm.set_mode(0o755);
12555 std::fs::set_permissions(&p, perm).unwrap();
12556 }
12557 p
12558 }
12559
12560 #[test]
12561 #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
12562 fn enumerate_finds_no_binaries_when_path_empty() {
12563 let bins = enumerate_path_wire_binaries_from("", None);
12564 assert!(
12565 bins.is_empty(),
12566 "empty PATH yields no binaries, got {bins:?}"
12567 );
12568 }
12569
12570 #[test]
12571 #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
12572 fn enumerate_detects_two_distinct_binaries_in_path_order() {
12573 let d1 = tempfile::tempdir().unwrap();
12574 let d2 = tempfile::tempdir().unwrap();
12575 let p1 = write_fake_wire(d1.path(), b"#!/bin/sh\necho A\n");
12576 let p2 = write_fake_wire(d2.path(), b"#!/bin/sh\necho B\n");
12577 let path = format!("{}:{}", d1.path().display(), d2.path().display());
12578
12579 let bins = enumerate_path_wire_binaries_from(&path, None);
12580 assert_eq!(bins.len(), 2, "expected two distinct binaries: {bins:?}");
12581 assert_eq!(bins[0].path_index, 0);
12582 assert_eq!(bins[1].path_index, 1);
12583 assert!(bins[0].is_active(), "first PATH entry is active");
12584 assert!(!bins[1].is_active(), "second PATH entry is not active");
12585 assert_ne!(
12587 bins[0].sha256, bins[1].sha256,
12588 "distinct contents must hash differently"
12589 );
12590 assert_eq!(bins[0].path, p1);
12592 assert_eq!(bins[1].path, p2);
12593 }
12594
12595 #[test]
12596 #[cfg_attr(windows, ignore = "PATH separator + symlink semantics differ")]
12597 fn enumerate_collapses_symlink_chains_to_one_entry() {
12598 let real_dir = tempfile::tempdir().unwrap();
12599 let link_dir = tempfile::tempdir().unwrap();
12600 let real = write_fake_wire(real_dir.path(), b"#!/bin/sh\necho real\n");
12601 let link = link_dir.path().join("wire");
12602 #[cfg(unix)]
12603 std::os::unix::fs::symlink(&real, &link).unwrap();
12604
12605 let path = format!(
12609 "{}:{}",
12610 link_dir.path().display(),
12611 real_dir.path().display()
12612 );
12613 let bins = enumerate_path_wire_binaries_from(&path, None);
12614 assert_eq!(
12615 bins.len(),
12616 1,
12617 "symlink chain must collapse to a single entry: {bins:?}"
12618 );
12619 assert!(bins[0].is_active());
12620 assert_eq!(bins[0].path, link);
12622 assert_eq!(bins[0].canonical, real.canonicalize().unwrap());
12623 }
12624
12625 #[test]
12626 #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
12627 fn shadow_warning_off_path_when_current_exe_not_on_path() {
12628 let d = tempfile::tempdir().unwrap();
12631 write_fake_wire(d.path(), b"#!/bin/sh\necho only\n");
12632 let elsewhere = tempfile::tempdir().unwrap();
12633 let cur = elsewhere.path().join("not-on-path-wire");
12634 let bins = enumerate_path_wire_binaries_from(&d.path().display().to_string(), Some(&cur));
12635 assert_eq!(bins.len(), 1);
12636 assert!(!bins[0].is_current_exe);
12637 let warn = path_shadow_warning(&bins).expect("off-path single bin must warn");
12638 assert!(
12639 warn.contains("off-PATH binary"),
12640 "off-path WARN must mention off-PATH; got: {warn}"
12641 );
12642 }
12643
12644 #[test]
12645 fn shadow_warning_fires_when_no_binaries_at_all() {
12646 let bins: Vec<PathWireBinary> = Vec::new();
12647 let warn = path_shadow_warning(&bins).expect("empty must warn");
12648 assert!(warn.contains("no `wire` binary on PATH"), "got: {warn}");
12649 }
12650
12651 #[test]
12652 #[cfg_attr(windows, ignore = "PATH separator differs")]
12653 fn shadow_warning_multi_binaries_names_active_and_recommends_fix() {
12654 let d1 = tempfile::tempdir().unwrap();
12655 let d2 = tempfile::tempdir().unwrap();
12656 write_fake_wire(d1.path(), b"published\n");
12657 write_fake_wire(d2.path(), b"head\n");
12658 let path = format!("{}:{}", d1.path().display(), d2.path().display());
12659 let bins = enumerate_path_wire_binaries_from(&path, None);
12660 let warn = path_shadow_warning(&bins).expect("two distinct bins must warn");
12661 assert!(warn.contains("2 distinct"), "got: {warn}");
12662 assert!(warn.contains("ACTIVE"), "must mark the active binary");
12663 assert!(
12664 warn.contains("which -a wire") || warn.contains("none of the PATH-resident"),
12665 "must guide the operator to a fix; got: {warn}"
12666 );
12667 }
12668}
12669
12670fn cmd_upgrade(
12671 check_only: bool,
12672 local: bool,
12673 restart_mcp: bool,
12674 refresh_stale_children: bool,
12675 as_json: bool,
12676) -> Result<()> {
12677 let update: Option<UpdateOutcome> = if local {
12683 None
12684 } else {
12685 match self_update_step(!check_only) {
12686 Ok(o) => Some(o),
12687 Err(e) => {
12688 if !check_only {
12689 eprintln!("wire upgrade: update check skipped — {e:#}");
12690 }
12691 None
12692 }
12693 }
12694 };
12695 if let Some(o) = &update
12696 && o.installed
12697 {
12698 eprintln!(
12699 "wire upgrade: installed {} (was {}, via {}); restarting the daemon on the new binary.",
12700 o.latest,
12701 o.current,
12702 o.via.unwrap_or("self-update")
12703 );
12704 }
12705
12706 let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
12715 let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
12716 let mcp_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire mcp");
12724 let running_pids: Vec<u32> = daemon_pids
12725 .iter()
12726 .chain(relay_pids.iter())
12727 .copied()
12728 .collect();
12729
12730 let record = crate::ensure_up::read_pid_record("daemon");
12732 let recorded_version: Option<String> = match &record {
12733 crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
12734 crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
12735 _ => None,
12736 };
12737 let cli_version = env!("CARGO_PKG_VERSION").to_string();
12738
12739 let my_daemon_pid = record.pid();
12753 let owned_session_pids: std::collections::HashSet<u32> = crate::session::list_sessions()
12754 .unwrap_or_default()
12755 .iter()
12756 .filter_map(|s| crate::session::session_daemon_pid(&s.home_dir))
12757 .collect();
12758 let mut kill_set = upgrade_kill_set(my_daemon_pid, &daemon_pids, &owned_session_pids);
12759 let stale_children_killed: Vec<serde_json::Value> = if refresh_stale_children {
12770 match crate::daemon_supervisor::read_supervisor_state() {
12771 Ok(sv) => {
12772 let mut killed: Vec<serde_json::Value> = Vec::new();
12773 let cli_v = env!("CARGO_PKG_VERSION");
12774 for s in &sv.sessions {
12775 if !sv.stale_binary_sessions.contains(&s.name) {
12776 continue;
12777 }
12778 if let Some(pid) = s.daemon_pid {
12779 if !kill_set.contains(&pid) {
12783 kill_set.push(pid);
12784 }
12785 killed.push(json!({
12786 "session": s.name,
12787 "pid": pid,
12788 "prev_version": s.daemon_version,
12789 "cli_version": cli_v,
12790 }));
12791 }
12792 }
12793 if !killed.is_empty() && !as_json {
12794 eprintln!(
12795 "wire upgrade: --refresh-stale-children will kill {} stale-binary session daemon(s); supervisor respawns each on next 10s poll.",
12796 killed.len()
12797 );
12798 }
12799 killed
12800 }
12801 Err(e) => {
12802 if !as_json {
12803 eprintln!(
12804 "wire upgrade: --refresh-stale-children skipped — could not read supervisor state ({e:#}). \
12805 The flag is a no-op when no `wire daemon --all-sessions` supervisor is running."
12806 );
12807 }
12808 Vec::new()
12809 }
12810 }
12811 } else {
12812 Vec::new()
12813 };
12814
12815 if check_only {
12816 let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
12818 .unwrap_or_default()
12819 .iter()
12820 .filter(|s| s.daemon_running)
12821 .map(|s| s.name.clone())
12822 .collect();
12823 let path_bins = enumerate_path_wire_binaries();
12824 let path_dupes: Vec<String> = path_bins
12825 .iter()
12826 .map(|b| b.canonical.to_string_lossy().into_owned())
12827 .collect();
12828 let path_binaries_detail: Vec<serde_json::Value> = path_bins
12829 .iter()
12830 .map(|b| {
12831 json!({
12832 "path": b.path.to_string_lossy(),
12833 "canonical": b.canonical.to_string_lossy(),
12834 "sha256": b.sha256,
12835 "mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
12836 "path_index": b.path_index,
12837 "is_active": b.is_active(),
12838 "is_current_exe": b.is_current_exe,
12839 })
12840 })
12841 .collect();
12842 let path_warning_check = path_shadow_warning(&path_bins);
12843 let installed_service_kinds: Vec<&'static str> = [
12846 (crate::service::ServiceKind::Daemon, "daemon"),
12847 (crate::service::ServiceKind::LocalRelay, "local-relay"),
12848 ]
12849 .into_iter()
12850 .filter_map(|(k, label)| {
12851 crate::service::status_kind(k)
12852 .ok()
12853 .filter(|r| r.status != "absent")
12854 .map(|_| label)
12855 })
12856 .collect();
12857 let (update_latest, update_available) = match &update {
12858 Some(o) => (Some(o.latest.clone()), o.available),
12859 None => (None, false),
12860 };
12861 let report = json!({
12862 "running_pids": running_pids,
12863 "running_daemons": daemon_pids,
12864 "running_relay_servers": relay_pids,
12865 "running_mcp_servers": mcp_pids,
12872 "would_warn_stale_mcp_servers": !mcp_pids.is_empty() && !restart_mcp,
12873 "would_restart_mcp_servers": restart_mcp && !mcp_pids.is_empty(),
12874 "restart_mcp_requested": restart_mcp,
12875 "pidfile_version": recorded_version,
12876 "cli_version": cli_version,
12877 "latest_published": update_latest,
12878 "update_available": update_available,
12879 "would_kill": kill_set,
12880 "would_refresh_services": installed_service_kinds,
12881 "session_daemons_running": sessions_with_daemons,
12882 "path_binaries": path_dupes,
12883 "path_binaries_detail": path_binaries_detail,
12884 "path_duplicate_warning": path_dupes.len() > 1,
12885 "path_warning": path_warning_check,
12886 });
12887 if as_json {
12888 println!("{}", serde_json::to_string(&report)?);
12889 } else {
12890 println!("wire upgrade --check");
12891 println!(" cli version: {cli_version}");
12892 match (&update_latest, update_available) {
12893 (Some(l), true) => println!(" latest published: {l} (UPDATE AVAILABLE)"),
12894 (Some(l), false) => println!(" latest published: {l} (up to date)"),
12895 (None, _) => println!(" latest published: (crates.io check skipped)"),
12896 }
12897 println!(
12898 " pidfile version: {}",
12899 recorded_version.as_deref().unwrap_or("(missing)")
12900 );
12901 if running_pids.is_empty() {
12902 println!(" running daemons: none");
12903 println!(" running relays: none");
12904 } else {
12905 if daemon_pids.is_empty() {
12906 println!(" running daemons: none");
12907 } else {
12908 let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
12909 println!(" running daemons: pids {}", p.join(", "));
12910 }
12911 if relay_pids.is_empty() {
12912 println!(" running relays: none");
12913 } else {
12914 let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
12915 println!(" running relays: pids {}", p.join(", "));
12916 }
12917 println!(" would kill all + spawn fresh");
12918 }
12919 if !mcp_pids.is_empty() {
12924 let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
12925 if restart_mcp {
12926 println!(
12927 " wire mcp servers: pids {} (would be killed via --restart-mcp; host respawns on new binary)",
12928 p.join(", ")
12929 );
12930 } else {
12931 println!(
12932 " wire mcp servers: pids {} (NOT killed; each Claude tab must `/mcp` reconnect, or re-run with --restart-mcp to signal them now)",
12933 p.join(", ")
12934 );
12935 }
12936 }
12937 if !installed_service_kinds.is_empty() {
12938 println!(
12939 " would refresh: {} installed service unit(s) → new binary path",
12940 installed_service_kinds.join(", ")
12941 );
12942 }
12943 if !sessions_with_daemons.is_empty() {
12944 println!(
12945 " session daemons: {} (would respawn under new binary)",
12946 sessions_with_daemons.join(", ")
12947 );
12948 }
12949 if let Ok(sv) = crate::daemon_supervisor::read_supervisor_state()
12953 && !sv.stale_binary_sessions.is_empty()
12954 {
12955 let cli_v = env!("CARGO_PKG_VERSION");
12956 if refresh_stale_children {
12957 println!(
12958 " stale children: {} session(s) on old binary; --refresh-stale-children WOULD kill each so supervisor respawns on v{cli_v}",
12959 sv.stale_binary_sessions.len()
12960 );
12961 } else {
12962 println!(
12963 " stale children: {} session(s) on old binary (v{cli_v} is current); rerun with --refresh-stale-children to refresh them",
12964 sv.stale_binary_sessions.len()
12965 );
12966 }
12967 for name in &sv.stale_binary_sessions {
12968 let ver = sv
12969 .sessions
12970 .iter()
12971 .find(|s| &s.name == name)
12972 .and_then(|s| s.daemon_version.clone())
12973 .unwrap_or_else(|| "?".to_string());
12974 println!(" - {name} running v{ver}");
12975 }
12976 }
12977 if let Some(w) = &path_warning_check {
12978 println!(" PATH check:");
12979 for line in w.lines() {
12980 println!(" {line}");
12981 }
12982 }
12983 }
12984 return Ok(());
12985 }
12986
12987 for pid in &kill_set {
12999 let _ = crate::platform::kill_process(*pid, false); }
13001 if !kill_set.is_empty() {
13002 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
13004 while std::time::Instant::now() < deadline && kill_set.iter().any(|p| process_alive_pid(*p))
13005 {
13006 std::thread::sleep(std::time::Duration::from_millis(50));
13007 }
13008 for pid in &kill_set {
13011 if process_alive_pid(*pid) {
13012 let _ = crate::platform::kill_process(*pid, true);
13013 }
13014 }
13015 std::thread::sleep(std::time::Duration::from_millis(200)); }
13017 let killed: Vec<u32> = kill_set
13019 .iter()
13020 .copied()
13021 .filter(|p| !process_alive_pid(*p))
13022 .collect();
13023
13024 let pidfile = config::state_dir()?.join("daemon.pid");
13027 if pidfile.exists() {
13028 let _ = std::fs::remove_file(&pidfile);
13029 }
13030
13031 let path_bins = enumerate_path_wire_binaries();
13043 let path_dupes: Vec<String> = path_bins
13044 .iter()
13045 .map(|b| b.canonical.to_string_lossy().into_owned())
13046 .collect();
13047 let path_binaries_detail: Vec<Value> = path_bins
13048 .iter()
13049 .map(|b| {
13050 json!({
13051 "path": b.path.to_string_lossy(),
13052 "canonical": b.canonical.to_string_lossy(),
13053 "sha256": b.sha256,
13054 "mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
13055 "path_index": b.path_index,
13056 "is_active": b.is_active(),
13057 "is_current_exe": b.is_current_exe,
13058 })
13059 })
13060 .collect();
13061 let path_warning = path_shadow_warning(&path_bins);
13062
13063 let mut service_refreshes: Vec<Value> = Vec::new();
13077 for kind in [
13078 crate::service::ServiceKind::Daemon,
13079 crate::service::ServiceKind::LocalRelay,
13080 ] {
13081 let already_installed = crate::service::status_kind(kind)
13082 .map(|r| r.status != "absent")
13083 .unwrap_or(false);
13084 if !already_installed {
13085 continue;
13086 }
13087 match crate::service::install_kind(kind) {
13088 Ok(rep) => service_refreshes.push(json!({
13089 "kind": rep.kind,
13090 "platform": rep.platform,
13091 "status": rep.status,
13092 "unit_path": rep.unit_path,
13093 "action": "refreshed",
13094 })),
13095 Err(e) => service_refreshes.push(json!({
13096 "kind": format!("{kind:?}"),
13097 "action": "refresh_failed",
13098 "error": format!("{e:#}"),
13099 })),
13100 }
13101 }
13102
13103 let supervisor_will_spawn = service_refreshes.iter().any(|r| {
13130 let kind = r.get("kind").and_then(Value::as_str).unwrap_or("");
13131 let action = r.get("action").and_then(Value::as_str).unwrap_or("");
13132 let status = r.get("status").and_then(Value::as_str).unwrap_or("");
13133 kind == "daemon"
13134 && action == "refreshed"
13135 && matches!(
13136 status,
13137 "loaded" | "enabled" | "active" | "registered" | "running"
13138 )
13139 });
13140 let spawned = if supervisor_will_spawn {
13141 None
13144 } else {
13145 Some(crate::ensure_up::ensure_daemon_running()?)
13146 };
13147
13148 let session_respawns: Vec<Value> = Vec::new();
13153
13154 let new_record = crate::ensure_up::read_pid_record("daemon");
13155 let new_pid = new_record.pid();
13156 let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
13157 Some(d.version.clone())
13158 } else {
13159 None
13160 };
13161
13162 let killed_mcp: Vec<u32> = if restart_mcp && !mcp_pids.is_empty() {
13179 for pid in &mcp_pids {
13180 let _ = crate::platform::kill_process(*pid, false);
13181 }
13182 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
13183 while std::time::Instant::now() < deadline && mcp_pids.iter().any(|p| process_alive_pid(*p))
13184 {
13185 std::thread::sleep(std::time::Duration::from_millis(50));
13186 }
13187 for pid in &mcp_pids {
13188 if process_alive_pid(*pid) {
13189 let _ = crate::platform::kill_process(*pid, true);
13190 }
13191 }
13192 mcp_pids
13193 .iter()
13194 .copied()
13195 .filter(|p| !process_alive_pid(*p))
13196 .collect()
13197 } else {
13198 Vec::new()
13199 };
13200
13201 if as_json {
13202 println!(
13203 "{}",
13204 serde_json::to_string(&json!({
13205 "killed": killed,
13206 "found_daemons": daemon_pids,
13207 "spared_relay_servers": relay_pids,
13208 "stale_mcp_server_pids": mcp_pids,
13218 "killed_mcp_server_pids": killed_mcp,
13219 "restart_mcp_requested": restart_mcp,
13220 "stale_mcp_warning": if mcp_pids.is_empty() || restart_mcp {
13221 Value::Null
13222 } else {
13223 json!(format!(
13224 "{} `wire mcp` server subprocess(es) still on pre-upgrade code; each Claude tab must `/mcp` reconnect to pick up the new binary (or re-run with `wire upgrade --restart-mcp` to signal them now)",
13225 mcp_pids.len()
13226 ))
13227 },
13228 "service_refreshes": service_refreshes,
13229 "spawned_fresh_daemon": spawned,
13230 "new_pid": new_pid,
13231 "new_version": new_version,
13232 "cli_version": cli_version,
13233 "session_respawns": session_respawns,
13234 "stale_children_killed": stale_children_killed,
13235 "path_binaries": path_dupes,
13236 "path_binaries_detail": path_binaries_detail,
13237 "path_warning": path_warning,
13238 }))?
13239 );
13240 } else {
13241 if killed.is_empty() {
13242 println!("wire upgrade: no stale wire processes running");
13243 } else {
13244 let killed_list = killed
13245 .iter()
13246 .map(|p| p.to_string())
13247 .collect::<Vec<_>>()
13248 .join(", ");
13249 if relay_pids.is_empty() {
13254 println!(
13255 "wire upgrade: killed {} daemon(s) [{killed_list}]",
13256 killed.len()
13257 );
13258 } else {
13259 let relay_list = relay_pids
13260 .iter()
13261 .map(|p| p.to_string())
13262 .collect::<Vec<_>>()
13263 .join(", ");
13264 println!(
13265 "wire upgrade: killed {} daemon(s) [{killed_list}]; spared {} shared relay-server(s) [{relay_list}]",
13266 killed.len(),
13267 relay_pids.len()
13268 );
13269 }
13270 }
13271 if !stale_children_killed.is_empty() {
13272 let cli_v = env!("CARGO_PKG_VERSION");
13273 println!(
13274 "wire upgrade: refreshed {} stale-binary session daemon(s) (supervisor respawns on v{cli_v} on next 10s poll):",
13275 stale_children_killed.len()
13276 );
13277 for entry in &stale_children_killed {
13278 let name = entry.get("session").and_then(Value::as_str).unwrap_or("?");
13279 let pid = entry.get("pid").and_then(Value::as_u64).unwrap_or(0);
13280 let prev = entry
13281 .get("prev_version")
13282 .and_then(Value::as_str)
13283 .unwrap_or("?");
13284 println!(" - {name} (pid {pid}, was v{prev})");
13285 }
13286 }
13287 if !service_refreshes.is_empty() {
13288 println!(
13289 "wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
13290 service_refreshes.len()
13291 );
13292 for r in &service_refreshes {
13293 let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
13294 let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
13295 let status = r.get("status").and_then(Value::as_str).unwrap_or("");
13296 let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
13297 if action == "refreshed" {
13298 println!(" - {kind}: {action} ({status}, {platform})");
13299 } else {
13300 let err = r.get("error").and_then(Value::as_str).unwrap_or("");
13301 println!(" - {kind}: {action} ({err})");
13302 }
13303 }
13304 }
13305 match spawned {
13306 Some(true) => println!(
13307 "wire upgrade: spawned fresh daemon (pid {} v{})",
13308 new_pid
13309 .map(|p| p.to_string())
13310 .unwrap_or_else(|| "?".to_string()),
13311 new_version.as_deref().unwrap_or(&cli_version),
13312 ),
13313 Some(false) => {
13314 println!("wire upgrade: daemon was already running on current binary");
13315 }
13316 None => println!(
13323 "wire upgrade: daemon refresh deferred to {} supervisor (will spawn within 10s)",
13324 if cfg!(target_os = "macos") {
13325 "launchd"
13326 } else if cfg!(target_os = "linux") {
13327 "systemd"
13328 } else if cfg!(target_os = "windows") {
13329 "Task Scheduler"
13330 } else {
13331 "OS"
13332 }
13333 ),
13334 }
13335 if !session_respawns.is_empty() {
13336 println!(
13337 "wire upgrade: refreshed {} session daemon(s):",
13338 session_respawns.len()
13339 );
13340 for r in &session_respawns {
13341 let h = r["session_home"].as_str().unwrap_or("?");
13342 let s = r["status"].as_str().unwrap_or("?");
13343 let label = std::path::Path::new(h)
13344 .file_name()
13345 .map(|f| f.to_string_lossy().into_owned())
13346 .unwrap_or_else(|| h.to_string());
13347 println!(" {label:<24} {s}");
13348 }
13349 }
13350 if let Some(msg) = &path_warning {
13351 eprintln!("wire upgrade: {msg}");
13352 }
13353 if restart_mcp {
13362 if !killed_mcp.is_empty() {
13363 let p: Vec<String> = killed_mcp.iter().map(|p| p.to_string()).collect();
13364 println!(
13365 "wire upgrade: killed {} `wire mcp` server subprocess(es) [{}]; host (Claude Code / Claude.app / Copilot CLI) will respawn on the new binary.",
13366 killed_mcp.len(),
13367 p.join(", ")
13368 );
13369 } else if mcp_pids.is_empty() {
13370 println!(
13374 "wire upgrade: --restart-mcp set, but no `wire mcp` server subprocesses were running."
13375 );
13376 } else {
13377 let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
13381 eprintln!(
13382 "wire upgrade: WARNING — --restart-mcp requested but {} `wire mcp` subprocess(es) [{}] survived signaling. Check process ownership / OS permissions.",
13383 mcp_pids.len(),
13384 p.join(", ")
13385 );
13386 }
13387 } else if !mcp_pids.is_empty() {
13388 let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
13389 eprintln!(
13390 "wire upgrade: NOTE — {} `wire mcp` server subprocess(es) [{}] still on pre-upgrade code (Claude Code / Claude.app pin these at session start). Each Claude tab must `/mcp` reconnect (or restart the host app) to pick up the new binary. Run `wire upgrade --restart-mcp` to signal them now.",
13391 mcp_pids.len(),
13392 p.join(", ")
13393 );
13394 }
13395 }
13396 Ok(())
13397}
13398
13399fn json_default(explicit: bool) -> bool {
13409 if explicit {
13410 return true;
13411 }
13412 if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
13413 return false;
13414 }
13415 use std::io::IsTerminal;
13416 !std::io::stdout().is_terminal()
13417}
13418
13419fn process_alive_pid(pid: u32) -> bool {
13420 crate::platform::process_alive(pid)
13425}
13426
13427fn levenshtein_ci(a: &str, b: &str) -> usize {
13433 let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
13434 let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
13435 let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
13436 let (m, n) = (a.len(), b.len());
13437 if m == 0 {
13438 return n;
13439 }
13440 let mut prev: Vec<usize> = (0..=m).collect();
13441 let mut curr = vec![0usize; m + 1];
13442 for j in 1..=n {
13443 curr[0] = j;
13444 for i in 1..=m {
13445 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
13446 curr[i] = std::cmp::min(
13447 std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
13448 prev[i - 1] + cost,
13449 );
13450 }
13451 std::mem::swap(&mut prev, &mut curr);
13452 }
13453 prev[m]
13454}
13455
13456pub fn closest_candidates(
13460 needle: &str,
13461 pool: &[String],
13462 max_distance: usize,
13463 max_results: usize,
13464) -> Vec<String> {
13465 let mut scored: Vec<(usize, &String)> = pool
13466 .iter()
13467 .map(|c| (levenshtein_ci(needle, c), c))
13468 .filter(|(d, _)| *d <= max_distance)
13469 .collect();
13470 scored.sort_by_key(|(d, _)| *d);
13471 scored
13472 .into_iter()
13473 .take(max_results)
13474 .map(|(_, c)| c.clone())
13475 .collect()
13476}
13477
13478fn known_local_names() -> Vec<String> {
13483 let mut names: Vec<String> = Vec::new();
13484 if let Ok(trust) = config::read_trust() {
13485 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
13491 for (handle, agent) in agents {
13492 names.push(handle.clone());
13493 if let Some(did) = agent.get("did").and_then(Value::as_str) {
13494 let ch = crate::character::Character::from_did(did);
13495 names.push(ch.nickname);
13496 }
13497 }
13498 }
13499 }
13500 if let Ok(sessions) = crate::session::list_sessions() {
13501 for s in sessions {
13502 names.push(s.name.clone());
13503 if let Some(h) = &s.handle {
13504 names.push(h.clone());
13505 }
13506 if let Some(ch) = &s.character {
13507 names.push(ch.nickname.clone());
13508 }
13509 }
13510 }
13511 names.sort();
13512 names.dedup();
13513 names
13514}
13515
13516fn deprecation_warn(verb: &str, replacement: &str, json_mode: bool) {
13524 if json_mode {
13525 return;
13526 }
13527 let key = format!("WIRE_DEPRECATION_NAGGED_{}", verb.replace('-', "_"));
13535 if std::env::var(&key).is_ok() {
13536 return;
13537 }
13538 unsafe {
13542 std::env::set_var(&key, "1");
13543 }
13544 eprintln!(
13545 "wire {verb}: DEPRECATED in v0.9 — use `wire {replacement}`. \
13546 Will be removed in v1.0 (target 2026-Q3). \
13547 Suppress: set WIRE_DEPRECATION_NAGGED_{}=1.",
13548 verb.replace('-', "_")
13549 );
13550}
13551
13552#[derive(Clone, Debug, serde::Serialize)]
13556pub struct DoctorCheck {
13557 pub id: String,
13560 pub status: String,
13562 pub detail: String,
13564 #[serde(skip_serializing_if = "Option::is_none")]
13566 pub fix: Option<String>,
13567}
13568
13569impl DoctorCheck {
13570 fn pass(id: &str, detail: impl Into<String>) -> Self {
13571 Self {
13572 id: id.into(),
13573 status: "PASS".into(),
13574 detail: detail.into(),
13575 fix: None,
13576 }
13577 }
13578 fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
13579 Self {
13580 id: id.into(),
13581 status: "WARN".into(),
13582 detail: detail.into(),
13583 fix: Some(fix.into()),
13584 }
13585 }
13586 fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
13587 Self {
13588 id: id.into(),
13589 status: "FAIL".into(),
13590 detail: detail.into(),
13591 fix: Some(fix.into()),
13592 }
13593 }
13594}
13595
13596fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
13601 let checks: Vec<DoctorCheck> = vec![
13602 check_daemon_health(),
13603 check_daemon_pid_consistency(),
13604 check_relay_reachable(),
13605 check_pair_rejections(recent_rejections),
13606 check_cursor_progress(),
13607 check_peer_staleness(7),
13608 check_and_heal_self_userinfo_endpoints(),
13609 check_stale_inbound_pairs(),
13610 ];
13611
13612 let fails = checks.iter().filter(|c| c.status == "FAIL").count();
13613 let warns = checks.iter().filter(|c| c.status == "WARN").count();
13614
13615 if as_json {
13616 println!(
13617 "{}",
13618 serde_json::to_string(&json!({
13619 "checks": checks,
13620 "fail_count": fails,
13621 "warn_count": warns,
13622 "ok": fails == 0,
13623 }))?
13624 );
13625 } else {
13626 println!("wire doctor — {} checks", checks.len());
13627 for c in &checks {
13628 let bullet = match c.status.as_str() {
13629 "PASS" => "✓",
13630 "WARN" => "!",
13631 "FAIL" => "✗",
13632 _ => "?",
13633 };
13634 println!(" {bullet} [{}] {}: {}", c.status, c.id, c.detail);
13635 if let Some(fix) = &c.fix {
13636 println!(" fix: {fix}");
13637 }
13638 }
13639 println!();
13640 if fails == 0 && warns == 0 {
13641 println!("ALL GREEN");
13642 } else {
13643 println!("{fails} FAIL, {warns} WARN");
13644 }
13645 }
13646
13647 if fails > 0 {
13648 std::process::exit(1);
13649 }
13650 Ok(())
13651}
13652
13653fn check_daemon_health() -> DoctorCheck {
13660 let snap = crate::ensure_up::daemon_liveness();
13666 let pgrep_pids = &snap.pgrep_pids;
13667 let pidfile_pid = snap.pidfile_pid;
13668 let pidfile_alive = snap.pidfile_alive;
13669 let orphan_pids = &snap.orphan_pids;
13670
13671 let fmt_pids = |xs: &[u32]| -> String {
13672 xs.iter()
13673 .map(|p| p.to_string())
13674 .collect::<Vec<_>>()
13675 .join(", ")
13676 };
13677
13678 match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
13679 (0, _, _) => DoctorCheck::fail(
13680 "daemon",
13681 "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
13682 "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
13683 ),
13684 (1, true, true) => DoctorCheck::pass(
13686 "daemon",
13687 format!(
13688 "one daemon running (pid {}, matches pidfile)",
13689 pgrep_pids[0]
13690 ),
13691 ),
13692 (n, true, false) => DoctorCheck::fail(
13694 "daemon",
13695 format!(
13696 "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
13697 The orphans race the relay cursor — they advance past events your current binary can't process. \
13698 (Issue #2 exact class.)",
13699 fmt_pids(pgrep_pids),
13700 pidfile_pid.unwrap(),
13701 fmt_pids(orphan_pids),
13702 ),
13703 "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
13704 ),
13705 (n, false, _) => DoctorCheck::fail(
13707 "daemon",
13708 format!(
13709 "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
13710 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
13711 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
13712 fmt_pids(pgrep_pids),
13713 match pidfile_pid {
13714 Some(p) => format!("claims pid {p} which is dead"),
13715 None => "is missing".to_string(),
13716 },
13717 ),
13718 "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
13719 ),
13720 (n, true, true) => {
13732 let supervisor_pid: Option<u32> = crate::session::sessions_root()
13734 .ok()
13735 .map(|root| root.join("supervisor.pid"))
13736 .filter(|p| p.exists())
13737 .and_then(|p| std::fs::read_to_string(p).ok())
13738 .and_then(|s| s.trim().parse::<u32>().ok())
13739 .filter(|p| crate::ensure_up::pid_is_alive(*p));
13740 if let Some(sup) = supervisor_pid
13741 && pgrep_pids.contains(&sup)
13742 {
13743 let child_count = n.saturating_sub(1);
13744 DoctorCheck::pass(
13745 "daemon",
13746 format!(
13747 "supervisor (pid {sup}) + {child_count} session child daemon(s) — legitimate #170 `--all-sessions` topology, no orphans"
13748 ),
13749 )
13750 } else {
13751 DoctorCheck::warn(
13752 "daemon",
13753 format!(
13754 "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
13755 fmt_pids(pgrep_pids)
13756 ),
13757 "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
13758 )
13759 }
13760 }
13761 }
13762}
13763
13764fn check_daemon_pid_consistency() -> DoctorCheck {
13776 let snap = crate::ensure_up::daemon_liveness();
13777 match &snap.record {
13778 crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
13779 "daemon_pid_consistency",
13780 "no daemon.pid yet — fresh box or daemon never started",
13781 ),
13782 crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
13783 "daemon_pid_consistency",
13784 format!("daemon.pid is corrupt: {reason}"),
13785 "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
13786 ),
13787 crate::ensure_up::PidRecord::LegacyInt(pid) => {
13788 let pid = *pid;
13791 if !crate::ensure_up::pid_is_alive(pid) {
13792 return DoctorCheck::warn(
13793 "daemon_pid_consistency",
13794 format!(
13795 "daemon.pid (legacy-int) points at pid {pid} which is not running. \
13796 Stale pidfile from a crashed pre-0.5.11 daemon. \
13797 (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
13798 ),
13799 "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
13800 );
13801 }
13802 DoctorCheck::warn(
13803 "daemon_pid_consistency",
13804 format!(
13805 "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
13806 Daemon was started by a pre-0.5.11 binary."
13807 ),
13808 "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
13809 )
13810 }
13811 crate::ensure_up::PidRecord::Json(d) => {
13812 if !snap.pidfile_alive {
13816 return DoctorCheck::warn(
13817 "daemon_pid_consistency",
13818 format!(
13819 "daemon.pid records pid {pid} (v{version}) but that process is not running — \
13820 pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
13821 silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
13822 pid = d.pid,
13823 version = d.version,
13824 ),
13825 "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
13826 (kills any orphan daemon advancing the cursor without coordination)",
13827 );
13828 }
13829 let mut issues: Vec<String> = Vec::new();
13830 if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
13831 issues.push(format!(
13832 "schema={} (expected {})",
13833 d.schema,
13834 crate::ensure_up::DAEMON_PID_SCHEMA
13835 ));
13836 }
13837 let cli_version = env!("CARGO_PKG_VERSION");
13838 if d.version != cli_version {
13839 issues.push(format!("version daemon={} cli={cli_version}", d.version));
13840 }
13841 if !std::path::Path::new(&d.bin_path).exists() {
13842 issues.push(format!("bin_path {} missing on disk", d.bin_path));
13843 }
13844 if let Ok(card) = config::read_agent_card()
13846 && let Some(current_did) = card.get("did").and_then(Value::as_str)
13847 && let Some(recorded_did) = &d.did
13848 && recorded_did != current_did
13849 {
13850 issues.push(format!(
13851 "did daemon={recorded_did} config={current_did} — identity drift"
13852 ));
13853 }
13854 if let Ok(state) = config::read_relay_state()
13855 && let Some(current_relay) = state
13856 .get("self")
13857 .and_then(|s| s.get("relay_url"))
13858 .and_then(Value::as_str)
13859 && let Some(recorded_relay) = &d.relay_url
13860 && recorded_relay != current_relay
13861 {
13862 issues.push(format!(
13863 "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
13864 ));
13865 }
13866 if issues.is_empty() {
13867 DoctorCheck::pass(
13868 "daemon_pid_consistency",
13869 format!(
13870 "daemon v{} bound to {} as {}",
13871 d.version,
13872 d.relay_url.as_deref().unwrap_or("?"),
13873 d.did.as_deref().unwrap_or("?")
13874 ),
13875 )
13876 } else {
13877 DoctorCheck::warn(
13878 "daemon_pid_consistency",
13879 format!("daemon pidfile drift: {}", issues.join("; ")),
13880 "`wire upgrade` to atomically restart daemon with current config".to_string(),
13881 )
13882 }
13883 }
13884 }
13885}
13886
13887fn check_relay_reachable() -> DoctorCheck {
13889 let state = match config::read_relay_state() {
13890 Ok(s) => s,
13891 Err(e) => {
13892 return DoctorCheck::fail(
13893 "relay",
13894 format!("could not read relay state: {e}"),
13895 "run `wire up <handle>@<relay>` to bootstrap",
13896 );
13897 }
13898 };
13899 let url = state
13900 .get("self")
13901 .and_then(|s| s.get("relay_url"))
13902 .and_then(Value::as_str)
13903 .unwrap_or("");
13904 if url.is_empty() {
13905 return DoctorCheck::warn(
13906 "relay",
13907 "no relay bound — wire send/pull will not work",
13908 "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
13909 );
13910 }
13911 let client = crate::relay_client::RelayClient::new(url);
13912 match client.check_healthz() {
13913 Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
13914 Err(e) => DoctorCheck::fail(
13915 "relay",
13916 format!("{url} unreachable: {e}"),
13917 format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
13918 ),
13919 }
13920}
13921
13922fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
13926 let path = match config::state_dir() {
13927 Ok(d) => d.join("pair-rejected.jsonl"),
13928 Err(e) => {
13929 return DoctorCheck::warn(
13930 "pair_rejections",
13931 format!("could not resolve state dir: {e}"),
13932 "set WIRE_HOME or fix XDG_STATE_HOME",
13933 );
13934 }
13935 };
13936 if !path.exists() {
13937 return DoctorCheck::pass(
13938 "pair_rejections",
13939 "no pair-rejected.jsonl — no recorded pair failures",
13940 );
13941 }
13942 let body = match std::fs::read_to_string(&path) {
13943 Ok(b) => b,
13944 Err(e) => {
13945 return DoctorCheck::warn(
13946 "pair_rejections",
13947 format!("could not read {path:?}: {e}"),
13948 "check file permissions",
13949 );
13950 }
13951 };
13952 let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
13953 if lines.is_empty() {
13954 return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
13955 }
13956 let total = lines.len();
13957 let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
13958 let mut summary: Vec<String> = Vec::new();
13959 for line in &recent {
13960 if let Ok(rec) = serde_json::from_str::<Value>(line) {
13961 let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
13962 let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
13963 summary.push(format!("{peer}/{code}"));
13964 }
13965 }
13966 DoctorCheck::warn(
13967 "pair_rejections",
13968 format!(
13969 "{total} pair failures recorded. recent: [{}]",
13970 summary.join(", ")
13971 ),
13972 format!(
13973 "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
13974 ),
13975 )
13976}
13977
13978fn check_and_heal_self_userinfo_endpoints() -> DoctorCheck {
14042 let mut state = match config::read_relay_state() {
14043 Ok(s) => s,
14044 Err(_) => {
14045 return DoctorCheck::pass(
14046 "self-userinfo-endpoints",
14047 "no relay state yet — nothing published to heal".to_string(),
14048 );
14049 }
14050 };
14051 let self_block = match state.get_mut("self").and_then(Value::as_object_mut) {
14052 Some(s) => s,
14053 None => {
14054 return DoctorCheck::pass(
14055 "self-userinfo-endpoints",
14056 "no self block in relay state — nothing published to heal".to_string(),
14057 );
14058 }
14059 };
14060
14061 let mut stripped: Vec<String> = Vec::new();
14062 let mut clean_seed: Option<(String, String, String)> = None;
14063
14064 if let Some(endpoints) = self_block
14065 .get_mut("endpoints")
14066 .and_then(Value::as_array_mut)
14067 {
14068 endpoints.retain(|ep| {
14069 let url = ep.get("relay_url").and_then(Value::as_str).unwrap_or("");
14070 if assert_relay_url_clean_for_publish(url).is_err() {
14074 stripped.push(url.to_string());
14075 false
14076 } else {
14077 if clean_seed.is_none() {
14078 clean_seed = Some((
14079 url.to_string(),
14080 ep.get("slot_id")
14081 .and_then(Value::as_str)
14082 .unwrap_or("")
14083 .to_string(),
14084 ep.get("slot_token")
14085 .and_then(Value::as_str)
14086 .unwrap_or("")
14087 .to_string(),
14088 ));
14089 }
14090 true
14091 }
14092 });
14093 }
14094
14095 let mut legacy_healed = false;
14100 let legacy_url = self_block
14101 .get("relay_url")
14102 .and_then(Value::as_str)
14103 .unwrap_or("")
14104 .to_string();
14105 if !legacy_url.is_empty() && assert_relay_url_clean_for_publish(&legacy_url).is_err() {
14106 if let Some((url, sid, tok)) = &clean_seed {
14107 self_block.insert("relay_url".to_string(), Value::String(url.clone()));
14108 self_block.insert("slot_id".to_string(), Value::String(sid.clone()));
14109 self_block.insert("slot_token".to_string(), Value::String(tok.clone()));
14110 legacy_healed = true;
14111 stripped.push(format!("(legacy top-level) {legacy_url}"));
14112 } else {
14113 return DoctorCheck::warn(
14118 "self-userinfo-endpoints",
14119 format!(
14120 "your published endpoint is malformed (`{legacy_url}` — handle as URL \
14121 userinfo, the bug PR #61 prevents going forward) AND no clean endpoint \
14122 exists to fall back to. Inbound POSTs to this endpoint 4xx; bilateral \
14123 pairing can't complete."
14124 ),
14125 "Bind a clean federation slot first, then re-run doctor to heal: \
14126 `wire bind-relay https://wireup.net` (or your own relay). The bind \
14127 adds a clean endpoint additively; the next `wire doctor` run then \
14128 strips the malformed one safely. Finally re-publish your card with \
14129 `wire claim <your-persona>` so the phonebook serves the clean shape."
14130 .to_string(),
14131 );
14132 }
14133 }
14134
14135 if stripped.is_empty() && !legacy_healed {
14136 return DoctorCheck::pass(
14137 "self-userinfo-endpoints",
14138 "no malformed endpoints in self-state".to_string(),
14139 );
14140 }
14141
14142 if let Err(e) = config::write_relay_state(&state) {
14146 return DoctorCheck::warn(
14147 "self-userinfo-endpoints",
14148 format!(
14149 "detected {} malformed userinfo-bearing endpoint(s) in self-state but \
14150 failed to persist the heal: {e:#}. Found: {}",
14151 stripped.len(),
14152 stripped.join(", ")
14153 ),
14154 "re-run `wire doctor` — likely a transient lock contention".to_string(),
14155 );
14156 }
14157
14158 DoctorCheck::warn(
14159 "self-userinfo-endpoints",
14160 format!(
14161 "healed {} malformed endpoint(s) in self-state on disk: {}. \
14162 These were the `https://<handle>@<host>` shape that PR #61 prevents \
14163 at the write side but couldn't retroactively scrub from existing \
14164 operators. relay.json is now clean.",
14165 stripped.len(),
14166 stripped.join(", ")
14167 ),
14168 "re-publish your agent-card to the phonebook so peers resolve to the \
14169 clean endpoint: `wire claim <your-persona>` (find your persona with \
14170 `wire whoami`)."
14171 .to_string(),
14172 )
14173}
14174
14175fn check_stale_inbound_pairs() -> DoctorCheck {
14184 let pinned_verified: std::collections::HashSet<String> = config::read_trust()
14185 .ok()
14186 .and_then(|t| t.get("agents").and_then(Value::as_object).cloned())
14187 .map(|agents| {
14188 agents
14189 .into_iter()
14190 .filter_map(|(h, a)| {
14191 let tier = a.get("tier").and_then(Value::as_str).unwrap_or("");
14192 if matches!(tier, "VERIFIED" | "ORG_VERIFIED" | "ATTESTED") {
14193 Some(h)
14194 } else {
14195 None
14196 }
14197 })
14198 .collect()
14199 })
14200 .unwrap_or_default();
14201 let stale: Vec<String> = crate::pending_inbound_pair::list_pending_inbound()
14202 .unwrap_or_default()
14203 .into_iter()
14204 .filter(|p| pinned_verified.contains(&p.peer_handle))
14205 .map(|p| p.peer_handle)
14206 .collect();
14207 if stale.is_empty() {
14208 return DoctorCheck::pass(
14209 "stale-inbound-pairs",
14210 "no pre-#171 leftover pending_inbound records for VERIFIED peers",
14211 );
14212 }
14213 let n = stale.len();
14214 let list = stale.join(", ");
14215 let fix_list = stale
14216 .iter()
14217 .map(|h| format!("wire reject {h}"))
14218 .collect::<Vec<_>>()
14219 .join(" && ");
14220 DoctorCheck::warn(
14221 "stale-inbound-pairs",
14222 format!(
14223 "{n} VERIFIED peer(s) still carry a pre-#171 pending_inbound record: {list}. Benign but leaks into `wire status --json.pending_pairs.stale_inbound_handles`."
14224 ),
14225 format!("clear with `{fix_list}`"),
14226 )
14227}
14228
14229fn check_peer_staleness(max_silent_days: u64) -> DoctorCheck {
14230 let state = match config::read_relay_state() {
14231 Ok(s) => s,
14232 Err(_) => {
14233 return DoctorCheck::pass(
14234 "peer-staleness",
14235 "no relay state yet — nothing pinned to check".to_string(),
14236 );
14237 }
14238 };
14239 let peers = match state.get("peers").and_then(Value::as_object) {
14240 Some(p) => p,
14241 None => {
14242 return DoctorCheck::pass("peer-staleness", "no pinned peers".to_string());
14243 }
14244 };
14245 if peers.is_empty() {
14246 return DoctorCheck::pass("peer-staleness", "no pinned peers".to_string());
14247 }
14248 let inbox_dir = match config::inbox_dir() {
14249 Ok(d) => d,
14250 Err(_) => {
14251 return DoctorCheck::warn(
14252 "peer-staleness",
14253 "could not resolve inbox dir; skipping peer-staleness check".to_string(),
14254 "check `wire status` for state-dir resolution".to_string(),
14255 );
14256 }
14257 };
14258 let threshold_secs = max_silent_days * 24 * 60 * 60;
14259 let threshold = std::time::Duration::from_secs(threshold_secs);
14260 let now = std::time::SystemTime::now();
14261 let now_unix = now
14269 .duration_since(std::time::UNIX_EPOCH)
14270 .map(|d| d.as_secs() as i64)
14271 .unwrap_or(0);
14272 let mut stale: Vec<(String, u64, &'static str)> = Vec::new();
14273 for (peer, info) in peers {
14274 let daemon_signal_ts = info
14276 .get("last_inbound_event_at")
14277 .and_then(Value::as_str)
14278 .and_then(|s| {
14279 time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
14280 })
14281 .map(|odt| odt.unix_timestamp());
14282 if let Some(ts) = daemon_signal_ts {
14283 let age = now_unix.saturating_sub(ts) as u64;
14284 if age > threshold_secs {
14285 stale.push((peer.clone(), age / (24 * 60 * 60), "silent"));
14286 }
14287 continue;
14288 }
14289 let path = inbox_dir.join(format!("{peer}.jsonl"));
14291 let (age_days, kind) = match std::fs::metadata(&path) {
14292 Ok(meta) => match meta
14293 .modified()
14294 .ok()
14295 .and_then(|m| now.duration_since(m).ok())
14296 {
14297 Some(d) if d > threshold => (d.as_secs() / (24 * 60 * 60), "silent"),
14298 Some(_) => continue, None => (0, "unknown-mtime"),
14300 },
14301 Err(_) => (max_silent_days + 1, "no-inbox-file"),
14302 };
14303 stale.push((peer.clone(), age_days, kind));
14304 }
14305 if stale.is_empty() {
14306 return DoctorCheck::pass(
14307 "peer-staleness",
14308 format!(
14309 "all {} pinned peer(s) have inbox traffic within the last {max_silent_days} day(s)",
14310 peers.len()
14311 ),
14312 );
14313 }
14314 let detail = stale
14315 .iter()
14316 .map(|(p, d, k)| match *k {
14317 "no-inbox-file" => format!("{p} (no inbox file)"),
14318 "unknown-mtime" => format!("{p} (unknown last-event time)"),
14319 _ => format!("{p} ({d}d silent)"),
14320 })
14321 .collect::<Vec<_>>()
14322 .join(", ");
14323 DoctorCheck::warn(
14324 "peer-staleness",
14325 format!(
14326 "{} pinned peer(s) silent for >{max_silent_days}d: {detail}. \
14327 If the peer re-bound their relay slot, our pin is now stale — \
14328 we push successfully to a dead slot and they never see us \
14329 (asymmetric failure, both sides report green).",
14330 stale.len()
14331 ),
14332 "re-pair with `wire add <peer>@<relay>` to refresh the slot. \
14333 Once issue #15 lands, this also auto-resolves on 410 Gone."
14334 .to_string(),
14335 )
14336}
14337
14338fn check_cursor_progress() -> DoctorCheck {
14339 let state = match config::read_relay_state() {
14340 Ok(s) => s,
14341 Err(e) => {
14342 return DoctorCheck::warn(
14343 "cursor",
14344 format!("could not read relay state: {e}"),
14345 "check ~/Library/Application Support/wire/relay.json",
14346 );
14347 }
14348 };
14349 let cursor = state
14350 .get("self")
14351 .and_then(|s| s.get("last_pulled_event_id"))
14352 .and_then(Value::as_str)
14353 .map(|s| s.chars().take(16).collect::<String>())
14354 .unwrap_or_else(|| "<none>".to_string());
14355 DoctorCheck::pass(
14356 "cursor",
14357 format!(
14358 "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
14359 ),
14360 )
14361}
14362
14363#[cfg(test)]
14364mod doctor_tests {
14365 use super::*;
14366
14367 #[test]
14368 fn doctor_check_constructors_set_status_correctly() {
14369 let p = DoctorCheck::pass("x", "ok");
14374 assert_eq!(p.status, "PASS");
14375 assert_eq!(p.fix, None);
14376
14377 let w = DoctorCheck::warn("x", "watch out", "do this");
14378 assert_eq!(w.status, "WARN");
14379 assert_eq!(w.fix, Some("do this".to_string()));
14380
14381 let f = DoctorCheck::fail("x", "broken", "fix it");
14382 assert_eq!(f.status, "FAIL");
14383 assert_eq!(f.fix, Some("fix it".to_string()));
14384 }
14385
14386 #[test]
14387 fn check_pair_rejections_no_file_is_pass() {
14388 config::test_support::with_temp_home(|| {
14391 config::ensure_dirs().unwrap();
14392 let c = check_pair_rejections(5);
14393 assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
14394 });
14395 }
14396
14397 #[test]
14398 fn check_pair_rejections_with_entries_warns() {
14399 config::test_support::with_temp_home(|| {
14403 config::ensure_dirs().unwrap();
14404 crate::pair_invite::record_pair_rejection(
14405 "willard",
14406 "pair_drop_ack_send_failed",
14407 "POST 502",
14408 );
14409 let c = check_pair_rejections(5);
14410 assert_eq!(c.status, "WARN");
14411 assert!(c.detail.contains("1 pair failures"));
14412 assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
14413 });
14414 }
14415
14416 #[test]
14417 fn check_peer_staleness_no_peers_is_pass() {
14418 config::test_support::with_temp_home(|| {
14421 config::ensure_dirs().unwrap();
14422 let c = check_peer_staleness(7);
14423 assert_eq!(c.status, "PASS", "no peers should be PASS, got {c:?}");
14424 });
14425 }
14426
14427 #[test]
14428 fn check_peer_staleness_pinned_with_no_inbox_file_warns() {
14429 config::test_support::with_temp_home(|| {
14434 config::ensure_dirs().unwrap();
14435 let mut state = json!({
14437 "peers": {
14438 "stale-peer": {
14439 "relay_url": "https://wireup.net",
14440 "slot_id": "deadslot",
14441 "slot_token": "tok",
14442 }
14443 }
14444 });
14445 state["self"] = json!({});
14446 config::write_relay_state(&state).unwrap();
14447
14448 let c = check_peer_staleness(7);
14449 assert_eq!(
14450 c.status, "WARN",
14451 "pinned peer with no inbox file must surface: {c:?}"
14452 );
14453 assert!(
14454 c.detail.contains("stale-peer"),
14455 "WARN must name the silent peer so the operator can act: {}",
14456 c.detail
14457 );
14458 assert!(
14459 c.detail.contains("asymmetric")
14460 || c.detail.contains("stale")
14461 || c.detail.contains("dead slot"),
14462 "WARN must surface the failure-mode language so the operator \
14463 finds the diagnosis without re-tracing: {}",
14464 c.detail
14465 );
14466 assert!(
14467 c.fix
14468 .as_ref()
14469 .is_some_and(|f| f.contains("wire add") && f.contains("#15")),
14470 "fix pointer must reference both the manual re-pair AND the \
14471 follow-up issue (#15) that will automate this: {:?}",
14472 c.fix
14473 );
14474 });
14475 }
14476
14477 #[test]
14478 fn check_peer_staleness_pinned_with_fresh_inbox_is_pass() {
14479 config::test_support::with_temp_home(|| {
14483 config::ensure_dirs().unwrap();
14484 let mut state = json!({
14485 "peers": {
14486 "active-peer": {
14487 "relay_url": "https://wireup.net",
14488 "slot_id": "freshslot",
14489 "slot_token": "tok",
14490 }
14491 }
14492 });
14493 state["self"] = json!({});
14494 config::write_relay_state(&state).unwrap();
14495
14496 let inbox = config::inbox_dir().unwrap();
14497 std::fs::create_dir_all(&inbox).unwrap();
14498 std::fs::write(
14499 inbox.join("active-peer.jsonl"),
14500 "{\"event_id\":\"recent\"}\n",
14501 )
14502 .unwrap();
14503
14504 let c = check_peer_staleness(7);
14505 assert_eq!(c.status, "PASS", "fresh inbox should not warn: {c:?}");
14506 });
14507 }
14508
14509 #[test]
14510 fn check_peer_staleness_daemon_field_overrides_mtime() {
14511 config::test_support::with_temp_home(|| {
14517 config::ensure_dirs().unwrap();
14518 let mut state = json!({
14519 "peers": {
14520 "ghost-peer": {
14521 "relay_url": "https://wireup.net",
14522 "slot_id": "ghostslot",
14523 "slot_token": "tok",
14524 "last_inbound_event_at": "2026-05-01T00:00:00Z",
14525 }
14526 }
14527 });
14528 state["self"] = json!({});
14529 config::write_relay_state(&state).unwrap();
14530 let inbox = config::inbox_dir().unwrap();
14532 std::fs::create_dir_all(&inbox).unwrap();
14533 std::fs::write(inbox.join("ghost-peer.jsonl"), "{\"event_id\":\"x\"}\n").unwrap();
14534 let c = check_peer_staleness(7);
14535 assert_eq!(
14536 c.status, "WARN",
14537 "daemon-field staleness must override fresh mtime: {c:?}"
14538 );
14539 assert!(c.detail.contains("ghost-peer"), "got: {}", c.detail);
14540 });
14541 }
14542
14543 #[test]
14544 fn check_peer_staleness_daemon_field_fresh_overrides_old_mtime() {
14545 config::test_support::with_temp_home(|| {
14550 config::ensure_dirs().unwrap();
14551 let now = time::OffsetDateTime::now_utc()
14553 .format(&time::format_description::well_known::Rfc3339)
14554 .unwrap();
14555 let mut state = json!({
14556 "peers": {
14557 "active-peer": {
14558 "relay_url": "https://wireup.net",
14559 "slot_id": "freshslot",
14560 "slot_token": "tok",
14561 "last_inbound_event_at": now,
14562 }
14563 }
14564 });
14565 state["self"] = json!({});
14566 config::write_relay_state(&state).unwrap();
14567 let c = check_peer_staleness(7);
14571 assert_eq!(
14572 c.status, "PASS",
14573 "recent daemon-field stamp must PASS regardless of mtime: {c:?}"
14574 );
14575 });
14576 }
14577
14578 #[test]
14579 fn check_self_userinfo_no_state_is_pass() {
14580 config::test_support::with_temp_home(|| {
14584 let c = check_and_heal_self_userinfo_endpoints();
14586 assert_eq!(c.status, "PASS", "no state should be PASS, got {c:?}");
14587 });
14588 }
14589
14590 #[test]
14591 fn check_self_userinfo_clean_state_is_pass_no_mutation() {
14592 config::test_support::with_temp_home(|| {
14596 config::ensure_dirs().unwrap();
14597 let state = json!({
14598 "self": {
14599 "endpoints": [
14600 {
14601 "relay_url": "https://wireup.net",
14602 "scope": "Federation",
14603 "slot_id": "abc",
14604 "slot_token": "tok"
14605 }
14606 ],
14607 "relay_url": "https://wireup.net",
14608 "slot_id": "abc",
14609 "slot_token": "tok"
14610 },
14611 "peers": {}
14612 });
14613 config::write_relay_state(&state).unwrap();
14614
14615 let c = check_and_heal_self_userinfo_endpoints();
14616 assert_eq!(c.status, "PASS", "clean state should be PASS: {c:?}");
14617
14618 let after = config::read_relay_state().unwrap();
14620 assert_eq!(after, state, "PASS path must NOT mutate relay.json");
14621 });
14622 }
14623
14624 #[test]
14625 fn check_self_userinfo_heals_malformed_endpoint_and_promotes_clean() {
14626 config::test_support::with_temp_home(|| {
14633 config::ensure_dirs().unwrap();
14634 let state = json!({
14635 "self": {
14636 "endpoints": [
14637 {
14638 "relay_url": "https://copilot-agent@wireup.net",
14639 "scope": "Federation",
14640 "slot_id": "stale-id",
14641 "slot_token": "stale-token"
14642 },
14643 {
14644 "relay_url": "https://wireup.net",
14645 "scope": "Federation",
14646 "slot_id": "clean-id",
14647 "slot_token": "clean-token"
14648 }
14649 ],
14650 "relay_url": "https://copilot-agent@wireup.net",
14651 "slot_id": "stale-id",
14652 "slot_token": "stale-token"
14653 },
14654 "peers": {}
14655 });
14656 config::write_relay_state(&state).unwrap();
14657
14658 let c = check_and_heal_self_userinfo_endpoints();
14659 assert_eq!(c.status, "WARN", "heal should report WARN: {c:?}");
14660 assert!(
14661 c.detail.contains("healed") && c.detail.contains("copilot-agent@wireup.net"),
14662 "WARN must name the stripped URL so the operator sees what changed: {}",
14663 c.detail
14664 );
14665 assert!(
14666 c.fix.as_ref().is_some_and(|f| f.contains("wire claim")),
14667 "fix must point at re-publishing the agent-card so the phonebook entry \
14668 matches the healed state on disk: {:?}",
14669 c.fix
14670 );
14671
14672 let after = config::read_relay_state().unwrap();
14676 let endpoints = after["self"]["endpoints"].as_array().unwrap();
14677 assert_eq!(endpoints.len(), 1, "malformed endpoint must be removed");
14678 assert_eq!(endpoints[0]["relay_url"], "https://wireup.net");
14679 assert_eq!(after["self"]["relay_url"], "https://wireup.net");
14680 assert_eq!(after["self"]["slot_id"], "clean-id");
14681 assert_eq!(after["self"]["slot_token"], "clean-token");
14682 });
14683 }
14684
14685 #[test]
14686 fn check_self_userinfo_no_clean_fallback_warns_without_mutating() {
14687 config::test_support::with_temp_home(|| {
14693 config::ensure_dirs().unwrap();
14694 let state = json!({
14695 "self": {
14696 "endpoints": [
14697 {
14698 "relay_url": "https://copilot-agent@wireup.net",
14699 "scope": "Federation",
14700 "slot_id": "stale-id",
14701 "slot_token": "stale-token"
14702 }
14703 ],
14704 "relay_url": "https://copilot-agent@wireup.net",
14705 "slot_id": "stale-id",
14706 "slot_token": "stale-token"
14707 },
14708 "peers": {}
14709 });
14710 config::write_relay_state(&state).unwrap();
14711
14712 let c = check_and_heal_self_userinfo_endpoints();
14713 assert_eq!(c.status, "WARN");
14714 assert!(
14715 c.fix
14716 .as_ref()
14717 .is_some_and(|f| f.contains("wire bind-relay") && f.contains("wire claim")),
14718 "no-clean-fallback fix must require BOTH a clean bind AND a re-claim: {:?}",
14719 c.fix
14720 );
14721
14722 let after = config::read_relay_state().unwrap();
14725 assert_eq!(
14726 after, state,
14727 "no-clean-fallback path must NOT mutate state (would strand operator)"
14728 );
14729 });
14730 }
14731}
14732
14733fn cmd_up(
14745 relay_arg: Option<&str>,
14746 name: Option<&str>,
14747 with_local: Option<&str>,
14748 no_local: bool,
14749 as_json: bool,
14750) -> Result<()> {
14751 let relay_url = match relay_arg {
14755 Some(r) => {
14756 let r = r.trim_start_matches('@');
14757 if r.starts_with("http://") || r.starts_with("https://") {
14758 r.to_string()
14759 } else {
14760 format!("https://{r}")
14761 }
14762 }
14763 None => crate::pair_invite::DEFAULT_RELAY.to_string(),
14764 };
14765
14766 let relay_url = strip_relay_url_userinfo(&relay_url);
14773
14774 let mut report: Vec<(String, String)> = Vec::new();
14775 let mut step = |stage: &str, detail: String| {
14776 report.push((stage.to_string(), detail.clone()));
14777 if !as_json {
14778 eprintln!("wire up: {stage} — {detail}");
14779 }
14780 };
14781
14782 if config::is_initialized()? {
14785 step("init", "already initialized".to_string());
14786 } else {
14787 cmd_init(
14788 None,
14789 name,
14790 Some(&relay_url),
14791 false,
14792 false,
14793 )?;
14794 step("init", format!("created identity bound to {relay_url}"));
14795 }
14796
14797 let canonical = {
14799 let card = config::read_agent_card()?;
14800 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
14801 crate::agent_card::display_handle_from_did(did).to_string()
14802 };
14803 step("identity", format!("persona is `{canonical}`"));
14804
14805 let relay_state = config::read_relay_state()?;
14809 let bound_relay = relay_state
14810 .get("self")
14811 .and_then(|s| s.get("relay_url"))
14812 .and_then(Value::as_str)
14813 .unwrap_or("")
14814 .to_string();
14815 if bound_relay.is_empty() {
14816 cmd_bind_relay(
14820 &relay_url, None, false, false, false,
14822 )?;
14823 step("bind-relay", format!("bound to {relay_url}"));
14824 } else if bound_relay != relay_url {
14825 step(
14826 "bind-relay",
14827 format!(
14828 "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
14829 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
14830 ),
14831 );
14832 } else {
14833 step("bind-relay", format!("already bound to {bound_relay}"));
14834 }
14835
14836 match cmd_claim(
14839 &canonical,
14840 Some(&relay_url),
14841 None,
14842 false,
14843 false,
14844 ) {
14845 Ok(()) => step(
14846 "claim",
14847 format!("{canonical}@{} claimed", strip_proto(&relay_url)),
14848 ),
14849 Err(e) => step(
14850 "claim",
14851 format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
14852 ),
14853 }
14854
14855 if no_local {
14860 step("local-slot", "skipped (--no-local)".to_string());
14861 } else {
14862 let local_url = with_local
14863 .unwrap_or("http://127.0.0.1:8771")
14864 .trim_end_matches('/');
14865 let already_local = crate::endpoints::self_endpoints(
14866 &config::read_relay_state().unwrap_or_else(|_| json!({})),
14867 )
14868 .iter()
14869 .any(|e| e.relay_url == local_url);
14870 if relay_url.trim_end_matches('/') == local_url || already_local {
14871 step("local-slot", "already covered".to_string());
14872 } else if crate::relay_client::RelayClient::new(local_url)
14873 .check_healthz()
14874 .is_ok()
14875 {
14876 match cmd_bind_relay(
14877 local_url,
14878 Some("local"),
14879 false,
14880 false,
14881 false,
14882 ) {
14883 Ok(()) => step(
14884 "local-slot",
14885 format!("dual-bound local relay {local_url} for sister routing"),
14886 ),
14887 Err(e) => step("local-slot", format!("skipped local relay: {e}")),
14888 }
14889 } else {
14890 step(
14891 "local-slot",
14892 format!(
14893 "no local relay reachable at {local_url} — federation only \
14894 (sisters resolve via session-list)"
14895 ),
14896 );
14897 }
14898 }
14899
14900 match crate::ensure_up::ensure_daemon_running() {
14902 Ok(true) => step("daemon", "started fresh background daemon".to_string()),
14903 Ok(false) => step("daemon", "already running".to_string()),
14904 Err(e) => step(
14905 "daemon",
14906 format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
14907 ),
14908 }
14909
14910 let summary =
14912 "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
14913 `wire monitor` to watch incoming events."
14914 .to_string();
14915 step("ready", summary.clone());
14916
14917 if as_json {
14918 let steps_json: Vec<_> = report
14919 .iter()
14920 .map(|(k, v)| json!({"stage": k, "detail": v}))
14921 .collect();
14922 println!(
14923 "{}",
14924 serde_json::to_string(&json!({
14925 "nick": canonical,
14926 "relay": relay_url,
14927 "steps": steps_json,
14928 }))?
14929 );
14930 }
14931 Ok(())
14932}
14933
14934fn strip_proto(url: &str) -> String {
14936 url.trim_start_matches("https://")
14937 .trim_start_matches("http://")
14938 .to_string()
14939}
14940
14941pub fn error_smells_like_slot_4xx(last_err: &str) -> bool {
15018 fn is_token_boundary(b: u8) -> bool {
15019 matches!(b, b' ' | b':' | b'\t' | b'\n' | b'\r')
15020 }
15021 let bytes = last_err.as_bytes();
15022 for code in ["410", "404"] {
15023 let code_bytes = code.as_bytes();
15024 let mut search_from = 0usize;
15025 while let Some(rel) = last_err[search_from..].find(code) {
15026 let abs = search_from + rel;
15027 let end = abs + code_bytes.len();
15028 let before_ok = abs == 0 || is_token_boundary(bytes[abs - 1]);
15029 let after_ok = end == bytes.len() || is_token_boundary(bytes[end]);
15030 if before_ok && after_ok {
15031 return true;
15032 }
15033 search_from = abs + 1;
15037 }
15038 }
15039 false
15040}
15041
15042fn try_reresolve_peer_on_slot_4xx(
15077 state: &mut Value,
15078 peer_handle: &str,
15079 last_err: &str,
15080 already_tried: &std::collections::HashSet<String>,
15081) -> Result<bool> {
15082 if !error_smells_like_slot_4xx(last_err) {
15083 return Ok(false);
15085 }
15086 if already_tried.contains(peer_handle) {
15087 return Ok(false);
15089 }
15090 let peer_entry = state
15092 .get("peers")
15093 .and_then(|p| p.get(peer_handle))
15094 .ok_or_else(|| anyhow!("peer `{peer_handle}` not in relay_state"))?;
15095 let peer_relay = peer_entry
15096 .get("endpoints")
15097 .and_then(Value::as_array)
15098 .and_then(|arr| {
15099 arr.iter().find(|e| {
15100 e.get("scope").and_then(Value::as_str) == Some("federation")
15101 || e.get("scope").and_then(Value::as_str) == Some("Federation")
15102 })
15103 })
15104 .and_then(|e| e.get("relay_url").and_then(Value::as_str))
15105 .or_else(|| peer_entry.get("relay_url").and_then(Value::as_str))
15106 .ok_or_else(|| {
15107 anyhow!("peer `{peer_handle}` has no federation endpoint to re-resolve against")
15108 })?
15109 .to_string();
15110 let domain = peer_relay
15113 .trim_start_matches("https://")
15114 .trim_start_matches("http://")
15115 .split('/')
15116 .next()
15117 .unwrap_or(&peer_relay)
15118 .to_string();
15119 let handle = crate::pair_profile::Handle {
15120 nick: peer_handle.to_string(),
15121 domain,
15122 };
15123 let resolved = crate::pair_profile::resolve_handle(&handle, Some(&peer_relay))?;
15124 let new_slot_id = resolved
15125 .get("slot_id")
15126 .and_then(Value::as_str)
15127 .ok_or_else(|| anyhow!("re-resolved payload missing slot_id"))?
15128 .to_string();
15129 let peers = state
15131 .get_mut("peers")
15132 .and_then(Value::as_object_mut)
15133 .ok_or_else(|| anyhow!("relay_state.peers missing or wrong shape"))?;
15134 let peer_entry = peers
15135 .get_mut(peer_handle)
15136 .ok_or_else(|| anyhow!("peer `{peer_handle}` disappeared from state mid-resolve"))?;
15137 let current_slot_id = peer_entry
15138 .get("endpoints")
15139 .and_then(Value::as_array)
15140 .and_then(|arr| {
15141 arr.iter().find(|e| {
15142 let scope = e.get("scope").and_then(Value::as_str);
15143 scope == Some("federation") || scope == Some("Federation")
15144 })
15145 })
15146 .and_then(|e| e.get("slot_id").and_then(Value::as_str))
15147 .unwrap_or("")
15148 .to_string();
15149 if current_slot_id == new_slot_id {
15150 return Ok(false);
15152 }
15153 if let Some(endpoints) = peer_entry
15162 .get_mut("endpoints")
15163 .and_then(Value::as_array_mut)
15164 {
15165 for ep in endpoints.iter_mut() {
15166 let scope = ep.get("scope").and_then(Value::as_str);
15167 if scope == Some("federation") || scope == Some("Federation") {
15168 ep["slot_id"] = Value::String(new_slot_id.clone());
15169 ep["slot_token"] = Value::String(String::new());
15170 }
15171 }
15172 }
15173 peer_entry["slot_id"] = Value::String(new_slot_id.clone());
15176 peer_entry["slot_token"] = Value::String(String::new());
15177 eprintln!(
15178 "wire push: peer `{peer_handle}` rotated their relay slot (was `{current_slot_id}`, \
15179 now `{new_slot_id}`); pin updated in place. Re-pair via `wire add \
15180 {peer_handle}@<relay>` to refresh the slot_token."
15181 );
15182 Ok(true)
15183}
15184
15185fn reject_self_pair_after_resolution(our_did: &str, peer_did: &str) -> Result<()> {
15186 if our_did == peer_did {
15187 bail!(
15188 "refusing to self-pair: resolved peer DID `{peer_did}` matches your own \
15189 DID. Two terminals can collapse onto one wire identity when the per-\
15190 session key isn't reaching the wire process (issue #30 / #29).\n\n\
15191 Diagnose:\n \
15192 • `wire whoami` in each terminal — DIDs MUST differ.\n \
15193 • `echo $WIRE_SESSION_ID` (bash) / `echo $env:WIRE_SESSION_ID` \
15194 (PowerShell) — must be set + distinct per session.\n\n\
15195 Force distinct identities before relaunching the agent:\n \
15196 • bash/zsh: `export WIRE_SESSION_ID=\"$(uuidgen)\"`\n \
15197 • PowerShell: `$env:WIRE_SESSION_ID = [guid]::NewGuid().ToString()`"
15198 );
15199 }
15200 Ok(())
15201}
15202
15203fn strip_relay_url_userinfo(url: &str) -> String {
15204 let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
15207 let rest = &url[authority_start..];
15208 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
15209 let authority = &rest[..authority_end];
15210
15211 let Some(at_pos) = authority.find('@') else {
15212 return url.to_string();
15213 };
15214
15215 let userinfo = &authority[..at_pos];
15216 let host = &authority[at_pos + 1..];
15217 let scheme = &url[..authority_start];
15218 let tail = &rest[authority_end..];
15219 let cleaned = format!("{scheme}{host}{tail}");
15220
15221 eprintln!(
15222 "wire: ignoring `{userinfo}@` prefix on relay URL `{url}` — \
15223 in v0.11+ your handle is DID-derived (one-name rule), so the relay URL \
15224 is just the bare relay. Binding to `{cleaned}` instead."
15225 );
15226
15227 cleaned
15228}
15229
15230fn assert_relay_url_clean_for_publish(url: &str) -> Result<()> {
15238 let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
15239 let rest = &url[authority_start..];
15240 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
15241 let authority = &rest[..authority_end];
15242 if authority.contains('@') {
15243 bail!(
15244 "internal invariant violated: relay URL `{url}` still carries userinfo at \
15245 the persist/publish boundary — `strip_relay_url_userinfo` must be called \
15246 before this point. Refusing to publish a malformed endpoint."
15247 );
15248 }
15249 Ok(())
15250}
15251
15252fn cmd_pair_megacommand(
15266 handle_arg: &str,
15267 relay_override: Option<&str>,
15268 timeout_secs: u64,
15269 _as_json: bool,
15270) -> Result<()> {
15271 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
15272 let peer_handle = parsed.nick.clone();
15273
15274 eprintln!("wire pair: resolving {handle_arg}...");
15275 cmd_add(
15276 handle_arg,
15277 relay_override,
15278 false,
15279 false,
15280 )?;
15281
15282 eprintln!(
15283 "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
15284 to ack (their daemon must be running + pulling)..."
15285 );
15286
15287 let _ = run_sync_pull();
15291
15292 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
15293 let poll_interval = std::time::Duration::from_millis(500);
15294
15295 loop {
15296 let _ = run_sync_pull();
15298 let relay_state = config::read_relay_state()?;
15299 let peer_entry = relay_state
15300 .get("peers")
15301 .and_then(|p| p.get(&peer_handle))
15302 .cloned();
15303 let token = peer_entry
15304 .as_ref()
15305 .and_then(|e| e.get("slot_token"))
15306 .and_then(Value::as_str)
15307 .unwrap_or("");
15308
15309 if !token.is_empty() {
15310 let trust = config::read_trust()?;
15312 let pinned_in_trust = trust
15313 .get("agents")
15314 .and_then(|a| a.get(&peer_handle))
15315 .is_some();
15316 println!(
15317 "wire pair: paired with {peer_handle}.\n trust: {} bilateral: yes (slot_token recorded)\n next: `wire send {peer_handle} \"<msg>\"`",
15318 if pinned_in_trust {
15319 "VERIFIED"
15320 } else {
15321 "MISSING (bug)"
15322 }
15323 );
15324 return Ok(());
15325 }
15326
15327 if std::time::Instant::now() >= deadline {
15328 bail!(
15335 "wire pair: timed out after {timeout_secs}s. \
15336 peer {peer_handle} never sent pair_drop_ack. \
15337 likely causes: (a) their daemon is down — ask them to run \
15338 `wire status` and `wire daemon &`; (b) their binary is older \
15339 than 0.5.x and doesn't understand pair_drop events — ask \
15340 them to `wire upgrade`; (c) network / relay blip — re-run \
15341 `wire pair {handle_arg}` to retry."
15342 );
15343 }
15344
15345 std::thread::sleep(poll_interval);
15346 }
15347}
15348
15349fn cmd_claim(
15350 nick: &str,
15351 relay_override: Option<&str>,
15352 public_url: Option<&str>,
15353 hidden: bool,
15354 as_json: bool,
15355) -> Result<()> {
15356 let (_did, relay_url, slot_id, slot_token) =
15359 crate::pair_invite::ensure_self_with_relay(relay_override)?;
15360 let card = config::read_agent_card()?;
15361
15362 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
15371 let canonical = crate::agent_card::display_handle_from_did(did).to_string();
15372 if !canonical.is_empty() && nick != canonical && !as_json {
15373 eprintln!(
15374 "wire claim: typed `{nick}` ignored — one-name rule. Claiming your persona `{canonical}`."
15375 );
15376 }
15377 let nick = if canonical.is_empty() {
15378 nick
15379 } else {
15380 canonical.as_str()
15381 };
15382 if !crate::pair_profile::is_valid_nick(nick) {
15383 bail!(
15384 "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
15385 );
15386 }
15387
15388 let client = crate::relay_client::RelayClient::new(&relay_url);
15389 let discoverable = if hidden { Some(false) } else { None };
15393 let resp =
15394 client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
15395
15396 if as_json {
15397 println!(
15398 "{}",
15399 serde_json::to_string(&json!({
15400 "nick": nick,
15401 "relay": relay_url,
15402 "response": resp,
15403 }))?
15404 );
15405 } else {
15406 let domain = public_url
15410 .unwrap_or(&relay_url)
15411 .trim_start_matches("https://")
15412 .trim_start_matches("http://")
15413 .trim_end_matches('/')
15414 .split('/')
15415 .next()
15416 .unwrap_or("<this-relay-domain>")
15417 .to_string();
15418 println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
15419 println!("verify with: wire whois {nick}@{domain}");
15420 }
15421 Ok(())
15422}
15423
15424fn cmd_profile(action: ProfileAction) -> Result<()> {
15425 match action {
15426 ProfileAction::Set { field, value, json } => {
15427 let parsed: Value =
15431 serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
15432 let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
15433 let published = republish_card_to_phonebook();
15434 if json {
15435 println!(
15436 "{}",
15437 serde_json::to_string(&json!({
15438 "field": field,
15439 "profile": new_profile,
15440 "published_to": published,
15441 }))?
15442 );
15443 } else {
15444 println!("profile.{field} set");
15445 print_profile_publish_result(&published);
15446 }
15447 }
15448 ProfileAction::Get { json } => return cmd_whois(None, json, None),
15449 ProfileAction::Clear { field, json } => {
15450 let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
15451 let published = republish_card_to_phonebook();
15452 if json {
15453 println!(
15454 "{}",
15455 serde_json::to_string(&json!({
15456 "field": field,
15457 "cleared": true,
15458 "profile": new_profile,
15459 "published_to": published,
15460 }))?
15461 );
15462 } else {
15463 println!("profile.{field} cleared");
15464 print_profile_publish_result(&published);
15465 }
15466 }
15467 }
15468 Ok(())
15469}
15470
15471fn republish_card_to_phonebook() -> Vec<String> {
15479 let Ok(card) = config::read_agent_card() else {
15480 return Vec::new();
15481 };
15482 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
15483 let persona = crate::agent_card::display_handle_from_did(did).to_string();
15484 if persona.is_empty() {
15485 return Vec::new();
15486 }
15487 let Ok(state) = config::read_relay_state() else {
15488 return Vec::new();
15489 };
15490 let mut published = Vec::new();
15491 for ep in crate::endpoints::self_endpoints(&state) {
15492 if ep.scope != crate::endpoints::EndpointScope::Federation
15493 || ep.slot_id.is_empty()
15494 || ep.slot_token.is_empty()
15495 {
15496 continue;
15497 }
15498 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
15499 if client
15500 .handle_claim_v2(&persona, &ep.slot_id, &ep.slot_token, None, &card, None)
15501 .is_ok()
15502 {
15503 published.push(ep.relay_url.clone());
15504 }
15505 }
15506 published
15507}
15508
15509fn print_profile_publish_result(published: &[String]) {
15510 if published.is_empty() {
15511 println!(
15512 " (local only — not bound to a federation relay; run `wire up` to publish to the phonebook)"
15513 );
15514 } else {
15515 println!(" published to phonebook: {}", published.join(", "));
15516 }
15517}
15518
15519fn cmd_setup(apply: bool) -> Result<()> {
15522 use crate::adapters::harness::HARNESS_ADAPTERS;
15523 use std::path::PathBuf;
15524
15525 let entry = json!({
15532 "command": "wire",
15533 "args": ["mcp"]
15534 });
15535 let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
15536
15537 let mut targets: Vec<(&str, PathBuf)> = Vec::new();
15541 for adapter in HARNESS_ADAPTERS {
15542 for path in (adapter.paths_fn)() {
15543 targets.push((adapter.name, path));
15544 }
15545 }
15546
15547 println!("wire setup\n");
15548 println!("MCP server snippet (add this to your client's mcpServers):");
15549 println!();
15550 println!("{entry_pretty}");
15551 println!();
15552
15553 if !apply {
15554 println!("Probable MCP host config locations on this machine:");
15555 for (name, path) in &targets {
15556 let marker = if path.exists() {
15557 "✓ found"
15558 } else {
15559 " (would create)"
15560 };
15561 println!(" {marker:14} {name}: {}", path.display());
15562 }
15563 println!();
15564 println!("Run `wire setup --apply` to merge wire into each config above.");
15565 println!(
15566 "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
15567 );
15568 return Ok(());
15569 }
15570
15571 let mut modified: Vec<String> = Vec::new();
15572 let mut skipped: Vec<String> = Vec::new();
15573 for adapter in HARNESS_ADAPTERS {
15574 for path in (adapter.paths_fn)() {
15575 match (adapter.upsert_fn)(&path, "wire", &entry) {
15576 Ok(true) => {
15577 modified.push(format!("✓ {} ({})", adapter.name, path.display()));
15578 }
15579 Ok(false) => skipped.push(format!(
15580 " {} ({}): already configured",
15581 adapter.name,
15582 path.display()
15583 )),
15584 Err(e) => skipped.push(format!("✗ {} ({}): {e}", adapter.name, path.display())),
15585 }
15586 }
15587 }
15588 if !modified.is_empty() {
15589 println!("Modified:");
15590 for line in &modified {
15591 println!(" {line}");
15592 }
15593 println!();
15594 println!("Restart the app(s) above to load wire MCP.");
15595 }
15596 if !skipped.is_empty() {
15597 println!();
15598 println!("Skipped:");
15599 for line in &skipped {
15600 println!(" {line}");
15601 }
15602 }
15603 Ok(())
15604}
15605
15606const STATUSLINE_RENDERER: &str = include_str!("../assets/wire-statusline.sh");
15618
15619fn cmd_setup_statusline(apply: bool, remove: bool) -> Result<()> {
15625 use std::path::PathBuf;
15626 let cfg_dir: PathBuf = std::env::var_os("CLAUDE_CONFIG_DIR")
15627 .map(PathBuf::from)
15628 .or_else(|| dirs::home_dir().map(|h| h.join(".claude")))
15629 .ok_or_else(|| anyhow!("cannot locate Claude config dir (set $CLAUDE_CONFIG_DIR)"))?;
15630 let settings_path = cfg_dir.join("settings.json");
15631 let script_path = cfg_dir.join("wire-statusline.sh");
15632 let (command, command_warn) = statusline_command(&script_path);
15637
15638 println!("wire setup --statusline\n");
15639 println!("Claude config dir: {}", cfg_dir.display());
15640 println!(" renderer: {}", script_path.display());
15641 println!(" settings: {}", settings_path.display());
15642 if let Some(w) = &command_warn {
15643 println!(" ⚠ {w}");
15644 }
15645 println!();
15646
15647 if remove {
15648 if !apply {
15649 println!("Would REMOVE the statusLine key from settings.json and delete the renderer.");
15650 println!("Run `wire setup --statusline --remove --apply` to do it.");
15651 return Ok(());
15652 }
15653 let dropped = remove_statusline_entry(&settings_path)?;
15654 let script_gone = if script_path.exists() {
15655 std::fs::remove_file(&script_path).is_ok()
15656 } else {
15657 false
15658 };
15659 println!(
15660 "Removed: statusLine key {} · renderer {}",
15661 if dropped { "dropped" } else { "absent" },
15662 if script_gone { "deleted" } else { "absent" }
15663 );
15664 return Ok(());
15665 }
15666
15667 if !apply {
15668 println!("Would write the renderer above and merge into settings.json:");
15669 println!();
15670 println!(" \"statusLine\": {{ \"type\": \"command\", \"command\": \"{command}\" }}");
15671 println!();
15672 println!("Resulting statusline: ● <emoji> <nickname> · <cwd>");
15673 println!("Run `wire setup --statusline --apply` to install.");
15674 println!("(Existing settings.json keys are preserved; an invalid settings.json aborts.)");
15675 return Ok(());
15676 }
15677
15678 if let Some(parent) = script_path.parent() {
15679 std::fs::create_dir_all(parent).context("creating Claude config dir")?;
15680 }
15681 std::fs::write(&script_path, STATUSLINE_RENDERER).context("writing renderer script")?;
15682 #[cfg(unix)]
15683 {
15684 use std::os::unix::fs::PermissionsExt;
15685 if let Ok(meta) = std::fs::metadata(&script_path) {
15686 let mut perms = meta.permissions();
15687 perms.set_mode(0o755);
15688 let _ = std::fs::set_permissions(&script_path, perms);
15689 }
15690 }
15691 let changed = upsert_statusline_entry(&settings_path, &command)?;
15692 println!("✓ renderer written: {}", script_path.display());
15693 if changed {
15694 println!("✓ merged statusLine into: {}", settings_path.display());
15695 } else {
15696 println!(
15697 " settings.json already configured: {}",
15698 settings_path.display()
15699 );
15700 }
15701 println!();
15702 println!("Restart Claude Code (or reopen the session) to see your persona in the statusline.");
15703 Ok(())
15704}
15705
15706fn upsert_statusline_entry(path: &std::path::Path, command: &str) -> Result<bool> {
15710 let mut cfg: Value = if path.exists() {
15711 let body = std::fs::read_to_string(path).context("reading settings.json")?;
15712 if body.trim().is_empty() {
15713 json!({})
15714 } else {
15715 serde_json::from_str(&body).context(
15716 "settings.json exists but is not valid JSON — refusing to clobber; fix or remove it first",
15717 )?
15718 }
15719 } else {
15720 json!({})
15721 };
15722 if !cfg.is_object() {
15723 bail!("settings.json root is not a JSON object — refusing to clobber");
15724 }
15725 let desired = json!({"type": "command", "command": command});
15726 let root = cfg.as_object_mut().unwrap();
15727 if root.get("statusLine") == Some(&desired) {
15728 return Ok(false);
15729 }
15730 root.insert("statusLine".to_string(), desired);
15731 if let Some(parent) = path.parent()
15732 && !parent.as_os_str().is_empty()
15733 {
15734 std::fs::create_dir_all(parent).context("creating parent dir")?;
15735 }
15736 let out = serde_json::to_string_pretty(&cfg)? + "\n";
15737 std::fs::write(path, out).context("writing settings.json")?;
15738 Ok(true)
15739}
15740
15741fn remove_statusline_entry(path: &std::path::Path) -> Result<bool> {
15744 if !path.exists() {
15745 return Ok(false);
15746 }
15747 let body = std::fs::read_to_string(path).context("reading settings.json")?;
15748 if body.trim().is_empty() {
15749 return Ok(false);
15750 }
15751 let mut cfg: Value = serde_json::from_str(&body)
15752 .context("settings.json is not valid JSON — refusing to edit")?;
15753 let Some(root) = cfg.as_object_mut() else {
15754 return Ok(false);
15755 };
15756 if root.remove("statusLine").is_none() {
15757 return Ok(false);
15758 }
15759 let out = serde_json::to_string_pretty(&cfg)? + "\n";
15760 std::fs::write(path, out).context("writing settings.json")?;
15761 Ok(true)
15762}
15763
15764fn statusline_command(script_path: &std::path::Path) -> (String, Option<String>) {
15767 #[cfg(windows)]
15768 {
15769 match resolve_git_bash() {
15770 Some(bash) => (format!("\"{}\" \"{}\"", bash, script_path.display()), None),
15771 None => (
15772 format!("bash \"{}\"", script_path.display()),
15773 Some(
15774 "could not locate git-bash; using bare `bash`. On Windows that may resolve to \
15775 WSL (System32\\bash.exe) and the statusline will be blank — install Git for \
15776 Windows or set statusLine.command to your git-bash bash.exe path."
15777 .to_string(),
15778 ),
15779 ),
15780 }
15781 }
15782 #[cfg(unix)]
15783 {
15784 (format!("bash \"{}\"", script_path.display()), None)
15785 }
15786}
15787
15788#[cfg(windows)]
15792fn resolve_git_bash() -> Option<String> {
15793 use std::path::PathBuf;
15794 if let Ok(out) = std::process::Command::new("where.exe").arg("bash").output()
15797 && out.status.success()
15798 {
15799 for line in String::from_utf8_lossy(&out.stdout).lines() {
15800 let p = line.trim();
15801 if !p.is_empty() && !p.to_lowercase().contains("\\system32\\") {
15802 return Some(p.to_string());
15803 }
15804 }
15805 }
15806 let candidates = [
15808 std::env::var("ProgramFiles")
15809 .ok()
15810 .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
15811 std::env::var("ProgramFiles(x86)")
15812 .ok()
15813 .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
15814 std::env::var("LocalAppData")
15815 .ok()
15816 .map(|p| format!("{p}\\Programs\\Git\\bin\\bash.exe")),
15817 ];
15818 candidates
15819 .into_iter()
15820 .flatten()
15821 .find(|c| PathBuf::from(c).exists())
15822}
15823
15824#[cfg(test)]
15825mod scan_jsonl_dir_tests {
15826 use super::*;
15827
15828 #[test]
15829 fn scan_jsonl_dir_excludes_pushed_audit_files() {
15830 let dir = tempfile::tempdir().unwrap();
15836 std::fs::write(
15838 dir.path().join("alice.jsonl"),
15839 "{\"event_id\":\"a\"}\n{\"event_id\":\"b\"}\n",
15840 )
15841 .unwrap();
15842 let many: String = (0..100)
15844 .map(|i| format!("{{\"event_id\":\"x{i}\",\"ts\":\"...\"}}\n"))
15845 .collect();
15846 std::fs::write(dir.path().join("alice.pushed.jsonl"), many).unwrap();
15847 let result = scan_jsonl_dir(dir.path()).unwrap();
15848 assert_eq!(
15849 result["events"], 2,
15850 "events count must include only live outbox lines, not pushed-log audit lines"
15851 );
15852 assert_eq!(
15853 result["files"], 1,
15854 "files count must reflect 1 live outbox file (the .pushed.jsonl audit log doesn't count as a queued-events surface)"
15855 );
15856 }
15857
15858 #[test]
15859 fn scan_jsonl_dir_zero_when_only_pushed_log_present() {
15860 let dir = tempfile::tempdir().unwrap();
15865 std::fs::write(
15866 dir.path().join("alice.pushed.jsonl"),
15867 "{\"event_id\":\"a\"}\n",
15868 )
15869 .unwrap();
15870 let result = scan_jsonl_dir(dir.path()).unwrap();
15871 assert_eq!(result["events"], 0);
15872 assert_eq!(result["files"], 0);
15873 }
15874
15875 #[test]
15876 fn scan_jsonl_dir_returns_zero_for_missing_dir() {
15877 let result = scan_jsonl_dir(std::path::Path::new("/nonexistent")).unwrap();
15878 assert_eq!(result["events"], 0);
15879 assert_eq!(result["files"], 0);
15880 }
15881}
15882
15883#[cfg(test)]
15888mod statusline_tests {
15889 use super::*;
15890
15891 #[test]
15892 fn statusline_merge_preserves_keys_and_is_idempotent() {
15893 let dir = tempfile::tempdir().unwrap();
15894 let path = dir.path().join("settings.json");
15895 std::fs::write(&path, r#"{"theme":"dark","model":"opus"}"#).unwrap();
15896 assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
15898 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15899 assert_eq!(v["theme"], "dark");
15900 assert_eq!(v["model"], "opus");
15901 assert_eq!(v["statusLine"]["type"], "command");
15902 assert_eq!(v["statusLine"]["command"], "bash /x.sh");
15903 assert!(!upsert_statusline_entry(&path, "bash /x.sh").unwrap());
15905 assert!(remove_statusline_entry(&path).unwrap());
15907 let v2: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15908 assert_eq!(v2["theme"], "dark");
15909 assert!(v2.get("statusLine").is_none());
15910 assert!(!remove_statusline_entry(&path).unwrap());
15912 }
15913
15914 #[test]
15915 fn statusline_merge_refuses_to_clobber_invalid_json() {
15916 let dir = tempfile::tempdir().unwrap();
15917 let path = dir.path().join("settings.json");
15918 std::fs::write(&path, "this is not json {").unwrap();
15919 let err = upsert_statusline_entry(&path, "bash /x.sh").unwrap_err();
15920 assert!(
15921 format!("{err:#}").contains("not valid JSON"),
15922 "err: {err:#}"
15923 );
15924 assert_eq!(
15926 std::fs::read_to_string(&path).unwrap(),
15927 "this is not json {"
15928 );
15929 }
15930
15931 #[test]
15932 fn statusline_creates_settings_when_absent() {
15933 let dir = tempfile::tempdir().unwrap();
15934 let path = dir.path().join("settings.json");
15935 assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
15936 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15937 assert_eq!(v["statusLine"]["command"], "bash /x.sh");
15938 }
15939}
15940
15941fn cmd_notify(
15944 interval_secs: u64,
15945 peer_filter: Option<&str>,
15946 once: bool,
15947 as_json: bool,
15948) -> Result<()> {
15949 use crate::inbox_watch::InboxWatcher;
15950 let cursor_path = config::state_dir()?.join("notify.cursor");
15951 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
15952 if !once {
15956 crate::session::warn_on_identity_collision(std::process::id(), "notify");
15957 }
15958
15959 let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
15960 let events = watcher.poll()?;
15961 for ev in events {
15962 if let Some(p) = peer_filter
15963 && ev.peer != p
15964 {
15965 continue;
15966 }
15967 if as_json {
15968 println!("{}", serde_json::to_string(&ev)?);
15969 } else {
15970 os_notify_inbox_event(&ev);
15971 }
15972 }
15973 watcher.save_cursors(&cursor_path)?;
15974 Ok(())
15975 };
15976
15977 if once {
15978 return sweep(&mut watcher);
15979 }
15980
15981 let interval = std::time::Duration::from_secs(interval_secs.max(1));
15982 loop {
15983 if let Err(e) = sweep(&mut watcher) {
15984 eprintln!("wire notify: sweep error: {e}");
15985 }
15986 std::thread::sleep(interval);
15987 }
15988}
15989
15990fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
15991 let who = persona_label(&ev.peer);
15992 let title = if ev.verified {
15993 format!("wire ← {who}")
15994 } else {
15995 format!("wire ← {who} (UNVERIFIED)")
15996 };
15997 let body = format!("{}: {}", ev.kind, ev.body_preview);
15998 let id = if ev.event_id.is_empty() {
16004 ev.body_preview.as_str()
16005 } else {
16006 ev.event_id.as_str()
16007 };
16008 let dedup_key = format!("inbox:{}:{}", ev.peer, id);
16009 crate::os_notify::toast_dedup(&dedup_key, &title, &body);
16010}
16011
16012#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
16013fn os_toast(title: &str, body: &str) {
16014 eprintln!("[wire notify] {title}\n {body}");
16015}
16016
16017#[cfg(test)]
16020mod relay_url_tests {
16021 use super::*;
16022
16023 #[test]
16024 fn strip_relay_url_userinfo_strips_handle_and_returns_cleaned() {
16025 assert_eq!(
16037 strip_relay_url_userinfo("https://copilot-agent@wireup.net"),
16038 "https://wireup.net",
16039 "https URL with handle userinfo is stripped to the bare host"
16040 );
16041 assert_eq!(
16042 strip_relay_url_userinfo("http://copilot-agent@127.0.0.1:8771"),
16043 "http://127.0.0.1:8771",
16044 "http + port + userinfo is stripped, port preserved"
16045 );
16046 assert_eq!(strip_relay_url_userinfo("https://u:p@host"), "https://host");
16048 assert_eq!(
16050 strip_relay_url_userinfo("https://nick@host:8443"),
16051 "https://host:8443"
16052 );
16053 assert_eq!(strip_relay_url_userinfo("nick@wireup.net"), "wireup.net");
16057 assert_eq!(
16059 strip_relay_url_userinfo("https://nick@wireup.net/v1/events?x=1#frag"),
16060 "https://wireup.net/v1/events?x=1#frag"
16061 );
16062 }
16063
16064 #[test]
16065 fn strip_relay_url_userinfo_passes_clean_urls_through_unchanged() {
16066 for ok in [
16068 "https://wireup.net",
16069 "http://wireup.net",
16070 "http://127.0.0.1:8771",
16071 "https://relay.example.com:9443/v1/wire",
16072 "https://wireup.net/?env=prod",
16073 "https://wireup.net/users/me@example.com",
16075 "https://wireup.net/?to=me@example.com",
16076 "https://wireup.net/#contact@me",
16078 "http://[::1]:8771",
16080 "wireup.net",
16082 "wireup.net:8443",
16083 ] {
16084 assert_eq!(
16085 strip_relay_url_userinfo(ok),
16086 ok,
16087 "clean URL `{ok}` must pass through unchanged"
16088 );
16089 }
16090 }
16091
16092 #[test]
16093 fn assert_relay_url_clean_for_publish_blocks_userinfo_at_persist_site() {
16094 assert!(assert_relay_url_clean_for_publish("https://wireup.net").is_ok());
16100 assert!(assert_relay_url_clean_for_publish("http://127.0.0.1:8771").is_ok());
16101 assert!(
16102 assert_relay_url_clean_for_publish("https://wireup.net/?to=me@example.com").is_ok()
16103 );
16104
16105 let err = assert_relay_url_clean_for_publish("https://nick@wireup.net")
16106 .unwrap_err()
16107 .to_string();
16108 assert!(
16109 err.contains("invariant violated"),
16110 "persist-site failure must be flagged as an internal invariant violation, not user error: {err}"
16111 );
16112 assert!(
16113 err.contains("strip_relay_url_userinfo"),
16114 "error must name the upstream filter so the caller can audit the bypass: {err}"
16115 );
16116 assert!(assert_relay_url_clean_for_publish("https://u:p@host").is_err());
16118 assert!(assert_relay_url_clean_for_publish("https://nick@host:8443").is_err());
16120 }
16121
16122 #[test]
16123 fn strip_proto_no_longer_doubles_handle_after_userinfo_fix() {
16124 let after_strip = strip_relay_url_userinfo("https://nick@wireup.net");
16130 assert_eq!(after_strip, "https://wireup.net");
16131 assert_eq!(strip_proto(&after_strip), "wireup.net");
16132 assert!(
16134 strip_proto("https://nick@wireup.net").contains('@'),
16135 "strip_proto preserves userinfo by design; the userinfo guard upstream is what prevents the doubled echo"
16136 );
16137 }
16138}
16139
16140#[cfg(test)]
16141mod self_pair_guard_tests {
16142 use super::*;
16143
16144 #[test]
16145 fn reject_self_pair_after_resolution_blocks_matching_dids() {
16146 let err = reject_self_pair_after_resolution(
16153 "did:wire:winter-bay-4092b577",
16154 "did:wire:winter-bay-4092b577",
16155 )
16156 .unwrap_err()
16157 .to_string();
16158 assert!(
16159 err.contains("refusing to self-pair"),
16160 "must explicitly refuse, not silently bail: {err}"
16161 );
16162 assert!(
16163 err.contains("did:wire:winter-bay-4092b577"),
16164 "must include the colliding DID so the operator can grep their `wire whoami` output: {err}"
16165 );
16166 assert!(
16167 err.contains("issue #30") || err.contains("issue #29"),
16168 "must point at the tracking issue so historical context is one search away: {err}"
16169 );
16170 assert!(
16173 err.contains("WIRE_SESSION_ID"),
16174 "remediation must name the env var operators set: {err}"
16175 );
16176 assert!(
16177 err.contains("uuidgen") || err.contains("NewGuid"),
16178 "remediation must include a concrete command to mint a unique id: {err}"
16179 );
16180 }
16181
16182 #[test]
16183 fn reject_self_pair_after_resolution_allows_distinct_dids() {
16184 reject_self_pair_after_resolution(
16189 "did:wire:winter-bay-4092b577",
16190 "did:wire:cedar-bayou-0616dc6c",
16191 )
16192 .unwrap();
16193 reject_self_pair_after_resolution("did:wire:ed25519:abc123", "did:wire:ed25519:def456")
16194 .unwrap();
16195 reject_self_pair_after_resolution(
16199 "did:wire:noble-canyon-deadbeef",
16200 "did:wire:noble-canyon-cafef00d",
16201 )
16202 .unwrap();
16203 }
16204}
16205
16206#[cfg(test)]
16207mod slot_reresolve_tests {
16208 use super::*;
16209
16210 #[test]
16231 fn try_reresolve_skips_when_error_is_not_4xx_shape() {
16232 let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
16233 let already = std::collections::HashSet::new();
16234 let res =
16237 try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "post failed: 502", &already)
16238 .unwrap();
16239 assert!(!res, "502 must NOT trigger a re-resolve");
16240
16241 let res =
16242 try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "connection refused", &already)
16243 .unwrap();
16244 assert!(!res, "transport errors must NOT trigger a re-resolve");
16245
16246 let res = try_reresolve_peer_on_slot_4xx(
16247 &mut state,
16248 "some-peer",
16249 "post failed: 401 Unauthorized",
16250 &already,
16251 )
16252 .unwrap();
16253 assert!(
16254 !res,
16255 "401 (auth) is a token problem, not a slot rotation — must NOT trigger a re-resolve"
16256 );
16257 }
16258
16259 #[test]
16260 fn try_reresolve_rate_limits_one_attempt_per_peer_per_push() {
16261 let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
16266 let mut already = std::collections::HashSet::new();
16267 already.insert("some-peer".to_string());
16268 let res = try_reresolve_peer_on_slot_4xx(
16269 &mut state,
16270 "some-peer",
16271 "post failed: 410 Gone",
16272 &already,
16273 )
16274 .unwrap();
16275 assert!(
16276 !res,
16277 "peer already in `already_tried` must NOT trigger another re-resolve in the same push"
16278 );
16279 }
16280
16281 #[test]
16282 fn try_reresolve_errors_when_peer_missing_from_state() {
16283 let mut state = json!({"peers": {}});
16287 let already = std::collections::HashSet::new();
16288 let err = try_reresolve_peer_on_slot_4xx(
16289 &mut state,
16290 "missing-peer",
16291 "post failed: 410 Gone",
16292 &already,
16293 )
16294 .unwrap_err()
16295 .to_string();
16296 assert!(
16297 err.contains("missing-peer") && err.contains("not in relay_state"),
16298 "missing-peer error must name the peer + the failure: {err}"
16299 );
16300 }
16301
16302 #[test]
16303 fn try_reresolve_errors_when_peer_has_no_federation_endpoint() {
16304 let mut state = json!({
16311 "peers": {
16312 "local-only": {
16313 "endpoints": [
16314 {
16315 "scope": "Local",
16316 "relay_url": "http://127.0.0.1:8771",
16317 "slot_id": "loc",
16318 "slot_token": "tok"
16319 }
16320 ]
16321 }
16322 }
16323 });
16324 let already = std::collections::HashSet::new();
16325 let err = try_reresolve_peer_on_slot_4xx(
16326 &mut state,
16327 "local-only",
16328 "post failed: 410 Gone",
16329 &already,
16330 )
16331 .unwrap_err()
16332 .to_string();
16333 assert!(
16334 err.contains("federation endpoint"),
16335 "no-federation error must name the problem: {err}"
16336 );
16337 }
16338
16339 #[test]
16355 fn error_smells_like_slot_4xx_matches_reqwest_status_display_shape() {
16356 assert!(error_smells_like_slot_4xx(
16359 "post_event failed: 410 Gone: slot rotated by peer"
16360 ));
16361 assert!(error_smells_like_slot_4xx(
16362 "post_event failed: 404 Not Found: handle no longer claimed"
16363 ));
16364 }
16365
16366 #[test]
16367 fn error_smells_like_slot_4xx_matches_uds_bare_u16_shape() {
16368 assert!(error_smells_like_slot_4xx(
16372 "post_event (uds /tmp/wire-relay.sock) failed: 410: gone"
16373 ));
16374 assert!(error_smells_like_slot_4xx(
16375 "post_event (uds /tmp/wire-relay.sock) failed: 404: not found"
16376 ));
16377 }
16378
16379 #[test]
16380 fn error_smells_like_slot_4xx_rejects_substring_lookalikes() {
16381 let false_positives = [
16385 "push aborted: slot 4101 expired",
16386 "post_event failed: 502 Bad Gateway: request_id=410abc-deadbeef",
16387 "post_event failed: 500: received 4040 bytes, expected envelope",
16388 "post_event failed: 500: event 0x4104 malformed",
16389 "post_event failed: 503: backlog=4102 entries pending",
16390 "post_event failed: 500: tx_id=4044beef",
16392 "post_event failed: 500: hash=abc410def",
16394 ];
16395 for case in false_positives {
16396 assert!(
16397 !error_smells_like_slot_4xx(case),
16398 "must NOT trigger re-resolve on substring lookalike: {case:?}"
16399 );
16400 }
16401 }
16402
16403 #[test]
16404 fn error_smells_like_slot_4xx_handles_edge_positions() {
16405 assert!(error_smells_like_slot_4xx("410 Gone"));
16407 assert!(error_smells_like_slot_4xx("404 Not Found"));
16408 assert!(error_smells_like_slot_4xx("got 410"));
16410 assert!(error_smells_like_slot_4xx("got 404"));
16411 assert!(error_smells_like_slot_4xx("post_event failed:\t410\tGone"));
16413 assert!(error_smells_like_slot_4xx("post_event failed:\n410\nGone"));
16414 assert!(error_smells_like_slot_4xx("410"));
16416 assert!(error_smells_like_slot_4xx("404"));
16417 assert!(!error_smells_like_slot_4xx(""));
16419 assert!(!error_smells_like_slot_4xx("no relevant status"));
16420 assert!(!error_smells_like_slot_4xx(
16423 "post_event failed: 401 Unauthorized"
16424 ));
16425 assert!(!error_smells_like_slot_4xx(
16426 "post_event failed: 403 Forbidden"
16427 ));
16428 assert!(!error_smells_like_slot_4xx(
16429 "post_event failed: 411 Length Required"
16430 ));
16431 }
16432}
16433
16434#[cfg(test)]
16437mod op_claims_surfacing_tests {
16438 use super::*;
16439
16440 #[test]
16441 fn op_claims_extracts_present_non_null_fields() {
16442 let card = json!({
16443 "did": "did:wire:foo-deadbeef",
16444 "handle": "foo",
16445 "op_did": "did:wire:op:foo-aaaa",
16446 "op_pubkey": "PKB64==",
16447 "op_cert": "SIGB64==",
16448 "org_memberships": [{"org_did": "did:wire:org:slancha-bbbb"}],
16449 "schema_version": "v3.2",
16450 });
16451 let claims = op_claims_from_card(&card);
16452 assert_eq!(claims.len(), 5);
16453 assert_eq!(
16454 claims.get("op_did").and_then(Value::as_str),
16455 Some("did:wire:op:foo-aaaa")
16456 );
16457 assert!(
16458 claims
16459 .get("org_memberships")
16460 .and_then(Value::as_array)
16461 .is_some()
16462 );
16463 }
16464
16465 #[test]
16466 fn op_claims_empty_on_pre_v014_card() {
16467 let card = json!({
16472 "did": "did:wire:bar-cafebabe",
16473 "handle": "bar",
16474 "capabilities": ["wire/v3.1"],
16475 });
16476 assert!(op_claims_from_card(&card).is_empty());
16477 }
16478
16479 #[test]
16480 fn op_claims_skips_explicit_null_fields() {
16481 let card = json!({
16485 "did": "did:wire:baz-12341234",
16486 "op_did": Value::Null,
16487 "org_memberships": Value::Null,
16488 "schema_version": "v3.2",
16489 });
16490 let claims = op_claims_from_card(&card);
16491 assert_eq!(claims.len(), 1);
16492 assert!(claims.get("op_did").is_none());
16493 assert!(claims.get("org_memberships").is_none());
16494 assert_eq!(
16495 claims.get("schema_version").and_then(Value::as_str),
16496 Some("v3.2")
16497 );
16498 }
16499}
16500
16501#[cfg(test)]
16502mod enroll_add_membership_tests {
16503 use super::*;
16504 use crate::enroll::issue_member_cert;
16505 use crate::signing::{b64encode, generate_keypair};
16506
16507 fn seed_op() -> ([u8; 32], [u8; 32], String) {
16508 let (sk, pk) = generate_keypair();
16509 crate::config::write_op_key(&sk).unwrap();
16510 crate::config::write_op_handle("opfoo").unwrap();
16511 let op_did = crate::agent_card::did_for_op("opfoo", &pk);
16512 (sk, pk, op_did)
16513 }
16514
16515 #[test]
16516 fn add_membership_happy_path_stores_and_is_idempotent() {
16517 config::test_support::with_temp_home(|| {
16518 config::ensure_dirs().unwrap();
16519 let (_op_sk, _op_pk, op_did) = seed_op();
16520 let (org_sk, org_pk) = generate_keypair();
16521 let org_did = crate::agent_card::did_for_org("acme", &org_pk);
16522 let cert = issue_member_cert(&org_sk, &op_did).unwrap();
16523 let bundle = json!({
16524 "org_did": org_did,
16525 "org_pubkey": b64encode(&org_pk),
16526 "member_cert": cert,
16527 })
16528 .to_string();
16529 cmd_enroll_add_membership(Some(bundle.clone()), None, None, None, true).unwrap();
16530 let stored = config::read_memberships().unwrap();
16531 assert_eq!(stored.len(), 1);
16532 assert_eq!(
16533 stored[0].get("org_did").and_then(Value::as_str),
16534 Some(org_did.as_str())
16535 );
16536 cmd_enroll_add_membership(Some(bundle), None, None, None, true).unwrap();
16538 assert_eq!(config::read_memberships().unwrap().len(), 1);
16539 });
16540 }
16541
16542 #[test]
16543 fn add_membership_rejects_cert_for_wrong_op_did() {
16544 config::test_support::with_temp_home(|| {
16545 config::ensure_dirs().unwrap();
16546 let (_op_sk, _op_pk, _op_did) = seed_op();
16547 let (org_sk, org_pk) = generate_keypair();
16548 let org_did = crate::agent_card::did_for_org("acme", &org_pk);
16549 let other_did = "did:wire:op:ghost-deadbeefdeadbeefdeadbeefdeadbeef";
16551 let cert = issue_member_cert(&org_sk, other_did).unwrap();
16552 let bundle = json!({
16553 "org_did": org_did,
16554 "org_pubkey": b64encode(&org_pk),
16555 "member_cert": cert,
16556 })
16557 .to_string();
16558 let err = cmd_enroll_add_membership(Some(bundle), None, None, None, true).unwrap_err();
16559 assert!(
16560 err.to_string().contains("verification failed"),
16561 "got: {err:#}"
16562 );
16563 assert!(config::read_memberships().unwrap().is_empty());
16565 });
16566 }
16567
16568 #[test]
16569 fn add_membership_rejects_when_not_enrolled() {
16570 config::test_support::with_temp_home(|| {
16571 config::ensure_dirs().unwrap();
16572 let (org_sk, org_pk) = generate_keypair();
16574 let org_did = crate::agent_card::did_for_org("acme", &org_pk);
16575 let cert = issue_member_cert(&org_sk, "did:wire:op:anybody-aaaa").unwrap();
16576 let bundle = json!({
16577 "org_did": org_did,
16578 "org_pubkey": b64encode(&org_pk),
16579 "member_cert": cert,
16580 })
16581 .to_string();
16582 let err = cmd_enroll_add_membership(Some(bundle), None, None, None, true).unwrap_err();
16583 assert!(err.to_string().contains("not enrolled"), "got: {err:#}");
16584 });
16585 }
16586
16587 #[test]
16588 fn add_membership_rejects_malformed_org_did() {
16589 config::test_support::with_temp_home(|| {
16590 config::ensure_dirs().unwrap();
16591 let _ = seed_op();
16592 let bundle = json!({
16593 "org_did": "did:wire:not-an-org",
16594 "org_pubkey": "AAAA",
16595 "member_cert": "AAAA",
16596 })
16597 .to_string();
16598 let err = cmd_enroll_add_membership(Some(bundle), None, None, None, true).unwrap_err();
16599 assert!(
16600 err.to_string().contains("not a valid organization DID"),
16601 "got: {err:#}"
16602 );
16603 });
16604 }
16605}