1use anyhow::{Context, Result, anyhow, bail};
15use clap::{Parser, Subcommand};
16use serde_json::{Value, json};
17
18use crate::{
19 agent_card::{build_agent_card, sign_agent_card},
20 config,
21 signing::{fingerprint, generate_keypair, make_key_id, sign_message_v31, verify_message_v31},
22 trust::{add_self_to_trust, empty_trust},
23};
24
25#[derive(Parser, Debug)]
27#[command(name = "wire", version, about = "Magic-wormhole for AI agents — bilateral signed-message bus", long_about = None)]
28pub struct Cli {
29 #[command(subcommand)]
30 pub command: Command,
31}
32
33#[derive(Subcommand, Debug)]
34pub enum Command {
35 #[command(hide = true)]
51 Init {
52 handle: String,
54 #[arg(long)]
56 name: Option<String>,
57 #[arg(long)]
62 relay: Option<String>,
63 #[arg(long, conflicts_with = "relay")]
68 offline: bool,
69 #[arg(long)]
71 json: bool,
72 },
73 Whoami {
75 #[arg(long)]
76 json: bool,
77 #[arg(long, conflicts_with = "json")]
80 short: bool,
81 #[arg(long, conflicts_with_all = ["json", "short"])]
84 colored: bool,
85 },
86 Peers {
88 #[arg(long)]
89 json: bool,
90 },
91 Completions {
102 #[arg(value_enum)]
104 shell: clap_complete::Shell,
105 },
106 Here {
114 #[arg(long)]
115 json: bool,
116 },
117 Pending {
121 #[arg(long)]
122 json: bool,
123 },
124 Send {
132 peer: String,
134 kind_or_body: String,
139 body: Option<String>,
143 #[arg(long)]
145 deadline: Option<String>,
146 #[arg(long)]
151 no_auto_pair: bool,
152 #[arg(long)]
160 queue: bool,
161 #[arg(long)]
163 json: bool,
164 },
165 Dial {
184 name: String,
188 message: Option<String>,
192 #[arg(long)]
194 json: bool,
195 },
196 Tail {
204 peer: Option<String>,
206 #[arg(long)]
208 json: bool,
209 #[arg(long, default_value_t = 0)]
211 limit: usize,
212 #[arg(long)]
215 oldest: bool,
216 },
217 Monitor {
228 #[arg(long)]
230 peer: Option<String>,
231 #[arg(long)]
233 json: bool,
234 #[arg(long)]
237 include_handshake: bool,
238 #[arg(long, default_value_t = 500)]
240 interval_ms: u64,
241 #[arg(long, default_value_t = 0)]
243 replay: usize,
244 },
245 Verify {
247 path: String,
249 #[arg(long)]
251 json: bool,
252 },
253 Mcp,
257 RelayServer {
259 #[arg(long, default_value = "127.0.0.1:8770")]
261 bind: String,
262 #[arg(long)]
270 local_only: bool,
271 #[arg(long)]
277 uds: Option<std::path::PathBuf>,
278 },
279 BindRelay {
288 url: String,
290 #[arg(long)]
296 scope: Option<String>,
297 #[arg(long)]
303 replace: bool,
304 #[arg(long)]
310 migrate_pinned: bool,
311 #[arg(long)]
312 json: bool,
313 },
314 AddPeerSlot {
317 handle: String,
319 url: String,
321 slot_id: String,
323 slot_token: String,
325 #[arg(long)]
326 json: bool,
327 },
328 Push {
330 peer: Option<String>,
332 #[arg(long)]
333 json: bool,
334 },
335 Pull {
337 #[arg(long)]
338 json: bool,
339 },
340 Status {
343 #[arg(long)]
345 peer: Option<String>,
346 #[arg(long)]
347 json: bool,
348 },
349 Responder {
351 #[command(subcommand)]
352 command: ResponderCommand,
353 },
354 Pin {
357 card_file: String,
359 #[arg(long)]
360 json: bool,
361 },
362 RotateSlot {
373 #[arg(long)]
376 no_announce: bool,
377 #[arg(long)]
378 json: bool,
379 },
380 ForgetPeer {
384 handle: String,
386 #[arg(long)]
388 purge: bool,
389 #[arg(long)]
390 json: bool,
391 },
392 Supervisor {
400 #[arg(long)]
403 json: bool,
404 },
405 Daemon {
409 #[arg(long, default_value_t = 5)]
411 interval: u64,
412 #[arg(long)]
414 once: bool,
415 #[arg(long)]
425 all_sessions: bool,
426 #[arg(long)]
432 session: Option<String>,
433 #[arg(long)]
434 json: bool,
435 },
436 #[command(subcommand)]
446 Session(SessionCommand),
447 Identity {
452 #[command(subcommand)]
453 cmd: IdentityCommand,
454 },
455 #[command(subcommand)]
460 Mesh(MeshCommand),
461 #[command(subcommand)]
465 Group(GroupCommand),
466 #[command(subcommand)]
469 Enroll(EnrollCommand),
470 Setup {
475 #[arg(long)]
477 apply: bool,
478 #[arg(long)]
484 statusline: bool,
485 #[arg(long)]
488 remove: bool,
489 },
490 Whois {
494 handle: Option<String>,
496 #[arg(long)]
497 json: bool,
498 #[arg(long)]
501 relay: Option<String>,
502 },
503 Add {
509 handle: String,
512 #[arg(long)]
514 relay: Option<String>,
515 #[arg(long)]
523 local_sister: bool,
524 #[arg(long)]
525 json: bool,
526 },
527 Up {
540 relay: Option<String>,
544 #[arg(long)]
547 name: Option<String>,
548 #[arg(long)]
553 with_local: Option<String>,
554 #[arg(long)]
556 no_local: bool,
557 #[arg(long)]
558 json: bool,
559 },
560 Doctor {
567 #[arg(long)]
569 json: bool,
570 #[arg(long, default_value_t = 5)]
572 recent_rejections: usize,
573 },
574 #[command(visible_alias = "update")]
586 Upgrade {
587 #[arg(long)]
589 check: bool,
590 #[arg(long)]
593 local: bool,
594 #[arg(long = "restart-mcp")]
600 restart_mcp: bool,
601 #[arg(long = "refresh-stale-children")]
611 refresh_stale_children: bool,
612 #[arg(long)]
613 json: bool,
614 },
615 Nuke {
620 #[arg(long, visible_alias = "yes")]
623 force: bool,
624 #[arg(long)]
626 purge: bool,
627 #[arg(long)]
629 dry_run: bool,
630 #[arg(long)]
631 json: bool,
632 },
633 Service {
638 #[command(subcommand)]
639 action: ServiceAction,
640 },
641 Diag {
646 #[command(subcommand)]
647 action: DiagAction,
648 },
649 #[command(hide = true)]
661 Claim {
662 nick: String,
664 #[arg(long)]
666 relay: Option<String>,
667 #[arg(long)]
669 public_url: Option<String>,
670 #[arg(long)]
678 hidden: bool,
679 #[arg(long)]
680 json: bool,
681 },
682 Profile {
692 #[command(subcommand)]
693 action: ProfileAction,
694 },
695 #[command(hide = true)] Invite {
700 #[arg(long, default_value = "https://wireup.net")]
702 relay: String,
703 #[arg(long, default_value_t = 86_400)]
705 ttl: u64,
706 #[arg(long, default_value_t = 1)]
709 uses: u32,
710 #[arg(long)]
714 share: bool,
715 #[arg(long)]
717 json: bool,
718 },
719 Accept {
728 target: String,
730 #[arg(long)]
732 json: bool,
733 },
734 #[command(alias = "invite-accept")]
742 AcceptInvite {
743 url: String,
745 #[arg(long)]
747 json: bool,
748 },
749 Reject {
751 peer: String,
753 #[arg(long)]
755 json: bool,
756 },
757 Notify {
762 #[arg(long, default_value_t = 2)]
764 interval: u64,
765 #[arg(long)]
767 peer: Option<String>,
768 #[arg(long)]
770 once: bool,
771 #[arg(long)]
775 json: bool,
776 },
777 Quiet {
784 #[command(subcommand)]
785 action: QuietAction,
786 },
787}
788
789#[derive(Subcommand, Debug)]
790pub enum QuietAction {
791 On,
794 Off,
797 Status {
800 #[arg(long)]
803 json: bool,
804 },
805}
806
807#[derive(Subcommand, Debug)]
808pub enum DiagAction {
809 Tail {
811 #[arg(long, default_value_t = 20)]
812 limit: usize,
813 #[arg(long)]
814 json: bool,
815 },
816 Enable,
819 Disable,
821 Status {
823 #[arg(long)]
824 json: bool,
825 },
826}
827
828#[derive(Subcommand, Debug)]
833pub enum EnrollCommand {
834 Op {
836 #[arg(long, default_value = "operator")]
838 handle: String,
839 #[arg(long)]
840 json: bool,
841 },
842 OrgCreate {
844 #[arg(long)]
846 handle: String,
847 #[arg(long)]
848 json: bool,
849 },
850 OrgAddMember {
854 op_did: String,
856 #[arg(long)]
858 org: String,
859 #[arg(long)]
860 json: bool,
861 },
862 Republish {
870 #[arg(long)]
871 json: bool,
872 },
873 AddMembership {
889 #[arg(long)]
892 bundle: Option<String>,
893 #[arg(long)]
895 org: Option<String>,
896 #[arg(long = "org-pubkey")]
898 org_pubkey: Option<String>,
899 #[arg(long = "member-cert")]
902 member_cert: Option<String>,
903 #[arg(long)]
904 json: bool,
905 },
906}
907
908#[derive(Subcommand, Debug)]
909pub enum IdentityCommand {
910 Show {
913 #[arg(long)]
914 json: bool,
915 },
916 List {
921 #[arg(long)]
922 json: bool,
923 },
924 #[command(hide = true)]
932 Publish {
933 nick: String,
935 #[arg(long)]
938 relay: Option<String>,
939 #[arg(long, alias = "public")]
942 public_url: Option<String>,
943 #[arg(long)]
947 hidden: bool,
948 #[arg(long)]
949 json: bool,
950 },
951 Destroy {
955 name: String,
957 #[arg(long)]
959 force: bool,
960 #[arg(long)]
961 json: bool,
962 },
963 Create {
975 #[arg(long)]
978 name: Option<String>,
979 #[arg(long, conflicts_with = "local")]
982 anonymous: bool,
983 #[arg(long)]
986 local: bool,
987 #[arg(long)]
988 json: bool,
989 },
990 Persist {
995 name: String,
997 #[arg(long = "as", value_name = "NEW_NAME")]
999 as_name: Option<String>,
1000 #[arg(long)]
1001 json: bool,
1002 },
1003 Demote {
1013 name: String,
1015 #[arg(long)]
1016 json: bool,
1017 },
1018}
1019
1020#[derive(Subcommand, Debug)]
1021pub enum SessionCommand {
1022 New {
1030 name: Option<String>,
1032 #[arg(long, default_value = "https://wireup.net")]
1034 relay: String,
1035 #[arg(long)]
1042 with_local: bool,
1043 #[arg(long, default_value = "http://127.0.0.1:8771")]
1047 local_relay: String,
1048 #[arg(long)]
1055 with_lan: bool,
1056 #[arg(long)]
1060 lan_relay: Option<String>,
1061 #[arg(long)]
1068 with_uds: bool,
1069 #[arg(long)]
1073 uds_socket: Option<std::path::PathBuf>,
1074 #[arg(long)]
1077 no_daemon: bool,
1078 #[arg(long)]
1086 local_only: bool,
1087 #[arg(long)]
1089 json: bool,
1090 },
1091 List {
1094 #[arg(long)]
1095 json: bool,
1096 },
1097 ListLocal {
1103 #[arg(long)]
1104 json: bool,
1105 },
1106 PairAllLocal {
1122 #[arg(long, default_value_t = 1)]
1127 settle_secs: u64,
1128 #[arg(long, default_value = "https://wireup.net")]
1133 federation_relay: String,
1134 #[arg(long)]
1135 json: bool,
1136 },
1137 MeshStatus {
1151 #[arg(long, default_value_t = 300)]
1156 stale_secs: u64,
1157 #[arg(long)]
1158 json: bool,
1159 },
1160 Env {
1164 name: Option<String>,
1166 #[arg(long)]
1167 json: bool,
1168 },
1169 Current {
1173 #[arg(long)]
1174 json: bool,
1175 },
1176 Bind {
1184 name: Option<String>,
1188 #[arg(long)]
1189 json: bool,
1190 },
1191 Destroy {
1195 name: String,
1196 #[arg(long)]
1198 force: bool,
1199 #[arg(long)]
1200 json: bool,
1201 },
1202}
1203
1204#[derive(Subcommand, Debug)]
1210pub enum GroupCommand {
1211 Create {
1213 name: String,
1215 #[arg(long)]
1216 json: bool,
1217 },
1218 Add {
1220 group: String,
1222 peer: String,
1224 #[arg(long)]
1225 json: bool,
1226 },
1227 Send {
1229 group: String,
1231 message: String,
1233 #[arg(long)]
1234 json: bool,
1235 },
1236 Tail {
1238 group: String,
1240 #[arg(long, default_value_t = 20)]
1242 limit: usize,
1243 #[arg(long)]
1244 json: bool,
1245 },
1246 List {
1248 #[arg(long)]
1249 json: bool,
1250 },
1251 Invite {
1256 group: String,
1258 #[arg(long)]
1259 json: bool,
1260 },
1261 Join {
1265 code: String,
1267 #[arg(long)]
1268 json: bool,
1269 },
1270}
1271
1272#[derive(Subcommand, Debug)]
1274pub enum MeshCommand {
1275 Status {
1278 #[arg(long, default_value_t = 300)]
1280 stale_secs: u64,
1281 #[arg(long)]
1282 json: bool,
1283 },
1284 Broadcast {
1303 #[arg(long, default_value = "claim")]
1306 kind: String,
1307 #[arg(long, default_value = "local")]
1309 scope: String,
1310 #[arg(long)]
1312 exclude: Vec<String>,
1313 #[arg(long)]
1317 noreply: bool,
1318 body: String,
1320 #[arg(long)]
1321 json: bool,
1322 },
1323 Role {
1332 #[command(subcommand)]
1333 action: MeshRoleAction,
1334 },
1335 Route {
1351 role: String,
1353 #[arg(long, default_value = "round-robin")]
1355 strategy: String,
1356 #[arg(long)]
1358 exclude: Vec<String>,
1359 #[arg(long, default_value = "claim")]
1362 kind: String,
1363 body: String,
1365 #[arg(long)]
1366 json: bool,
1367 },
1368}
1369
1370#[derive(Subcommand, Debug)]
1372pub enum MeshRoleAction {
1373 Set {
1378 role: String,
1379 #[arg(long)]
1380 json: bool,
1381 },
1382 Get {
1385 peer: Option<String>,
1386 #[arg(long)]
1387 json: bool,
1388 },
1389 List {
1392 #[arg(long)]
1393 json: bool,
1394 },
1395 Clear {
1398 #[arg(long)]
1399 json: bool,
1400 },
1401}
1402
1403#[derive(Subcommand, Debug)]
1404pub enum ServiceAction {
1405 Install {
1415 #[arg(long)]
1417 local_relay: bool,
1418 #[arg(long)]
1419 json: bool,
1420 },
1421 Uninstall {
1425 #[arg(long)]
1427 local_relay: bool,
1428 #[arg(long)]
1429 json: bool,
1430 },
1431 Status {
1433 #[arg(long)]
1435 local_relay: bool,
1436 #[arg(long)]
1437 json: bool,
1438 },
1439}
1440
1441#[derive(Subcommand, Debug)]
1442pub enum ResponderCommand {
1443 Set {
1445 status: String,
1447 #[arg(long)]
1449 reason: Option<String>,
1450 #[arg(long)]
1452 json: bool,
1453 },
1454 Get {
1456 peer: Option<String>,
1458 #[arg(long)]
1460 json: bool,
1461 },
1462}
1463
1464#[derive(Subcommand, Debug)]
1465pub enum ProfileAction {
1466 Set {
1470 field: String,
1471 value: String,
1472 #[arg(long)]
1473 json: bool,
1474 },
1475 Get {
1477 #[arg(long)]
1478 json: bool,
1479 },
1480 Clear {
1482 field: String,
1483 #[arg(long)]
1484 json: bool,
1485 },
1486}
1487
1488pub fn run() -> Result<()> {
1490 crate::session::maybe_adopt_session_wire_home("cli");
1501 let cli = Cli::parse();
1502 match cli.command {
1503 Command::Init {
1504 handle,
1505 name,
1506 relay,
1507 offline,
1508 json,
1509 } => cmd_init(
1510 Some(&handle),
1511 name.as_deref(),
1512 relay.as_deref(),
1513 offline,
1514 json,
1515 ),
1516 Command::Status { peer, json } => {
1517 if let Some(peer) = peer {
1518 cmd_status_peer(&peer, json)
1519 } else {
1520 cmd_status(json)
1521 }
1522 }
1523 Command::Whoami {
1524 json,
1525 short,
1526 colored,
1527 } => cmd_whoami(json_default(json), short, colored),
1528 Command::Peers { json } => cmd_peers(json_default(json)),
1529 Command::Here { json } => cmd_here(json_default(json)),
1530 Command::Completions { shell } => {
1531 use clap::CommandFactory;
1538 let mut cmd = Cli::command();
1539 clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1540 Ok(())
1541 }
1542 Command::Pending { json } => cmd_pair_list_inbound(json_default(json)),
1543 Command::Reject { peer, json } => cmd_pair_reject(&peer, json_default(json)),
1544 Command::Send {
1545 peer,
1546 kind_or_body,
1547 body,
1548 deadline,
1549 no_auto_pair,
1550 queue,
1551 json,
1552 } => {
1553 let (kind, body) = match body {
1556 Some(real_body) => (kind_or_body, real_body),
1557 None => ("claim".to_string(), kind_or_body),
1558 };
1559 cmd_send(
1560 &peer,
1561 &kind,
1562 &body,
1563 deadline.as_deref(),
1564 no_auto_pair,
1565 queue,
1566 json_default(json),
1567 )
1568 }
1569 Command::Dial {
1570 name,
1571 message,
1572 json,
1573 } => cmd_dial(&name, message.as_deref(), json_default(json)),
1574 Command::Tail {
1575 peer,
1576 json,
1577 limit,
1578 oldest,
1579 } => cmd_tail(peer.as_deref(), json, limit, oldest),
1580 Command::Monitor {
1581 peer,
1582 json,
1583 include_handshake,
1584 interval_ms,
1585 replay,
1586 } => cmd_monitor(
1587 peer.as_deref(),
1588 json,
1589 include_handshake,
1590 interval_ms,
1591 replay,
1592 ),
1593 Command::Verify { path, json } => cmd_verify(&path, json),
1594 Command::Responder { command } => match command {
1595 ResponderCommand::Set {
1596 status,
1597 reason,
1598 json,
1599 } => cmd_responder_set(&status, reason.as_deref(), json),
1600 ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1601 },
1602 Command::Mcp => cmd_mcp(),
1603 Command::RelayServer {
1604 bind,
1605 local_only,
1606 uds,
1607 } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1608 Command::BindRelay {
1609 url,
1610 scope,
1611 replace,
1612 migrate_pinned,
1613 json,
1614 } => cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1615 Command::AddPeerSlot {
1616 handle,
1617 url,
1618 slot_id,
1619 slot_token,
1620 json,
1621 } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1622 Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1623 Command::Pull { json } => cmd_pull(json),
1624 Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1625 Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1626 Command::ForgetPeer {
1627 handle,
1628 purge,
1629 json,
1630 } => cmd_forget_peer(&handle, purge, json),
1631 Command::Supervisor { json } => cmd_supervisor(json),
1632 Command::Daemon {
1633 interval,
1634 once,
1635 all_sessions,
1636 session,
1637 json,
1638 } => cmd_daemon(interval, once, all_sessions, session, json),
1639 Command::Session(cmd) => cmd_session(cmd),
1640 Command::Identity { cmd } => cmd_identity(cmd),
1641 Command::Mesh(cmd) => cmd_mesh(cmd),
1642 Command::Group(cmd) => cmd_group(cmd),
1643 Command::Enroll(cmd) => cmd_enroll(cmd),
1644 Command::Invite {
1645 relay,
1646 ttl,
1647 uses,
1648 share,
1649 json,
1650 } => cmd_invite(&relay, ttl, uses, share, json),
1651 Command::Accept { target, json } => {
1652 let j = json_default(json);
1655 if target.starts_with("wire://pair?") || target.starts_with("http") {
1656 anyhow::bail!(
1657 "`wire accept` takes a peer name, not a URL. \
1658 Use `wire accept-invite {target}` to accept an invite URL."
1659 );
1660 } else {
1661 cmd_pair_accept(&target, j)
1662 }
1663 }
1664 Command::AcceptInvite { url, json } => cmd_accept(&url, json_default(json)),
1665 Command::Whois {
1666 handle,
1667 json,
1668 relay,
1669 } => {
1670 match handle.as_deref() {
1679 Some(h) if !h.contains('@') => cmd_whois_local(h, json),
1680 other => cmd_whois(other, json, relay.as_deref()),
1681 }
1682 }
1683 Command::Add {
1684 handle,
1685 relay,
1686 local_sister,
1687 json,
1688 } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1689 Command::Up {
1690 relay,
1691 name,
1692 with_local,
1693 no_local,
1694 json,
1695 } => cmd_up(
1696 relay.as_deref(),
1697 name.as_deref(),
1698 with_local.as_deref(),
1699 no_local,
1700 json,
1701 ),
1702 Command::Doctor {
1703 json,
1704 recent_rejections,
1705 } => cmd_doctor(json, recent_rejections),
1706 Command::Upgrade {
1707 check,
1708 local,
1709 restart_mcp,
1710 refresh_stale_children,
1711 json,
1712 } => cmd_upgrade(check, local, restart_mcp, refresh_stale_children, json),
1713 Command::Service { action } => cmd_service(action),
1714 Command::Diag { action } => cmd_diag(action),
1715 Command::Claim {
1716 nick,
1717 relay,
1718 public_url,
1719 hidden,
1720 json,
1721 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1722 Command::Profile { action } => cmd_profile(action),
1723 Command::Setup {
1724 apply,
1725 statusline,
1726 remove,
1727 } => {
1728 if statusline {
1729 cmd_setup_statusline(apply, remove)
1730 } else {
1731 cmd_setup(apply)
1732 }
1733 }
1734 Command::Notify {
1735 interval,
1736 peer,
1737 once,
1738 json,
1739 } => cmd_notify(interval, peer.as_deref(), once, json),
1740 Command::Nuke {
1741 force,
1742 purge,
1743 dry_run,
1744 json,
1745 } => cmd_nuke(force, purge, dry_run, json),
1746 Command::Quiet { action } => cmd_quiet(action),
1747 }
1748}
1749
1750fn quiet_flag_path() -> Result<std::path::PathBuf> {
1757 Ok(config::config_dir()?.join("quiet"))
1758}
1759
1760fn cmd_quiet(action: QuietAction) -> Result<()> {
1761 match action {
1762 QuietAction::On => {
1763 let path = quiet_flag_path()?;
1764 if let Some(parent) = path.parent() {
1765 std::fs::create_dir_all(parent).with_context(|| {
1766 format!("creating config dir for quiet flag: {}", parent.display())
1767 })?;
1768 }
1769 std::fs::OpenOptions::new()
1771 .create(true)
1772 .truncate(true)
1773 .write(true)
1774 .open(&path)
1775 .with_context(|| format!("writing {}", path.display()))?;
1776 println!(
1777 "wire quiet: ON (toasts silenced — file at {})",
1778 path.display()
1779 );
1780 Ok(())
1781 }
1782 QuietAction::Off => {
1783 let path = quiet_flag_path()?;
1784 match std::fs::remove_file(&path) {
1785 Ok(()) => println!("wire quiet: OFF (toasts re-enabled)"),
1786 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1787 println!("wire quiet: OFF (was already off)")
1788 }
1789 Err(e) => return Err(anyhow!("removing {}: {e}", path.display())),
1790 }
1791 if std::env::var("WIRE_NO_TOASTS").is_ok_and(|v| !v.is_empty() && v != "0") {
1793 println!(
1794 " 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).",
1795 std::env::var("WIRE_NO_TOASTS").unwrap_or_default()
1796 );
1797 }
1798 Ok(())
1799 }
1800 QuietAction::Status { json } => {
1801 let env_set = std::env::var("WIRE_NO_TOASTS").is_ok_and(|v| !v.is_empty() && v != "0");
1802 let file_present = quiet_flag_path()?.exists();
1803 let (state, via) = match (env_set, file_present) {
1804 (true, _) => ("on", "env"),
1805 (false, true) => ("on", "file"),
1806 (false, false) => ("off", "none"),
1807 };
1808 if json {
1809 println!(
1810 "{}",
1811 serde_json::to_string(&json!({
1812 "state": state,
1813 "via": via,
1814 "file": quiet_flag_path()?.display().to_string(),
1815 "env_WIRE_NO_TOASTS": std::env::var("WIRE_NO_TOASTS").ok(),
1816 }))?
1817 );
1818 } else {
1819 match (env_set, file_present) {
1820 (true, _) => println!(
1821 "wire quiet: ON (via WIRE_NO_TOASTS={} in env)",
1822 std::env::var("WIRE_NO_TOASTS").unwrap_or_default()
1823 ),
1824 (false, true) => println!(
1825 "wire quiet: ON (via file at {})",
1826 quiet_flag_path()?.display()
1827 ),
1828 (false, false) => println!("wire quiet: OFF"),
1829 }
1830 }
1831 Ok(())
1832 }
1833 }
1834}
1835
1836fn cmd_nuke(force: bool, purge: bool, dry_run: bool, as_json: bool) -> Result<()> {
1839 use std::io::{IsTerminal, Write};
1840 let plan = crate::nuke::NukePlan::compute(purge)?;
1841
1842 if as_json && dry_run {
1844 println!("{}", serde_json::to_string_pretty(&plan)?);
1845 return Ok(());
1846 }
1847 if !as_json {
1848 eprintln!("wire nuke will remove:");
1849 for p in &plan.paths {
1850 eprintln!(" dir {}", p.display());
1851 }
1852 for m in &plan.mcp_files {
1853 eprintln!(" mcp {} (de-register `wire`)", m.display());
1854 }
1855 eprintln!(" units launchd/systemd/schtasks (daemon + local-relay)");
1856 eprintln!(" procs any running wire daemon / supervisor / relay-server");
1857 if purge {
1858 eprintln!(" PURGE the `wire` binary + shell PATH/env lines");
1859 }
1860 }
1861 if dry_run {
1862 return Ok(());
1863 }
1864
1865 if !crate::nuke::should_proceed(force, std::io::stdin().is_terminal(), || {
1867 eprint!("\nType `nuke` to confirm: ");
1868 let _ = std::io::stderr().flush();
1869 let mut line = String::new();
1870 let _ = std::io::stdin().read_line(&mut line);
1871 line
1872 }) {
1873 if !as_json {
1874 eprintln!("aborted — nothing removed. (Use --force for automation.)");
1875 }
1876 anyhow::bail!("nuke not confirmed");
1877 }
1878
1879 let killed = kill_wire_processes();
1881
1882 let mut report = plan.execute()?;
1884 report.killed_pids = killed;
1885
1886 if purge {
1888 report.binary_removed = purge_binary_and_shell(&mut report.warnings);
1889 }
1890
1891 if as_json {
1892 println!("{}", serde_json::to_string_pretty(&report)?);
1893 } else {
1894 eprintln!(
1895 "nuked: {} dir(s), {} mcp entr(ies), {} unit(s), {} proc(s){}",
1896 report.removed_paths.len(),
1897 report.removed_mcp_entries.len(),
1898 report.removed_units.len(),
1899 report.killed_pids.len(),
1900 if report.binary_removed {
1901 ", binary+shell"
1902 } else {
1903 ""
1904 },
1905 );
1906 for w in &report.warnings {
1907 eprintln!(" warn: {w}");
1908 }
1909 }
1910 Ok(())
1911}
1912
1913fn kill_wire_processes() -> Vec<u32> {
1916 let mut killed = Vec::new();
1917 #[cfg(unix)]
1918 for pat in ["wire daemon", "relay-server"] {
1919 if let Ok(out) = std::process::Command::new("pkill")
1920 .arg("-f")
1921 .arg(pat)
1922 .output()
1923 {
1924 let _ = out;
1926 }
1927 }
1928 #[cfg(windows)]
1929 {
1930 let self_pid = std::process::id();
1934 if let Ok(out) = std::process::Command::new("tasklist")
1935 .args(["/FI", "IMAGENAME eq wire.exe", "/FO", "CSV", "/NH"])
1936 .output()
1937 {
1938 for line in String::from_utf8_lossy(&out.stdout).lines() {
1939 if let Some(pid) = line
1941 .split(',')
1942 .nth(1)
1943 .and_then(|s| s.trim().trim_matches('"').parse::<u32>().ok())
1944 {
1945 if pid != self_pid {
1946 let _ = std::process::Command::new("taskkill")
1947 .args(["/F", "/PID", &pid.to_string()])
1948 .output();
1949 killed.push(pid);
1950 }
1951 }
1952 }
1953 }
1954 }
1955 let _ = &mut killed;
1958 killed
1959}
1960
1961fn purge_binary_and_shell(warnings: &mut Vec<String>) -> bool {
1965 let exe = match std::env::current_exe() {
1966 Ok(e) => e,
1967 Err(e) => {
1968 warnings.push(format!("resolve exe: {e:#}"));
1969 return false;
1970 }
1971 };
1972 #[cfg(windows)]
1973 {
1974 eprintln!("purge: a running .exe can't delete itself. Remove it manually:");
1975 eprintln!(" del \"{}\"", exe.display());
1976 warnings.push("binary self-delete skipped on Windows (manual del printed)".into());
1977 return false;
1978 }
1979 #[cfg(unix)]
1980 {
1981 match std::fs::remove_file(&exe) {
1982 Ok(()) => {
1983 scrub_shell_lines(warnings);
1985 true
1986 }
1987 Err(e) => {
1988 warnings.push(format!("rm binary {}: {e:#}", exe.display()));
1989 false
1990 }
1991 }
1992 }
1993}
1994
1995#[cfg(unix)]
1996fn scrub_shell_lines(warnings: &mut Vec<String>) {
1997 let Some(home) = dirs::home_dir() else {
1998 return;
1999 };
2000 for rc in [".bashrc", ".zshrc", ".profile", ".config/fish/config.fish"] {
2001 let path = home.join(rc);
2002 let Ok(content) = std::fs::read_to_string(&path) else {
2003 continue;
2004 };
2005 let filtered: String = content
2006 .lines()
2007 .filter(|l| !(l.contains("wire") && (l.contains("PATH") || l.contains("WIRE_"))))
2008 .collect::<Vec<_>>()
2009 .join("\n");
2010 if filtered != content
2011 && let Err(e) = std::fs::write(&path, filtered + "\n")
2012 {
2013 warnings.push(format!("scrub {}: {e:#}", path.display()));
2014 }
2015 }
2016}
2017
2018fn cmd_init(
2021 handle: Option<&str>,
2022 name: Option<&str>,
2023 relay: Option<&str>,
2024 offline: bool,
2025 as_json: bool,
2026) -> Result<()> {
2027 if let Some(h) = handle
2033 && !h
2034 .chars()
2035 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
2036 {
2037 bail!("handle must be ASCII alphanumeric / '-' / '_' (got {h:?})");
2038 }
2039 if config::is_initialized()? {
2040 bail!(
2041 "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
2042 config::config_dir()?
2043 );
2044 }
2045 let mut resolved_relay: Option<String> = relay.map(str::to_string);
2059 if resolved_relay.is_none() && !offline {
2060 let default_local = "http://127.0.0.1:8771";
2061 let client = crate::relay_client::RelayClient::new(default_local);
2062 if client.check_healthz().is_ok() {
2063 eprintln!(
2064 "wire init: local relay at {default_local} reachable — auto-attaching. \
2065 Use --relay <url> to pick a different relay, --offline to skip."
2066 );
2067 resolved_relay = Some(default_local.to_string());
2068 } else {
2069 use std::io::{BufRead, IsTerminal, Write};
2075 let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
2076 if interactive && std::env::var("WIRE_NO_INTERACTIVE").is_err() {
2077 eprintln!("wire init: no local relay reachable at {default_local}.");
2078 eprint!(
2079 " Bind to public federation relay https://wireup.net instead? \
2080 [Y/n/offline/url]: "
2081 );
2082 let _ = std::io::stderr().flush();
2083 let mut input = String::new();
2084 let _ = std::io::stdin().lock().read_line(&mut input);
2085 let answer = input.trim();
2086 match answer {
2087 "" | "y" | "Y" | "yes" | "YES" => {
2088 eprintln!("wire init: binding to https://wireup.net");
2089 resolved_relay = Some("https://wireup.net".to_string());
2090 }
2091 "n" | "N" | "no" | "NO" => {
2092 bail!(
2093 "wire init: declined federation default; re-run with --relay <url> or --offline."
2094 );
2095 }
2096 "offline" | "OFFLINE" => {
2097 eprintln!(
2098 "wire init: proceeding offline. \
2099 Run `wire bind-relay <url>` before pairing."
2100 );
2101 }
2107 url if url.starts_with("http://") || url.starts_with("https://") => {
2108 eprintln!("wire init: binding to {url}");
2109 resolved_relay = Some(url.to_string());
2110 }
2111 other => {
2112 bail!(
2113 "wire init: unrecognized answer `{other}` — \
2114 expected Y/n/offline/<url>. Re-run with --relay or --offline."
2115 );
2116 }
2117 }
2118 } else {
2119 bail!(
2120 "wire init: no relay specified and no local relay reachable at \
2121 http://127.0.0.1:8771.\n\
2122 Pick one (or just run `wire up`):\n\
2123 • `wire service install --local-relay` — start the local relay, then re-run\n\
2124 • `wire up @wireup.net` — bind to public federation in one command\n\
2125 • `wire init --offline` — generate keypair only \
2126 (peers cannot reach you until you `wire bind-relay <url>` later)"
2127 );
2128 }
2129 }
2130 }
2131 let relay = resolved_relay.as_deref();
2132
2133 config::ensure_dirs()?;
2134 let (sk_seed, pk_bytes) = generate_keypair();
2135 config::write_private_key(&sk_seed)?;
2136
2137 let seed = handle.unwrap_or("agent");
2155 let synth_did = crate::agent_card::did_for_with_key(seed, &pk_bytes);
2156 let character = crate::character::Character::from_did(&synth_did);
2157 let canonical_handle: &str = &character.nickname;
2158 if let Some(typed) = handle
2159 && typed != canonical_handle
2160 {
2161 eprintln!(
2162 "wire init: one-name rule — typed `{typed}` ignored in favor of \
2163 DID-derived persona `{canonical_handle}`. Peers will reach you as `{canonical_handle}`."
2164 );
2165 }
2166
2167 let card = build_agent_card(canonical_handle, &pk_bytes, name, None, None);
2168 let card = crate::enroll::with_op_claims_if_enrolled(card)?;
2171 let signed = sign_agent_card(&card, &sk_seed);
2172 config::write_agent_card(&signed)?;
2173
2174 let mut trust = empty_trust();
2175 add_self_to_trust(&mut trust, canonical_handle, &pk_bytes);
2176 config::write_trust(&trust)?;
2177
2178 let fp = fingerprint(&pk_bytes);
2179 let key_id = make_key_id(canonical_handle, &pk_bytes);
2180 let handle = canonical_handle;
2183
2184 let mut relay_info: Option<(String, String)> = None;
2186 if let Some(url) = relay {
2187 let normalized = url.trim_end_matches('/');
2188 let client = crate::relay_client::RelayClient::new(normalized);
2189 client.check_healthz()?;
2190 let alloc = client.allocate_slot(Some(handle))?;
2191 let mut state = config::read_relay_state()?;
2192 state["self"] = json!({
2193 "relay_url": normalized,
2194 "slot_id": alloc.slot_id.clone(),
2195 "slot_token": alloc.slot_token,
2196 });
2197 config::write_relay_state(&state)?;
2198 relay_info = Some((normalized.to_string(), alloc.slot_id));
2199 }
2200
2201 let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
2202 if as_json {
2203 let mut out = json!({
2204 "did": did_str.clone(),
2205 "fingerprint": fp,
2206 "key_id": key_id,
2207 "config_dir": config::config_dir()?.to_string_lossy(),
2208 });
2209 if let Some((url, slot_id)) = &relay_info {
2210 out["relay_url"] = json!(url);
2211 out["slot_id"] = json!(slot_id);
2212 }
2213 println!("{}", serde_json::to_string(&out)?);
2214 } else {
2215 println!("generated {did_str} (ed25519:{key_id})");
2216 println!(
2217 "config written to {}",
2218 config::config_dir()?.to_string_lossy()
2219 );
2220 if let Some((url, slot_id)) = &relay_info {
2221 println!("bound to relay {url} (slot {slot_id})");
2222 println!();
2223 println!("next step: `wire dial <handle>@{url}` to pair with a peer.");
2224 } else {
2225 println!();
2226 println!("next step: `wire dial <handle>@<relay>` to bind a relay + pair with a peer.");
2227 }
2228 }
2229 Ok(())
2230}
2231
2232fn cmd_status(as_json: bool) -> Result<()> {
2235 let initialized = config::is_initialized()?;
2236
2237 let mut summary = json!({
2238 "initialized": initialized,
2239 });
2240
2241 if initialized {
2242 let card = config::read_agent_card()?;
2243 let did = card
2244 .get("did")
2245 .and_then(Value::as_str)
2246 .unwrap_or("")
2247 .to_string();
2248 let handle = card
2252 .get("handle")
2253 .and_then(Value::as_str)
2254 .map(str::to_string)
2255 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2256 let pk_b64 = card
2257 .get("verify_keys")
2258 .and_then(Value::as_object)
2259 .and_then(|m| m.values().next())
2260 .and_then(|v| v.get("key"))
2261 .and_then(Value::as_str)
2262 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2263 let pk_bytes = crate::signing::b64decode(pk_b64)?;
2264 summary["did"] = json!(did);
2265 summary["handle"] = json!(handle);
2266 summary["fingerprint"] = json!(fingerprint(&pk_bytes));
2267 summary["capabilities"] = card
2268 .get("capabilities")
2269 .cloned()
2270 .unwrap_or_else(|| json!([]));
2271
2272 let trust = config::read_trust()?;
2273 let relay_state_for_tier =
2274 config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2275 let mut peers = Vec::new();
2276 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
2277 for (peer_handle, _agent) in agents {
2278 if peer_handle == &handle {
2279 continue; }
2281 peers.push(json!({
2286 "handle": peer_handle,
2287 "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
2288 }));
2289 }
2290 }
2291 summary["peers"] = json!(peers);
2292
2293 let relay_state = config::read_relay_state()?;
2294 summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
2295 if !summary["self_relay"].is_null() {
2296 if let Some(obj) = summary["self_relay"].as_object_mut() {
2298 obj.remove("slot_token");
2299 }
2300 }
2301 summary["peer_slots_count"] = json!(
2302 relay_state
2303 .get("peers")
2304 .and_then(Value::as_object)
2305 .map(|m| m.len())
2306 .unwrap_or(0)
2307 );
2308
2309 let outbox = config::outbox_dir()?;
2311 let inbox = config::inbox_dir()?;
2312 summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
2313 summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
2314
2315 let snap = crate::ensure_up::daemon_liveness();
2321 let mut daemon = json!({
2322 "running": snap.pidfile_alive,
2323 "pid": snap.pidfile_pid,
2324 "all_running_pids": snap.pgrep_pids,
2325 "orphans": snap.orphan_pids,
2326 });
2327 if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
2328 daemon["version"] = json!(d.version);
2329 daemon["bin_path"] = json!(d.bin_path);
2330 daemon["did"] = json!(d.did);
2331 daemon["relay_url"] = json!(d.relay_url);
2332 daemon["started_at"] = json!(d.started_at);
2333 daemon["schema"] = json!(d.schema);
2334 if d.version != env!("CARGO_PKG_VERSION") {
2335 daemon["version_mismatch"] = json!({
2336 "daemon": d.version.clone(),
2337 "cli": env!("CARGO_PKG_VERSION"),
2338 });
2339 }
2340 }
2341 let last_sync_age = crate::ensure_up::last_sync_age_seconds();
2347 if let Some(rec) = crate::ensure_up::read_last_sync_record() {
2348 daemon["last_sync_at"] = json!(rec.ts);
2349 daemon["last_sync_age_seconds"] = json!(last_sync_age);
2350 daemon["last_sync_push_n"] = json!(rec.push_n);
2351 daemon["last_sync_pull_n"] = json!(rec.pull_n);
2352 daemon["last_sync_rejected_n"] = json!(rec.rejected_n);
2353 } else {
2354 daemon["last_sync_at"] = Value::Null;
2355 daemon["last_sync_age_seconds"] = Value::Null;
2356 }
2357 let pending_breakdown = config::compute_pending_push_breakdown();
2370 let pending_total: u64 = pending_breakdown.iter().map(|p| p.count).sum();
2371 daemon["pending_push_count"] = json!(pending_total);
2372 daemon["pending_push_breakdown"] = json!(pending_breakdown);
2373 daemon["stale_sync"] = json!(config::stale_sync(last_sync_age));
2374 daemon["stream_state"] = config::read_stream_state();
2375 let pid_session_map = crate::session::pid_to_session_map();
2392 let orphans_detail: Vec<Value> = snap
2393 .orphan_pids
2394 .iter()
2395 .map(|pid| {
2396 let cmdline = crate::platform::pid_cmdline(*pid);
2397 let session = pid_session_map.get(pid).cloned().or_else(|| {
2398 cmdline
2399 .as_deref()
2400 .and_then(crate::platform::parse_session_arg)
2401 .map(str::to_string)
2402 });
2403 json!({
2404 "pid": pid,
2405 "cmdline": cmdline,
2406 "session": session,
2407 })
2408 })
2409 .collect();
2410 daemon["orphans_detail"] = json!(orphans_detail);
2411 summary["daemon"] = daemon;
2412
2413 let pinned_verified_handles: std::collections::HashSet<String> =
2426 crate::config::read_trust()
2427 .ok()
2428 .and_then(|t| t.get("agents").and_then(Value::as_object).cloned())
2429 .map(|agents| {
2430 agents
2431 .into_iter()
2432 .filter_map(|(handle, agent)| {
2433 let tier = agent.get("tier").and_then(Value::as_str).unwrap_or("");
2434 if matches!(tier, "VERIFIED" | "ORG_VERIFIED") {
2435 Some(handle)
2436 } else {
2437 None
2438 }
2439 })
2440 .collect()
2441 })
2442 .unwrap_or_default();
2443 let raw_pending_inbound =
2444 crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
2445 let stale_inbound_handles: Vec<&str> = raw_pending_inbound
2446 .iter()
2447 .filter(|p| pinned_verified_handles.contains(&p.peer_handle))
2448 .map(|p| p.peer_handle.as_str())
2449 .collect();
2450 let pending_inbound: Vec<_> = raw_pending_inbound
2451 .iter()
2452 .filter(|p| !pinned_verified_handles.contains(&p.peer_handle))
2453 .collect();
2454 let inbound_handles: Vec<&str> = pending_inbound
2455 .iter()
2456 .map(|p| p.peer_handle.as_str())
2457 .collect();
2458 summary["pending_pairs"] = json!({
2459 "inbound_count": pending_inbound.len(),
2460 "inbound_handles": inbound_handles,
2461 "stale_inbound_count": stale_inbound_handles.len(),
2465 "stale_inbound_handles": stale_inbound_handles,
2466 });
2467 }
2468
2469 if as_json {
2470 println!("{}", serde_json::to_string(&summary)?);
2471 } else if !initialized {
2472 println!("not initialized — run `wire init <handle>` first");
2473 } else {
2474 println!("did: {}", summary["did"].as_str().unwrap_or("?"));
2475 println!(
2476 "fingerprint: {}",
2477 summary["fingerprint"].as_str().unwrap_or("?")
2478 );
2479 println!("capabilities: {}", summary["capabilities"]);
2480 if !summary["self_relay"].is_null() {
2481 println!(
2482 "self relay: {} (slot {})",
2483 summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
2484 summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
2485 );
2486 } else {
2487 println!("self relay: (not bound — run `wire bind-relay <url>` to bind)");
2488 }
2489 println!(
2490 "peers: {}",
2491 summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
2492 );
2493 for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
2494 println!(
2495 " - {:<20} tier={}",
2496 p["handle"].as_str().unwrap_or(""),
2497 p["tier"].as_str().unwrap_or("?")
2498 );
2499 }
2500 println!(
2501 "outbox: {} file(s), {} event(s) queued",
2502 summary["outbox"]["files"].as_u64().unwrap_or(0),
2503 summary["outbox"]["events"].as_u64().unwrap_or(0)
2504 );
2505 println!(
2506 "inbox: {} file(s), {} event(s) received",
2507 summary["inbox"]["files"].as_u64().unwrap_or(0),
2508 summary["inbox"]["events"].as_u64().unwrap_or(0)
2509 );
2510 let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
2511 let daemon_pid = summary["daemon"]["pid"]
2512 .as_u64()
2513 .map(|p| p.to_string())
2514 .unwrap_or_else(|| "—".to_string());
2515 let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
2516 let version_suffix = if !daemon_version.is_empty() {
2517 format!(" v{daemon_version}")
2518 } else {
2519 String::new()
2520 };
2521 println!(
2522 "daemon: {} (pid {}{})",
2523 if daemon_running { "running" } else { "DOWN" },
2524 daemon_pid,
2525 version_suffix,
2526 );
2527 if let Some(mm) = summary["daemon"].get("version_mismatch") {
2529 println!(
2530 " !! version mismatch: daemon={} CLI={}. \
2531 run `wire upgrade` to swap atomically.",
2532 mm["daemon"].as_str().unwrap_or("?"),
2533 mm["cli"].as_str().unwrap_or("?"),
2534 );
2535 }
2536 if let Some(orphans) = summary["daemon"]["orphans"].as_array()
2537 && !orphans.is_empty()
2538 {
2539 let pids: Vec<String> = orphans
2540 .iter()
2541 .filter_map(|v| v.as_u64().map(|p| p.to_string()))
2542 .collect();
2543 println!(
2544 " !! orphan daemon process(es): pids {}. \
2545 pgrep saw them but pidfile didn't — likely stale process from \
2546 prior install. Multiple daemons race the relay cursor.",
2547 pids.join(", ")
2548 );
2549 if let Some(details) = summary["daemon"]["orphans_detail"].as_array() {
2556 for d in details {
2557 let pid = d["pid"].as_u64().unwrap_or(0);
2558 let session = d["session"].as_str();
2559 let cmdline = d["cmdline"].as_str();
2560 let is_supervisor = cmdline
2568 .map(|c| c.contains("--all-sessions"))
2569 .unwrap_or(false);
2570 match (session, cmdline, is_supervisor) {
2571 (Some(s), _, _) => {
2572 println!(" pid {pid}: serving session '{s}'")
2573 }
2574 (None, Some(c), true) if !c.is_empty() => println!(
2575 " pid {pid}: supervisor — orchestrates one daemon per session, doesn't sync directly (cmdline={c})"
2576 ),
2577 (None, Some(c), false) if !c.is_empty() => println!(
2578 " pid {pid}: (no --session — serving default WIRE_HOME) cmdline={c}"
2579 ),
2580 _ => println!(
2581 " pid {pid}: (cmdline unavailable — pid may have just exited)"
2582 ),
2583 }
2584 }
2585 }
2586 }
2587 let last_sync_age = summary["daemon"]["last_sync_age_seconds"].as_u64();
2593 let last_sync_at = summary["daemon"]["last_sync_at"].as_str();
2594 match (last_sync_at, last_sync_age) {
2595 (Some(ts), Some(age)) => {
2596 let stale = summary["daemon"]["stale_sync"].as_bool().unwrap_or(false);
2597 let stale_tag = if stale { " !! STALE (>60s)" } else { "" };
2598 let p = summary["daemon"]["last_sync_push_n"].as_u64().unwrap_or(0);
2599 let pl = summary["daemon"]["last_sync_pull_n"].as_u64().unwrap_or(0);
2600 let r = summary["daemon"]["last_sync_rejected_n"]
2601 .as_u64()
2602 .unwrap_or(0);
2603 println!(
2604 "last sync: {ts} ({age}s ago) push={p} pull={pl} rejected={r}{stale_tag}"
2605 );
2606 }
2607 _ => {
2608 println!(
2609 "last sync: (none recorded) — daemon hasn't completed a cycle in this WIRE_HOME"
2610 );
2611 }
2612 }
2613 let pending_push = summary["daemon"]["pending_push_count"]
2614 .as_u64()
2615 .unwrap_or(0);
2616 if pending_push > 0 {
2617 println!(
2618 "pending push: {pending_push} event(s) queued but not yet pushed to relay — \
2619 if stale_sync, this is the silent-send class (#162 fix #2)"
2620 );
2621 if let Some(breakdown) = summary["daemon"]["pending_push_breakdown"].as_array() {
2627 for entry in breakdown {
2628 let peer = entry.get("peer").and_then(Value::as_str).unwrap_or("?");
2629 let tier = entry
2630 .get("tier")
2631 .and_then(Value::as_str)
2632 .unwrap_or("UNKNOWN");
2633 let count = entry.get("count").and_then(Value::as_u64).unwrap_or(0);
2634 let hint = match tier {
2642 "PENDING_ACK" => {
2643 " — pair never completed; daemon won't push until accept/reject"
2644 }
2645 "UNTRUSTED" => " — peer not pinned; daemon won't push to UNTRUSTED",
2646 _ => "",
2647 };
2648 println!(" {count:>4} → {peer} ({tier}){hint}");
2649 }
2650 }
2651 } else {
2652 println!("pending push: 0");
2653 }
2654 match summary["daemon"]["stream_state"]
2655 .get("state")
2656 .and_then(Value::as_str)
2657 {
2658 Some(s) => {
2659 let last_evt = summary["daemon"]["stream_state"]
2660 .get("last_event_at")
2661 .and_then(Value::as_str)
2662 .unwrap_or("never");
2663 let reconnects = summary["daemon"]["stream_state"]
2664 .get("reconnect_count")
2665 .and_then(Value::as_u64)
2666 .unwrap_or(0);
2667 println!("stream: {s} (last event {last_evt}, reconnects {reconnects})");
2668 }
2669 None => {
2670 println!(
2671 "stream: (no stream_state.json) — daemon predates #168 or hasn't \
2672 subscribed yet; live monitor will fall back to polling cadence"
2673 );
2674 }
2675 }
2676 let inbound_count = summary["pending_pairs"]["inbound_count"]
2677 .as_u64()
2678 .unwrap_or(0);
2679 if inbound_count == 0 {
2680 println!("pending pairs: none");
2681 }
2682 if inbound_count > 0 {
2686 let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
2687 .as_array()
2688 .map(|a| {
2689 a.iter()
2690 .filter_map(|v| v.as_str().map(str::to_string))
2691 .collect()
2692 })
2693 .unwrap_or_default();
2694 println!(
2695 "inbound pair requests ({inbound_count}): {} — `wire pending` to inspect, `wire accept <peer>` to accept, `wire reject <peer>` to refuse",
2696 handles.join(", "),
2697 );
2698 }
2699 }
2700 Ok(())
2701}
2702
2703pub(crate) fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
2704 if !dir.exists() {
2705 return Ok(json!({"files": 0, "events": 0}));
2706 }
2707 let mut files = 0usize;
2708 let mut events = 0usize;
2709 for entry in std::fs::read_dir(dir)? {
2710 let path = entry?.path();
2711 if path.extension().map(|x| x == "jsonl").unwrap_or(false)
2721 && !path
2722 .file_name()
2723 .and_then(|s| s.to_str())
2724 .map(|n| n.ends_with(".pushed.jsonl"))
2725 .unwrap_or(false)
2726 {
2727 files += 1;
2728 if let Ok(body) = std::fs::read_to_string(&path) {
2729 events += body.lines().filter(|l| !l.trim().is_empty()).count();
2730 }
2731 }
2732 }
2733 Ok(json!({"files": files, "events": events}))
2734}
2735
2736fn responder_status_allowed(status: &str) -> bool {
2739 matches!(
2740 status,
2741 "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
2742 )
2743}
2744
2745fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
2746 let state = config::read_relay_state()?;
2747 let (label, slot_info) = match peer {
2748 Some(peer) => (
2749 peer.to_string(),
2750 state
2751 .get("peers")
2752 .and_then(|p| p.get(peer))
2753 .ok_or_else(|| {
2754 anyhow!(
2755 "unknown peer {peer:?} in relay state — pair with them first:\n \
2756 wire add {peer}@wireup.net (or {peer}@<their-relay>)\n\
2757 (`wire peers` lists who you've already paired with.)"
2758 )
2759 })?,
2760 ),
2761 None => (
2762 "self".to_string(),
2763 state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
2764 anyhow!("self slot not bound — run `wire bind-relay <url>` first")
2765 })?,
2766 ),
2767 };
2768 let relay_url = slot_info["relay_url"]
2769 .as_str()
2770 .ok_or_else(|| anyhow!("{label} relay_url missing"))?
2771 .to_string();
2772 let slot_id = slot_info["slot_id"]
2773 .as_str()
2774 .ok_or_else(|| anyhow!("{label} slot_id missing"))?
2775 .to_string();
2776 let slot_token = slot_info["slot_token"]
2777 .as_str()
2778 .ok_or_else(|| anyhow!("{label} slot_token missing"))?
2779 .to_string();
2780 Ok((label, relay_url, slot_id, slot_token))
2781}
2782
2783fn cmd_supervisor(as_json: bool) -> Result<()> {
2790 let state = crate::daemon_supervisor::read_supervisor_state()?;
2791 if as_json {
2792 println!("{}", serde_json::to_string(&state)?);
2793 return Ok(());
2794 }
2795 let pid_label = state
2796 .supervisor_pid
2797 .map(|p| p.to_string())
2798 .unwrap_or_else(|| "—".to_string());
2799 println!(
2800 "supervisor: {} (pid {pid_label})",
2801 if state.supervisor_alive {
2802 "running"
2803 } else {
2804 "DOWN"
2805 },
2806 );
2807 let sessions_total = state.sessions.len();
2808 let sessions_with_daemon = state.sessions.iter().filter(|s| s.daemon_alive).count();
2809 println!(
2810 "sessions: {sessions_total} initialized, {sessions_with_daemon} with live daemon"
2811 );
2812 let mut shown = 0usize;
2817 for s in &state.sessions {
2818 if s.daemon_pid.is_none() {
2821 continue;
2822 }
2823 let recent = matches!(s.last_sync_age_seconds, Some(age) if age <= 60);
2826 if s.daemon_alive && recent {
2827 continue;
2828 }
2829 shown += 1;
2830 let age = s
2831 .last_sync_age_seconds
2832 .map(|a| format!("{a}s"))
2833 .unwrap_or_else(|| "?".to_string());
2834 let pid = s
2835 .daemon_pid
2836 .map(|p| p.to_string())
2837 .unwrap_or_else(|| "—".to_string());
2838 let liveness = if s.daemon_alive { "running" } else { "DOWN" };
2839 println!(
2840 " {:<24} pid {:<7} {} last_sync {}",
2841 s.name, pid, liveness, age
2842 );
2843 }
2844 if shown == 0 && sessions_with_daemon > 0 {
2845 println!(
2846 " (every session with a daemon is alive + synced within 60s — pass --json for full per-session detail)"
2847 );
2848 }
2849 if !state.unmanaged_pids.is_empty() {
2850 let pids: Vec<String> = state.unmanaged_pids.iter().map(u32::to_string).collect();
2851 println!(
2852 "unmanaged: {} pid(s) — {} — `wire daemon` processes not mapped to any session's pidfile.",
2853 state.unmanaged_pids.len(),
2854 pids.join(", ")
2855 );
2856 for pid in &state.unmanaged_pids {
2859 let cmdline = crate::platform::pid_cmdline(*pid);
2860 let session = cmdline
2861 .as_deref()
2862 .and_then(crate::platform::parse_session_arg);
2863 match (session, cmdline.as_deref()) {
2864 (Some(s), _) => println!(" pid {pid}: --session '{s}'"),
2865 (None, Some(c)) if !c.is_empty() => println!(" pid {pid}: cmdline={c}"),
2866 _ => println!(" pid {pid}: cmdline unavailable"),
2867 }
2868 }
2869 }
2870 if !state.stale_binary_sessions.is_empty() {
2877 let our_version = env!("CARGO_PKG_VERSION");
2878 println!(
2879 "stale binary: {} session(s) running daemons older than this CLI (v{our_version}). Supervisor won't respawn them until they exit.",
2880 state.stale_binary_sessions.len()
2881 );
2882 for name in &state.stale_binary_sessions {
2883 let session = state.sessions.iter().find(|s| &s.name == name);
2887 let ver = session
2888 .and_then(|s| s.daemon_version.clone())
2889 .unwrap_or_else(|| "?".to_string());
2890 let pid = session
2891 .and_then(|s| s.daemon_pid)
2892 .map(|p| p.to_string())
2893 .unwrap_or_else(|| "?".to_string());
2894 println!(" {name:<24} running v{ver} (pid {pid})");
2895 }
2896 }
2897 Ok(())
2898}
2899
2900fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
2901 if !responder_status_allowed(status) {
2902 bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
2903 }
2904 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
2905 let now = time::OffsetDateTime::now_utc()
2906 .format(&time::format_description::well_known::Rfc3339)
2907 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2908 let mut record = json!({
2909 "status": status,
2910 "set_at": now,
2911 });
2912 if let Some(reason) = reason {
2913 record["reason"] = json!(reason);
2914 }
2915 if status == "online" {
2916 record["last_success_at"] = json!(now);
2917 }
2918 let client = crate::relay_client::RelayClient::new(&relay_url);
2919 let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
2920 if as_json {
2921 println!("{}", serde_json::to_string(&saved)?);
2922 } else {
2923 let reason = saved
2924 .get("reason")
2925 .and_then(Value::as_str)
2926 .map(|r| format!(" — {r}"))
2927 .unwrap_or_default();
2928 println!(
2929 "responder {}{}",
2930 saved
2931 .get("status")
2932 .and_then(Value::as_str)
2933 .unwrap_or(status),
2934 reason
2935 );
2936 }
2937 Ok(())
2938}
2939
2940fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
2941 let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
2942 let client = crate::relay_client::RelayClient::new(&relay_url);
2943 let health = client.responder_health_get(&slot_id, &slot_token)?;
2944 if as_json {
2945 println!(
2946 "{}",
2947 serde_json::to_string(&json!({
2948 "target": label,
2949 "responder_health": health,
2950 }))?
2951 );
2952 } else if health.is_null() {
2953 println!("{label}: responder health not reported");
2954 } else {
2955 let status = health
2956 .get("status")
2957 .and_then(Value::as_str)
2958 .unwrap_or("unknown");
2959 let reason = health
2960 .get("reason")
2961 .and_then(Value::as_str)
2962 .map(|r| format!(" — {r}"))
2963 .unwrap_or_default();
2964 let last_success = health
2965 .get("last_success_at")
2966 .and_then(Value::as_str)
2967 .map(|t| format!(" (last_success: {t})"))
2968 .unwrap_or_default();
2969 println!("{label}: {status}{reason}{last_success}");
2970 }
2971 Ok(())
2972}
2973
2974fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2975 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2976 let client = crate::relay_client::RelayClient::new(&relay_url);
2977
2978 let started = std::time::Instant::now();
2979 let transport_ok = client.healthz().unwrap_or(false);
2980 let latency_ms = started.elapsed().as_millis() as u64;
2981
2982 let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2983 let now = std::time::SystemTime::now()
2984 .duration_since(std::time::UNIX_EPOCH)
2985 .map(|d| d.as_secs())
2986 .unwrap_or(0);
2987 let attention = match last_pull_at_unix {
2988 Some(last) if now.saturating_sub(last) <= 300 => json!({
2989 "status": "ok",
2990 "last_pull_at_unix": last,
2991 "age_seconds": now.saturating_sub(last),
2992 "event_count": event_count,
2993 }),
2994 Some(last) => json!({
2995 "status": "stale",
2996 "last_pull_at_unix": last,
2997 "age_seconds": now.saturating_sub(last),
2998 "event_count": event_count,
2999 }),
3000 None => json!({
3001 "status": "never_pulled",
3002 "last_pull_at_unix": Value::Null,
3003 "event_count": event_count,
3004 }),
3005 };
3006
3007 let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
3008 let responder = if responder_health.is_null() {
3009 json!({"status": "not_reported", "record": Value::Null})
3010 } else {
3011 json!({
3012 "status": responder_health
3013 .get("status")
3014 .and_then(Value::as_str)
3015 .unwrap_or("unknown"),
3016 "record": responder_health,
3017 })
3018 };
3019
3020 let report = json!({
3021 "peer": peer,
3022 "transport": {
3023 "status": if transport_ok { "ok" } else { "error" },
3024 "relay_url": relay_url,
3025 "latency_ms": latency_ms,
3026 },
3027 "attention": attention,
3028 "responder": responder,
3029 });
3030
3031 if as_json {
3032 println!("{}", serde_json::to_string(&report)?);
3033 } else {
3034 let transport_line = if transport_ok {
3035 format!("ok relay reachable ({latency_ms}ms)")
3036 } else {
3037 "error relay unreachable".to_string()
3038 };
3039 println!("transport {transport_line}");
3040 match report["attention"]["status"].as_str().unwrap_or("unknown") {
3041 "ok" => println!(
3042 "attention ok last pull {}s ago",
3043 report["attention"]["age_seconds"].as_u64().unwrap_or(0)
3044 ),
3045 "stale" => println!(
3046 "attention stale last pull {}m ago",
3047 report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
3048 ),
3049 "never_pulled" => println!("attention never pulled since relay reset"),
3050 other => println!("attention {other}"),
3051 }
3052 if report["responder"]["status"] == "not_reported" {
3053 println!("auto-responder not reported");
3054 } else {
3055 let record = &report["responder"]["record"];
3056 let status = record
3057 .get("status")
3058 .and_then(Value::as_str)
3059 .unwrap_or("unknown");
3060 let reason = record
3061 .get("reason")
3062 .and_then(Value::as_str)
3063 .map(|r| format!(" — {r}"))
3064 .unwrap_or_default();
3065 println!("auto-responder {status}{reason}");
3066 }
3067 }
3068 Ok(())
3069}
3070
3071fn current_cwd_display() -> String {
3079 let cwd = match std::env::current_dir() {
3080 Ok(c) => c,
3081 Err(_) => return String::from("?"),
3082 };
3083 if let Some(home) = dirs::home_dir()
3084 && let Ok(rel) = cwd.strip_prefix(&home)
3085 {
3086 let rel_str = rel.to_string_lossy();
3088 if rel_str.is_empty() {
3089 return String::from("~");
3090 }
3091 return format!("~/{rel_str}");
3092 }
3093 cwd.to_string_lossy().into_owned()
3094}
3095
3096pub(crate) fn op_claims_from_card(card: &Value) -> serde_json::Map<String, Value> {
3110 let mut out = serde_json::Map::new();
3111 for key in [
3112 "op_did",
3113 "op_pubkey",
3114 "op_cert",
3115 "org_memberships",
3116 "schema_version",
3117 ] {
3118 if let Some(v) = card.get(key)
3119 && !v.is_null()
3120 {
3121 out.insert(key.to_string(), v.clone());
3122 }
3123 }
3124 out
3125}
3126
3127fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
3128 if !config::is_initialized()? {
3129 if short {
3142 println!("(uninitialized) · {}", current_cwd_display());
3143 return Ok(());
3144 }
3145 if colored {
3146 println!(
3147 "\x1b[2m(uninitialized)\x1b[0m \x1b[2m·\x1b[0m {}",
3148 current_cwd_display()
3149 );
3150 return Ok(());
3151 }
3152 if as_json {
3153 println!(
3154 "{}",
3155 serde_json::to_string(&json!({
3156 "initialized": false,
3157 "cwd": current_cwd_display(),
3158 }))?
3159 );
3160 return Ok(());
3161 }
3162 bail!("not initialized — run `wire init <handle>` first");
3163 }
3164 let card = config::read_agent_card()?;
3165 let did = card
3166 .get("did")
3167 .and_then(Value::as_str)
3168 .unwrap_or("")
3169 .to_string();
3170 let handle = card
3171 .get("handle")
3172 .and_then(Value::as_str)
3173 .map(str::to_string)
3174 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
3175 let character = crate::character::Character::from_did(&did);
3179
3180 let cwd_display = current_cwd_display();
3186
3187 if short {
3190 println!("{} · {}", character.short(), cwd_display);
3191 return Ok(());
3192 }
3193 if colored {
3194 println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
3195 return Ok(());
3196 }
3197
3198 let pk_b64 = card
3199 .get("verify_keys")
3200 .and_then(Value::as_object)
3201 .and_then(|m| m.values().next())
3202 .and_then(|v| v.get("key"))
3203 .and_then(Value::as_str)
3204 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
3205 let pk_bytes = crate::signing::b64decode(pk_b64)?;
3206 let fp = fingerprint(&pk_bytes);
3207 let key_id = make_key_id(&handle, &pk_bytes);
3208 let capabilities = card
3209 .get("capabilities")
3210 .cloned()
3211 .unwrap_or_else(|| json!(["wire/v3.1"]));
3212
3213 if as_json {
3214 let has_override = false;
3218 let mut payload = serde_json::Map::new();
3219 payload.insert("initialized".into(), json!(true));
3222 payload.insert("did".into(), json!(did));
3223 payload.insert("handle".into(), json!(handle));
3224 payload.insert("fingerprint".into(), json!(fp));
3225 payload.insert("key_id".into(), json!(key_id));
3226 payload.insert("public_key_b64".into(), json!(pk_b64));
3227 payload.insert("capabilities".into(), capabilities);
3228 payload.insert(
3229 "config_dir".into(),
3230 json!(config::config_dir()?.to_string_lossy()),
3231 );
3232 payload.insert(
3237 "session_source".into(),
3238 json!(crate::session::session_source()),
3239 );
3240 payload.insert("persona".into(), serde_json::to_value(&character)?);
3241 payload.insert("persona_override".into(), json!(has_override));
3242 for (k, v) in op_claims_from_card(&card) {
3246 payload.insert(k, v);
3247 }
3248 println!("{}", serde_json::to_string(&payload)?);
3249 } else {
3250 println!("{}", character.colored());
3251 println!("{did} (ed25519:{key_id})");
3252 println!("fingerprint: {fp}");
3253 println!("capabilities: {capabilities}");
3254 if let Some(op_did) = card.get("op_did").and_then(Value::as_str) {
3259 let memberships = card
3260 .get("org_memberships")
3261 .and_then(Value::as_array)
3262 .map(|a| a.len())
3263 .unwrap_or(0);
3264 let plural = if memberships == 1 { "" } else { "s" };
3265 println!("enrolled: {op_did} ({memberships} org membership{plural})");
3266 }
3267 }
3268 Ok(())
3269}
3270
3271fn cmd_enroll(cmd: EnrollCommand) -> Result<()> {
3274 match cmd {
3275 EnrollCommand::Op { handle, json } => {
3276 let (sk, pk) = crate::signing::generate_keypair();
3277 crate::config::write_op_key(&sk)?;
3278 crate::config::write_op_handle(&handle)?;
3279 let op_did = crate::agent_card::did_for_op(&handle, &pk);
3280 let op_pubkey = crate::signing::b64encode(&pk);
3281 if json {
3282 println!(
3283 "{}",
3284 serde_json::to_string(&json!({"op_did": op_did, "op_pubkey": op_pubkey}))?
3285 );
3286 } else {
3287 println!(
3288 "→ operator enrolled\n op_did: {op_did}\n op_pubkey: {op_pubkey}\n key saved 0600 at {:?}",
3289 crate::config::op_key_path()?
3290 );
3291 }
3292 Ok(())
3293 }
3294 EnrollCommand::OrgCreate { handle, json } => {
3295 let (sk, pk) = crate::signing::generate_keypair();
3296 let org_did = crate::agent_card::did_for_org(&handle, &pk);
3297 crate::config::write_org_key(&org_did, &sk)?;
3298 let org_pubkey = crate::signing::b64encode(&pk);
3299 if json {
3300 println!(
3301 "{}",
3302 serde_json::to_string(&json!({"org_did": org_did, "org_pubkey": org_pubkey}))?
3303 );
3304 } else {
3305 println!(
3306 "→ organization created\n org_did: {org_did}\n org_pubkey: {org_pubkey}\n key saved 0600 at {:?}",
3307 crate::config::org_key_path(&org_did)?
3308 );
3309 }
3310 Ok(())
3311 }
3312 EnrollCommand::OrgAddMember { op_did, org, json } => {
3313 if !crate::agent_card::is_op_did(&op_did) {
3314 bail!("not a valid operator DID (did:wire:op:<handle>-<32hex>): {op_did}");
3315 }
3316 let org_sk = crate::config::read_org_key(&org).with_context(|| {
3317 format!("no stored key for org {org} — run `wire enroll org-create` first")
3318 })?;
3319 let org_pk = ed25519_dalek::SigningKey::from_bytes(&org_sk)
3320 .verifying_key()
3321 .to_bytes();
3322 let member_cert = crate::enroll::issue_member_cert(&org_sk, &op_did)?;
3323 let org_pubkey = crate::signing::b64encode(&org_pk);
3324 crate::config::add_membership(&org, &org_pubkey, &member_cert)?;
3327 if json {
3328 println!(
3329 "{}",
3330 serde_json::to_string(&json!({
3331 "org_did": org, "org_pubkey": org_pubkey, "member_cert": member_cert
3332 }))?
3333 );
3334 } else {
3335 println!(
3336 "→ 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}\"}}"
3337 );
3338 }
3339 Ok(())
3340 }
3341 EnrollCommand::AddMembership {
3342 bundle,
3343 org,
3344 org_pubkey,
3345 member_cert,
3346 json,
3347 } => cmd_enroll_add_membership(bundle, org, org_pubkey, member_cert, json),
3348 EnrollCommand::Republish { json } => {
3349 let card = crate::enroll::rebuild_card_with_current_claims()?;
3353 let published = republish_card_to_phonebook();
3354 let op_did = card
3355 .get("op_did")
3356 .and_then(Value::as_str)
3357 .map(str::to_string);
3358 let n_memberships = card
3359 .get("org_memberships")
3360 .and_then(Value::as_array)
3361 .map(Vec::len)
3362 .unwrap_or(0);
3363 if json {
3364 println!(
3365 "{}",
3366 serde_json::to_string(&json!({
3367 "op_did": op_did,
3368 "org_memberships": n_memberships,
3369 "published": published,
3370 }))?
3371 );
3372 } else {
3373 match op_did {
3374 Some(did) => println!(
3375 "→ card rebuilt with current enrollment\n op_did: {did}\n memberships: {n_memberships}"
3376 ),
3377 None => println!(
3378 "→ card rebuilt — no operator enrolled (claims stripped if previously present)"
3379 ),
3380 }
3381 print_profile_publish_result(&published);
3382 }
3383 Ok(())
3384 }
3385 }
3386}
3387
3388fn cmd_enroll_add_membership(
3396 bundle: Option<String>,
3397 org: Option<String>,
3398 org_pubkey: Option<String>,
3399 member_cert: Option<String>,
3400 as_json: bool,
3401) -> Result<()> {
3402 let (org_did, org_pk_b64, cert_b64) = if let Some(b) = bundle {
3404 let v: Value = serde_json::from_str(&b).with_context(|| "parsing --bundle as JSON")?;
3405 let o = v
3406 .get("org_did")
3407 .and_then(Value::as_str)
3408 .ok_or_else(|| anyhow!("--bundle missing 'org_did'"))?
3409 .to_string();
3410 let p = v
3411 .get("org_pubkey")
3412 .and_then(Value::as_str)
3413 .ok_or_else(|| anyhow!("--bundle missing 'org_pubkey'"))?
3414 .to_string();
3415 let c = v
3416 .get("member_cert")
3417 .and_then(Value::as_str)
3418 .ok_or_else(|| anyhow!("--bundle missing 'member_cert'"))?
3419 .to_string();
3420 (o, p, c)
3421 } else {
3422 let o = org.ok_or_else(|| anyhow!("--org is required when --bundle is not set"))?;
3423 let p = org_pubkey
3424 .ok_or_else(|| anyhow!("--org-pubkey is required when --bundle is not set"))?;
3425 let c = member_cert
3426 .ok_or_else(|| anyhow!("--member-cert is required when --bundle is not set"))?;
3427 (o, p, c)
3428 };
3429
3430 if !crate::agent_card::is_org_did(&org_did) {
3432 bail!("not a valid organization DID (did:wire:org:<handle>-<32hex>): {org_did}");
3433 }
3434
3435 let op_sk = crate::config::read_op_key().with_context(
3440 || "this operator is not enrolled — run `wire enroll op` first to mint op_did",
3441 )?;
3442 let op_handle = crate::config::read_op_handle()
3443 .ok()
3444 .flatten()
3445 .unwrap_or_else(|| "operator".to_string());
3446 let op_pk = ed25519_dalek::SigningKey::from_bytes(&op_sk)
3447 .verifying_key()
3448 .to_bytes();
3449 let op_did = crate::agent_card::did_for_op(&op_handle, &op_pk);
3450
3451 let org_pk_bytes =
3455 crate::signing::b64decode(&org_pk_b64).with_context(|| "decoding --org-pubkey (base64)")?;
3456 crate::identity::verify_member_cert(&org_pk_bytes, &cert_b64, &op_did)
3457 .map_err(|e| anyhow!("member_cert verification failed: {e:?} — bundle is not valid for this operator (op_did={op_did})"))?;
3458
3459 crate::config::add_membership(&org_did, &org_pk_b64, &cert_b64)?;
3463
3464 if as_json {
3465 println!(
3466 "{}",
3467 serde_json::to_string(&json!({
3468 "stored": true,
3469 "org_did": org_did,
3470 "op_did": op_did,
3471 "note": "run `wire enroll republish` to attach the claim to your agent card and republish",
3472 }))?
3473 );
3474 } else {
3475 println!(
3476 "→ membership stored\n org_did: {org_did}\n op_did: {op_did}\n next: `wire enroll republish` to attach + publish"
3477 );
3478 }
3479 Ok(())
3480}
3481
3482fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
3483 match cmd {
3484 IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
3491 IdentityCommand::List { json } => cmd_session_list(json),
3492 IdentityCommand::Publish {
3493 nick,
3494 relay,
3495 public_url,
3496 hidden,
3497 json,
3498 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
3499 IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
3500 IdentityCommand::Create {
3501 name,
3502 anonymous,
3503 local: _,
3504 json,
3505 } => cmd_identity_create(name.as_deref(), anonymous, json),
3506 IdentityCommand::Persist {
3507 name,
3508 as_name,
3509 json,
3510 } => cmd_identity_persist(&name, as_name.as_deref(), json),
3511 IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
3512 }
3513}
3514
3515fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
3520 if anonymous {
3521 let rand_suffix = format!("{:08x}", rand::random::<u32>());
3523 let anon_name = name
3524 .map(crate::session::sanitize_name)
3525 .unwrap_or_else(|| format!("anon-{rand_suffix}"));
3526 let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
3527 std::fs::create_dir_all(&anon_root)
3528 .with_context(|| format!("creating anon root {anon_root:?}"))?;
3529 let session_home = anon_root.join("sessions").join(&anon_name);
3531 std::fs::create_dir_all(&session_home)?;
3532 let status = run_wire_with_home(&session_home, &["init", &anon_name, "--offline"])?;
3533 if !status.success() {
3534 bail!("anonymous identity init failed: {status}");
3535 }
3536 let marker = anon_root.join("anon-marker.json");
3539 std::fs::write(
3540 &marker,
3541 serde_json::to_vec_pretty(&serde_json::json!({
3542 "name": anon_name,
3543 "session_home": session_home.to_string_lossy(),
3544 "created_at": time::OffsetDateTime::now_utc()
3545 .format(&time::format_description::well_known::Rfc3339)
3546 .unwrap_or_default(),
3547 "kind": "anonymous",
3548 }))?,
3549 )?;
3550 let card = serde_json::from_slice::<Value>(&std::fs::read(
3551 session_home
3552 .join("config")
3553 .join("wire")
3554 .join("agent-card.json"),
3555 )?)?;
3556 let did = card
3557 .get("did")
3558 .and_then(Value::as_str)
3559 .unwrap_or("")
3560 .to_string();
3561 if as_json {
3562 println!(
3563 "{}",
3564 serde_json::to_string(&json!({
3565 "kind": "anonymous",
3566 "name": anon_name,
3567 "did": did,
3568 "session_home": session_home.to_string_lossy(),
3569 "anon_root": anon_root.to_string_lossy(),
3570 }))?
3571 );
3572 } else {
3573 println!("created anonymous identity `{anon_name}` ({did})");
3574 println!(
3575 " session_home: {} (dies on reboot — /tmp)",
3576 session_home.display()
3577 );
3578 println!();
3579 println!("activate in this shell:");
3580 println!(" export WIRE_HOME={}", session_home.display());
3581 println!();
3582 println!("promote to persistent later with:");
3583 println!(" wire identity persist {anon_name}");
3584 }
3585 return Ok(());
3586 }
3587 let name_arg = name.map(|s| s.to_string());
3589 cmd_session_new(
3590 name_arg.as_deref(),
3591 "https://wireup.net",
3592 false,
3593 "http://127.0.0.1:8771",
3594 false,
3595 None,
3596 false,
3597 None,
3598 true, true, as_json,
3601 )
3602}
3603
3604fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
3607 let temp = std::env::temp_dir();
3609 let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
3610 for entry in std::fs::read_dir(&temp)?.flatten() {
3611 let path = entry.path();
3612 if !path
3613 .file_name()
3614 .and_then(|s| s.to_str())
3615 .map(|s| s.starts_with("wire-anon-"))
3616 .unwrap_or(false)
3617 {
3618 continue;
3619 }
3620 let marker = path.join("anon-marker.json");
3621 if let Ok(bytes) = std::fs::read(&marker)
3622 && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
3623 && json.get("name").and_then(Value::as_str) == Some(name)
3624 {
3625 let session_home = json
3626 .get("session_home")
3627 .and_then(Value::as_str)
3628 .map(std::path::PathBuf::from)
3629 .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
3630 found = Some((path, session_home));
3631 break;
3632 }
3633 }
3634 let (anon_root, anon_session_home) = found.ok_or_else(|| {
3635 anyhow!(
3636 "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
3637 run `wire identity list` to see available identities"
3638 )
3639 })?;
3640
3641 let new_name = as_name.unwrap_or(name);
3642 let new_session_home = crate::session::session_dir(new_name)?;
3643 if new_session_home.exists() {
3644 bail!(
3645 "target session `{new_name}` already exists at {new_session_home:?} — \
3646 pick a different name with --as <new-name>"
3647 );
3648 }
3649
3650 if let Some(parent) = new_session_home.parent() {
3652 std::fs::create_dir_all(parent)?;
3653 }
3654 std::fs::rename(&anon_session_home, &new_session_home)
3655 .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
3656
3657 let _ = std::fs::remove_dir_all(&anon_root);
3659
3660 let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
3663 let cwd_key = crate::session::normalize_cwd_key(&cwd);
3664 let new_name_for_reg = new_name.to_string();
3665 if let Err(e) = crate::session::update_registry(|reg| {
3666 reg.by_cwd.insert(cwd_key, new_name_for_reg);
3667 Ok(())
3668 }) {
3669 eprintln!("wire identity persist: failed to update registry: {e:#}");
3670 }
3671
3672 if as_json {
3673 println!(
3674 "{}",
3675 serde_json::to_string(&json!({
3676 "kind": "persisted",
3677 "from_name": name,
3678 "to_name": new_name,
3679 "session_home": new_session_home.to_string_lossy(),
3680 }))?
3681 );
3682 } else {
3683 println!("persisted anonymous identity `{name}` → local session `{new_name}`");
3684 println!(
3685 " session_home: {} (survives reboot)",
3686 new_session_home.display()
3687 );
3688 println!(" registered cwd: {}", cwd.display());
3689 }
3690 Ok(())
3691}
3692
3693fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
3699 let sessions = crate::session::list_sessions()?;
3700 let session = sessions
3701 .iter()
3702 .find(|s| s.name == name)
3703 .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
3704 let relay_state_path = session
3705 .home_dir
3706 .join("config")
3707 .join("wire")
3708 .join("relay.json");
3709 if !relay_state_path.exists() {
3710 bail!("session `{name}` has no relay state — already demoted?");
3711 }
3712 let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
3713 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
3714 let had_fed = self_obj
3715 .get("relay_url")
3716 .and_then(Value::as_str)
3717 .map(|u| {
3718 u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
3719 })
3720 .unwrap_or(false);
3721 if !had_fed {
3722 if as_json {
3723 println!(
3724 "{}",
3725 serde_json::to_string(
3726 &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
3727 )?
3728 );
3729 } else {
3730 println!("session `{name}` has no federation slot — nothing to demote");
3731 }
3732 return Ok(());
3733 }
3734 if let Some(self_mut) = state
3737 .as_object_mut()
3738 .and_then(|m| m.get_mut("self"))
3739 .and_then(|s| s.as_object_mut())
3740 {
3741 self_mut.remove("relay_url");
3742 self_mut.remove("slot_id");
3743 self_mut.remove("slot_token");
3744 if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
3745 eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
3746 }
3747 }
3748 std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
3749
3750 if as_json {
3751 println!(
3752 "{}",
3753 serde_json::to_string(
3754 &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
3755 )?
3756 );
3757 } else {
3758 println!("demoted `{name}` from federation → local");
3759 println!(" relay slot binding removed; keypair + agent-card retained");
3760 println!(" re-publish with `wire identity publish <nick>`");
3761 }
3762 Ok(())
3763}
3764
3765fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
3770 crate::trust::effective_tier(trust, relay_state, handle)
3771}
3772
3773fn cmd_peers(as_json: bool) -> Result<()> {
3774 let trust = config::read_trust()?;
3775 let agents = trust
3776 .get("agents")
3777 .and_then(Value::as_object)
3778 .cloned()
3779 .unwrap_or_default();
3780 let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
3781
3782 let mut self_did: Option<String> = None;
3783 if let Ok(card) = config::read_agent_card() {
3784 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
3785 }
3786
3787 let mut peers = Vec::new();
3788 for (handle, agent) in agents.iter() {
3789 let did = agent
3790 .get("did")
3791 .and_then(Value::as_str)
3792 .unwrap_or("")
3793 .to_string();
3794 if Some(did.as_str()) == self_did.as_deref() {
3795 continue; }
3797 let tier = effective_peer_tier(&trust, &relay_state, handle);
3798 let capabilities = agent
3799 .get("card")
3800 .and_then(|c| c.get("capabilities"))
3801 .cloned()
3802 .unwrap_or_else(|| json!([]));
3803 let character = if did.is_empty() {
3808 None
3809 } else {
3810 let card_obj = agent.get("card");
3811 Some(match card_obj {
3812 Some(card) => crate::character::Character::from_card(card),
3813 None => crate::character::Character::from_did(&did),
3814 })
3815 };
3816 let peer_op_claims = agent
3820 .get("card")
3821 .map(op_claims_from_card)
3822 .unwrap_or_default();
3823 let mut row = serde_json::Map::new();
3824 row.insert("handle".into(), json!(handle));
3825 row.insert("did".into(), json!(did));
3826 row.insert("tier".into(), json!(tier));
3827 row.insert("capabilities".into(), capabilities);
3828 row.insert("persona".into(), serde_json::to_value(&character)?);
3829 for (k, v) in peer_op_claims {
3830 row.insert(k, v);
3831 }
3832 peers.push(Value::Object(row));
3833 }
3834
3835 if as_json {
3836 println!("{}", serde_json::to_string(&peers)?);
3837 } else if peers.is_empty() {
3838 println!("no peers pinned (run `wire join <code>` to pair)");
3839 } else {
3840 for p in &peers {
3846 let char_json = &p["persona"];
3847 let (colored_char, plain_len): (String, usize) = match char_json {
3848 serde_json::Value::Null => ("?".to_string(), 1),
3849 v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
3850 Ok(c) => {
3851 let plain = c.short().chars().count() + 1; (c.colored(), plain)
3853 }
3854 Err(_) => ("?".to_string(), 1),
3855 },
3856 };
3857 let pad = 22usize.saturating_sub(plain_len);
3858 println!(
3859 "{}{} {:<20} {:<10} {}",
3860 colored_char,
3861 " ".repeat(pad),
3862 p["handle"].as_str().unwrap_or(""),
3863 p["tier"].as_str().unwrap_or(""),
3864 p["did"].as_str().unwrap_or(""),
3865 );
3866 }
3867 }
3868 Ok(())
3869}
3870
3871fn maybe_warn_peer_attentiveness(peer: &str) {
3881 let state = match config::read_relay_state() {
3882 Ok(s) => s,
3883 Err(_) => return,
3884 };
3885 let p = state.get("peers").and_then(|p| p.get(peer));
3886 let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
3887 Some(s) if !s.is_empty() => s,
3888 _ => return,
3889 };
3890 let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
3891 Some(s) if !s.is_empty() => s,
3892 _ => return,
3893 };
3894 let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
3895 Some(s) if !s.is_empty() => s.to_string(),
3896 _ => match state
3897 .get("self")
3898 .and_then(|s| s.get("relay_url"))
3899 .and_then(Value::as_str)
3900 {
3901 Some(s) if !s.is_empty() => s.to_string(),
3902 _ => return,
3903 },
3904 };
3905 let client = crate::relay_client::RelayClient::new(&relay_url);
3906 let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
3907 Ok(t) => t,
3908 Err(_) => return,
3909 };
3910 let now = std::time::SystemTime::now()
3911 .duration_since(std::time::UNIX_EPOCH)
3912 .map(|d| d.as_secs())
3913 .unwrap_or(0);
3914 match last_pull {
3915 None => {
3916 eprintln!(
3917 "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
3918 );
3919 }
3920 Some(t) if now.saturating_sub(t) > 300 => {
3921 let mins = now.saturating_sub(t) / 60;
3922 eprintln!(
3923 "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
3924 );
3925 }
3926 _ => {}
3927 }
3928}
3929
3930pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
3931 let trimmed = input.trim();
3932 if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
3933 {
3934 return Ok(trimmed.to_string());
3935 }
3936 let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
3937 let n: i64 = amount
3938 .parse()
3939 .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
3940 if n <= 0 {
3941 bail!("deadline duration must be positive: {input:?}");
3942 }
3943 let duration = match unit {
3944 "m" => time::Duration::minutes(n),
3945 "h" => time::Duration::hours(n),
3946 "d" => time::Duration::days(n),
3947 _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
3948 };
3949 Ok((time::OffsetDateTime::now_utc() + duration)
3950 .format(&time::format_description::well_known::Rfc3339)
3951 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
3952}
3953
3954fn cmd_send(
3955 peer: &str,
3956 kind: &str,
3957 body_arg: &str,
3958 deadline: Option<&str>,
3959 no_auto_pair: bool,
3963 queue: bool,
3967 as_json: bool,
3968) -> Result<()> {
3969 if !config::is_initialized()? {
3970 bail!("not initialized — run `wire init <handle>` first");
3971 }
3972 let peer_in = crate::agent_card::bare_handle(peer).to_string();
3973 let peer = match resolve_peer_handle(&peer_in) {
3981 Ok(Some(resolved)) if resolved != peer_in => {
3982 eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
3983 resolved
3984 }
3985 Ok(Some(canonical)) => canonical, Ok(None) => peer_in, Err(ResolveError::Ambiguous(candidates)) => bail!(
3988 "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
3989 Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
3990 candidates.len(),
3991 candidates.join(", ")
3992 ),
3993 Err(ResolveError::NotFound) => peer_in, };
3995
3996 let peer_is_pinned = config::read_relay_state()
4003 .ok()
4004 .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
4005 .map(|peers| peers.contains_key(&peer))
4006 .unwrap_or(false);
4007 if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) {
4008 if no_auto_pair {
4009 bail!(
4010 "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \
4011 and --no-auto-pair was passed. Run `wire dial {peer}` first, \
4012 then re-run send."
4013 );
4014 }
4015 eprintln!(
4016 "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \
4017 Pass --no-auto-pair to refuse implicit dialing."
4018 );
4019 cmd_add_local_sister(&sister_name, true).map_err(|e| {
4020 anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}")
4021 })?;
4022 }
4023
4024 let peer = peer.as_str();
4025 let sk_seed = config::read_private_key()?;
4026 let card = config::read_agent_card()?;
4027 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4028 let handle = crate::agent_card::display_handle_from_did(did).to_string();
4029 let pk_b64 = card
4030 .get("verify_keys")
4031 .and_then(Value::as_object)
4032 .and_then(|m| m.values().next())
4033 .and_then(|v| v.get("key"))
4034 .and_then(Value::as_str)
4035 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
4036 let pk_bytes = crate::signing::b64decode(pk_b64)?;
4037
4038 let body_value: Value = if body_arg == "-" {
4043 use std::io::Read;
4044 let mut raw = String::new();
4045 std::io::stdin()
4046 .read_to_string(&mut raw)
4047 .with_context(|| "reading body from stdin")?;
4048 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
4051 } else if let Some(path) = body_arg.strip_prefix('@') {
4052 let raw =
4053 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
4054 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
4055 } else {
4056 Value::String(body_arg.to_string())
4057 };
4058
4059 let kind_id = parse_kind(kind)?;
4060
4061 let now = time::OffsetDateTime::now_utc()
4062 .format(&time::format_description::well_known::Rfc3339)
4063 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
4064
4065 let trust_for_did = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
4072 let to_did = crate::trust::resolve_peer_did(&trust_for_did, peer);
4073 let mut event = json!({
4074 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4075 "timestamp": now,
4076 "from": did,
4077 "to": to_did,
4078 "type": kind,
4079 "kind": kind_id,
4080 "body": body_value,
4081 });
4082 if let Some(deadline) = deadline {
4083 event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
4084 }
4085 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
4086 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
4087
4088 maybe_warn_peer_attentiveness(peer);
4094
4095 if !queue {
4102 let outcome = crate::send::attempt_deliver(peer, &signed)?;
4103 if as_json {
4104 println!(
4105 "{}",
4106 serde_json::to_string(&crate::send::delivery_json(&outcome, peer))?
4107 );
4108 } else {
4109 use crate::send::SyncDelivery;
4110 match &outcome {
4111 SyncDelivery::Delivered {
4112 event_id,
4113 relay_url,
4114 slot_id,
4115 } => println!("delivered {event_id} → {peer} (relay {relay_url} slot {slot_id})"),
4116 SyncDelivery::Duplicate {
4117 event_id,
4118 relay_url,
4119 slot_id,
4120 } => println!(
4121 "duplicate {event_id} → {peer} (already on relay {relay_url} slot {slot_id} — change the body to send a distinct event)"
4122 ),
4123 SyncDelivery::PeerUnknown { event_id } => println!(
4124 "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."
4125 ),
4126 SyncDelivery::SlotStale {
4127 event_id, detail, ..
4128 } => println!(
4129 "FAILED {event_id} → {peer}: relay says slot is stale ({detail}). Run `wire dial {peer}` to re-pair."
4130 ),
4131 SyncDelivery::TransportError {
4132 event_id, detail, ..
4133 } => println!(
4134 "FAILED {event_id} → {peer}: transport error ({detail}). Retry, or pass --queue to outbox the event for daemon retry."
4135 ),
4136 }
4137 }
4138 if !outcome.reached_relay() {
4142 std::process::exit(2);
4143 }
4144 return Ok(());
4145 }
4146
4147 let peer_pinned_in_trust = trust_for_did
4161 .get("agents")
4162 .and_then(Value::as_object)
4163 .map(|a| a.contains_key(peer))
4164 .unwrap_or(false);
4165 if !peer_pinned_in_trust && !peer_is_pinned {
4166 let pending_inbound = crate::pending_inbound_pair::list_pending_inbound()
4168 .ok()
4169 .map(|v| v.iter().any(|p| p.peer_handle == peer))
4170 .unwrap_or(false);
4171 if !pending_inbound {
4172 eprintln!(
4173 "wire send: WARN — `{peer}` is not pinned and has no pending pair. \
4174 The event will sit in outbox forever unless you pair first \
4175 (`wire dial {peer}` or accept an inbound invite)."
4176 );
4177 }
4178 }
4179 let line = serde_json::to_vec(&signed)?;
4180 let outbox = config::append_outbox_record(peer, &line)?;
4181 if as_json {
4182 println!(
4183 "{}",
4184 serde_json::to_string(&json!({
4185 "event_id": event_id,
4186 "status": "queued",
4187 "peer": peer,
4188 "outbox": outbox.to_string_lossy(),
4189 }))?
4190 );
4191 } else {
4192 println!(
4193 "queued event {event_id} → {peer} (outbox: {}; daemon will push)",
4194 outbox.display()
4195 );
4196 }
4197 Ok(())
4198}
4199
4200fn parse_kind(s: &str) -> Result<u32> {
4201 if let Ok(n) = s.parse::<u32>() {
4202 return Ok(n);
4203 }
4204 for (id, name) in crate::signing::kinds() {
4205 if *name == s {
4206 return Ok(*id);
4207 }
4208 }
4209 Ok(1)
4211}
4212
4213fn cmd_here(as_json: bool) -> Result<()> {
4219 let initialized = config::is_initialized().unwrap_or(false);
4220
4221 let (self_did, self_handle, self_character) = if initialized {
4223 let card = config::read_agent_card().ok();
4224 let did = card
4225 .as_ref()
4226 .and_then(|c| c.get("did").and_then(Value::as_str))
4227 .unwrap_or("")
4228 .to_string();
4229 let handle = if did.is_empty() {
4230 String::new()
4231 } else {
4232 crate::agent_card::display_handle_from_did(&did).to_string()
4233 };
4234 let character = if did.is_empty() {
4235 None
4236 } else {
4237 Some(crate::character::Character::from_did(&did))
4239 };
4240 (did, handle, character)
4241 } else {
4242 (String::new(), String::new(), None)
4243 };
4244
4245 let cwd = std::env::current_dir()
4246 .map(|p| p.to_string_lossy().into_owned())
4247 .unwrap_or_default();
4248 let wire_home = std::env::var("WIRE_HOME").unwrap_or_default();
4249
4250 let mut sisters: Vec<Value> = Vec::new();
4252 if let Ok(listing) = crate::session::list_local_sessions() {
4253 for group in listing.local.values() {
4254 for s in group {
4255 if s.handle.as_deref() == Some(self_handle.as_str()) {
4256 continue; }
4258 let ch = s.did.as_deref().map(crate::character::Character::from_did);
4259 sisters.push(json!({
4260 "session": s.name,
4261 "handle": s.handle,
4262 "persona": ch,
4263 }));
4264 }
4265 }
4266 }
4267
4268 let mut peers: Vec<Value> = Vec::new();
4270 if initialized
4271 && let Ok(trust) = config::read_trust()
4272 && let Some(agents) = trust.get("agents").and_then(Value::as_object)
4273 {
4274 let relay_state =
4278 config::read_relay_state().unwrap_or_else(|_| json!({"self": null, "peers": {}}));
4279 for (handle, agent) in agents {
4280 if handle == &self_handle {
4281 continue; }
4283 let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
4284 let ch = if did.is_empty() {
4285 None
4286 } else {
4287 Some(crate::character::Character::from_did(did))
4288 };
4289 peers.push(json!({
4298 "handle": handle,
4299 "did": did,
4300 "tier": crate::trust::effective_tier(&trust, &relay_state, handle),
4301 "persona": ch,
4302 }));
4303 }
4304 }
4305
4306 if as_json {
4307 println!(
4308 "{}",
4309 serde_json::to_string(&json!({
4310 "self": {
4311 "handle": self_handle,
4312 "did": self_did,
4313 "persona": self_character,
4314 "cwd": cwd,
4315 "wire_home": wire_home,
4316 },
4317 "sister_sessions": sisters,
4318 "pinned_peers": peers,
4319 }))?
4320 );
4321 return Ok(());
4322 }
4323
4324 if !initialized {
4326 println!("not initialized — run `wire init <handle>` to bootstrap.");
4327 return Ok(());
4328 }
4329 let glyph = self_character
4330 .as_ref()
4331 .map(crate::character::emoji_with_fallback)
4332 .unwrap_or_else(|| "?".to_string());
4333 let nick = self_character
4334 .as_ref()
4335 .map(|c| c.nickname.clone())
4336 .unwrap_or_default();
4337 println!("you are {glyph} {nick} ({self_handle})");
4338 if !cwd.is_empty() {
4339 println!(" cwd: {cwd}");
4340 }
4341 let render_glyph = |character: &Value| -> String {
4346 let emoji = character
4347 .get("emoji")
4348 .and_then(Value::as_str)
4349 .unwrap_or("?");
4350 let nickname = character
4351 .get("nickname")
4352 .and_then(Value::as_str)
4353 .unwrap_or("?");
4354 if crate::character::terminal_supports_emoji() {
4355 return emoji.to_string();
4356 }
4357 let synth = crate::character::Character {
4360 nickname: nickname.to_string(),
4361 emoji: emoji.to_string(),
4362 palette: crate::character::Palette {
4363 primary_hex: String::new(),
4364 accent_hex: String::new(),
4365 ansi256_primary: 0,
4366 ansi256_accent: 0,
4367 },
4368 };
4369 crate::character::emoji_with_fallback(&synth)
4370 };
4371 if !sisters.is_empty() {
4372 println!();
4373 println!("sister sessions on this machine:");
4374 for s in &sisters {
4375 let session = s["session"].as_str().unwrap_or("?");
4376 let ch_nick = s["persona"]["nickname"].as_str().unwrap_or("?");
4377 let glyph = render_glyph(&s["persona"]);
4378 println!(" {glyph} {ch_nick} ({session})");
4379 }
4380 }
4381 if !peers.is_empty() {
4382 println!();
4383 println!("pinned peers:");
4384 for p in &peers {
4385 let handle = p["handle"].as_str().unwrap_or("?");
4386 let tier = p["tier"].as_str().unwrap_or("");
4387 let ch_nick = p["persona"]["nickname"].as_str().unwrap_or("?");
4388 let glyph = render_glyph(&p["persona"]);
4389 println!(" {glyph} {ch_nick} ({handle}) [{tier}]");
4390 }
4391 }
4392 if sisters.is_empty() && peers.is_empty() {
4393 println!();
4394 println!(
4395 "no neighbors yet — `wire session new` to add a sister, or `wire dial <peer>` to reach out."
4396 );
4397 }
4398 Ok(())
4399}
4400
4401fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
4413 if name.contains('@') {
4414 cmd_add(name, None, false, true)
4420 .map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
4421 if let Some(msg) = message {
4422 let bare = name.split('@').next().unwrap_or(name);
4424 cmd_send(bare, "claim", msg, None, false, false, as_json)?;
4425 }
4426 return Ok(());
4427 }
4428
4429 let resolution = match resolve_name_to_target(name) {
4434 Ok(r) => r,
4435 Err(e) if as_json => {
4436 let pool = known_local_names();
4437 let suggestions = closest_candidates(name, &pool, 3, 3);
4438 println!(
4439 "{}",
4440 serde_json::to_string(&json!({
4441 "name_input": name,
4442 "found": false,
4443 "candidates": suggestions,
4444 "error": format!("{e:#}"),
4445 }))?
4446 );
4447 return Ok(());
4448 }
4449 Err(e) => return Err(e),
4450 };
4451 let mut steps: Vec<Value> = Vec::new();
4452
4453 match &resolution {
4454 DialTarget::PinnedPeer { handle, .. } => {
4455 steps.push(json!({
4456 "step": "resolved",
4457 "kind": "already_pinned",
4458 "handle": handle,
4459 }));
4460 }
4461 DialTarget::LocalSister { session_name, .. } => {
4462 steps.push(json!({
4463 "step": "resolved",
4464 "kind": "local_sister",
4465 "session": session_name,
4466 }));
4467 cmd_add_local_sister(session_name, true).map_err(|e| {
4473 anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
4474 })?;
4475 steps.push(json!({
4476 "step": "paired",
4477 "via": "local_sister",
4478 }));
4479 }
4480 }
4481
4482 let send_handle = match &resolution {
4483 DialTarget::PinnedPeer { handle, .. } => handle.clone(),
4484 DialTarget::LocalSister { handle, .. } => handle.clone(),
4485 };
4486
4487 let send_result = if let Some(msg) = message {
4488 let r = cmd_send(&send_handle, "claim", msg, None, false, false, true);
4489 match &r {
4490 Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
4491 Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
4492 }
4493 Some(r)
4494 } else {
4495 None
4496 };
4497
4498 if as_json {
4499 println!(
4500 "{}",
4501 serde_json::to_string(&json!({
4502 "name_input": name,
4503 "resolved_handle": send_handle,
4504 "steps": steps,
4505 }))?
4506 );
4507 } else {
4508 println!("wire dial: resolved `{name}` → handle `{send_handle}`");
4509 for s in &steps {
4510 let step = s.get("step").and_then(Value::as_str).unwrap_or("?");
4511 println!(" - {step}");
4512 }
4513 if message.is_some() {
4514 println!(" (use `wire tail {send_handle}` to read replies)");
4515 }
4516 }
4517 if let Some(Err(e)) = send_result {
4518 return Err(e);
4519 }
4520 Ok(())
4521}
4522
4523fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
4529 let resolution = match resolve_name_to_target(name) {
4535 Ok(r) => r,
4536 Err(e) if as_json => {
4537 let pool = known_local_names();
4538 let suggestions = closest_candidates(name, &pool, 3, 3);
4539 println!(
4540 "{}",
4541 serde_json::to_string(&json!({
4542 "name_input": name,
4543 "found": false,
4544 "candidates": suggestions,
4545 "error": format!("{e:#}"),
4546 }))?
4547 );
4548 return Ok(());
4549 }
4550 Err(e) => return Err(e),
4551 };
4552 match resolution {
4553 DialTarget::PinnedPeer {
4554 handle,
4555 did,
4556 nickname,
4557 emoji,
4558 tier,
4559 } => {
4560 let op_claims = config::read_trust()
4564 .ok()
4565 .and_then(|t| {
4566 t.get("agents")
4567 .and_then(Value::as_object)
4568 .and_then(|m| m.get(&handle))
4569 .and_then(|a| a.get("card").cloned())
4570 })
4571 .map(|c| op_claims_from_card(&c))
4572 .unwrap_or_default();
4573
4574 if as_json {
4575 let mut payload = serde_json::Map::new();
4576 payload.insert("kind".into(), json!("pinned_peer"));
4577 payload.insert("handle".into(), json!(handle));
4578 payload.insert("did".into(), json!(did));
4579 payload.insert("nickname".into(), json!(nickname));
4580 payload.insert("emoji".into(), json!(emoji));
4581 payload.insert("tier".into(), json!(tier));
4582 for (k, v) in &op_claims {
4583 payload.insert(k.clone(), v.clone());
4584 }
4585 println!("{}", serde_json::to_string(&payload)?);
4586 } else {
4587 let n = nickname.as_deref().unwrap_or("(no character)");
4588 let e = emoji.as_deref().unwrap_or("?");
4589 println!("{e} {n}");
4590 println!(" handle: {handle}");
4591 println!(" did: {did}");
4592 println!(" tier: {tier}");
4593 if let Some(op_did) = op_claims.get("op_did").and_then(Value::as_str) {
4596 println!(" op_did: {op_did}");
4597 }
4598 println!(" reach: pinned peer (already in trust ring + slot pinned)");
4599 }
4600 }
4601 DialTarget::LocalSister {
4602 session_name,
4603 handle,
4604 did,
4605 nickname,
4606 emoji,
4607 } => {
4608 if as_json {
4609 println!(
4610 "{}",
4611 serde_json::to_string(&json!({
4612 "kind": "local_sister",
4613 "session_name": session_name,
4614 "handle": handle,
4615 "did": did,
4616 "nickname": nickname,
4617 "emoji": emoji,
4618 }))?
4619 );
4620 } else {
4621 let n = nickname.as_deref().unwrap_or("(no character)");
4622 let e = emoji.as_deref().unwrap_or("?");
4623 println!("{e} {n}");
4624 println!(" session: {session_name}");
4625 println!(" handle: {handle}");
4626 println!(
4627 " did: {}",
4628 did.as_deref().unwrap_or("(card unreadable)")
4629 );
4630 println!(" reach: local sister on this machine — `wire dial {n}` pairs us");
4631 }
4632 }
4633 }
4634 Ok(())
4635}
4636
4637pub(crate) enum DialTarget {
4638 PinnedPeer {
4639 handle: String,
4640 did: String,
4641 nickname: Option<String>,
4642 emoji: Option<String>,
4643 tier: String,
4644 },
4645 LocalSister {
4646 session_name: String,
4647 handle: String,
4648 did: Option<String>,
4649 nickname: Option<String>,
4650 emoji: Option<String>,
4651 },
4652}
4653
4654pub(crate) fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
4664 let needle = name.trim();
4665 if needle.is_empty() {
4666 bail!("empty name");
4667 }
4668
4669 if config::is_initialized().unwrap_or(false) {
4672 let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
4673 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
4674 for (handle_key, agent) in agents {
4675 let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
4676 if did.is_empty() {
4677 continue;
4678 }
4679 let handle = handle_key.clone();
4680 let character = crate::character::Character::from_did(did);
4681 let tier = agent
4682 .get("tier")
4683 .and_then(Value::as_str)
4684 .unwrap_or("UNKNOWN")
4685 .to_string();
4686 let matches = handle.eq_ignore_ascii_case(needle)
4687 || did.eq_ignore_ascii_case(needle)
4688 || character.nickname.eq_ignore_ascii_case(needle);
4689 if matches {
4690 return Ok(DialTarget::PinnedPeer {
4691 handle,
4692 did: did.to_string(),
4693 nickname: Some(character.nickname),
4694 emoji: Some(character.emoji.to_string()),
4695 tier,
4696 });
4697 }
4698 }
4699 }
4700 }
4701
4702 if let Some(session_name) = crate::session::resolve_local_sister(needle) {
4704 let sessions = crate::session::list_sessions().unwrap_or_default();
4705 let s = sessions.iter().find(|s| s.name == session_name);
4706 if let Some(s) = s {
4707 return Ok(DialTarget::LocalSister {
4708 session_name: s.name.clone(),
4709 handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
4710 did: s.did.clone(),
4711 nickname: s.character.as_ref().map(|c| c.nickname.clone()),
4712 emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
4713 });
4714 }
4715 }
4716
4717 let pool = known_local_names();
4722 let suggestions = closest_candidates(name, &pool, 3, 3);
4723 if suggestions.is_empty() {
4724 bail!(
4725 "no peer matched `{name}`.\n\
4726 Tried: pinned peers (`wire peers`) + local sister sessions \
4727 (`wire session list-local`).\n\
4728 For cross-machine federation: `wire dial <handle>@<relay-domain>`."
4729 );
4730 }
4731 bail!(
4732 "no peer matched `{name}`.\n\
4733 Did you mean: {}?\n\
4734 List all: `wire peers`, `wire session list-local`.",
4735 suggestions
4736 .iter()
4737 .map(|s| format!("`{s}`"))
4738 .collect::<Vec<_>>()
4739 .join(", ")
4740 );
4741}
4742
4743fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize, oldest: bool) -> Result<()> {
4759 let inbox = config::inbox_dir()?;
4760 if !inbox.exists() {
4761 if !as_json {
4762 eprintln!("no inbox yet — daemon hasn't run, or no events received");
4763 }
4764 return Ok(());
4765 }
4766 let trust = config::read_trust()?;
4767
4768 let entries: Vec<_> = std::fs::read_dir(&inbox)?
4769 .filter_map(|e| e.ok())
4770 .map(|e| e.path())
4771 .filter(|p| {
4772 p.extension().map(|x| x == "jsonl").unwrap_or(false)
4773 && match peer {
4774 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
4775 None => true,
4776 }
4777 })
4778 .collect();
4779
4780 let mut events: Vec<(String, usize, Value)> = Vec::new();
4786 for path in &entries {
4787 let body = std::fs::read_to_string(path)?;
4788 for (idx, line) in body.lines().enumerate() {
4789 let event: Value = match serde_json::from_str(line) {
4790 Ok(v) => v,
4791 Err(_) => continue,
4792 };
4793 let ts = event
4794 .get("timestamp")
4795 .and_then(Value::as_str)
4796 .unwrap_or("")
4797 .to_string();
4798 events.push((ts, idx, event));
4799 }
4800 }
4801 events.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
4802
4803 let total = events.len();
4805 let window: &[(String, usize, Value)] = if limit == 0 {
4806 &events[..]
4807 } else if oldest {
4808 &events[..limit.min(total)]
4809 } else {
4810 let start = total.saturating_sub(limit);
4811 &events[start..]
4812 };
4813
4814 for (_, _, event) in window {
4815 let verified = verify_message_v31(event, &trust).is_ok();
4816 if as_json {
4817 let mut event_with_meta = event.clone();
4818 if let Some(obj) = event_with_meta.as_object_mut() {
4819 obj.insert("verified".into(), json!(verified));
4820 }
4821 println!("{}", serde_json::to_string(&event_with_meta)?);
4822 } else {
4823 let ts = event
4824 .get("timestamp")
4825 .and_then(Value::as_str)
4826 .unwrap_or("?");
4827 let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
4828 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
4829 let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
4830 let summary = event
4831 .get("body")
4832 .map(|b| match b {
4833 Value::String(s) => s.clone(),
4834 _ => b.to_string(),
4835 })
4836 .unwrap_or_default();
4837 let mark = if verified { "✓" } else { "✗" };
4838 let deadline = event
4839 .get("time_sensitive_until")
4840 .and_then(Value::as_str)
4841 .map(|d| format!(" deadline: {d}"))
4842 .unwrap_or_default();
4843 println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
4844 }
4845 }
4846 Ok(())
4847}
4848
4849fn monitor_is_noise_kind(kind: &str) -> bool {
4855 matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
4856}
4857
4858fn resolve_persona(peer_handle: &str) -> Option<crate::character::Character> {
4862 let trust = config::read_trust().ok()?;
4863 let agent = trust.get("agents").and_then(|a| a.get(peer_handle))?;
4864 if let Some(card) = agent.get("card") {
4865 Some(crate::character::Character::from_card(card))
4866 } else {
4867 let did = agent.get("did").and_then(Value::as_str)?;
4868 Some(crate::character::Character::from_did(did))
4869 }
4870}
4871
4872fn persona_label(peer_handle: &str) -> String {
4874 match resolve_persona(peer_handle) {
4875 Some(ch) => format!("{} {}", ch.emoji, ch.nickname),
4876 None => peer_handle.to_string(),
4877 }
4878}
4879
4880fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
4888 if as_json {
4889 Ok(serde_json::to_string(e)?)
4890 } else {
4891 let eid_short: String = e.event_id.chars().take(12).collect();
4892 let body = e.body_preview.replace('\n', " ");
4893 let ts: String = e.timestamp.chars().take(19).collect();
4894 Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
4895 }
4896}
4897
4898fn cmd_monitor(
4914 peer_filter: Option<&str>,
4915 as_json: bool,
4916 include_handshake: bool,
4917 interval_ms: u64,
4918 replay: usize,
4919) -> Result<()> {
4920 let inbox_dir = config::inbox_dir()?;
4921 if !inbox_dir.exists() && !as_json {
4922 eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
4923 }
4924 crate::session::warn_on_identity_collision(std::process::id(), "monitor");
4929 if replay > 0 && inbox_dir.exists() {
4935 let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
4936 for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
4937 let path = entry.path();
4938 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
4939 continue;
4940 }
4941 let peer = match path.file_stem().and_then(|s| s.to_str()) {
4942 Some(s) => s.to_string(),
4943 None => continue,
4944 };
4945 if let Some(filter) = peer_filter
4946 && peer != filter
4947 {
4948 continue;
4949 }
4950 let body = std::fs::read_to_string(&path).unwrap_or_default();
4951 for line in body.lines() {
4952 let line = line.trim();
4953 if line.is_empty() {
4954 continue;
4955 }
4956 let signed: Value = match serde_json::from_str(line) {
4957 Ok(v) => v,
4958 Err(_) => continue,
4959 };
4960 let ev = crate::inbox_watch::InboxEvent::from_signed(
4961 &peer, signed, true,
4962 );
4963 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
4964 continue;
4965 }
4966 all.push(ev);
4967 }
4968 }
4969 all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
4972 let start = all.len().saturating_sub(replay);
4973 for ev in &all[start..] {
4974 println!("{}", monitor_render(ev, as_json)?);
4975 }
4976 use std::io::Write;
4977 std::io::stdout().flush().ok();
4978 }
4979
4980 let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
4983 let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
4984
4985 loop {
4986 let events = match w.poll() {
4993 Ok(evs) => evs,
4994 Err(e) => {
4995 eprintln!("wire monitor: poll error (continuing to watch): {e:#}");
4996 std::thread::sleep(sleep_dur);
4997 continue;
4998 }
4999 };
5000 let mut wrote = false;
5001 for ev in events {
5002 if let Some(filter) = peer_filter
5003 && ev.peer != filter
5004 {
5005 continue;
5006 }
5007 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
5008 continue;
5009 }
5010 println!("{}", monitor_render(&ev, as_json)?);
5011 wrote = true;
5012 }
5013 if wrote {
5014 use std::io::Write;
5015 std::io::stdout().flush().ok();
5016 }
5017 std::thread::sleep(sleep_dur);
5018 }
5019}
5020
5021#[cfg(test)]
5022mod tier_tests {
5023 use super::*;
5024 use serde_json::json;
5025
5026 fn trust_with(handle: &str, tier: &str) -> Value {
5027 json!({
5028 "version": 1,
5029 "agents": {
5030 handle: {
5031 "tier": tier,
5032 "did": format!("did:wire:{handle}"),
5033 "card": {"capabilities": ["wire/v3.1"]}
5034 }
5035 }
5036 })
5037 }
5038
5039 #[test]
5040 fn pending_ack_when_verified_but_no_slot_token() {
5041 let trust = trust_with("willard", "VERIFIED");
5045 let relay_state = json!({
5046 "peers": {
5047 "willard": {
5048 "relay_url": "https://relay",
5049 "slot_id": "abc",
5050 "slot_token": "",
5051 }
5052 }
5053 });
5054 assert_eq!(
5055 effective_peer_tier(&trust, &relay_state, "willard"),
5056 "PENDING_ACK"
5057 );
5058 }
5059
5060 #[test]
5061 fn verified_when_slot_token_present() {
5062 let trust = trust_with("willard", "VERIFIED");
5063 let relay_state = json!({
5064 "peers": {
5065 "willard": {
5066 "relay_url": "https://relay",
5067 "slot_id": "abc",
5068 "slot_token": "tok123",
5069 }
5070 }
5071 });
5072 assert_eq!(
5073 effective_peer_tier(&trust, &relay_state, "willard"),
5074 "VERIFIED"
5075 );
5076 }
5077
5078 #[test]
5079 fn raw_tier_passes_through_for_non_verified() {
5080 let trust = trust_with("willard", "UNTRUSTED");
5083 let relay_state = json!({
5084 "peers": {"willard": {"slot_token": ""}}
5085 });
5086 assert_eq!(
5087 effective_peer_tier(&trust, &relay_state, "willard"),
5088 "UNTRUSTED"
5089 );
5090 }
5091
5092 #[test]
5093 fn pending_ack_when_relay_state_missing_peer() {
5094 let trust = trust_with("willard", "VERIFIED");
5098 let relay_state = json!({"peers": {}});
5099 assert_eq!(
5100 effective_peer_tier(&trust, &relay_state, "willard"),
5101 "PENDING_ACK"
5102 );
5103 }
5104}
5105
5106#[cfg(test)]
5107mod monitor_tests {
5108 use super::*;
5109 use crate::inbox_watch::InboxEvent;
5110 use serde_json::Value;
5111
5112 fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
5113 InboxEvent {
5114 peer: peer.to_string(),
5115 event_id: "abcd1234567890ef".to_string(),
5116 kind: kind.to_string(),
5117 body_preview: body.to_string(),
5118 verified: true,
5119 timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
5120 raw: Value::Null,
5121 }
5122 }
5123
5124 #[test]
5125 fn monitor_filter_drops_handshake_kinds_by_default() {
5126 assert!(monitor_is_noise_kind("pair_drop"));
5131 assert!(monitor_is_noise_kind("pair_drop_ack"));
5132 assert!(monitor_is_noise_kind("heartbeat"));
5133
5134 assert!(!monitor_is_noise_kind("claim"));
5136 assert!(!monitor_is_noise_kind("decision"));
5137 assert!(!monitor_is_noise_kind("ack"));
5138 assert!(!monitor_is_noise_kind("request"));
5139 assert!(!monitor_is_noise_kind("note"));
5140 assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
5144 }
5145
5146 #[test]
5147 fn monitor_render_plain_is_one_short_line() {
5148 let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
5149 let line = monitor_render(&e, false).unwrap();
5150 assert!(!line.contains('\n'), "render must be one line: {line}");
5152 assert!(line.contains("willard"));
5154 assert!(line.contains("claim"));
5155 assert!(line.contains("real v8 train"));
5156 assert!(line.contains("abcd12345678"));
5158 assert!(
5159 !line.contains("abcd1234567890ef"),
5160 "should truncate full id"
5161 );
5162 assert!(line.contains("2026-05-15T23:14:07"));
5164 }
5165
5166 #[test]
5167 fn monitor_render_strips_newlines_from_body() {
5168 let e = ev("spark", "claim", "line one\nline two\nline three");
5173 let line = monitor_render(&e, false).unwrap();
5174 assert!(!line.contains('\n'), "newlines must be stripped: {line}");
5175 assert!(line.contains("line one line two line three"));
5176 }
5177
5178 #[test]
5179 fn monitor_render_json_is_valid_jsonl() {
5180 let e = ev("spark", "claim", "hi");
5181 let line = monitor_render(&e, true).unwrap();
5182 assert!(!line.contains('\n'));
5183 let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
5184 assert_eq!(parsed["peer"], "spark");
5185 assert_eq!(parsed["kind"], "claim");
5186 assert_eq!(parsed["body_preview"], "hi");
5187 }
5188
5189 #[test]
5190 fn monitor_does_not_drop_on_verified_null() {
5191 let mut e = ev("spark", "claim", "from disk with verified=null");
5202 e.verified = false; let line = monitor_render(&e, false).unwrap();
5204 assert!(line.contains("from disk with verified=null"));
5205 assert!(!monitor_is_noise_kind("claim"));
5207 }
5208}
5209
5210fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
5213 let body = if path == "-" {
5214 let mut buf = String::new();
5215 use std::io::Read;
5216 std::io::stdin().read_to_string(&mut buf)?;
5217 buf
5218 } else {
5219 std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
5220 };
5221 let event: Value = serde_json::from_str(&body)?;
5222 let trust = config::read_trust()?;
5223 match verify_message_v31(&event, &trust) {
5224 Ok(()) => {
5225 if as_json {
5226 println!("{}", serde_json::to_string(&json!({"verified": true}))?);
5227 } else {
5228 println!("verified ✓");
5229 }
5230 Ok(())
5231 }
5232 Err(e) => {
5233 let reason = e.to_string();
5234 if as_json {
5235 println!(
5236 "{}",
5237 serde_json::to_string(&json!({"verified": false, "reason": reason}))?
5238 );
5239 } else {
5240 eprintln!("FAILED: {reason}");
5241 }
5242 std::process::exit(1);
5243 }
5244 }
5245}
5246
5247fn cmd_mcp() -> Result<()> {
5250 crate::mcp::run()
5251}
5252
5253fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
5254 if let Some(socket_path) = uds {
5259 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
5260 std::path::PathBuf::from(home)
5261 .join("state")
5262 .join("wire-relay")
5263 .join("uds")
5264 } else {
5265 dirs::state_dir()
5266 .or_else(dirs::data_local_dir)
5267 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
5268 .join("wire-relay")
5269 .join("uds")
5270 };
5271 let runtime = tokio::runtime::Builder::new_multi_thread()
5272 .enable_all()
5273 .build()?;
5274 return runtime.block_on(crate::relay_server::serve_uds(
5275 socket_path.to_path_buf(),
5276 base,
5277 ));
5278 }
5279 if local_only {
5283 validate_loopback_bind(bind)?;
5284 }
5285 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
5291 std::path::PathBuf::from(home)
5292 .join("state")
5293 .join("wire-relay")
5294 } else {
5295 dirs::state_dir()
5296 .or_else(dirs::data_local_dir)
5297 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
5298 .join("wire-relay")
5299 };
5300 let state_dir = if local_only { base.join("local") } else { base };
5301 let runtime = tokio::runtime::Builder::new_multi_thread()
5302 .enable_all()
5303 .build()?;
5304 runtime.block_on(crate::relay_server::serve_with_mode(
5305 bind,
5306 state_dir,
5307 crate::relay_server::ServerMode { local_only },
5308 ))
5309}
5310
5311fn validate_loopback_bind(bind: &str) -> Result<()> {
5329 let host = if let Some(stripped) = bind.strip_prefix('[') {
5331 let close = stripped
5332 .find(']')
5333 .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
5334 stripped[..close].to_string()
5335 } else {
5336 bind.rsplit_once(':')
5337 .map(|(h, _)| h.to_string())
5338 .unwrap_or_else(|| bind.to_string())
5339 };
5340 use std::net::{IpAddr, ToSocketAddrs};
5341 let probe = format!("{host}:0");
5342 let resolved: Vec<_> = probe
5343 .to_socket_addrs()
5344 .with_context(|| format!("resolving bind host {host:?}"))?
5345 .collect();
5346 if resolved.is_empty() {
5347 bail!("--local-only: bind host {host:?} resolved to no addresses");
5348 }
5349 for addr in &resolved {
5350 let ip = addr.ip();
5351 let is_acceptable = match ip {
5352 IpAddr::V4(v4) => {
5353 v4.is_loopback() || v4.is_private() || {
5354 let octets = v4.octets();
5356 octets[0] == 100 && (64..=127).contains(&octets[1])
5357 }
5358 }
5359 IpAddr::V6(v6) => v6.is_loopback(), };
5361 if !is_acceptable {
5362 bail!(
5363 "--local-only refuses non-private bind: {host:?} resolves to {ip} \
5364 which is not loopback (127/8, ::1), RFC 1918 private \
5365 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
5366 (100.64.0.0/10). Remove --local-only to bind publicly."
5367 );
5368 }
5369 }
5370 Ok(())
5371}
5372
5373fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
5376 use crate::endpoints::EndpointScope;
5377 match s.to_lowercase().as_str() {
5378 "federation" | "fed" => Ok(EndpointScope::Federation),
5379 "local" => Ok(EndpointScope::Local),
5380 "lan" => Ok(EndpointScope::Lan),
5381 "uds" => Ok(EndpointScope::Uds),
5382 other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
5383 }
5384}
5385
5386fn cmd_bind_relay(
5392 url: &str,
5393 scope: Option<&str>,
5394 replace: bool,
5395 migrate_pinned: bool,
5396 as_json: bool,
5397) -> Result<()> {
5398 use crate::endpoints::{Endpoint, self_endpoints};
5399
5400 if !config::is_initialized()? {
5401 bail!("not initialized — run `wire init <handle>` first");
5402 }
5403 let card = config::read_agent_card()?;
5404 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
5405 let handle = crate::agent_card::display_handle_from_did(did).to_string();
5406
5407 let normalized_raw = url.trim_end_matches('/');
5408 let normalized_owned = strip_relay_url_userinfo(normalized_raw);
5412 let normalized = normalized_owned.as_str();
5413 assert_relay_url_clean_for_publish(normalized)?;
5417 let new_scope = match scope {
5418 Some(s) => parse_scope(s)?,
5419 None => crate::endpoints::infer_scope_from_url(normalized),
5420 };
5421
5422 let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
5423 let pinned: Vec<String> = existing
5424 .get("peers")
5425 .and_then(|p| p.as_object())
5426 .map(|o| o.keys().cloned().collect())
5427 .unwrap_or_default();
5428
5429 let existing_eps = self_endpoints(&existing);
5430 let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
5431
5432 let destructive = replace || is_rebind_same;
5439 if destructive && !pinned.is_empty() && !migrate_pinned {
5440 let list = pinned.join(", ");
5441 let why = if replace {
5442 "`--replace` drops your other slot(s)"
5443 } else {
5444 "re-binding the same relay rotates its slot"
5445 };
5446 bail!(
5447 "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
5448 pinned to your CURRENT slot and would keep pushing to a slot you no longer \
5449 read.\n\n\
5450 SAFE PATHS:\n\
5451 • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
5452 slots — no black-hole.\n\
5453 • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
5454 • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
5455 peer out-of-band.\n\n\
5456 Issue #7 (silent black-hole on relay change) caught this.",
5457 n = pinned.len(),
5458 );
5459 }
5460
5461 let client = crate::relay_client::RelayClient::new(normalized);
5462 client.check_healthz()?;
5463 let alloc = client.allocate_slot(Some(&handle))?;
5464
5465 if destructive && !pinned.is_empty() {
5466 eprintln!(
5467 "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
5468 until they re-pin: {peers}",
5469 mode = if replace { "replacing" } else { "rotating" },
5470 n = pinned.len(),
5471 peers = pinned.join(", "),
5472 );
5473 }
5474
5475 let mut state = existing;
5479 if replace {
5480 state["self"] = Value::Null;
5481 }
5482 crate::endpoints::upsert_self_endpoint(
5483 &mut state,
5484 Endpoint {
5485 relay_url: normalized.to_string(),
5486 slot_id: alloc.slot_id.clone(),
5487 slot_token: alloc.slot_token.clone(),
5488 scope: new_scope,
5489 },
5490 );
5491 config::write_relay_state(&state)?;
5492 let eps = self_endpoints(&state);
5493
5494 let scope_str = format!("{new_scope:?}").to_lowercase();
5495 if as_json {
5496 println!(
5497 "{}",
5498 serde_json::to_string(&json!({
5499 "relay_url": normalized,
5500 "slot_id": alloc.slot_id,
5501 "scope": scope_str,
5502 "endpoints": eps.len(),
5503 "additive": !replace,
5504 "slot_token_present": true,
5505 }))?
5506 );
5507 } else {
5508 println!(
5509 "bound {scope_str} slot on {normalized} (slot {})",
5510 alloc.slot_id
5511 );
5512 println!(
5513 "self now has {n} endpoint(s): {list}",
5514 n = eps.len(),
5515 list = eps
5516 .iter()
5517 .map(|e| format!("{}({:?})", e.relay_url, e.scope))
5518 .collect::<Vec<_>>()
5519 .join(", "),
5520 );
5521 }
5522 Ok(())
5523}
5524
5525fn cmd_add_peer_slot(
5528 handle: &str,
5529 url: &str,
5530 slot_id: &str,
5531 slot_token: &str,
5532 as_json: bool,
5533) -> Result<()> {
5534 use crate::endpoints::{Endpoint, infer_scope_from_url, pin_peer_endpoints};
5535 let mut state = config::read_relay_state()?;
5536
5537 let new_ep = Endpoint {
5544 relay_url: url.to_string(),
5545 slot_id: slot_id.to_string(),
5546 slot_token: slot_token.to_string(),
5547 scope: infer_scope_from_url(url),
5548 };
5549 let mut endpoints: Vec<Endpoint> = state
5550 .get("peers")
5551 .and_then(|p| p.get(handle))
5552 .and_then(|e| e.get("endpoints"))
5553 .and_then(|a| serde_json::from_value::<Vec<Endpoint>>(a.clone()).ok())
5554 .unwrap_or_default();
5555 if endpoints.is_empty()
5557 && let Some(peer) = state.get("peers").and_then(|p| p.get(handle))
5558 && let (Some(ru), Some(si), Some(st)) = (
5559 peer.get("relay_url").and_then(Value::as_str),
5560 peer.get("slot_id").and_then(Value::as_str),
5561 peer.get("slot_token").and_then(Value::as_str),
5562 )
5563 {
5564 endpoints.push(Endpoint {
5565 relay_url: ru.to_string(),
5566 slot_id: si.to_string(),
5567 slot_token: st.to_string(),
5568 scope: infer_scope_from_url(ru),
5569 });
5570 }
5571 if let Some(existing) = endpoints
5573 .iter_mut()
5574 .find(|e| e.relay_url == new_ep.relay_url)
5575 {
5576 *existing = new_ep;
5577 } else {
5578 endpoints.push(new_ep);
5579 }
5580 let n = endpoints.len();
5581 pin_peer_endpoints(&mut state, handle, &endpoints)?;
5582 config::write_relay_state(&state)?;
5583 if as_json {
5584 println!(
5585 "{}",
5586 serde_json::to_string(&json!({
5587 "handle": handle,
5588 "relay_url": url,
5589 "slot_id": slot_id,
5590 "added": true,
5591 "endpoint_count": n,
5592 }))?
5593 );
5594 } else {
5595 println!(
5596 "pinned peer slot for {handle} at {url} ({slot_id}) — peer now has {n} endpoint(s)"
5597 );
5598 }
5599 Ok(())
5600}
5601
5602fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
5605 let mut state = config::read_relay_state()?;
5606 let peers = state["peers"].as_object().cloned().unwrap_or_default();
5607 if peers.is_empty() {
5608 bail!(
5609 "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
5610 );
5611 }
5612 let outbox_dir = config::outbox_dir()?;
5613 if outbox_dir.exists() {
5618 let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
5619 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
5620 let path = entry.path();
5621 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
5622 continue;
5623 }
5624 let stem = match path.file_stem().and_then(|s| s.to_str()) {
5625 Some(s) => s.to_string(),
5626 None => continue,
5627 };
5628 if pinned.contains(&stem) {
5629 continue;
5630 }
5631 let bare = crate::agent_card::bare_handle(&stem);
5634 if pinned.contains(bare) {
5635 eprintln!(
5636 "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
5637 Merge with: `cat {} >> {}` then delete the FQDN file.",
5638 stem,
5639 path.display(),
5640 outbox_dir.join(format!("{bare}.jsonl")).display(),
5641 );
5642 }
5643 }
5644 }
5645 if !outbox_dir.exists() {
5646 if as_json {
5647 println!(
5648 "{}",
5649 serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
5650 );
5651 } else {
5652 println!("phyllis: nothing to dial out — write a message first with `wire send`");
5653 }
5654 return Ok(());
5655 }
5656
5657 let mut pushed = Vec::new();
5658 let mut skipped = Vec::new();
5659
5660 let mut rotated_this_push: std::collections::HashSet<String> = std::collections::HashSet::new();
5665 let mut state_dirty = false;
5668
5669 for (peer_handle, _) in peers.iter() {
5675 if let Some(want) = peer_filter
5676 && peer_handle != want
5677 {
5678 continue;
5679 }
5680 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5681 if !outbox.exists() {
5682 continue;
5683 }
5684 let mut ordered_endpoints =
5685 crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
5686 if ordered_endpoints.is_empty() {
5687 for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
5691 let event: Value = match serde_json::from_str(line) {
5692 Ok(v) => v,
5693 Err(_) => continue,
5694 };
5695 let event_id = event
5696 .get("event_id")
5697 .and_then(Value::as_str)
5698 .unwrap_or("")
5699 .to_string();
5700 skipped.push(json!({
5701 "peer": peer_handle,
5702 "event_id": event_id,
5703 "reason": "no reachable endpoint pinned for peer",
5704 }));
5705 }
5706 continue;
5707 }
5708 let body = std::fs::read_to_string(&outbox)?;
5709 for line in body.lines() {
5710 let event: Value = match serde_json::from_str(line) {
5711 Ok(v) => v,
5712 Err(_) => continue,
5713 };
5714 let event_id = event
5715 .get("event_id")
5716 .and_then(Value::as_str)
5717 .unwrap_or("")
5718 .to_string();
5719
5720 let last_err: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
5729 match crate::relay_client::try_post_event_with_failover(
5730 &ordered_endpoints,
5731 &event,
5732 |endpoint, ev| {
5733 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
5734 match client.post_event(&endpoint.slot_id, &endpoint.slot_token, ev) {
5735 Ok(resp) => Ok(resp),
5736 Err(e) => {
5737 *last_err.borrow_mut() =
5738 Some(crate::relay_client::format_transport_error(&e));
5739 Err(e)
5740 }
5741 }
5742 },
5743 ) {
5744 Ok((endpoint, resp)) => {
5745 if resp.status == "duplicate" {
5746 skipped.push(json!({
5747 "peer": peer_handle,
5748 "event_id": event_id,
5749 "reason": "duplicate",
5750 "endpoint": endpoint.relay_url,
5751 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5752 }));
5753 } else {
5754 pushed.push(json!({
5755 "peer": peer_handle,
5756 "event_id": event_id,
5757 "endpoint": endpoint.relay_url,
5758 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5759 }));
5760 }
5761 }
5762 Err(_) => {
5763 let last_err_text = last_err.borrow().clone().unwrap_or_default();
5773 let mut delivered_via_retry: Option<(crate::endpoints::Endpoint, _)> = None;
5774 match try_reresolve_peer_on_slot_4xx(
5775 &mut state,
5776 peer_handle,
5777 &last_err_text,
5778 &rotated_this_push,
5779 ) {
5780 Ok(true) => {
5781 rotated_this_push.insert(peer_handle.clone());
5783 state_dirty = true;
5784 ordered_endpoints = crate::endpoints::peer_endpoints_in_priority_order(
5789 &state,
5790 peer_handle,
5791 );
5792 *last_err.borrow_mut() = None;
5793 if let Ok((endpoint, resp)) =
5794 crate::relay_client::try_post_event_with_failover(
5795 &ordered_endpoints,
5796 &event,
5797 |endpoint, ev| {
5798 let client = crate::relay_client::RelayClient::new(
5799 &endpoint.relay_url,
5800 );
5801 match client.post_event(
5802 &endpoint.slot_id,
5803 &endpoint.slot_token,
5804 ev,
5805 ) {
5806 Ok(resp) => Ok(resp),
5807 Err(e) => {
5808 *last_err.borrow_mut() = Some(
5809 crate::relay_client::format_transport_error(&e),
5810 );
5811 Err(e)
5812 }
5813 }
5814 },
5815 )
5816 {
5817 delivered_via_retry = Some((endpoint, resp));
5818 }
5819 }
5820 Ok(false) => {
5821 }
5825 Err(e) => {
5826 *last_err.borrow_mut() = Some(format!(
5831 "{}; re-resolve also failed: {e:#}",
5832 last_err.borrow().clone().unwrap_or_default()
5833 ));
5834 rotated_this_push.insert(peer_handle.clone());
5836 }
5837 }
5838 if let Some((endpoint, resp)) = delivered_via_retry {
5839 if resp.status == "duplicate" {
5840 skipped.push(json!({
5841 "peer": peer_handle,
5842 "event_id": event_id,
5843 "reason": "duplicate",
5844 "endpoint": endpoint.relay_url,
5845 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5846 "via": "slot_reresolve_retry",
5847 }));
5848 } else {
5849 pushed.push(json!({
5850 "peer": peer_handle,
5851 "event_id": event_id,
5852 "endpoint": endpoint.relay_url,
5853 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5854 "via": "slot_reresolve_retry",
5855 }));
5856 }
5857 } else {
5858 skipped.push(json!({
5863 "peer": peer_handle,
5864 "event_id": event_id,
5865 "reason": last_err
5866 .borrow()
5867 .clone()
5868 .unwrap_or_else(|| "all endpoints failed".to_string()),
5869 }));
5870 }
5871 }
5872 }
5873 }
5874 }
5875
5876 if state_dirty && let Err(e) = config::write_relay_state(&state) {
5881 eprintln!(
5882 "wire push: WARN failed to persist rotated peer slots: {e:#}. \
5883 Slot rotation will be re-attempted on next push."
5884 );
5885 }
5886
5887 if as_json {
5888 println!(
5889 "{}",
5890 serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
5891 );
5892 } else {
5893 println!(
5894 "pushed {} event(s); skipped {} ({})",
5895 pushed.len(),
5896 skipped.len(),
5897 if skipped.is_empty() {
5898 "none"
5899 } else {
5900 "see --json for detail"
5901 }
5902 );
5903 }
5904 Ok(())
5905}
5906
5907fn cmd_pull(as_json: bool) -> Result<()> {
5910 let state = config::read_relay_state()?;
5911 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
5912 if self_state.is_null() {
5913 bail!("self slot not bound — run `wire bind-relay <url>` first");
5914 }
5915
5916 let endpoints = crate::endpoints::self_endpoints(&state);
5925 if endpoints.is_empty() {
5926 bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
5927 }
5928
5929 let inbox_dir = config::inbox_dir()?;
5930 config::ensure_dirs()?;
5931
5932 let mut total_seen = 0usize;
5933 let mut all_written: Vec<Value> = Vec::new();
5934 let mut all_rejected: Vec<Value> = Vec::new();
5935 let mut all_blocked = false;
5936 let mut all_advance_cursor_to: Option<String> = None;
5937
5938 for endpoint in &endpoints {
5939 let cursor_key = endpoint_cursor_key(endpoint.scope);
5940 let last_event_id = self_state
5941 .get(&cursor_key)
5942 .and_then(Value::as_str)
5943 .map(str::to_string);
5944 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
5945 let events = match client.list_events(
5946 &endpoint.slot_id,
5947 &endpoint.slot_token,
5948 last_event_id.as_deref(),
5949 Some(1000),
5950 ) {
5951 Ok(ev) => ev,
5952 Err(e) => {
5953 eprintln!(
5957 "wire pull: endpoint {} ({:?}) errored: {}; continuing",
5958 endpoint.relay_url,
5959 endpoint.scope,
5960 crate::relay_client::format_transport_error(&e),
5961 );
5962 continue;
5963 }
5964 };
5965 total_seen += events.len();
5966 let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
5967 all_written.extend(result.written.iter().cloned());
5968 all_rejected.extend(result.rejected.iter().cloned());
5969 if result.blocked {
5970 all_blocked = true;
5971 }
5972 if let Some(eid) = result.advance_cursor_to.clone() {
5975 if endpoint.scope == crate::endpoints::EndpointScope::Federation {
5976 all_advance_cursor_to = Some(eid.clone());
5977 }
5978 let key = cursor_key.clone();
5979 config::update_relay_state(|state| {
5980 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5981 self_obj.insert(key, Value::String(eid));
5982 }
5983 Ok(())
5984 })?;
5985 }
5986 }
5987
5988 let result = crate::pull::PullResult {
5993 written: all_written,
5994 rejected: all_rejected,
5995 blocked: all_blocked,
5996 advance_cursor_to: all_advance_cursor_to,
5997 };
5998 let events_len = total_seen;
5999
6000 if as_json {
6004 println!(
6005 "{}",
6006 serde_json::to_string(&json!({
6007 "written": result.written,
6008 "rejected": result.rejected,
6009 "total_seen": events_len,
6010 "cursor_blocked": result.blocked,
6011 "cursor_advanced_to": result.advance_cursor_to,
6012 }))?
6013 );
6014 } else {
6015 let blocking = result
6016 .rejected
6017 .iter()
6018 .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
6019 .count();
6020 if blocking > 0 {
6021 println!(
6022 "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
6023 events_len,
6024 result.written.len(),
6025 result.rejected.len(),
6026 blocking,
6027 );
6028 } else {
6029 println!(
6030 "pulled {} event(s); wrote {}; rejected {}",
6031 events_len,
6032 result.written.len(),
6033 result.rejected.len(),
6034 );
6035 }
6036 }
6037 Ok(())
6038}
6039
6040fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
6045 match scope {
6046 crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
6047 crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
6048 crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
6049 crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
6050 }
6051}
6052
6053fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
6056 if !config::is_initialized()? {
6057 bail!("not initialized — run `wire init <handle>` first");
6058 }
6059 let mut state = config::read_relay_state()?;
6060 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
6061 if self_state.is_null() {
6062 bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
6063 }
6064 let primary = crate::endpoints::self_primary_endpoint(&state)
6068 .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
6069 let url = primary.relay_url.clone();
6070 let old_slot_id = primary.slot_id.clone();
6071 let old_slot_token = primary.slot_token.clone();
6072
6073 let card = config::read_agent_card()?;
6075 let did = card
6076 .get("did")
6077 .and_then(Value::as_str)
6078 .unwrap_or("")
6079 .to_string();
6080 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
6081 let pk_b64 = card
6082 .get("verify_keys")
6083 .and_then(Value::as_object)
6084 .and_then(|m| m.values().next())
6085 .and_then(|v| v.get("key"))
6086 .and_then(Value::as_str)
6087 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
6088 .to_string();
6089 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
6090 let sk_seed = config::read_private_key()?;
6091
6092 let normalized = url.trim_end_matches('/').to_string();
6094 let client = crate::relay_client::RelayClient::new(&normalized);
6095 client
6096 .check_healthz()
6097 .context("aborting rotation; old slot still valid")?;
6098 let alloc = client.allocate_slot(Some(&handle))?;
6099 let new_slot_id = alloc.slot_id.clone();
6100 let new_slot_token = alloc.slot_token.clone();
6101
6102 let mut announced: Vec<String> = Vec::new();
6109 if !no_announce {
6110 let now = time::OffsetDateTime::now_utc()
6111 .format(&time::format_description::well_known::Rfc3339)
6112 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
6113 let body = json!({
6114 "reason": "operator-initiated slot rotation",
6115 "new_relay_url": url,
6116 "new_slot_id": new_slot_id,
6117 });
6121 let peers = state["peers"].as_object().cloned().unwrap_or_default();
6122 for (peer_handle, _peer_info) in peers.iter() {
6123 let event = json!({
6124 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6125 "timestamp": now.clone(),
6126 "from": did,
6127 "to": format!("did:wire:{peer_handle}"),
6128 "type": "wire_close",
6129 "kind": 1201,
6130 "body": body.clone(),
6131 });
6132 let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
6133 Ok(s) => s,
6134 Err(e) => {
6135 eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
6136 continue;
6137 }
6138 };
6139 let peer_info = match state["peers"].get(peer_handle) {
6144 Some(p) => p.clone(),
6145 None => continue,
6146 };
6147 let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
6148 let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
6149 let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
6150 if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
6151 continue;
6152 }
6153 let peer_client = if peer_url == url {
6154 client.clone()
6155 } else {
6156 crate::relay_client::RelayClient::new(peer_url)
6157 };
6158 match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
6159 Ok(_) => announced.push(peer_handle.clone()),
6160 Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
6161 }
6162 }
6163 }
6164
6165 state["self"] = json!({
6167 "relay_url": url,
6168 "slot_id": new_slot_id,
6169 "slot_token": new_slot_token,
6170 });
6171 config::write_relay_state(&state)?;
6172
6173 if as_json {
6174 println!(
6175 "{}",
6176 serde_json::to_string(&json!({
6177 "rotated": true,
6178 "old_slot_id": old_slot_id,
6179 "new_slot_id": new_slot_id,
6180 "relay_url": url,
6181 "announced_to": announced,
6182 }))?
6183 );
6184 } else {
6185 println!("rotated slot on {url}");
6186 println!(
6187 " old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
6188 );
6189 println!(" new slot_id: {new_slot_id}");
6190 if !announced.is_empty() {
6191 println!(
6192 " announced wire_close (kind=1201) to: {}",
6193 announced.join(", ")
6194 );
6195 }
6196 println!();
6197 println!("next steps:");
6198 println!(" - peers see the wire_close event in their next `wire pull`");
6199 println!(
6200 " - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
6201 );
6202 println!(" (or full re-pair via `wire dial <handle>@<relay>`)");
6203 println!(" - until they do, you'll receive but they won't be able to reach you");
6204 let _ = old_slot_token;
6206 }
6207 Ok(())
6208}
6209
6210fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
6213 let mut trust = config::read_trust()?;
6214 let mut removed_from_trust = false;
6215 if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
6216 && agents.remove(handle).is_some()
6217 {
6218 removed_from_trust = true;
6219 }
6220 config::write_trust(&trust)?;
6221
6222 let mut state = config::read_relay_state()?;
6223 let mut removed_from_relay = false;
6224 if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
6225 && peers.remove(handle).is_some()
6226 {
6227 removed_from_relay = true;
6228 }
6229 config::write_relay_state(&state)?;
6230
6231 let mut purged: Vec<String> = Vec::new();
6232 if purge {
6233 for dir in [config::inbox_dir()?, config::outbox_dir()?] {
6234 let path = dir.join(format!("{handle}.jsonl"));
6235 if path.exists() {
6236 std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
6237 purged.push(path.to_string_lossy().into());
6238 }
6239 }
6240 }
6241
6242 if !removed_from_trust && !removed_from_relay {
6243 if as_json {
6244 println!(
6245 "{}",
6246 serde_json::to_string(&json!({
6247 "removed": false,
6248 "reason": format!("peer {handle:?} not pinned"),
6249 }))?
6250 );
6251 } else {
6252 eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
6253 }
6254 return Ok(());
6255 }
6256
6257 if as_json {
6258 println!(
6259 "{}",
6260 serde_json::to_string(&json!({
6261 "handle": handle,
6262 "removed_from_trust": removed_from_trust,
6263 "removed_from_relay_state": removed_from_relay,
6264 "purged_files": purged,
6265 }))?
6266 );
6267 } else {
6268 println!("forgot peer {handle:?}");
6269 if removed_from_trust {
6270 println!(" - removed from trust.json");
6271 }
6272 if removed_from_relay {
6273 println!(" - removed from relay.json");
6274 }
6275 if !purged.is_empty() {
6276 for p in &purged {
6277 println!(" - deleted {p}");
6278 }
6279 } else if !purge {
6280 println!(" (inbox/outbox files preserved; pass --purge to delete them)");
6281 }
6282 }
6283 Ok(())
6284}
6285
6286fn cmd_daemon(
6289 interval_secs: u64,
6290 once: bool,
6291 all_sessions: bool,
6292 session: Option<String>,
6293 as_json: bool,
6294) -> Result<()> {
6295 if all_sessions {
6300 if once {
6301 bail!("--all-sessions and --once are mutually exclusive (supervisor runs forever)");
6302 }
6303 if session.is_some() {
6304 bail!(
6305 "--all-sessions and --session are mutually exclusive (supervisor manages every session, not a single named one)"
6306 );
6307 }
6308 return crate::daemon_supervisor::run_supervisor(interval_secs, as_json);
6309 }
6310 if let Some(ref name) = session {
6315 let home = crate::session::find_session_home_by_name(name)
6324 .with_context(|| format!("resolving session home for --session {name}"))?
6325 .ok_or_else(|| {
6326 anyhow!(
6327 "session '{name}' not found — run `wire session list` to see initialized sessions"
6328 )
6329 })?;
6330 unsafe {
6333 std::env::set_var("WIRE_HOME", &home);
6334 }
6335 if !as_json {
6336 eprintln!(
6337 "wire daemon: pinned to session '{name}' (WIRE_HOME={})",
6338 home.display()
6339 );
6340 }
6341 }
6342 if !config::is_initialized()? {
6343 bail!("not initialized — run `wire init <handle>` first");
6344 }
6345 let _pid_guard = if !once && std::env::var("WIRE_DAEMON_NO_SINGLETON").is_err() {
6355 if let Some(holder_pid) = crate::ensure_up::daemon_singleton_holder() {
6356 if as_json {
6357 println!(
6358 "{}",
6359 serde_json::to_string(&json!({
6360 "status": "skipped",
6361 "reason": "daemon already running",
6362 "holder_pid": holder_pid,
6363 }))?
6364 );
6365 } else {
6366 eprintln!(
6367 "wire daemon: another daemon is already running (pid {holder_pid}); not starting a second polling loop. Set WIRE_DAEMON_NO_SINGLETON=1 to override."
6368 );
6369 }
6370 return Ok(());
6371 }
6372 Some(crate::ensure_up::claim_daemon_singleton()?)
6373 } else {
6374 None
6375 };
6376 if !once {
6381 crate::session::warn_on_identity_collision(std::process::id(), "daemon");
6382 }
6383 let interval = std::time::Duration::from_secs(interval_secs.max(1));
6384
6385 if !as_json {
6386 if once {
6387 eprintln!("wire daemon: single sync cycle, then exit");
6388 } else {
6389 eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
6390 }
6391 }
6392
6393 if let Err(e) = crate::ensure_up::write_self_daemon_pid() {
6397 eprintln!("daemon: pidfile write error: {e:#}");
6398 }
6399
6400 let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
6406 if !once {
6407 crate::daemon_stream::spawn_stream_subscriber(wake_tx);
6408 }
6409
6410 loop {
6411 let pushed = run_sync_push().unwrap_or_else(|e| {
6412 eprintln!("daemon: push error: {e:#}");
6413 json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
6414 });
6415 let pulled = run_sync_pull().unwrap_or_else(|e| {
6416 eprintln!("daemon: pull error: {e:#}");
6417 json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
6418 });
6419
6420 let cycle_push_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
6427 let cycle_pull_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
6428 let cycle_rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
6429 crate::ensure_up::write_last_sync_record(cycle_push_n, cycle_pull_n, cycle_rejected_n);
6430
6431 if as_json {
6432 println!(
6433 "{}",
6434 serde_json::to_string(&json!({
6435 "ts": time::OffsetDateTime::now_utc()
6436 .format(&time::format_description::well_known::Rfc3339)
6437 .unwrap_or_default(),
6438 "push": pushed,
6439 "pull": pulled,
6440 }))?
6441 );
6442 } else if cycle_push_n > 0 || cycle_pull_n > 0 || cycle_rejected_n > 0 {
6443 eprintln!(
6444 "daemon: pushed={cycle_push_n} pulled={cycle_pull_n} rejected={cycle_rejected_n}"
6445 );
6446 }
6447
6448 if once {
6449 return Ok(());
6450 }
6451 match wake_rx.recv_timeout(interval) {
6464 Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
6465 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
6466 std::thread::sleep(interval);
6467 }
6468 }
6469 while wake_rx.try_recv().is_ok() {}
6470 }
6471}
6472
6473fn run_sync_push() -> Result<Value> {
6476 let state = config::read_relay_state()?;
6477 let peers = state["peers"].as_object().cloned().unwrap_or_default();
6478 if peers.is_empty() {
6479 return Ok(json!({"pushed": [], "skipped": []}));
6480 }
6481 let outbox_dir = config::outbox_dir()?;
6482 if !outbox_dir.exists() {
6483 return Ok(json!({"pushed": [], "skipped": []}));
6484 }
6485 let mut pushed = Vec::new();
6486 let mut skipped = Vec::new();
6487 for (peer_handle, slot_info) in peers.iter() {
6488 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
6489 if !outbox.exists() {
6490 continue;
6491 }
6492 let url = slot_info["relay_url"].as_str().unwrap_or("");
6493 let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
6494 let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
6495 if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
6496 continue;
6497 }
6498 let client = crate::relay_client::RelayClient::new(url);
6499 let body = std::fs::read_to_string(&outbox)?;
6500 for line in body.lines() {
6501 let event: Value = match serde_json::from_str(line) {
6502 Ok(v) => v,
6503 Err(_) => continue,
6504 };
6505 let event_id = event
6506 .get("event_id")
6507 .and_then(Value::as_str)
6508 .unwrap_or("")
6509 .to_string();
6510 match client.post_event(slot_id, slot_token, &event) {
6511 Ok(resp) => {
6512 let now = time::OffsetDateTime::now_utc()
6521 .format(&time::format_description::well_known::Rfc3339)
6522 .unwrap_or_default();
6523 if let Err(e) = config::append_pushed_log(peer_handle, &event_id, &now) {
6524 eprintln!(
6525 "daemon: pushed-log append for {peer_handle}/{event_id} failed (non-fatal): {e:#}"
6526 );
6527 }
6528 if resp.status == "duplicate" {
6529 skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
6530 } else {
6531 pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
6532 }
6533 }
6534 Err(e) => {
6535 let reason = crate::relay_client::format_transport_error(&e);
6539 skipped
6540 .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
6541 }
6542 }
6543 }
6544 }
6545 Ok(json!({"pushed": pushed, "skipped": skipped}))
6546}
6547
6548pub fn run_sync_pull() -> Result<Value> {
6556 let state = config::read_relay_state()?;
6557 if state.get("self").map(Value::is_null).unwrap_or(true) {
6558 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
6559 }
6560 let endpoints = crate::endpoints::self_endpoints(&state);
6567 if endpoints.is_empty() {
6568 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
6569 }
6570 let inbox_dir = config::inbox_dir()?;
6571 config::ensure_dirs()?;
6572
6573 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
6578 let legacy_cursor = self_obj
6579 .get("last_pulled_event_id")
6580 .and_then(Value::as_str)
6581 .map(str::to_string);
6582 let primary_slot = crate::endpoints::self_primary_endpoint(&state).map(|e| e.slot_id);
6583 let mut cursors: serde_json::Map<String, Value> = self_obj
6584 .get("cursors")
6585 .and_then(Value::as_object)
6586 .cloned()
6587 .unwrap_or_default();
6588
6589 let mut all_written: Vec<Value> = Vec::new();
6590 let mut all_rejected: Vec<Value> = Vec::new();
6591 let mut total_seen = 0usize;
6592 let mut blocked_any = false;
6593
6594 for ep in &endpoints {
6595 if ep.relay_url.is_empty() {
6596 continue;
6597 }
6598 let cursor = cursors
6599 .get(&ep.slot_id)
6600 .and_then(Value::as_str)
6601 .map(str::to_string)
6602 .or_else(|| {
6603 if Some(&ep.slot_id) == primary_slot.as_ref() {
6604 legacy_cursor.clone()
6605 } else {
6606 None
6607 }
6608 });
6609 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
6610 let events =
6613 match client.list_events(&ep.slot_id, &ep.slot_token, cursor.as_deref(), Some(1000)) {
6614 Ok(e) => e,
6615 Err(e) => {
6616 eprintln!(
6617 "daemon: pull error on {} slot {} (continuing): {e:#}",
6618 ep.relay_url, ep.slot_id
6619 );
6620 continue;
6621 }
6622 };
6623 total_seen += events.len();
6624 let result = crate::pull::process_events(&events, cursor, &inbox_dir)?;
6627 if let Some(eid) = &result.advance_cursor_to {
6628 cursors.insert(ep.slot_id.clone(), Value::String(eid.clone()));
6629 }
6630 blocked_any |= result.blocked;
6631 all_written.extend(result.written);
6632 all_rejected.extend(result.rejected);
6633 }
6634
6635 let primary_cursor = primary_slot
6639 .as_ref()
6640 .and_then(|s| cursors.get(s))
6641 .and_then(Value::as_str)
6642 .map(str::to_string);
6643 let mut latest_inbound: std::collections::HashMap<String, String> =
6651 std::collections::HashMap::new();
6652 for w in &all_written {
6653 let from = match w.get("from").and_then(Value::as_str) {
6654 Some(s) => s.to_string(),
6655 None => continue,
6656 };
6657 let ts = match w.get("timestamp").and_then(Value::as_str) {
6658 Some(s) if !s.is_empty() => s.to_string(),
6659 _ => continue,
6660 };
6661 latest_inbound
6662 .entry(from)
6663 .and_modify(|existing| {
6664 if ts > *existing {
6665 *existing = ts.clone();
6666 }
6667 })
6668 .or_insert(ts);
6669 }
6670 config::update_relay_state(|state| {
6671 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
6672 self_obj.insert("cursors".into(), Value::Object(cursors.clone()));
6673 if let Some(pc) = &primary_cursor {
6674 self_obj.insert("last_pulled_event_id".into(), Value::String(pc.clone()));
6675 }
6676 }
6677 if !latest_inbound.is_empty()
6678 && let Some(peers_obj) = state.get_mut("peers").and_then(Value::as_object_mut)
6679 {
6680 for (handle, ts) in &latest_inbound {
6681 let entry = peers_obj.entry(handle.clone()).or_insert_with(|| json!({}));
6682 if let Some(obj) = entry.as_object_mut() {
6683 obj.insert("last_inbound_event_at".into(), Value::String(ts.clone()));
6684 }
6685 }
6686 }
6687 Ok(())
6688 })?;
6689
6690 Ok(json!({
6691 "written": all_written,
6692 "rejected": all_rejected,
6693 "total_seen": total_seen,
6694 "cursor_blocked": blocked_any,
6695 "endpoints_pulled": endpoints.len(),
6696 }))
6697}
6698
6699fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
6702 let body =
6703 std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
6704 let card: Value =
6705 serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
6706 crate::agent_card::verify_agent_card(&card)
6707 .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
6708
6709 let mut trust = config::read_trust()?;
6710 crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
6711
6712 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
6713 let handle = crate::agent_card::display_handle_from_did(did).to_string();
6714 config::write_trust(&trust)?;
6715
6716 if as_json {
6717 println!(
6718 "{}",
6719 serde_json::to_string(&json!({
6720 "handle": handle,
6721 "did": did,
6722 "tier": "VERIFIED",
6723 "pinned": true,
6724 }))?
6725 );
6726 } else {
6727 println!("pinned {handle} ({did}) at tier VERIFIED");
6728 }
6729 Ok(())
6730}
6731
6732fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
6735 let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
6736
6737 let share_payload: Option<Value> = if share {
6740 let client = reqwest::blocking::Client::new();
6741 let single_use = if uses == 1 { Some(1u32) } else { None };
6742 let body = json!({
6743 "invite_url": url,
6744 "ttl_seconds": ttl,
6745 "uses": single_use,
6746 });
6747 let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
6748 let resp = client.post(&endpoint).json(&body).send()?;
6749 if !resp.status().is_success() {
6750 let code = resp.status();
6751 let txt = resp.text().unwrap_or_default();
6752 bail!("relay {code} on /v1/invite/register: {txt}");
6753 }
6754 let parsed: Value = resp.json()?;
6755 let token = parsed
6756 .get("token")
6757 .and_then(Value::as_str)
6758 .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
6759 .to_string();
6760 let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
6761 let curl_line = format!("curl -fsSL {share_url} | sh");
6762 Some(json!({
6763 "token": token,
6764 "share_url": share_url,
6765 "curl": curl_line,
6766 "expires_unix": parsed.get("expires_unix"),
6767 }))
6768 } else {
6769 None
6770 };
6771
6772 if as_json {
6773 let mut out = json!({
6774 "invite_url": url,
6775 "ttl_secs": ttl,
6776 "uses": uses,
6777 "relay": relay,
6778 });
6779 if let Some(s) = &share_payload {
6780 out["share"] = s.clone();
6781 }
6782 println!("{}", serde_json::to_string(&out)?);
6783 } else if let Some(s) = share_payload {
6784 let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
6785 eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
6786 eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
6787 println!("{curl}");
6788 } else {
6789 eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
6790 eprintln!("# TTL: {ttl}s. Uses: {uses}.");
6791 println!("{url}");
6792 }
6793 Ok(())
6794}
6795
6796fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
6797 let resolved = if url.starts_with("http://") || url.starts_with("https://") {
6801 let sep = if url.contains('?') { '&' } else { '?' };
6802 let resolve_url = format!("{url}{sep}format=url");
6803 let client = reqwest::blocking::Client::new();
6804 let resp = client
6805 .get(&resolve_url)
6806 .send()
6807 .with_context(|| format!("GET {resolve_url}"))?;
6808 if !resp.status().is_success() {
6809 bail!("could not resolve short URL {url} (HTTP {})", resp.status());
6810 }
6811 let body = resp.text().unwrap_or_default().trim().to_string();
6812 if !body.starts_with("wire://pair?") {
6813 bail!(
6814 "short URL {url} did not resolve to a wire:// invite. \
6815 (got: {}{})",
6816 body.chars().take(80).collect::<String>(),
6817 if body.chars().count() > 80 { "…" } else { "" }
6818 );
6819 }
6820 body
6821 } else {
6822 url.to_string()
6823 };
6824
6825 let result = crate::pair_invite::accept_invite(&resolved)?;
6826 if as_json {
6827 println!("{}", serde_json::to_string(&result)?);
6828 } else {
6829 let did = result
6830 .get("paired_with")
6831 .and_then(Value::as_str)
6832 .unwrap_or("?");
6833 println!("paired with {did}");
6834 println!(
6835 "you can now: wire send {} <kind> <body>",
6836 crate::agent_card::display_handle_from_did(did)
6837 );
6838 }
6839 Ok(())
6840}
6841
6842fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
6845 if let Some(h) = handle {
6846 let parsed = crate::pair_profile::parse_handle(h)?;
6847 if config::is_initialized()? {
6850 let card = config::read_agent_card()?;
6851 let local_handle = card
6852 .get("profile")
6853 .and_then(|p| p.get("handle"))
6854 .and_then(Value::as_str)
6855 .map(str::to_string);
6856 if local_handle.as_deref() == Some(h) {
6857 return cmd_whois(None, as_json, None);
6858 }
6859 }
6860 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6862 if as_json {
6863 println!("{}", serde_json::to_string(&resolved)?);
6864 } else {
6865 print_resolved_profile(&resolved);
6866 }
6867 return Ok(());
6868 }
6869 let card = config::read_agent_card()?;
6870 if as_json {
6871 let profile = card.get("profile").cloned().unwrap_or(Value::Null);
6872 let mut payload = serde_json::Map::new();
6873 payload.insert(
6874 "did".into(),
6875 card.get("did").cloned().unwrap_or(Value::Null),
6876 );
6877 payload.insert("profile".into(), profile);
6878 for (k, v) in op_claims_from_card(&card) {
6882 payload.insert(k, v);
6883 }
6884 println!("{}", serde_json::to_string(&payload)?);
6885 } else {
6886 print!("{}", crate::pair_profile::render_self_summary()?);
6887 }
6888 Ok(())
6889}
6890
6891fn print_resolved_profile(resolved: &Value) {
6892 let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
6893 let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
6894 let relay = resolved
6895 .get("relay_url")
6896 .and_then(Value::as_str)
6897 .unwrap_or("");
6898 let slot = resolved
6899 .get("slot_id")
6900 .and_then(Value::as_str)
6901 .unwrap_or("");
6902 let profile = resolved
6903 .get("card")
6904 .and_then(|c| c.get("profile"))
6905 .cloned()
6906 .unwrap_or(Value::Null);
6907 println!("{did}");
6908 println!(" nick: {nick}");
6909 if !relay.is_empty() {
6910 println!(" relay_url: {relay}");
6911 }
6912 if !slot.is_empty() {
6913 println!(" slot_id: {slot}");
6914 }
6915 let pick =
6916 |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
6917 if let Some(s) = pick("display_name") {
6918 println!(" display_name: {s}");
6919 }
6920 if let Some(s) = pick("emoji") {
6921 println!(" emoji: {s}");
6922 }
6923 if let Some(s) = pick("motto") {
6924 println!(" motto: {s}");
6925 }
6926 if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
6927 let joined: Vec<String> = arr
6928 .iter()
6929 .filter_map(|v| v.as_str().map(str::to_string))
6930 .collect();
6931 println!(" vibe: {}", joined.join(", "));
6932 }
6933 if let Some(s) = pick("pronouns") {
6934 println!(" pronouns: {s}");
6935 }
6936}
6937
6938fn host_of_url(url: &str) -> String {
6946 let no_scheme = url
6947 .trim_start_matches("https://")
6948 .trim_start_matches("http://");
6949 no_scheme
6950 .split('/')
6951 .next()
6952 .unwrap_or("")
6953 .split(':')
6954 .next()
6955 .unwrap_or("")
6956 .to_string()
6957}
6958
6959fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
6963 const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
6965 let peer_domain = peer_domain.trim().to_ascii_lowercase();
6966 if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
6967 return true;
6968 }
6969 let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
6972 if !our_host.is_empty() && our_host == peer_domain {
6973 return true;
6974 }
6975 false
6976}
6977
6978fn resolve_local_session<'a>(
6996 sessions: &'a [crate::session::SessionInfo],
6997 input: &str,
6998) -> Result<&'a crate::session::SessionInfo, ResolveError> {
6999 if let Some(s) = sessions.iter().find(|s| s.name == input) {
7002 return Ok(s);
7003 }
7004 let nick_matches: Vec<&crate::session::SessionInfo> = sessions
7005 .iter()
7006 .filter(|s| {
7007 s.character
7008 .as_ref()
7009 .map(|c| c.nickname == input)
7010 .unwrap_or(false)
7011 })
7012 .collect();
7013 match nick_matches.len() {
7014 0 => Err(ResolveError::NotFound),
7015 1 => Ok(nick_matches[0]),
7016 _ => Err(ResolveError::Ambiguous(
7017 nick_matches.iter().map(|s| s.name.clone()).collect(),
7018 )),
7019 }
7020}
7021
7022#[derive(Debug)]
7023enum ResolveError {
7024 NotFound,
7025 Ambiguous(Vec<String>),
7026}
7027
7028fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
7044 let trust = match config::read_trust() {
7045 Ok(t) => t,
7046 Err(_) => return Ok(None),
7047 };
7048 let agents = match trust.get("agents").and_then(|a| a.as_object()) {
7049 Some(a) => a,
7050 None => return Ok(None),
7051 };
7052 if agents.contains_key(input) {
7053 return Ok(Some(input.to_string()));
7054 }
7055 let mut nick_matches: Vec<String> = Vec::new();
7056 for (handle, agent) in agents.iter() {
7057 let character = match agent.get("card") {
7061 Some(card) => crate::character::Character::from_card(card),
7062 None => match agent.get("did").and_then(Value::as_str) {
7063 Some(did) => crate::character::Character::from_did(did),
7064 None => continue,
7065 },
7066 };
7067 if character.nickname == input {
7068 nick_matches.push(handle.clone());
7069 }
7070 }
7071 match nick_matches.len() {
7072 0 => Ok(None),
7073 1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
7074 _ => Err(ResolveError::Ambiguous(nick_matches)),
7075 }
7076}
7077
7078fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
7079 let sessions = crate::session::list_sessions()?;
7081 let sister = match resolve_local_session(&sessions, sister_name) {
7082 Ok(s) => s,
7083 Err(ResolveError::NotFound) => bail!(
7084 "no sister session named `{sister_name}` (matched by session name or character nickname). \
7085 Run `wire session list` to see what's available."
7086 ),
7087 Err(ResolveError::Ambiguous(candidates)) => bail!(
7088 "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
7089 Disambiguate by passing the session name (one of those listed) instead of the nickname.",
7090 candidates.len(),
7091 candidates.join(", ")
7092 ),
7093 };
7094 if sister.name != sister_name {
7097 eprintln!(
7098 "wire add: resolved nickname `{sister_name}` → session `{}`",
7099 sister.name
7100 );
7101 }
7102
7103 let our_card = config::read_agent_card()
7106 .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
7107 let our_did = our_card
7108 .get("did")
7109 .and_then(Value::as_str)
7110 .ok_or_else(|| anyhow!("agent-card missing did"))?
7111 .to_string();
7112 if let Some(sister_did) = sister.did.as_deref()
7113 && sister_did == our_did
7114 {
7115 bail!("refusing to add self (`{sister_name}` is this very session)");
7116 }
7117
7118 let sister_card_path = sister
7120 .home_dir
7121 .join("config")
7122 .join("wire")
7123 .join("agent-card.json");
7124 let sister_card: Value = serde_json::from_slice(
7125 &std::fs::read(&sister_card_path)
7126 .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
7127 )
7128 .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
7129 let sister_relay_state: Value = std::fs::read(
7130 sister
7131 .home_dir
7132 .join("config")
7133 .join("wire")
7134 .join("relay.json"),
7135 )
7136 .ok()
7137 .and_then(|b| serde_json::from_slice(&b).ok())
7138 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
7139
7140 let sister_did = sister_card
7141 .get("did")
7142 .and_then(Value::as_str)
7143 .ok_or_else(|| anyhow!("sister card missing did"))?
7144 .to_string();
7145 let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
7146
7147 let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
7151 if sister_endpoints.is_empty() {
7152 bail!(
7153 "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
7154 );
7155 }
7156 let sister_local = sister_endpoints
7157 .iter()
7158 .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
7159 let delivery_endpoint = match sister_local {
7160 Some(e) => e.clone(),
7161 None => sister_endpoints[0].clone(),
7162 };
7163
7164 let our_relay_state = config::read_relay_state()?;
7170 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
7171 if our_endpoints.is_empty() {
7172 bail!(
7173 "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
7174 );
7175 }
7176 let our_advertised = our_endpoints
7177 .iter()
7178 .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
7179 .cloned()
7180 .unwrap_or_else(|| our_endpoints[0].clone());
7181
7182 let mut trust = config::read_trust()?;
7186 crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
7187 config::write_trust(&trust)?;
7188 let mut relay_state = config::read_relay_state()?;
7189 crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
7190 config::write_relay_state(&relay_state)?;
7191
7192 let sk_seed = config::read_private_key()?;
7195 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
7196 let pk_b64 = our_card
7197 .get("verify_keys")
7198 .and_then(Value::as_object)
7199 .and_then(|m| m.values().next())
7200 .and_then(|v| v.get("key"))
7201 .and_then(Value::as_str)
7202 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
7203 let pk_bytes = crate::signing::b64decode(pk_b64)?;
7204 let now = time::OffsetDateTime::now_utc()
7205 .format(&time::format_description::well_known::Rfc3339)
7206 .unwrap_or_default();
7207 let mut body = json!({
7208 "card": our_card,
7209 "relay_url": our_advertised.relay_url,
7210 "slot_id": our_advertised.slot_id,
7211 "slot_token": our_advertised.slot_token,
7212 });
7213 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
7214 let event = json!({
7215 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7216 "timestamp": now,
7217 "from": our_did,
7218 "to": sister_did,
7219 "type": "pair_drop",
7220 "kind": 1100u32,
7221 "body": body,
7222 });
7223 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
7224 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
7225
7226 let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
7230 client
7231 .post_event(
7232 &delivery_endpoint.slot_id,
7233 &delivery_endpoint.slot_token,
7234 &signed,
7235 )
7236 .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
7237
7238 if as_json {
7239 println!(
7240 "{}",
7241 serde_json::to_string(&json!({
7242 "handle": sister_name,
7243 "paired_with": sister_did,
7244 "peer_handle": sister_handle,
7245 "event_id": event_id,
7246 "delivered_via": match delivery_endpoint.scope {
7247 crate::endpoints::EndpointScope::Local => "local",
7248 crate::endpoints::EndpointScope::Lan => "lan",
7249 crate::endpoints::EndpointScope::Uds => "uds",
7250 crate::endpoints::EndpointScope::Federation => "federation",
7251 },
7252 "status": "drop_sent",
7253 }))?
7254 );
7255 } else {
7256 let scope = match delivery_endpoint.scope {
7257 crate::endpoints::EndpointScope::Local => "local",
7258 crate::endpoints::EndpointScope::Lan => "lan",
7259 crate::endpoints::EndpointScope::Uds => "uds",
7260 crate::endpoints::EndpointScope::Federation => "federation",
7261 };
7262 println!(
7263 "→ 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.",
7264 delivery_endpoint.relay_url
7265 );
7266 }
7267 Ok(())
7268}
7269
7270fn cmd_add(
7271 handle_arg: &str,
7272 relay_override: Option<&str>,
7273 local_sister: bool,
7274 as_json: bool,
7275) -> Result<()> {
7276 if local_sister {
7284 let resolved = crate::session::resolve_local_sister(handle_arg)
7285 .unwrap_or_else(|| handle_arg.to_string());
7286 return cmd_add_local_sister(&resolved, as_json);
7287 }
7288 if !handle_arg.contains('@')
7289 && let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
7290 {
7291 eprintln!(
7292 "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
7293 — routing via --local-sister (disk-read card, no relay lookup)."
7294 );
7295 return cmd_add_local_sister(&resolved, as_json);
7296 }
7297 if !handle_arg.contains('@') {
7298 bail!(
7299 "`{handle_arg}` doesn't match any local sister session and has no \
7300 @<relay> suffix for federation.\n\
7301 — Local sisters: `wire session list-local` (operator types name OR \
7302 character nickname)\n\
7303 — Federation: `wire add <handle>@<relay-domain>` (e.g. \
7304 `wire add alice@wireup.net`)"
7305 );
7306 }
7307 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
7308
7309 let (our_did, our_relay, our_slot_id, our_slot_token) =
7311 crate::pair_invite::ensure_self_with_relay(relay_override)?;
7312 if our_did == format!("did:wire:{}", parsed.nick) {
7313 bail!("refusing to add self (handle matches own DID)");
7315 }
7316
7317 if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
7327 return cmd_add_accept_pending(
7328 handle_arg,
7329 &parsed.nick,
7330 &pending,
7331 &our_relay,
7332 &our_slot_id,
7333 &our_slot_token,
7334 as_json,
7335 );
7336 }
7337
7338 if !is_known_relay_domain(&parsed.domain, &our_relay) {
7355 eprintln!(
7356 "wire add: WARN unfamiliar relay domain `{}`.",
7357 parsed.domain
7358 );
7359 eprintln!(
7360 " This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
7361 host_of_url(&our_relay)
7362 );
7363 eprintln!(
7364 " and not on the known-good list. If you meant `{}@wireup.net`, ",
7365 parsed.nick
7366 );
7367 eprintln!(
7368 " run `wire add {}@wireup.net` instead. Otherwise verify with your",
7369 parsed.nick
7370 );
7371 eprintln!(" peer out-of-band that they actually run a relay at this domain");
7372 eprintln!(" before relying on the pair. (See issue #9.4.)");
7373 }
7374
7375 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
7377 let peer_card = resolved
7378 .get("card")
7379 .cloned()
7380 .ok_or_else(|| anyhow!("resolved missing card"))?;
7381 let peer_did = resolved
7382 .get("did")
7383 .and_then(Value::as_str)
7384 .ok_or_else(|| anyhow!("resolved missing did"))?
7385 .to_string();
7386 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
7387
7388 reject_self_pair_after_resolution(&our_did, &peer_did)?;
7393
7394 let peer_slot_id = resolved
7395 .get("slot_id")
7396 .and_then(Value::as_str)
7397 .ok_or_else(|| anyhow!("resolved missing slot_id"))?
7398 .to_string();
7399 let peer_relay = resolved
7400 .get("relay_url")
7401 .and_then(Value::as_str)
7402 .map(str::to_string)
7403 .or_else(|| relay_override.map(str::to_string))
7404 .unwrap_or_else(|| format!("https://{}", parsed.domain));
7405
7406 let mut trust = config::read_trust()?;
7408 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
7409 config::write_trust(&trust)?;
7410 let mut relay_state = config::read_relay_state()?;
7411 let mut endpoints: Vec<crate::endpoints::Endpoint> = relay_state
7424 .get("peers")
7425 .and_then(|p| p.get(&peer_handle))
7426 .and_then(|e| e.get("endpoints"))
7427 .and_then(|a| serde_json::from_value::<Vec<crate::endpoints::Endpoint>>(a.clone()).ok())
7428 .unwrap_or_default();
7429 let fed_token = endpoints
7430 .iter()
7431 .find(|e| {
7432 e.relay_url == peer_relay && e.scope == crate::endpoints::EndpointScope::Federation
7433 })
7434 .map(|e| e.slot_token.clone())
7435 .unwrap_or_default();
7436 let fed_ep = crate::endpoints::Endpoint {
7437 relay_url: peer_relay.clone(),
7438 slot_id: peer_slot_id.clone(),
7439 slot_token: fed_token, scope: crate::endpoints::EndpointScope::Federation,
7441 };
7442 if let Some(existing) = endpoints
7443 .iter_mut()
7444 .find(|e| e.relay_url == fed_ep.relay_url)
7445 {
7446 *existing = fed_ep;
7447 } else {
7448 endpoints.push(fed_ep);
7449 }
7450 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints)?;
7451 config::write_relay_state(&relay_state)?;
7452
7453 let our_card = config::read_agent_card()?;
7456 let sk_seed = config::read_private_key()?;
7457 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
7458 let pk_b64 = our_card
7459 .get("verify_keys")
7460 .and_then(Value::as_object)
7461 .and_then(|m| m.values().next())
7462 .and_then(|v| v.get("key"))
7463 .and_then(Value::as_str)
7464 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
7465 let pk_bytes = crate::signing::b64decode(pk_b64)?;
7466 let now = time::OffsetDateTime::now_utc()
7467 .format(&time::format_description::well_known::Rfc3339)
7468 .unwrap_or_default();
7469 let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
7474 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
7475 let mut body = json!({
7476 "card": our_card,
7477 "relay_url": our_relay,
7478 "slot_id": our_slot_id,
7479 "slot_token": our_slot_token,
7480 });
7481 if !our_endpoints.is_empty() {
7482 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
7483 }
7484 let event = json!({
7485 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7486 "timestamp": now,
7487 "from": our_did,
7488 "to": peer_did,
7489 "type": "pair_drop",
7490 "kind": 1100u32,
7491 "body": body,
7492 });
7493 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
7494
7495 let client = crate::relay_client::RelayClient::new(&peer_relay);
7497 let resp = client.handle_intro(&parsed.nick, &signed)?;
7498 let event_id = signed
7499 .get("event_id")
7500 .and_then(Value::as_str)
7501 .unwrap_or("")
7502 .to_string();
7503
7504 if as_json {
7505 println!(
7506 "{}",
7507 serde_json::to_string(&json!({
7508 "handle": handle_arg,
7509 "paired_with": peer_did,
7510 "peer_handle": peer_handle,
7511 "event_id": event_id,
7512 "drop_response": resp,
7513 "status": "drop_sent",
7514 }))?
7515 );
7516 } else {
7517 println!(
7518 "→ 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."
7519 );
7520 }
7521 Ok(())
7522}
7523
7524fn cmd_add_accept_pending(
7531 handle_arg: &str,
7532 peer_nick: &str,
7533 pending: &crate::pending_inbound_pair::PendingInboundPair,
7534 _our_relay: &str,
7535 _our_slot_id: &str,
7536 _our_slot_token: &str,
7537 as_json: bool,
7538) -> Result<()> {
7539 let mut trust = config::read_trust()?;
7542 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
7543 config::write_trust(&trust)?;
7544
7545 let mut relay_state = config::read_relay_state()?;
7551 let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
7552 vec![crate::endpoints::Endpoint::federation(
7553 pending.peer_relay_url.clone(),
7554 pending.peer_slot_id.clone(),
7555 pending.peer_slot_token.clone(),
7556 )]
7557 } else {
7558 pending.peer_endpoints.clone()
7559 };
7560 crate::endpoints::pin_peer_endpoints(
7561 &mut relay_state,
7562 &pending.peer_handle,
7563 &endpoints_to_pin,
7564 )?;
7565 config::write_relay_state(&relay_state)?;
7566
7567 crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &endpoints_to_pin).with_context(
7572 || {
7573 format!(
7574 "pair_drop_ack send to {} (across {} endpoint(s)) failed",
7575 pending.peer_handle,
7576 endpoints_to_pin.len()
7577 )
7578 },
7579 )?;
7580
7581 crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
7583
7584 if as_json {
7585 println!(
7586 "{}",
7587 serde_json::to_string(&json!({
7588 "handle": handle_arg,
7589 "paired_with": pending.peer_did,
7590 "peer_handle": pending.peer_handle,
7591 "status": "bilateral_accepted",
7592 "via": "pending_inbound",
7593 }))?
7594 );
7595 } else {
7596 println!(
7597 "→ 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} \"...\"`.",
7598 peer = pending.peer_handle,
7599 );
7600 }
7601 Ok(())
7602}
7603
7604fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
7611 let nick = crate::agent_card::bare_handle(peer_nick);
7612 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
7613 anyhow!(
7614 "no pending pair request from {nick}. Run `wire pending` to see who is waiting, \
7615 or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
7616 )
7617 })?;
7618 let (_our_did, our_relay, our_slot_id, our_slot_token) =
7619 crate::pair_invite::ensure_self_with_relay(None)?;
7620 let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
7621 cmd_add_accept_pending(
7622 &handle_arg,
7623 nick,
7624 &pending,
7625 &our_relay,
7626 &our_slot_id,
7627 &our_slot_token,
7628 as_json,
7629 )
7630}
7631
7632fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
7635 let items = crate::pending_inbound_pair::list_pending_inbound()?;
7636 if as_json {
7637 println!("{}", serde_json::to_string(&items)?);
7638 return Ok(());
7639 }
7640 if items.is_empty() {
7641 println!("no pending pair requests — your inbox is clear.");
7642 return Ok(());
7643 }
7644 let plural = if items.len() == 1 { "" } else { "s" };
7651 println!("{} pending pair request{plural}:\n", items.len());
7652 for p in &items {
7653 let ch = crate::character::Character::from_did(&p.peer_did);
7654 let glyph = crate::character::emoji_with_fallback(&ch);
7655 println!(
7658 " {glyph} {nick} ({handle}) wants to pair with you",
7659 nick = ch.nickname,
7660 handle = p.peer_handle,
7661 );
7662 }
7663 println!();
7664 println!(
7665 "→ to accept any: `wire accept <name>` (e.g. `wire accept {first}`)",
7666 first = items
7667 .first()
7668 .map(|p| {
7669 let ch = crate::character::Character::from_did(&p.peer_did);
7670 ch.nickname
7671 })
7672 .unwrap_or_else(|| "<name>".to_string())
7673 );
7674 println!("→ to refuse: `wire reject <name>`");
7675 Ok(())
7676}
7677
7678fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
7682 let nick = crate::agent_card::bare_handle(peer_nick);
7683 let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
7684 crate::pending_inbound_pair::consume_pending_inbound(nick)?;
7685
7686 if as_json {
7687 println!(
7688 "{}",
7689 serde_json::to_string(&json!({
7690 "peer": nick,
7691 "rejected": existed.is_some(),
7692 "had_pending": existed.is_some(),
7693 }))?
7694 );
7695 } else if existed.is_some() {
7696 println!(
7697 "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
7698 );
7699 } else {
7700 println!("no pending pair from {nick} — nothing to reject");
7701 }
7702 Ok(())
7703}
7704
7705fn cmd_group(cmd: GroupCommand) -> Result<()> {
7716 match cmd {
7717 GroupCommand::Create { name, json } => cmd_group_create(&name, json),
7718 GroupCommand::Add { group, peer, json } => cmd_group_add(&group, &peer, json),
7719 GroupCommand::Send {
7720 group,
7721 message,
7722 json,
7723 } => cmd_group_send(&group, &message, json),
7724 GroupCommand::Tail { group, limit, json } => cmd_group_tail(&group, limit, json),
7725 GroupCommand::List { json } => cmd_group_list(json),
7726 GroupCommand::Invite { group, json } => cmd_group_invite(&group, json),
7727 GroupCommand::Join { code, json } => cmd_group_join(&code, json),
7728 }
7729}
7730
7731fn group_self() -> Result<(String, String, String, String)> {
7734 let card = config::read_agent_card()?;
7735 let did = card
7736 .get("did")
7737 .and_then(Value::as_str)
7738 .ok_or_else(|| anyhow!("agent-card missing did — run `wire up` first"))?
7739 .to_string();
7740 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7741 let pk_b64 = card
7742 .get("verify_keys")
7743 .and_then(Value::as_object)
7744 .and_then(|m| m.values().next())
7745 .and_then(|v| v.get("key"))
7746 .and_then(Value::as_str)
7747 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
7748 .to_string();
7749 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7750 let key_id = make_key_id(&handle, &pk_bytes);
7751 Ok((did, handle, key_id, pk_b64))
7752}
7753
7754fn group_room_relay_url() -> Result<String> {
7757 use crate::endpoints::EndpointScope;
7758 let state = config::read_relay_state()?;
7759 let eps = crate::endpoints::self_endpoints(&state);
7760 let pick = eps
7761 .iter()
7762 .find(|e| e.scope == EndpointScope::Federation)
7763 .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Lan))
7764 .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Local))
7765 .or_else(|| eps.first());
7766 match pick {
7767 Some(e) if !e.relay_url.is_empty() => Ok(e.relay_url.clone()),
7768 _ => bail!("no relay endpoint on this identity — run `wire up --relay <url>` first"),
7769 }
7770}
7771
7772fn distribute_group_invite(group: &crate::group::Group, self_did: &str) -> Result<usize> {
7776 let (_, self_handle, _, pk_b64) = group_self()?;
7777 let sk_seed = config::read_private_key()?;
7778 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7779 let now_iso = time::OffsetDateTime::now_utc()
7780 .format(&time::format_description::well_known::Rfc3339)
7781 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7782 let group_json = serde_json::to_value(group)?;
7783 let mut delivered = 0usize;
7784 for handle in group.other_member_handles(self_did) {
7785 let event = json!({
7786 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7787 "timestamp": now_iso,
7788 "from": self_did,
7789 "to": format!("did:wire:{handle}"),
7790 "type": "group_invite",
7791 "kind": parse_kind("group_invite")?,
7792 "body": group_json,
7793 });
7794 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
7795 .map_err(|e| anyhow!("signing group_invite for `{handle}`: {e:?}"))?;
7796 let line = serde_json::to_vec(&signed)?;
7797 if config::append_outbox_record(&handle, &line).is_ok() {
7798 delivered += 1;
7799 }
7800 }
7801 Ok(delivered)
7802}
7803
7804fn introduce_pin(
7811 trust: &mut Value,
7812 handle: &str,
7813 did: &str,
7814 key_id: &str,
7815 key: &str,
7816 group_id: &str,
7817) -> bool {
7818 let now = time::OffsetDateTime::now_utc()
7819 .format(&time::format_description::well_known::Rfc3339)
7820 .unwrap_or_default();
7821 let agents = trust
7822 .as_object_mut()
7823 .expect("trust is an object")
7824 .entry("agents")
7825 .or_insert_with(|| json!({}));
7826 let key_rec = json!({"key_id": key_id, "key": key, "added_at": now, "active": true});
7827 match agents.get_mut(handle) {
7828 Some(existing) => {
7829 let keys = existing
7832 .as_object_mut()
7833 .and_then(|o| o.get_mut("public_keys"))
7834 .and_then(Value::as_array_mut);
7835 if let Some(keys) = keys {
7836 let have = keys
7837 .iter()
7838 .any(|k| k.get("key_id").and_then(Value::as_str) == Some(key_id));
7839 if !have {
7840 keys.push(key_rec);
7841 return true;
7842 }
7843 }
7844 false
7845 }
7846 None => {
7847 agents[handle] = json!({
7849 "tier": "UNTRUSTED",
7850 "did": did,
7851 "public_keys": [key_rec],
7852 "introduced_via": group_id,
7853 "pinned_at": now,
7854 });
7855 true
7856 }
7857 }
7858}
7859
7860fn ingest_group_invites() -> Result<()> {
7866 let inbox = config::inbox_dir()?;
7867 if !inbox.exists() {
7868 return Ok(());
7869 }
7870 let (self_did, ..) = group_self()?;
7871 let trust_now = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
7872 let mut best: std::collections::HashMap<String, crate::group::Group> =
7874 std::collections::HashMap::new();
7875
7876 for entry in std::fs::read_dir(&inbox)?.flatten() {
7877 let path = entry.path();
7878 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
7879 continue;
7880 }
7881 for line in std::fs::read_to_string(&path).unwrap_or_default().lines() {
7882 let event: Value = match serde_json::from_str(line) {
7883 Ok(v) => v,
7884 Err(_) => continue,
7885 };
7886 if event.get("type").and_then(Value::as_str) != Some("group_invite") {
7887 continue;
7888 }
7889 if verify_message_v31(&event, &trust_now).is_err() {
7892 continue;
7893 }
7894 let Some(body) = event.get("body") else {
7895 continue;
7896 };
7897 let group: crate::group::Group = match serde_json::from_value(body.clone()) {
7898 Ok(g) => g,
7899 Err(_) => continue,
7900 };
7901 if group.creator_did == self_did {
7902 continue; }
7904 let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
7906 if from_did != group.creator_did {
7907 continue;
7908 }
7909 let creator_handle = crate::agent_card::display_handle_from_did(&group.creator_did);
7912 let creator_key = trust_now
7913 .get("agents")
7914 .and_then(|a| a.get(creator_handle))
7915 .and_then(|a| a.get("public_keys"))
7916 .and_then(Value::as_array)
7917 .and_then(|ks| ks.first())
7918 .and_then(|k| k.get("key"))
7919 .and_then(Value::as_str)
7920 .and_then(|b| crate::signing::b64decode(b).ok());
7921 let Some(creator_key) = creator_key else {
7922 continue;
7923 };
7924 if !group.verify(&creator_key) {
7925 continue;
7926 }
7927 match best.get(&group.id) {
7928 Some(prev) if prev.epoch >= group.epoch => {}
7929 _ => {
7930 best.insert(group.id.clone(), group);
7931 }
7932 }
7933 }
7934 }
7935
7936 if best.is_empty() {
7937 return Ok(());
7938 }
7939 let mut trust = config::read_trust()?;
7940 for group in best.values() {
7941 if let Ok(local) = crate::group::load_group(&group.id)
7943 && local.epoch >= group.epoch
7944 {
7945 continue;
7946 }
7947 crate::group::save_group(group)?;
7948 for m in &group.members {
7949 if m.did == self_did || m.key.is_empty() {
7950 continue;
7951 }
7952 introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
7953 }
7954 }
7955 config::write_trust(&trust)?;
7956 Ok(())
7957}
7958
7959fn cmd_group_create(name: &str, as_json: bool) -> Result<()> {
7960 if !config::is_initialized()? {
7961 bail!("not initialized — run `wire up` first");
7962 }
7963 let (did, handle, key_id, pk_b64) = group_self()?;
7964 let relay_url = group_room_relay_url()?;
7965 let client = crate::relay_client::RelayClient::new(&relay_url);
7967 let room = client
7968 .allocate_slot(Some(&format!("group:{name}")))
7969 .with_context(|| format!("allocating group room on {relay_url}"))?;
7970 let id = format!("g{:016x}", rand::random::<u64>());
7971 let mut group = crate::group::Group::new(id.clone(), name.to_string(), handle, did.clone());
7972 group.set_room(relay_url, room.slot_id, room.slot_token);
7973 group.set_member_keys(&did, key_id, pk_b64)?;
7974 let sk = config::read_private_key()?;
7975 group.sign(&sk)?;
7976 crate::group::save_group(&group)?;
7977 if as_json {
7978 println!(
7979 "{}",
7980 serde_json::to_string(&json!({
7981 "id": id, "name": name, "members": 1, "relay_url": group.relay_url
7982 }))?
7983 );
7984 } else {
7985 println!(
7986 "created group `{name}` (id {id}) — room on {}. You are the creator.",
7987 group.relay_url
7988 );
7989 println!(" add peers: `wire group add {id} <peer>` talk: `wire group send {id} \"hi\"`");
7990 }
7991 Ok(())
7992}
7993
7994fn cmd_group_add(group_ref: &str, peer: &str, as_json: bool) -> Result<()> {
7995 let (self_did, ..) = group_self()?;
7996 let mut group = crate::group::resolve_group(group_ref)?;
7997 if group.creator_did != self_did {
7998 bail!("only the group creator can add members (the creator signs the roster)");
7999 }
8000 let bare = crate::agent_card::bare_handle(peer).to_string();
8002 let trust = config::read_trust()?;
8003 let agent = trust
8004 .get("agents")
8005 .and_then(|a| a.get(&bare))
8006 .ok_or_else(|| {
8007 anyhow!("`{bare}` is not a pinned peer — pair first (`wire dial {bare}@<relay>`)")
8008 })?;
8009 let tier = agent
8010 .get("tier")
8011 .and_then(Value::as_str)
8012 .unwrap_or("UNTRUSTED");
8013 if tier != "VERIFIED" {
8014 bail!(
8015 "`{bare}` is {tier}, not VERIFIED — only verified peers can be added as Members (T22 consent)"
8016 );
8017 }
8018 let peer_did = agent
8019 .get("did")
8020 .and_then(Value::as_str)
8021 .ok_or_else(|| anyhow!("trust entry for `{bare}` is missing a did"))?
8022 .to_string();
8023 let key = agent
8026 .get("public_keys")
8027 .and_then(Value::as_array)
8028 .and_then(|ks| {
8029 ks.iter()
8030 .find(|k| k.get("active").and_then(Value::as_bool).unwrap_or(true))
8031 })
8032 .ok_or_else(|| anyhow!("no active pinned key for `{bare}` in trust"))?;
8033 let peer_key_id = key
8034 .get("key_id")
8035 .and_then(Value::as_str)
8036 .unwrap_or_default()
8037 .to_string();
8038 let peer_pk = key
8039 .get("key")
8040 .and_then(Value::as_str)
8041 .unwrap_or_default()
8042 .to_string();
8043
8044 group.add_member(
8045 bare.clone(),
8046 peer_did.clone(),
8047 crate::group::GroupTier::Member,
8048 )?;
8049 group.set_member_keys(&peer_did, peer_key_id, peer_pk)?;
8050 let sk = config::read_private_key()?;
8051 group.sign(&sk)?;
8052 crate::group::save_group(&group)?;
8053 let delivered = distribute_group_invite(&group, &self_did).unwrap_or(0);
8056 if as_json {
8057 println!(
8058 "{}",
8059 serde_json::to_string(&json!({
8060 "group": group.id, "added": bare, "epoch": group.epoch,
8061 "members": group.members.len(), "invites_queued": delivered
8062 }))?
8063 );
8064 } else {
8065 println!(
8066 "added `{bare}` to `{}` — now {} member(s), epoch {} ({delivered} invite(s) queued; run `wire push`)",
8067 group.name,
8068 group.members.len(),
8069 group.epoch
8070 );
8071 }
8072 Ok(())
8073}
8074
8075fn cmd_group_send(group_ref: &str, message: &str, as_json: bool) -> Result<()> {
8076 if !config::is_initialized()? {
8077 bail!("not initialized — run `wire up` first");
8078 }
8079 ingest_group_invites()?;
8080 let (self_did, self_handle, _, pk_b64) = group_self()?;
8081 let group = crate::group::resolve_group(group_ref)?;
8082 if group.slot_id.is_empty() || group.relay_url.is_empty() {
8087 bail!(
8088 "group `{}` has no room slot (legacy/partial group)",
8089 group.name
8090 );
8091 }
8092 let sk_seed = config::read_private_key()?;
8093 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
8094 let now_iso = time::OffsetDateTime::now_utc()
8095 .format(&time::format_description::well_known::Rfc3339)
8096 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8097 let event = json!({
8098 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8099 "timestamp": now_iso,
8100 "from": self_did,
8101 "to": format!("did:wire:group:{}", group.id),
8102 "type": "group_msg",
8103 "kind": parse_kind("group_msg")?,
8104 "body": {
8105 "group_id": group.id,
8106 "group_name": group.name,
8107 "epoch": group.epoch,
8108 "text": message,
8109 },
8110 });
8111 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
8112 .map_err(|e| anyhow!("signing group_msg: {e:?}"))?;
8113 let client = crate::relay_client::RelayClient::new(&group.relay_url);
8115 client
8116 .post_event(&group.slot_id, &group.slot_token, &signed)
8117 .with_context(|| {
8118 format!(
8119 "posting to group room {} on {}",
8120 group.slot_id, group.relay_url
8121 )
8122 })?;
8123 if as_json {
8124 println!(
8125 "{}",
8126 serde_json::to_string(&json!({
8127 "group": group.id, "epoch": group.epoch, "status": "posted",
8128 "members": group.members.len()
8129 }))?
8130 );
8131 } else {
8132 println!(
8133 "group `{}`: posted to the room ({} member(s))",
8134 group.name,
8135 group.members.len()
8136 );
8137 }
8138 Ok(())
8139}
8140
8141fn cmd_group_tail(group_ref: &str, limit: usize, as_json: bool) -> Result<()> {
8142 ingest_group_invites()?;
8143 let group = crate::group::resolve_group(group_ref)?;
8144 if group.slot_id.is_empty() || group.relay_url.is_empty() {
8145 bail!(
8146 "group `{}` has no room slot (legacy/partial group)",
8147 group.name
8148 );
8149 }
8150 let mut trust = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
8151 let client = crate::relay_client::RelayClient::new(&group.relay_url);
8152 let fetch = if limit == 0 {
8154 1000
8155 } else {
8156 (limit * 4).min(1000)
8157 };
8158 let events = client
8159 .list_events(&group.slot_id, &group.slot_token, None, Some(fetch))
8160 .with_context(|| {
8161 format!(
8162 "pulling group room {} on {}",
8163 group.slot_id, group.relay_url
8164 )
8165 })?;
8166
8167 let mut trust_changed = false;
8173 for event in &events {
8174 if event.get("type").and_then(Value::as_str) != Some("group_join") {
8175 continue;
8176 }
8177 if let Some((h, did, kid, key)) = group_join_pin_material(event)
8178 && introduce_pin(&mut trust, &h, &did, &kid, &key, &group.id)
8179 {
8180 trust_changed = true;
8181 }
8182 }
8183 if trust_changed {
8184 let _ = config::write_trust(&trust);
8185 }
8186
8187 enum Line {
8190 Msg {
8191 from: String,
8192 text: String,
8193 verified: bool,
8194 },
8195 Join {
8196 who: String,
8197 },
8198 }
8199 let mut timeline: Vec<(String, Line)> = Vec::new();
8200 for event in &events {
8201 let ty = event.get("type").and_then(Value::as_str).unwrap_or("");
8202 let body = match event.get("body") {
8203 Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok(),
8204 Some(v) => Some(v.clone()),
8205 None => None,
8206 };
8207 let Some(body) = body else { continue };
8208 if body.get("group_id").and_then(Value::as_str) != Some(group.id.as_str()) {
8209 continue;
8210 }
8211 let ts = event
8212 .get("timestamp")
8213 .and_then(Value::as_str)
8214 .unwrap_or("")
8215 .to_string();
8216 let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
8217 let from_handle = crate::agent_card::display_handle_from_did(from_did).to_string();
8218 match ty {
8219 "group_msg" => {
8220 let text = body
8221 .get("text")
8222 .and_then(Value::as_str)
8223 .unwrap_or("")
8224 .to_string();
8225 let verified = verify_message_v31(event, &trust).is_ok();
8226 timeline.push((
8227 ts,
8228 Line::Msg {
8229 from: from_handle,
8230 text,
8231 verified,
8232 },
8233 ));
8234 }
8235 "group_join" => timeline.push((ts, Line::Join { who: from_handle })),
8236 _ => {}
8237 }
8238 }
8239 timeline.sort_by(|a, b| a.0.cmp(&b.0));
8240 let start = if limit > 0 {
8241 timeline.len().saturating_sub(limit)
8242 } else {
8243 0
8244 };
8245 let recent = &timeline[start..];
8246 if as_json {
8247 let arr: Vec<Value> = recent
8248 .iter()
8249 .map(|(ts, l)| match l {
8250 Line::Msg {
8251 from,
8252 text,
8253 verified,
8254 } => {
8255 json!({"ts": ts, "type": "msg", "from": from, "text": text, "verified": verified})
8256 }
8257 Line::Join { who } => json!({"ts": ts, "type": "join", "from": who}),
8258 })
8259 .collect();
8260 println!(
8261 "{}",
8262 serde_json::to_string(
8263 &json!({"group": group.id, "name": group.name, "messages": arr})
8264 )?
8265 );
8266 } else if recent.is_empty() {
8267 println!("group `{}`: no messages yet", group.name);
8268 } else {
8269 for (ts, l) in recent {
8270 let short_ts: String = ts.chars().take(19).collect();
8271 match l {
8272 Line::Msg {
8273 from,
8274 text,
8275 verified,
8276 } => {
8277 let mark = if *verified { "✓" } else { "✗" };
8278 println!("[{short_ts}] {} {mark}: {text}", persona_label(from));
8279 }
8280 Line::Join { who } => println!("[{short_ts}] {} joined", persona_label(who)),
8281 }
8282 }
8283 }
8284 Ok(())
8285}
8286
8287fn group_join_pin_material(event: &Value) -> Option<(String, String, String, String)> {
8293 let body = match event.get("body") {
8294 Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok()?,
8295 Some(v) => v.clone(),
8296 None => return None,
8297 };
8298 let card = body.get("joiner_card")?;
8299 let mut tmp = json!({"agents": {}});
8301 crate::trust::add_agent_card_pin(&mut tmp, card, Some("UNTRUSTED"));
8302 if verify_message_v31(event, &tmp).is_err() {
8303 return None;
8304 }
8305 let did = card.get("did").and_then(Value::as_str)?.to_string();
8306 let handle = card
8307 .get("handle")
8308 .and_then(Value::as_str)
8309 .map(str::to_string)
8310 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
8311 let (kid_full, krec) = card
8312 .get("verify_keys")
8313 .and_then(Value::as_object)
8314 .and_then(|m| m.iter().next())?;
8315 let key_id = kid_full
8316 .strip_prefix("ed25519:")
8317 .unwrap_or(kid_full)
8318 .to_string();
8319 let key = krec.get("key").and_then(Value::as_str)?.to_string();
8320 Some((handle, did, key_id, key))
8321}
8322
8323fn cmd_group_invite(group_ref: &str, as_json: bool) -> Result<()> {
8326 let group = crate::group::resolve_group(group_ref)?;
8327 if group.slot_id.is_empty() || group.relay_url.is_empty() {
8328 bail!(
8329 "group `{}` has no room slot — nothing to invite into",
8330 group.name
8331 );
8332 }
8333 if group.creator_sig.is_empty() {
8334 bail!(
8335 "group `{}` roster is unsigned — add a member or recreate before inviting",
8336 group.name
8337 );
8338 }
8339 let payload = serde_json::to_vec(&group)?;
8340 let code = format!("wire-group:{}", crate::signing::b64encode(&payload));
8341 if as_json {
8342 println!(
8343 "{}",
8344 serde_json::to_string(&json!({"group": group.id, "name": group.name, "code": code}))?
8345 );
8346 } else {
8347 println!(
8348 "join code for `{}` — share ONLY with people you want in the room (it IS the room key):\n",
8349 group.name
8350 );
8351 println!("{code}\n");
8352 println!("they run: wire group join <code>");
8353 }
8354 Ok(())
8355}
8356
8357fn cmd_group_join(code: &str, as_json: bool) -> Result<()> {
8361 if !config::is_initialized()? {
8362 bail!("not initialized — run `wire up` first");
8363 }
8364 let raw = code.trim();
8365 let b64 = raw.strip_prefix("wire-group:").unwrap_or(raw);
8366 let payload =
8367 crate::signing::b64decode(b64).map_err(|_| anyhow!("invalid join code (not base64)"))?;
8368 let group: crate::group::Group = serde_json::from_slice(&payload)
8369 .map_err(|_| anyhow!("invalid join code (not a group payload)"))?;
8370 if group.slot_id.is_empty() || group.relay_url.is_empty() {
8371 bail!("join code carries no room coords");
8372 }
8373 let creator_key = group
8376 .members
8377 .iter()
8378 .find(|m| m.did == group.creator_did)
8379 .map(|m| m.key.clone())
8380 .filter(|k| !k.is_empty())
8381 .and_then(|k| crate::signing::b64decode(&k).ok())
8382 .ok_or_else(|| anyhow!("join code is missing the creator's key"))?;
8383 if !group.verify(&creator_key) {
8384 bail!("join code failed its signature check (tampered or corrupt)");
8385 }
8386 let (self_did, self_handle, _, _) = group_self()?;
8387 if group.creator_did == self_did {
8388 bail!("you created group `{}` — you're already in it", group.name);
8389 }
8390
8391 crate::group::save_group(&group)?;
8393 let mut trust = config::read_trust()?;
8394 for m in &group.members {
8395 if m.did == self_did || m.key.is_empty() {
8396 continue;
8397 }
8398 introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
8399 }
8400 config::write_trust(&trust)?;
8401
8402 let card = config::read_agent_card()?;
8404 let sk_seed = config::read_private_key()?;
8405 let pk_b64 = card
8406 .get("verify_keys")
8407 .and_then(Value::as_object)
8408 .and_then(|m| m.values().next())
8409 .and_then(|v| v.get("key"))
8410 .and_then(Value::as_str)
8411 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8412 let pk_bytes = crate::signing::b64decode(pk_b64)?;
8413 let now_iso = time::OffsetDateTime::now_utc()
8414 .format(&time::format_description::well_known::Rfc3339)
8415 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8416 let event = json!({
8417 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8418 "timestamp": now_iso,
8419 "from": self_did,
8420 "to": format!("did:wire:group:{}", group.id),
8421 "type": "group_join",
8422 "kind": parse_kind("group_join")?,
8423 "body": {
8424 "group_id": group.id,
8425 "group_name": group.name,
8426 "epoch": group.epoch,
8427 "joiner_card": card,
8428 "text": "joined",
8429 },
8430 });
8431 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
8432 .map_err(|e| anyhow!("signing group_join: {e:?}"))?;
8433 let client = crate::relay_client::RelayClient::new(&group.relay_url);
8434 let announced = client
8435 .post_event(&group.slot_id, &group.slot_token, &signed)
8436 .is_ok();
8437
8438 if as_json {
8439 println!(
8440 "{}",
8441 serde_json::to_string(&json!({
8442 "group": group.id, "name": group.name, "joined": true,
8443 "members": group.members.len(), "announced": announced
8444 }))?
8445 );
8446 } else {
8447 println!(
8448 "joined group `{}` ({} member(s)) at Introduced tier.",
8449 group.name,
8450 group.members.len()
8451 );
8452 if announced {
8453 println!(" announced to the room — members will verify your messages.");
8454 } else {
8455 println!(
8456 " ⚠ couldn't reach the room relay to announce; retry a `wire group send` so members can verify you."
8457 );
8458 }
8459 println!(
8460 " read: `wire group tail {}` talk: `wire group send {} \"hi\"`",
8461 group.id, group.id
8462 );
8463 }
8464 Ok(())
8465}
8466
8467fn cmd_group_list(as_json: bool) -> Result<()> {
8468 let groups = crate::group::list_groups()?;
8469 if as_json {
8470 let arr: Vec<Value> = groups
8471 .iter()
8472 .map(|g| {
8473 json!({
8474 "id": g.id,
8475 "name": g.name,
8476 "epoch": g.epoch,
8477 "members": g.members.iter().map(|m| json!({"handle": m.handle, "tier": m.tier.as_str()})).collect::<Vec<_>>(),
8478 })
8479 })
8480 .collect();
8481 println!("{}", serde_json::to_string(&json!({"groups": arr}))?);
8482 } else if groups.is_empty() {
8483 println!("no groups yet — create one with `wire group create <name>`");
8484 } else {
8485 for g in &groups {
8486 println!(
8487 "{} ({}) — {} member(s), epoch {}",
8488 g.name,
8489 g.id,
8490 g.members.len(),
8491 g.epoch
8492 );
8493 for m in &g.members {
8494 println!(" {} [{}]", m.handle, m.tier.as_str());
8495 }
8496 }
8497 }
8498 Ok(())
8499}
8500
8501fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
8504 match cmd {
8505 MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
8506 MeshCommand::Broadcast {
8507 kind,
8508 scope,
8509 exclude,
8510 noreply,
8511 body,
8512 json,
8513 } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
8514 MeshCommand::Role { action } => cmd_mesh_role(action),
8515 MeshCommand::Route {
8516 role,
8517 strategy,
8518 exclude,
8519 kind,
8520 body,
8521 json,
8522 } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
8523 }
8524}
8525
8526fn cmd_mesh_route(
8531 role: &str,
8532 strategy: &str,
8533 exclude: &[String],
8534 kind: &str,
8535 body_arg: &str,
8536 as_json: bool,
8537) -> Result<()> {
8538 use std::time::Instant;
8539
8540 if !config::is_initialized()? {
8541 bail!("not initialized — run `wire init <handle>` first");
8542 }
8543 let strategy = strategy.to_ascii_lowercase();
8544 if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
8545 bail!("unknown strategy `{strategy}` — use round-robin | first | random");
8546 }
8547
8548 let state = config::read_relay_state()?;
8551 let pinned: std::collections::BTreeSet<String> = state["peers"]
8552 .as_object()
8553 .map(|m| m.keys().cloned().collect())
8554 .unwrap_or_default();
8555
8556 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
8557
8558 let sessions = crate::session::list_sessions()?;
8563 let mut candidates: Vec<(String, Option<String>)> = Vec::new(); for s in &sessions {
8565 let handle = match s.handle.as_ref() {
8566 Some(h) => h.clone(),
8567 None => continue,
8568 };
8569 if exclude_set.contains(handle.as_str()) {
8570 continue;
8571 }
8572 if !pinned.contains(&handle) {
8573 continue;
8574 }
8575 let card_path = s
8576 .home_dir
8577 .join("config")
8578 .join("wire")
8579 .join("agent-card.json");
8580 let card_role = std::fs::read(&card_path)
8581 .ok()
8582 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
8583 .and_then(|c| {
8584 c.get("profile")
8585 .and_then(|p| p.get("role"))
8586 .and_then(Value::as_str)
8587 .map(str::to_string)
8588 });
8589 if card_role.as_deref() == Some(role) {
8590 candidates.push((handle, s.did.clone()));
8591 }
8592 }
8593
8594 candidates.sort_by(|a, b| a.0.cmp(&b.0));
8595 candidates.dedup_by(|a, b| a.0 == b.0);
8596
8597 if candidates.is_empty() {
8598 bail!(
8599 "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
8600 );
8601 }
8602
8603 let chosen = match strategy.as_str() {
8604 "first" => candidates[0].clone(),
8605 "random" => {
8606 use rand::Rng;
8607 let idx = rand::thread_rng().gen_range(0..candidates.len());
8608 candidates[idx].clone()
8609 }
8610 "round-robin" => {
8611 let cursor_path = mesh_route_cursor_path()?;
8616 let mut cursors: std::collections::BTreeMap<String, String> =
8617 read_mesh_route_cursors(&cursor_path);
8618 let last = cursors.get(role).cloned();
8619 let pick = match last {
8620 None => candidates[0].clone(),
8621 Some(last_h) => candidates
8622 .iter()
8623 .find(|(h, _)| h.as_str() > last_h.as_str())
8624 .cloned()
8625 .unwrap_or_else(|| candidates[0].clone()),
8626 };
8627 cursors.insert(role.to_string(), pick.0.clone());
8628 write_mesh_route_cursors(&cursor_path, &cursors)?;
8629 pick
8630 }
8631 _ => unreachable!(),
8632 };
8633
8634 let (chosen_handle, _chosen_did) = chosen;
8635
8636 let body_value: Value = if body_arg == "-" {
8638 use std::io::Read;
8639 let mut raw = String::new();
8640 std::io::stdin()
8641 .read_to_string(&mut raw)
8642 .with_context(|| "reading body from stdin")?;
8643 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
8644 } else if let Some(path) = body_arg.strip_prefix('@') {
8645 let raw =
8646 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
8647 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
8648 } else {
8649 Value::String(body_arg.to_string())
8650 };
8651
8652 let sk_seed = config::read_private_key()?;
8653 let card = config::read_agent_card()?;
8654 let did = card
8655 .get("did")
8656 .and_then(Value::as_str)
8657 .ok_or_else(|| anyhow!("agent-card missing did"))?
8658 .to_string();
8659 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
8660 let pk_b64 = card
8661 .get("verify_keys")
8662 .and_then(Value::as_object)
8663 .and_then(|m| m.values().next())
8664 .and_then(|v| v.get("key"))
8665 .and_then(Value::as_str)
8666 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8667 let pk_bytes = crate::signing::b64decode(pk_b64)?;
8668
8669 let kind_id = parse_kind(kind)?;
8670 let now_iso = time::OffsetDateTime::now_utc()
8671 .format(&time::format_description::well_known::Rfc3339)
8672 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8673
8674 let event = json!({
8675 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8676 "timestamp": now_iso,
8677 "from": did,
8678 "to": format!("did:wire:{chosen_handle}"),
8679 "type": kind,
8680 "kind": kind_id,
8681 "body": json!({
8682 "content": body_value,
8683 "routed_via": {
8684 "role": role,
8685 "strategy": strategy,
8686 },
8687 }),
8688 });
8689 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
8690 .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
8691 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
8692
8693 let line = serde_json::to_vec(&signed)?;
8694 config::append_outbox_record(&chosen_handle, &line)?;
8695
8696 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
8697 if endpoints.is_empty() {
8698 bail!(
8699 "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
8700 );
8701 }
8702 let start = Instant::now();
8703 let mut delivered = false;
8704 let mut last_err: Option<String> = None;
8705 let mut via_scope: Option<String> = None;
8706 for ep in &endpoints {
8707 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
8712 Ok(_) => {
8713 delivered = true;
8714 via_scope = Some(
8715 match ep.scope {
8716 crate::endpoints::EndpointScope::Local => "local",
8717 crate::endpoints::EndpointScope::Lan => "lan",
8718 crate::endpoints::EndpointScope::Uds => "uds",
8719 crate::endpoints::EndpointScope::Federation => "federation",
8720 }
8721 .to_string(),
8722 );
8723 break;
8724 }
8725 Err(e) => last_err = Some(format!("{e:#}")),
8726 }
8727 }
8728 let rtt_ms = start.elapsed().as_millis() as u64;
8729
8730 let summary = json!({
8731 "role": role,
8732 "strategy": strategy,
8733 "routed_to": chosen_handle,
8734 "event_id": event_id,
8735 "delivered": delivered,
8736 "delivered_via": via_scope,
8737 "rtt_ms": rtt_ms,
8738 "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
8739 "error": last_err,
8740 });
8741
8742 if as_json {
8743 println!("{}", serde_json::to_string(&summary)?);
8744 } else if delivered {
8745 let via = via_scope.as_deref().unwrap_or("?");
8746 println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
8747 } else {
8748 let err = last_err.as_deref().unwrap_or("no endpoints reachable");
8749 bail!("delivery to `{chosen_handle}` failed: {err}");
8750 }
8751 Ok(())
8752}
8753
8754fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
8755 Ok(config::state_dir()?.join("mesh-route-cursor.json"))
8756}
8757
8758fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
8759 std::fs::read(path)
8760 .ok()
8761 .and_then(|b| serde_json::from_slice(&b).ok())
8762 .unwrap_or_default()
8763}
8764
8765fn write_mesh_route_cursors(
8766 path: &std::path::Path,
8767 cursors: &std::collections::BTreeMap<String, String>,
8768) -> Result<()> {
8769 if let Some(parent) = path.parent() {
8770 std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
8771 }
8772 let body = serde_json::to_vec_pretty(cursors)?;
8773 std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
8774 Ok(())
8775}
8776
8777fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
8782 match action {
8783 MeshRoleAction::Set { role, json } => {
8784 validate_role_tag(&role)?;
8785 let new_profile =
8786 crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
8787 if json {
8788 println!(
8789 "{}",
8790 serde_json::to_string(&json!({
8791 "role": role,
8792 "profile": new_profile,
8793 }))?
8794 );
8795 } else {
8796 println!("self role = {role} (signed into agent-card)");
8797 }
8798 }
8799 MeshRoleAction::Get { peer, json } => {
8800 let (who, role) = match peer.as_deref() {
8801 None => {
8802 let card = config::read_agent_card()?;
8803 let role = card
8804 .get("profile")
8805 .and_then(|p| p.get("role"))
8806 .and_then(Value::as_str)
8807 .map(str::to_string);
8808 let who = card
8809 .get("did")
8810 .and_then(Value::as_str)
8811 .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
8812 .unwrap_or_else(|| "self".to_string());
8813 (who, role)
8814 }
8815 Some(handle) => {
8816 let bare = crate::agent_card::bare_handle(handle).to_string();
8817 let trust = config::read_trust()?;
8818 let role = trust
8819 .get("agents")
8820 .and_then(|a| a.get(&bare))
8821 .and_then(|a| a.get("card"))
8822 .and_then(|c| c.get("profile"))
8823 .and_then(|p| p.get("role"))
8824 .and_then(Value::as_str)
8825 .map(str::to_string);
8826 (bare, role)
8827 }
8828 };
8829 if json {
8830 println!(
8831 "{}",
8832 serde_json::to_string(&json!({
8833 "handle": who,
8834 "role": role,
8835 }))?
8836 );
8837 } else {
8838 match role {
8839 Some(r) => println!("{who}: {r}"),
8840 None => println!("{who}: (unset)"),
8841 }
8842 }
8843 }
8844 MeshRoleAction::List { json } => {
8845 let mut self_did: Option<String> = None;
8846 if let Ok(card) = config::read_agent_card() {
8847 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
8848 }
8849 let sessions = crate::session::list_sessions()?;
8850 let mut rows: Vec<Value> = Vec::new();
8851 for s in &sessions {
8852 let card_path = s
8853 .home_dir
8854 .join("config")
8855 .join("wire")
8856 .join("agent-card.json");
8857 let role = std::fs::read(&card_path)
8858 .ok()
8859 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
8860 .and_then(|c| {
8861 c.get("profile")
8862 .and_then(|p| p.get("role"))
8863 .and_then(Value::as_str)
8864 .map(str::to_string)
8865 });
8866 let is_self = match (&self_did, &s.did) {
8867 (Some(a), Some(b)) => a == b,
8868 _ => false,
8869 };
8870 rows.push(json!({
8871 "name": s.name,
8872 "handle": s.handle,
8873 "role": role,
8874 "self": is_self,
8875 }));
8876 }
8877 rows.sort_by(|a, b| {
8878 a["name"]
8879 .as_str()
8880 .unwrap_or("")
8881 .cmp(b["name"].as_str().unwrap_or(""))
8882 });
8883 if json {
8884 println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
8885 } else if rows.is_empty() {
8886 println!("no sister sessions on this machine.");
8887 } else {
8888 println!("SISTER ROLES (this machine):");
8889 for r in &rows {
8890 let name = r["name"].as_str().unwrap_or("?");
8891 let role = r["role"].as_str().unwrap_or("(unset)");
8892 let marker = if r["self"].as_bool().unwrap_or(false) {
8893 " ← you"
8894 } else {
8895 ""
8896 };
8897 println!(" {name:<24} {role}{marker}");
8898 }
8899 }
8900 }
8901 MeshRoleAction::Clear { json } => {
8902 let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
8903 if json {
8904 println!(
8905 "{}",
8906 serde_json::to_string(&json!({
8907 "cleared": true,
8908 "profile": new_profile,
8909 }))?
8910 );
8911 } else {
8912 println!("self role cleared");
8913 }
8914 }
8915 }
8916 Ok(())
8917}
8918
8919fn validate_role_tag(role: &str) -> Result<()> {
8924 if role.is_empty() {
8925 bail!("role must not be empty (use `wire mesh role --clear` to unset)");
8926 }
8927 if role.len() > 32 {
8928 bail!("role too long ({} chars; max 32)", role.len());
8929 }
8930 for c in role.chars() {
8931 if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
8932 bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
8933 }
8934 }
8935 Ok(())
8936}
8937
8938fn cmd_mesh_broadcast(
8958 kind: &str,
8959 scope_str: &str,
8960 exclude: &[String],
8961 _noreply: bool,
8962 body_arg: &str,
8963 as_json: bool,
8964) -> Result<()> {
8965 use std::time::Instant;
8966
8967 if !config::is_initialized()? {
8968 bail!("not initialized — run `wire init <handle>` first");
8969 }
8970
8971 let scope = match scope_str {
8972 "local" => crate::endpoints::EndpointScope::Local,
8973 "federation" => crate::endpoints::EndpointScope::Federation,
8974 "both" => {
8975 crate::endpoints::EndpointScope::Local
8979 }
8980 other => bail!("unknown scope `{other}` — use local | federation | both"),
8981 };
8982 let any_scope = scope_str == "both";
8983
8984 let state = config::read_relay_state()?;
8985 let peers = state["peers"].as_object().cloned().unwrap_or_default();
8986 if peers.is_empty() {
8987 bail!(
8988 "no peers pinned — run `wire accept-invite <invite-url>` or `wire dial <peer>@<relay>` first"
8989 );
8990 }
8991
8992 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
8993
8994 struct Target {
8998 handle: String,
8999 endpoints: Vec<crate::endpoints::Endpoint>,
9000 }
9001 let mut targets: Vec<Target> = Vec::new();
9002 let mut skipped_wrong_scope: Vec<String> = Vec::new();
9003 let mut skipped_excluded: Vec<String> = Vec::new();
9004 for handle in peers.keys() {
9005 if exclude_set.contains(handle.as_str()) {
9006 skipped_excluded.push(handle.clone());
9007 continue;
9008 }
9009 let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
9010 let filtered: Vec<crate::endpoints::Endpoint> = ordered
9011 .into_iter()
9012 .filter(|ep| any_scope || ep.scope == scope)
9013 .collect();
9014 if filtered.is_empty() {
9015 skipped_wrong_scope.push(handle.clone());
9016 continue;
9017 }
9018 targets.push(Target {
9019 handle: handle.clone(),
9020 endpoints: filtered,
9021 });
9022 }
9023
9024 if targets.is_empty() {
9025 bail!(
9026 "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
9027 skipped_excluded.len(),
9028 skipped_wrong_scope.len()
9029 );
9030 }
9031
9032 let sk_seed = config::read_private_key()?;
9034 let card = config::read_agent_card()?;
9035 let did = card
9036 .get("did")
9037 .and_then(Value::as_str)
9038 .ok_or_else(|| anyhow!("agent-card missing did"))?
9039 .to_string();
9040 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
9041 let pk_b64 = card
9042 .get("verify_keys")
9043 .and_then(Value::as_object)
9044 .and_then(|m| m.values().next())
9045 .and_then(|v| v.get("key"))
9046 .and_then(Value::as_str)
9047 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
9048 let pk_bytes = crate::signing::b64decode(pk_b64)?;
9049
9050 let body_value: Value = if body_arg == "-" {
9051 use std::io::Read;
9052 let mut raw = String::new();
9053 std::io::stdin()
9054 .read_to_string(&mut raw)
9055 .with_context(|| "reading body from stdin")?;
9056 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
9057 } else if let Some(path) = body_arg.strip_prefix('@') {
9058 let raw =
9059 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
9060 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
9061 } else {
9062 Value::String(body_arg.to_string())
9063 };
9064
9065 let kind_id = parse_kind(kind)?;
9066 let now_iso = time::OffsetDateTime::now_utc()
9067 .format(&time::format_description::well_known::Rfc3339)
9068 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
9069
9070 let broadcast_id = generate_broadcast_id();
9071 let target_count = targets.len();
9072
9073 let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
9077 Vec::with_capacity(targets.len());
9078 for t in &targets {
9079 let body = json!({
9080 "content": body_value,
9081 "broadcast_id": broadcast_id,
9082 "broadcast_target_count": target_count,
9083 });
9084 let event = json!({
9085 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
9086 "timestamp": now_iso,
9087 "from": did,
9088 "to": format!("did:wire:{}", t.handle),
9089 "type": kind,
9090 "kind": kind_id,
9091 "body": body,
9092 });
9093 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
9094 .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
9095 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
9096 signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
9097 }
9098
9099 for (peer, _, signed, _) in &signed_per_peer {
9103 let line = serde_json::to_vec(signed)?;
9104 config::append_outbox_record(peer, &line)?;
9105 }
9106
9107 use std::sync::mpsc;
9111 let (tx, rx) = mpsc::channel::<Value>();
9112 std::thread::scope(|s| {
9113 for (peer, endpoints, signed, event_id) in &signed_per_peer {
9114 let tx = tx.clone();
9115 let peer = peer.clone();
9116 let event_id = event_id.clone();
9117 let endpoints = endpoints.clone();
9118 let signed = signed.clone();
9119 s.spawn(move || {
9120 let start = Instant::now();
9121 let mut delivered = false;
9122 let mut last_err: Option<String> = None;
9123 let mut delivered_via: Option<String> = None;
9124 for ep in &endpoints {
9125 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
9130 Ok(_) => {
9131 delivered = true;
9132 delivered_via = Some(
9133 match ep.scope {
9134 crate::endpoints::EndpointScope::Local => "local",
9135 crate::endpoints::EndpointScope::Lan => "lan",
9136 crate::endpoints::EndpointScope::Uds => "uds",
9137 crate::endpoints::EndpointScope::Federation => "federation",
9138 }
9139 .to_string(),
9140 );
9141 break;
9142 }
9143 Err(e) => last_err = Some(format!("{e:#}")),
9144 }
9145 }
9146 let rtt_ms = start.elapsed().as_millis() as u64;
9147 let _ = tx.send(json!({
9148 "peer": peer,
9149 "event_id": event_id,
9150 "delivered": delivered,
9151 "delivered_via": delivered_via,
9152 "rtt_ms": rtt_ms,
9153 "error": last_err,
9154 }));
9155 });
9156 }
9157 });
9158 drop(tx);
9159
9160 let mut results: Vec<Value> = rx.iter().collect();
9161 results.sort_by(|a, b| {
9162 a["peer"]
9163 .as_str()
9164 .unwrap_or("")
9165 .cmp(b["peer"].as_str().unwrap_or(""))
9166 });
9167
9168 let delivered = results
9169 .iter()
9170 .filter(|r| r["delivered"].as_bool().unwrap_or(false))
9171 .count();
9172 let failed = results.len() - delivered;
9173
9174 let summary = json!({
9175 "broadcast_id": broadcast_id,
9176 "kind": kind,
9177 "scope": scope_str,
9178 "target_count": target_count,
9179 "delivered": delivered,
9180 "failed": failed,
9181 "skipped_excluded": skipped_excluded,
9182 "skipped_wrong_scope": skipped_wrong_scope,
9183 "results": results,
9184 });
9185
9186 if as_json {
9187 println!("{}", serde_json::to_string(&summary)?);
9188 return Ok(());
9189 }
9190
9191 println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
9192 for r in &results {
9193 let peer = r["peer"].as_str().unwrap_or("?");
9194 let delivered = r["delivered"].as_bool().unwrap_or(false);
9195 let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
9196 let via = r["delivered_via"].as_str().unwrap_or("");
9197 if delivered {
9198 println!(" {peer:<24} ✓ delivered ({rtt}ms, {via})");
9199 } else {
9200 let err = r["error"].as_str().unwrap_or("?");
9201 println!(" {peer:<24} ✗ failed — {err}");
9202 }
9203 }
9204 if !skipped_excluded.is_empty() {
9205 println!(" excluded: {}", skipped_excluded.join(", "));
9206 }
9207 if !skipped_wrong_scope.is_empty() {
9208 println!(
9209 " skipped (wrong scope): {}",
9210 skipped_wrong_scope.join(", ")
9211 );
9212 }
9213 println!("broadcast_id: {broadcast_id}");
9214 Ok(())
9215}
9216
9217fn generate_broadcast_id() -> String {
9221 use rand::RngCore;
9222 let mut buf = [0u8; 16];
9223 rand::thread_rng().fill_bytes(&mut buf);
9224 let h = hex::encode(buf);
9225 format!(
9226 "{}-{}-{}-{}-{}",
9227 &h[0..8],
9228 &h[8..12],
9229 &h[12..16],
9230 &h[16..20],
9231 &h[20..32],
9232 )
9233}
9234
9235fn cmd_session(cmd: SessionCommand) -> Result<()> {
9236 match cmd {
9237 SessionCommand::New {
9238 name,
9239 relay,
9240 with_local,
9241 local_relay,
9242 with_lan,
9243 lan_relay,
9244 with_uds,
9245 uds_socket,
9246 no_daemon,
9247 local_only,
9248 json,
9249 } => cmd_session_new(
9250 name.as_deref(),
9251 &relay,
9252 with_local,
9253 &local_relay,
9254 with_lan,
9255 lan_relay.as_deref(),
9256 with_uds,
9257 uds_socket.as_deref(),
9258 no_daemon,
9259 local_only,
9260 json,
9261 ),
9262 SessionCommand::List { json } => cmd_session_list(json),
9263 SessionCommand::ListLocal { json } => cmd_session_list_local(json),
9264 SessionCommand::PairAllLocal {
9265 settle_secs,
9266 federation_relay,
9267 json,
9268 } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
9269 SessionCommand::MeshStatus { stale_secs, json } => {
9270 cmd_session_mesh_status(stale_secs, json)
9271 }
9272 SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
9273 SessionCommand::Current { json } => cmd_session_current(json),
9274 SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
9275 SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
9276 }
9277}
9278
9279fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
9280 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9281 let cwd_str = crate::session::normalize_cwd_key(&cwd);
9282
9283 let resolved_name = match name_arg {
9284 Some(n) => crate::session::sanitize_name(n),
9285 None => crate::session::sanitize_name(
9286 cwd.file_name()
9287 .and_then(|s| s.to_str())
9288 .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
9289 ),
9290 };
9291
9292 let session_home = crate::session::session_dir(&resolved_name)?;
9293 if !session_home.exists() {
9294 bail!(
9295 "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
9296 session_home.display()
9297 );
9298 }
9299
9300 let prior = crate::session::read_registry()
9301 .ok()
9302 .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
9303 if prior.as_deref() == Some(resolved_name.as_str()) {
9304 if json {
9305 println!(
9306 "{}",
9307 serde_json::to_string(&json!({
9308 "cwd": cwd_str,
9309 "session": resolved_name,
9310 "changed": false,
9311 }))?
9312 );
9313 } else {
9314 println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
9315 }
9316 return Ok(());
9317 }
9318 if let Some(prior_name) = &prior {
9319 eprintln!(
9320 "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
9321 );
9322 }
9323
9324 crate::session::update_registry(|reg| {
9325 reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
9326 Ok(())
9327 })?;
9328
9329 if json {
9330 println!(
9331 "{}",
9332 serde_json::to_string(&json!({
9333 "cwd": cwd_str,
9334 "session": resolved_name,
9335 "changed": true,
9336 "previous": prior,
9337 }))?
9338 );
9339 } else {
9340 println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
9341 println!("(next `wire` invocation from this cwd will auto-detect into this session)");
9342 }
9343 Ok(())
9344}
9345
9346fn resolve_session_name(name: Option<&str>) -> Result<String> {
9347 if let Some(n) = name {
9348 return Ok(crate::session::sanitize_name(n));
9349 }
9350 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9351 let registry = crate::session::read_registry().unwrap_or_default();
9352 Ok(crate::session::derive_name_from_cwd(&cwd, ®istry))
9353}
9354
9355#[allow(clippy::too_many_arguments)] fn cmd_session_new(
9359 name_arg: Option<&str>,
9360 relay: &str,
9361 with_local: bool,
9362 local_relay: &str,
9363 with_lan: bool,
9364 lan_relay: Option<&str>,
9365 with_uds: bool,
9366 uds_socket: Option<&std::path::Path>,
9367 no_daemon: bool,
9368 local_only: bool,
9369 as_json: bool,
9370) -> Result<()> {
9371 let with_local = with_local || local_only;
9374 if with_lan && lan_relay.is_none() {
9376 bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
9377 }
9378 if with_uds && uds_socket.is_none() {
9380 bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
9381 }
9382 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9383 let mut registry = crate::session::read_registry().unwrap_or_default();
9384 let name = match name_arg {
9385 Some(n) => crate::session::sanitize_name(n),
9386 None => crate::session::derive_name_from_cwd(&cwd, ®istry),
9387 };
9388 let session_home = crate::session::session_dir(&name)?;
9389
9390 let already_exists = session_home.exists()
9391 && session_home
9392 .join("config")
9393 .join("wire")
9394 .join("agent-card.json")
9395 .exists();
9396 if already_exists {
9397 registry
9401 .by_cwd
9402 .insert(cwd.to_string_lossy().into_owned(), name.clone());
9403 crate::session::write_registry(®istry)?;
9404 let info = render_session_info(&name, &session_home, &cwd)?;
9405 emit_session_new_result(&info, "already_exists", as_json)?;
9406 if !no_daemon {
9407 ensure_session_daemon(&session_home)?;
9408 }
9409 return Ok(());
9410 }
9411
9412 std::fs::create_dir_all(&session_home)
9413 .with_context(|| format!("creating session dir {session_home:?}"))?;
9414
9415 let init_args: Vec<&str> = if local_only {
9424 vec!["init", &name, "--offline"]
9425 } else {
9426 vec!["init", &name, "--relay", relay]
9427 };
9428 let init_status = run_wire_with_home(&session_home, &init_args)?;
9429 if !init_status.success() {
9430 let how = if local_only {
9431 format!("`wire init {name}` (local-only)")
9432 } else {
9433 format!("`wire init {name} --relay {relay}`")
9434 };
9435 bail!("{how} failed inside session dir {session_home:?}");
9436 }
9437
9438 let effective_handle = if local_only {
9443 name.clone()
9444 } else {
9445 let mut claim_attempt = 0u32;
9446 let mut effective = name.clone();
9447 loop {
9448 claim_attempt += 1;
9449 let status =
9450 run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
9451 if status.success() {
9452 break;
9453 }
9454 if claim_attempt >= 5 {
9455 bail!(
9456 "5 failed attempts to claim a handle on {relay} for session {name}. \
9457 Try `wire session destroy {name} --force` and re-run with a different name, \
9458 or use `--local-only` if you don't need a federation address."
9459 );
9460 }
9461 let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
9462 let suffix = crate::session::derive_name_from_cwd(&attempt_path, ®istry);
9463 let token = suffix
9464 .rsplit('-')
9465 .next()
9466 .filter(|t| t.len() == 4)
9467 .map(str::to_string)
9468 .unwrap_or_else(|| format!("{claim_attempt}"));
9469 effective = format!("{name}-{token}");
9470 }
9471 effective
9472 };
9473
9474 registry
9477 .by_cwd
9478 .insert(cwd.to_string_lossy().into_owned(), name.clone());
9479 crate::session::write_registry(®istry)?;
9480
9481 if with_local {
9492 try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
9493 if local_only {
9494 let relay_state_path = session_home.join("config").join("wire").join("relay.json");
9499 let state: Value = std::fs::read(&relay_state_path)
9500 .ok()
9501 .and_then(|b| serde_json::from_slice(&b).ok())
9502 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
9503 let endpoints = crate::endpoints::self_endpoints(&state);
9504 let has_local = endpoints
9505 .iter()
9506 .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
9507 if !has_local {
9508 bail!(
9509 "--local-only requested but local-relay probe at {local_relay} failed — \
9510 ensure the local relay is running (`wire service install --local-relay`), \
9511 then re-run `wire session new {name} --local-only`."
9512 );
9513 }
9514 }
9515 }
9516
9517 if with_lan && let Some(lan_url) = lan_relay {
9521 try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
9522 }
9523 if with_uds && let Some(socket_path) = uds_socket {
9525 try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
9526 }
9527
9528 if !no_daemon {
9529 ensure_session_daemon(&session_home)?;
9530 }
9531
9532 let info = render_session_info(&name, &session_home, &cwd)?;
9533 emit_session_new_result(&info, "created", as_json)
9534}
9535
9536#[cfg(unix)]
9546fn try_allocate_uds_slot(
9547 session_home: &std::path::Path,
9548 handle: &str,
9549 uds_socket: &std::path::Path,
9550) {
9551 let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
9554 Ok((200, _)) => true,
9555 Ok((status, body)) => {
9556 eprintln!(
9557 "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
9558 String::from_utf8_lossy(&body)
9559 );
9560 return;
9561 }
9562 Err(e) => {
9563 eprintln!(
9564 "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
9565 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
9566 );
9567 return;
9568 }
9569 };
9570 if !healthz {
9571 return;
9572 }
9573
9574 let alloc_body = serde_json::json!({"handle": handle}).to_string();
9576 let (status, body) = match crate::relay_client::uds_request(
9577 uds_socket,
9578 "POST",
9579 "/v1/slot/allocate",
9580 &[("Content-Type", "application/json")],
9581 alloc_body.as_bytes(),
9582 ) {
9583 Ok(r) => r,
9584 Err(e) => {
9585 eprintln!(
9586 "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
9587 );
9588 return;
9589 }
9590 };
9591 if status >= 300 {
9592 eprintln!(
9593 "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
9594 String::from_utf8_lossy(&body)
9595 );
9596 return;
9597 }
9598 let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
9599 Ok(a) => a,
9600 Err(e) => {
9601 eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
9602 return;
9603 }
9604 };
9605
9606 let state_path = session_home.join("config").join("wire").join("relay.json");
9607 let mut state: serde_json::Value = std::fs::read(&state_path)
9608 .ok()
9609 .and_then(|b| serde_json::from_slice(&b).ok())
9610 .unwrap_or_else(|| serde_json::json!({}));
9611
9612 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
9613 .get("self")
9614 .and_then(|s| s.get("endpoints"))
9615 .and_then(|e| e.as_array())
9616 .map(|arr| {
9617 arr.iter()
9618 .filter_map(|v| {
9619 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
9620 })
9621 .collect()
9622 })
9623 .unwrap_or_default();
9624 endpoints.push(crate::endpoints::Endpoint::uds(
9625 format!("unix://{}", uds_socket.display()),
9626 alloc.slot_id.clone(),
9627 alloc.slot_token.clone(),
9628 ));
9629
9630 let self_obj = state
9631 .as_object_mut()
9632 .expect("relay_state root is an object")
9633 .entry("self")
9634 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9635 if !self_obj.is_object() {
9636 *self_obj = serde_json::Value::Object(serde_json::Map::new());
9637 }
9638 if let Some(obj) = self_obj.as_object_mut() {
9639 obj.insert(
9640 "endpoints".into(),
9641 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9642 );
9643 }
9644 if let Err(e) = std::fs::write(
9645 &state_path,
9646 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
9647 ) {
9648 eprintln!("wire session new: failed to write {state_path:?}: {e}");
9649 return;
9650 }
9651 eprintln!(
9652 "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
9653 uds_socket.display(),
9654 alloc.slot_id
9655 );
9656}
9657
9658#[cfg(not(unix))]
9659fn try_allocate_uds_slot(
9660 _session_home: &std::path::Path,
9661 _handle: &str,
9662 _uds_socket: &std::path::Path,
9663) {
9664 eprintln!(
9665 "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
9666 );
9667}
9668
9669fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
9679 let probe = match crate::relay_client::build_blocking_client(Some(
9680 std::time::Duration::from_millis(500),
9681 )) {
9682 Ok(c) => c,
9683 Err(e) => {
9684 eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
9685 return;
9686 }
9687 };
9688 let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
9689 match probe.get(&healthz_url).send() {
9690 Ok(resp) if resp.status().is_success() => {}
9691 Ok(resp) => {
9692 eprintln!(
9693 "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
9694 resp.status()
9695 );
9696 return;
9697 }
9698 Err(e) => {
9699 eprintln!(
9700 "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
9701 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
9702 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
9703 );
9704 return;
9705 }
9706 };
9707
9708 let lan_client = crate::relay_client::RelayClient::new(lan_relay);
9709 let alloc = match lan_client.allocate_slot(Some(handle)) {
9710 Ok(a) => a,
9711 Err(e) => {
9712 eprintln!(
9713 "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
9714 );
9715 return;
9716 }
9717 };
9718
9719 let state_path = session_home.join("config").join("wire").join("relay.json");
9720 let mut state: serde_json::Value = std::fs::read(&state_path)
9721 .ok()
9722 .and_then(|b| serde_json::from_slice(&b).ok())
9723 .unwrap_or_else(|| serde_json::json!({}));
9724
9725 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
9728 .get("self")
9729 .and_then(|s| s.get("endpoints"))
9730 .and_then(|e| e.as_array())
9731 .map(|arr| {
9732 arr.iter()
9733 .filter_map(|v| {
9734 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
9735 })
9736 .collect()
9737 })
9738 .unwrap_or_default();
9739 endpoints.push(crate::endpoints::Endpoint::lan(
9740 lan_relay.trim_end_matches('/').to_string(),
9741 alloc.slot_id.clone(),
9742 alloc.slot_token.clone(),
9743 ));
9744
9745 let self_obj = state
9746 .as_object_mut()
9747 .expect("relay_state root is an object")
9748 .entry("self")
9749 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9750 if !self_obj.is_object() {
9751 *self_obj = serde_json::Value::Object(serde_json::Map::new());
9752 }
9753 if let Some(obj) = self_obj.as_object_mut() {
9754 obj.insert(
9755 "endpoints".into(),
9756 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9757 );
9758 }
9759 if let Err(e) = std::fs::write(
9760 &state_path,
9761 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
9762 ) {
9763 eprintln!("wire session new: failed to write {state_path:?}: {e}");
9764 return;
9765 }
9766 eprintln!(
9767 "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
9768 alloc.slot_id
9769 );
9770}
9771
9772fn try_allocate_local_slot(
9780 session_home: &std::path::Path,
9781 handle: &str,
9782 _federation_relay: &str,
9783 local_relay: &str,
9784) {
9785 let probe = match crate::relay_client::build_blocking_client(Some(
9788 std::time::Duration::from_millis(500),
9789 )) {
9790 Ok(c) => c,
9791 Err(e) => {
9792 eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
9793 return;
9794 }
9795 };
9796 let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
9797 match probe.get(&healthz_url).send() {
9798 Ok(resp) if resp.status().is_success() => {}
9799 Ok(resp) => {
9800 eprintln!(
9801 "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
9802 resp.status()
9803 );
9804 return;
9805 }
9806 Err(e) => {
9807 eprintln!(
9808 "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
9809 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
9810 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
9811 );
9812 return;
9813 }
9814 };
9815
9816 let local_client = crate::relay_client::RelayClient::new(local_relay);
9818 let alloc = match local_client.allocate_slot(Some(handle)) {
9819 Ok(a) => a,
9820 Err(e) => {
9821 eprintln!(
9822 "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
9823 );
9824 return;
9825 }
9826 };
9827
9828 let state_path = session_home.join("config").join("wire").join("relay.json");
9843 let mut state: serde_json::Value = std::fs::read(&state_path)
9844 .ok()
9845 .and_then(|b| serde_json::from_slice(&b).ok())
9846 .unwrap_or_else(|| serde_json::json!({}));
9847 let fed_endpoint = state.get("self").and_then(|s| {
9850 let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
9851 let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
9852 let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
9853 Some(crate::endpoints::Endpoint::federation(
9854 url.to_string(),
9855 slot_id.to_string(),
9856 slot_token.to_string(),
9857 ))
9858 });
9859
9860 let local_endpoint = crate::endpoints::Endpoint::local(
9861 local_relay.trim_end_matches('/').to_string(),
9862 alloc.slot_id.clone(),
9863 alloc.slot_token.clone(),
9864 );
9865
9866 let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
9867 if let Some(f) = fed_endpoint.clone() {
9868 endpoints.push(f);
9869 }
9870 endpoints.push(local_endpoint);
9871
9872 let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
9882 Some(f) => (f.relay_url, f.slot_id, f.slot_token),
9883 None => (
9884 local_relay.trim_end_matches('/').to_string(),
9885 alloc.slot_id.clone(),
9886 alloc.slot_token.clone(),
9887 ),
9888 };
9889 let self_obj = state
9890 .as_object_mut()
9891 .expect("relay_state root is an object")
9892 .entry("self")
9893 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9894 if !self_obj.is_object() {
9897 *self_obj = serde_json::Value::Object(serde_json::Map::new());
9898 }
9899 if let Some(obj) = self_obj.as_object_mut() {
9900 obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
9901 obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
9902 obj.insert(
9903 "slot_token".into(),
9904 serde_json::Value::String(legacy_slot_token),
9905 );
9906 obj.insert(
9907 "endpoints".into(),
9908 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9909 );
9910 }
9911
9912 if let Err(e) = std::fs::write(
9913 &state_path,
9914 serde_json::to_vec_pretty(&state).unwrap_or_default(),
9915 ) {
9916 eprintln!(
9917 "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
9918 );
9919 return;
9920 }
9921 eprintln!(
9922 "wire session new: local slot allocated on {local_relay} (slot_id={})",
9923 alloc.slot_id
9924 );
9925}
9926
9927fn render_session_info(
9928 name: &str,
9929 session_home: &std::path::Path,
9930 cwd: &std::path::Path,
9931) -> Result<serde_json::Value> {
9932 let card_path = session_home
9933 .join("config")
9934 .join("wire")
9935 .join("agent-card.json");
9936 let (did, handle) = if card_path.exists() {
9937 let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
9938 let did = card
9939 .get("did")
9940 .and_then(Value::as_str)
9941 .unwrap_or("")
9942 .to_string();
9943 let handle = card
9944 .get("handle")
9945 .and_then(Value::as_str)
9946 .map(str::to_string)
9947 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
9948 (did, handle)
9949 } else {
9950 (String::new(), String::new())
9951 };
9952 Ok(json!({
9953 "name": name,
9954 "home_dir": session_home.to_string_lossy(),
9955 "cwd": cwd.to_string_lossy(),
9956 "did": did,
9957 "handle": handle,
9958 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
9959 }))
9960}
9961
9962fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
9963 if as_json {
9964 let mut obj = info.clone();
9965 obj["status"] = json!(status);
9966 println!("{}", serde_json::to_string(&obj)?);
9967 } else {
9968 let name = info["name"].as_str().unwrap_or("?");
9969 let handle = info["handle"].as_str().unwrap_or("?");
9970 let home = info["home_dir"].as_str().unwrap_or("?");
9971 let did = info["did"].as_str().unwrap_or("?");
9972 let export = info["export"].as_str().unwrap_or("?");
9973 let prefix = if status == "already_exists" {
9974 "session already exists (re-registered cwd)"
9975 } else {
9976 "session created"
9977 };
9978 println!(
9979 "{prefix}\n name: {name}\n handle: {handle}\n did: {did}\n home: {home}\n\nactivate with:\n {export}"
9980 );
9981 }
9982 Ok(())
9983}
9984
9985fn run_wire_with_home(
9986 session_home: &std::path::Path,
9987 args: &[&str],
9988) -> Result<std::process::ExitStatus> {
9989 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
9990 let status = std::process::Command::new(&bin)
9991 .env("WIRE_HOME", session_home)
9992 .env_remove("RUST_LOG")
9993 .env("WIRE_AUTO_INIT", "0")
9996 .args(args)
9997 .status()
9998 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
9999 Ok(status)
10000}
10001
10002pub fn maybe_auto_init_cwd_session(label: &str) {
10021 if std::env::var("WIRE_HOME").is_ok() {
10022 return; }
10024 if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
10025 return; }
10027 let cwd = match std::env::current_dir() {
10028 Ok(c) => c,
10029 Err(_) => return,
10030 };
10031 if crate::session::detect_session_wire_home(&cwd).is_some() {
10034 return;
10035 }
10036
10037 use fs2::FileExt;
10054 let sessions_root = match crate::session::sessions_root() {
10055 Ok(r) => r,
10056 Err(_) => return,
10057 };
10058 if let Err(e) = std::fs::create_dir_all(&sessions_root) {
10059 eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
10060 return;
10061 }
10062 let lock_path = sessions_root.join(".auto-init.lock");
10063 let lock_file = match std::fs::OpenOptions::new()
10064 .create(true)
10065 .truncate(false)
10066 .read(true)
10067 .write(true)
10068 .open(&lock_path)
10069 {
10070 Ok(f) => f,
10071 Err(e) => {
10072 eprintln!(
10073 "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
10074 );
10075 return;
10076 }
10077 };
10078 if let Err(e) = lock_file.lock_exclusive() {
10079 eprintln!(
10080 "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
10081 );
10082 return;
10083 }
10084 let registry = crate::session::read_registry().unwrap_or_default();
10089 let name = crate::session::derive_name_from_cwd(&cwd, ®istry);
10090 let session_home = match crate::session::session_dir(&name) {
10091 Ok(h) => h,
10092 Err(_) => {
10093 let _ = fs2::FileExt::unlock(&lock_file);
10094 return;
10095 }
10096 };
10097 let agent_card_path = session_home
10098 .join("config")
10099 .join("wire")
10100 .join("agent-card.json");
10101 let needs_init = !agent_card_path.exists();
10102
10103 if needs_init {
10104 if let Err(e) = std::fs::create_dir_all(&session_home) {
10105 eprintln!(
10106 "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
10107 );
10108 let _ = fs2::FileExt::unlock(&lock_file);
10109 return;
10110 }
10111 match run_wire_with_home(&session_home, &["init", &name, "--offline"]) {
10116 Ok(status) if status.success() => {}
10117 Ok(status) => {
10118 eprintln!(
10119 "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
10120 );
10121 let _ = fs2::FileExt::unlock(&lock_file);
10122 return;
10123 }
10124 Err(e) => {
10125 eprintln!(
10126 "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
10127 );
10128 let _ = fs2::FileExt::unlock(&lock_file);
10129 return;
10130 }
10131 }
10132 try_allocate_local_slot(
10139 &session_home,
10140 &name,
10141 "https://wireup.net",
10142 "http://127.0.0.1:8771",
10143 );
10144 } else {
10145 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
10149 eprintln!(
10150 "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
10151 );
10152 }
10153 }
10154 let cwd_key = crate::session::normalize_cwd_key(&cwd);
10164 let name_for_reg = name.clone();
10165 if let Err(e) = crate::session::update_registry(|reg| {
10166 reg.by_cwd.insert(cwd_key, name_for_reg);
10167 Ok(())
10168 }) {
10169 eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
10170 }
10172 let _ = fs2::FileExt::unlock(&lock_file);
10175
10176 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
10177 eprintln!(
10178 "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
10179 cwd.display(),
10180 session_home.display()
10181 );
10182 }
10183 unsafe {
10186 std::env::set_var("WIRE_HOME", &session_home);
10187 }
10188}
10189
10190fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
10191 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
10194 if pidfile.exists() {
10195 let bytes = std::fs::read(&pidfile).unwrap_or_default();
10196 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
10197 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
10198 } else {
10199 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
10200 };
10201 if let Some(p) = pid {
10202 let alive = {
10203 #[cfg(target_os = "linux")]
10204 {
10205 std::path::Path::new(&format!("/proc/{p}")).exists()
10206 }
10207 #[cfg(not(target_os = "linux"))]
10208 {
10209 std::process::Command::new("kill")
10210 .args(["-0", &p.to_string()])
10211 .output()
10212 .map(|o| o.status.success())
10213 .unwrap_or(false)
10214 }
10215 };
10216 if alive {
10217 return Ok(());
10218 }
10219 }
10220 }
10221
10222 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
10225 let log_path = session_home.join("state").join("wire").join("daemon.log");
10226 if let Some(parent) = log_path.parent() {
10227 std::fs::create_dir_all(parent).ok();
10228 }
10229 let log_file = std::fs::OpenOptions::new()
10230 .create(true)
10231 .append(true)
10232 .open(&log_path)
10233 .with_context(|| format!("opening daemon log {log_path:?}"))?;
10234 let log_err = log_file.try_clone()?;
10235 std::process::Command::new(&bin)
10236 .env("WIRE_HOME", session_home)
10237 .env_remove("RUST_LOG")
10238 .args(["daemon", "--interval", "5"])
10239 .stdout(log_file)
10240 .stderr(log_err)
10241 .stdin(std::process::Stdio::null())
10242 .spawn()
10243 .with_context(|| "spawning session-local `wire daemon`")?;
10244 Ok(())
10245}
10246
10247fn cmd_session_list(as_json: bool) -> Result<()> {
10248 let items = crate::session::list_sessions()?;
10249 if as_json {
10250 println!("{}", serde_json::to_string(&items)?);
10251 return Ok(());
10252 }
10253 if items.is_empty() {
10254 println!("no sessions on this machine. `wire session new` to create one.");
10255 return Ok(());
10256 }
10257 println!(
10258 "{:<22} {:<24} {:<24} {:<10} CWD",
10259 "PERSONA", "NAME", "HANDLE", "DAEMON"
10260 );
10261 for s in items {
10262 let plain = s
10266 .character
10267 .as_ref()
10268 .map(|c| c.short())
10269 .unwrap_or_else(|| "?".to_string());
10270 let colored = s
10271 .character
10272 .as_ref()
10273 .map(|c| c.colored())
10274 .unwrap_or_else(|| "?".to_string());
10275 let displayed_width = plain.chars().count() + 1; let pad = 22usize.saturating_sub(displayed_width);
10280 println!(
10281 "{}{} {:<24} {:<24} {:<10} {}",
10282 colored,
10283 " ".repeat(pad),
10284 s.name,
10285 s.handle.as_deref().unwrap_or("?"),
10286 if s.daemon_running { "running" } else { "down" },
10287 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
10288 );
10289 }
10290 Ok(())
10291}
10292
10293fn cmd_session_list_local(as_json: bool) -> Result<()> {
10305 let listing = crate::session::list_local_sessions()?;
10306 if as_json {
10307 println!("{}", serde_json::to_string(&listing)?);
10308 return Ok(());
10309 }
10310
10311 if listing.local.is_empty() && listing.federation_only.is_empty() {
10312 println!(
10313 "no sessions on this machine. `wire session new --with-local` to create one \
10314 with a local-relay endpoint (start the relay first: \
10315 `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
10316 );
10317 return Ok(());
10318 }
10319
10320 if listing.local.is_empty() {
10321 println!(
10322 "no sister sessions reachable via a local relay. \
10323 Re-run `wire session new --with-local` to add a Local endpoint, or \
10324 start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
10325 );
10326 } else {
10327 let mut keys: Vec<&String> = listing.local.keys().collect();
10329 keys.sort();
10330 for relay_url in keys {
10331 let group = &listing.local[relay_url];
10332 println!("LOCAL RELAY: {relay_url}");
10333 println!(" {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
10334 for s in group {
10335 println!(
10336 " {:<24} {:<32} {:<10} {}",
10337 s.name,
10338 s.handle.as_deref().unwrap_or("?"),
10339 if s.daemon_running { "running" } else { "down" },
10340 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
10341 );
10342 }
10343 println!();
10344 }
10345 }
10346
10347 if !listing.federation_only.is_empty() {
10348 println!("federation-only (no local endpoint):");
10349 for s in &listing.federation_only {
10350 println!(
10351 " {:<24} {:<32} {}",
10352 s.name,
10353 s.handle.as_deref().unwrap_or("?"),
10354 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
10355 );
10356 }
10357 }
10358 Ok(())
10359}
10360
10361fn cmd_session_pair_all_local(
10380 settle_secs: u64,
10381 federation_relay: &str,
10382 as_json: bool,
10383) -> Result<()> {
10384 use std::collections::BTreeSet;
10385 use std::time::Duration;
10386
10387 let listing = crate::session::list_local_sessions()?;
10388 let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
10392 Default::default();
10393 for group in listing.local.into_values() {
10394 for s in group {
10395 by_name.entry(s.name.clone()).or_insert(s);
10396 }
10397 }
10398 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
10399
10400 if sessions.len() < 2 {
10401 let msg = format!(
10402 "{} sister session(s) with a local endpoint — need at least 2 to pair.",
10403 sessions.len()
10404 );
10405 if as_json {
10406 println!(
10407 "{}",
10408 serde_json::to_string(&json!({
10409 "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
10410 "pairs_attempted": 0,
10411 "pairs_succeeded": 0,
10412 "pairs_skipped_already_paired": 0,
10413 "pairs_failed": 0,
10414 "note": msg,
10415 }))?
10416 );
10417 } else {
10418 println!("{msg}");
10419 if let Some(s) = sessions.first() {
10420 println!(" - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
10421 }
10422 println!("Use `wire session new --with-local` to add more.");
10423 }
10424 return Ok(());
10425 }
10426
10427 let fed_host = host_of_url(federation_relay);
10428 if fed_host.is_empty() {
10429 bail!(
10430 "federation_relay `{federation_relay}` has no parseable host — \
10431 pass a full URL like `https://wireup.net`."
10432 );
10433 }
10434
10435 let mut attempted = 0u32;
10437 let mut succeeded = 0u32;
10438 let mut skipped_already = 0u32;
10439 let mut failed = 0u32;
10440 let mut per_pair: Vec<Value> = Vec::new();
10441
10442 for i in 0..sessions.len() {
10443 for j in (i + 1)..sessions.len() {
10444 let a = &sessions[i];
10445 let b = &sessions[j];
10446 attempted += 1;
10447
10448 let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
10454 let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
10455 let a_pinned_b = session_has_peer(&a.home_dir, b_handle);
10456 let b_pinned_a = session_has_peer(&b.home_dir, a_handle);
10457 if a_pinned_b && b_pinned_a {
10458 skipped_already += 1;
10459 per_pair.push(json!({
10460 "from": a.name,
10461 "to": b.name,
10462 "status": "already_paired",
10463 }));
10464 continue;
10465 }
10466
10467 let pair_result = drive_bilateral_pair(
10468 &a.home_dir,
10469 &a.name,
10470 &b.home_dir,
10471 &b.name,
10472 &fed_host,
10473 federation_relay,
10474 settle_secs,
10475 );
10476
10477 match pair_result {
10478 Ok(()) => {
10479 succeeded += 1;
10480 per_pair.push(json!({
10481 "from": a.name,
10482 "to": b.name,
10483 "status": "paired",
10484 }));
10485 }
10486 Err(e) => {
10487 failed += 1;
10488 let detail = format!("{e:#}");
10489 per_pair.push(json!({
10490 "from": a.name,
10491 "to": b.name,
10492 "status": "failed",
10493 "error": detail,
10494 }));
10495 }
10496 }
10497
10498 std::thread::sleep(Duration::from_millis(200));
10501 }
10502 }
10503
10504 let _ = BTreeSet::<String>::new(); let summary = json!({
10506 "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
10507 "pairs_attempted": attempted,
10508 "pairs_succeeded": succeeded,
10509 "pairs_skipped_already_paired": skipped_already,
10510 "pairs_failed": failed,
10511 "results": per_pair,
10512 });
10513 if as_json {
10514 println!("{}", serde_json::to_string(&summary)?);
10515 } else {
10516 println!(
10517 "wire session pair-all-local: {} session(s), {} pair(s) attempted",
10518 sessions.len(),
10519 attempted
10520 );
10521 println!(" paired: {succeeded}");
10522 println!(" skipped (already pinned): {skipped_already}");
10523 println!(" failed: {failed}");
10524 for entry in summary["results"].as_array().unwrap_or(&vec![]) {
10525 let from = entry["from"].as_str().unwrap_or("?");
10526 let to = entry["to"].as_str().unwrap_or("?");
10527 let status = entry["status"].as_str().unwrap_or("?");
10528 let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
10529 if err.is_empty() {
10530 println!(" {from:<24} ↔ {to:<24} {status}");
10531 } else {
10532 println!(" {from:<24} ↔ {to:<24} {status} — {err}");
10533 }
10534 }
10535 }
10536 Ok(())
10537}
10538
10539fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
10542 val_session_relay_state(session_home)
10543 .and_then(|v| v.get("peers").cloned())
10544 .and_then(|p| p.get(peer_name).cloned())
10545 .is_some()
10546}
10547
10548fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
10553 let path = session_home.join("config").join("wire").join("relay.json");
10554 let bytes = std::fs::read(&path).ok()?;
10555 serde_json::from_slice(&bytes).ok()
10556}
10557
10558fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
10562 use std::collections::BTreeMap;
10563
10564 let listing = crate::session::list_local_sessions()?;
10567 let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
10568 for group in listing.local.into_values() {
10569 for s in group {
10570 by_name.entry(s.name.clone()).or_insert(s);
10571 }
10572 }
10573 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
10574 let federation_only = listing.federation_only;
10575
10576 if sessions.is_empty() {
10577 let msg = "no sister sessions with a local endpoint on this machine.".to_string();
10578 if as_json {
10579 println!(
10580 "{}",
10581 serde_json::to_string(&json!({
10582 "sessions": [],
10583 "edges": [],
10584 "local_relay": null,
10585 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
10586 "summary": {
10587 "session_count": 0,
10588 "edge_count": 0,
10589 "healthy": 0,
10590 "stale": 0,
10591 "asymmetric": 0,
10592 },
10593 "note": msg,
10594 }))?
10595 );
10596 } else {
10597 println!("{msg}");
10598 println!("Use `wire session new --with-local` to create one.");
10599 }
10600 return Ok(());
10601 }
10602
10603 struct SessionState {
10605 view: crate::session::LocalSessionView,
10606 relay_state: Value,
10607 local_relay_url: Option<String>,
10608 }
10609 let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
10610 for s in sessions {
10611 let relay_state = val_session_relay_state(&s.home_dir)
10612 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
10613 let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
10614 sstates.push(SessionState {
10615 view: s,
10616 relay_state,
10617 local_relay_url,
10618 });
10619 }
10620
10621 let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
10624 for s in &sstates {
10625 if let Some(url) = &s.local_relay_url
10626 && !local_relays.contains_key(url)
10627 {
10628 let healthy = probe_relay_healthz(url);
10629 local_relays.insert(url.clone(), healthy);
10630 }
10631 }
10632
10633 let now = std::time::SystemTime::now()
10634 .duration_since(std::time::UNIX_EPOCH)
10635 .map(|d| d.as_secs())
10636 .unwrap_or(0);
10637
10638 let mut edges: Vec<Value> = Vec::new();
10642 let mut healthy_count = 0u32;
10643 let mut stale_count = 0u32;
10644 let mut asymmetric_count = 0u32;
10645
10646 for i in 0..sstates.len() {
10647 for j in (i + 1)..sstates.len() {
10648 let a = &sstates[i];
10649 let b = &sstates[j];
10650 let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
10655 let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
10656 let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
10657 let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
10658
10659 let bilateral = a_to_b.pinned && b_to_a.pinned;
10660 let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
10664 (Some("local"), _) | (_, Some("local")) => "local",
10665 (Some("federation"), _) | (_, Some("federation")) => "federation",
10666 _ => "unknown",
10667 };
10668
10669 let mut status = if bilateral { "healthy" } else { "asymmetric" };
10672 if bilateral {
10673 let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
10674 Some(s) => s > stale_secs,
10675 None => d.probed,
10676 });
10677 if either_stale {
10678 status = "stale";
10679 }
10680 }
10681
10682 match status {
10683 "healthy" => healthy_count += 1,
10684 "stale" => stale_count += 1,
10685 "asymmetric" => asymmetric_count += 1,
10686 _ => {}
10687 }
10688
10689 edges.push(json!({
10690 "from": a.view.name,
10691 "to": b.view.name,
10692 "bilateral": bilateral,
10693 "scope": scope,
10694 "status": status,
10695 "directions": {
10696 a.view.name.clone(): direction_summary(&a_to_b),
10697 b.view.name.clone(): direction_summary(&b_to_a),
10698 },
10699 }));
10700 }
10701 }
10702
10703 let summary = json!({
10704 "sessions": sstates.iter().map(|s| json!({
10705 "name": s.view.name,
10706 "handle": s.view.handle,
10707 "cwd": s.view.cwd,
10708 "daemon_running": s.view.daemon_running,
10709 "local_relay": s.local_relay_url,
10710 })).collect::<Vec<_>>(),
10711 "edges": edges,
10712 "local_relays": local_relays.iter().map(|(url, healthy)| json!({
10713 "url": url,
10714 "healthy": healthy,
10715 })).collect::<Vec<_>>(),
10716 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
10717 "summary": {
10718 "session_count": sstates.len(),
10719 "edge_count": edges.len(),
10720 "healthy": healthy_count,
10721 "stale": stale_count,
10722 "asymmetric": asymmetric_count,
10723 "stale_threshold_secs": stale_secs,
10724 },
10725 });
10726
10727 if as_json {
10728 println!("{}", serde_json::to_string(&summary)?);
10729 return Ok(());
10730 }
10731
10732 println!(
10733 "wire mesh: {} session(s), {} edge(s)",
10734 sstates.len(),
10735 edges.len()
10736 );
10737 for (url, healthy) in &local_relays {
10738 let tick = if *healthy { "✓" } else { "✗" };
10739 println!(" local-relay {url} {tick}");
10740 }
10741 if !federation_only.is_empty() {
10742 print!(" federation-only sessions:");
10743 for f in &federation_only {
10744 print!(" {}", f.name);
10745 }
10746 println!();
10747 }
10748
10749 let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
10751 let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
10752 print!("\n{:>col_w$}", "", col_w = col_w);
10753 for n in &names {
10754 print!("{n:>col_w$}");
10755 }
10756 println!();
10757 for (i, row) in names.iter().enumerate() {
10758 print!("{row:>col_w$}");
10759 for (j, col) in names.iter().enumerate() {
10760 let cell = if i == j {
10761 "self".to_string()
10762 } else {
10763 let d = probe_directed_edge(&sstates[i].relay_state, col, now);
10764 match d.scope.as_deref() {
10765 Some("local") => "local".to_string(),
10766 Some("federation") => "fed".to_string(),
10767 _ => "—".to_string(),
10768 }
10769 };
10770 print!("{cell:>col_w$}");
10771 }
10772 println!();
10773 }
10774
10775 println!("\nHealth (stale threshold: {stale_secs}s):");
10776 for e in &edges {
10777 let from = e["from"].as_str().unwrap_or("?");
10778 let to = e["to"].as_str().unwrap_or("?");
10779 let scope = e["scope"].as_str().unwrap_or("?");
10780 let status = e["status"].as_str().unwrap_or("?");
10781 let mark = match status {
10782 "healthy" => "✓",
10783 "stale" => "⚠",
10784 "asymmetric" => "!",
10785 _ => "?",
10786 };
10787 let dirs = e["directions"].as_object().cloned().unwrap_or_default();
10788 let mut details: Vec<String> = Vec::new();
10789 for (who, d) in &dirs {
10790 let silent = d.get("silent_secs").and_then(Value::as_u64);
10791 let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
10792 let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
10793 let label = match (pinned, probed, silent) {
10794 (false, _, _) => format!("{who} has not pinned"),
10795 (true, false, _) => format!("{who} pinned but no endpoint to probe"),
10796 (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
10797 (true, true, Some(s)) => format!("{who} silent {s}s"),
10798 (true, true, None) => format!("{who} never pulled"),
10799 };
10800 details.push(label);
10801 }
10802 println!(
10803 " {mark} {from} ↔ {to} scope={scope} {status:>10} [{}]",
10804 details.join(" | ")
10805 );
10806 }
10807 Ok(())
10808}
10809
10810#[derive(Default)]
10811struct DirectedEdge {
10812 pinned: bool,
10813 scope: Option<String>,
10814 last_pull_at_unix: Option<u64>,
10815 silent_secs: Option<u64>,
10816 probed: bool,
10817 event_count: usize,
10818}
10819
10820fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
10826 let pinned = from_state
10827 .get("peers")
10828 .and_then(|p| p.get(to_name))
10829 .is_some();
10830 if !pinned {
10831 return DirectedEdge::default();
10832 }
10833 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
10834 let ep = match endpoints.into_iter().next() {
10835 Some(e) => e,
10836 None => {
10837 return DirectedEdge {
10838 pinned: true,
10839 ..Default::default()
10840 };
10841 }
10842 };
10843 let scope = Some(
10844 match ep.scope {
10845 crate::endpoints::EndpointScope::Local => "local",
10846 crate::endpoints::EndpointScope::Lan => "lan",
10847 crate::endpoints::EndpointScope::Uds => "uds",
10848 crate::endpoints::EndpointScope::Federation => "federation",
10849 }
10850 .to_string(),
10851 );
10852 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
10853 let (count, last) = client
10854 .slot_state(&ep.slot_id, &ep.slot_token)
10855 .unwrap_or((0, None));
10856 let silent = last.map(|t| now.saturating_sub(t));
10857 DirectedEdge {
10858 pinned: true,
10859 scope,
10860 last_pull_at_unix: last,
10861 silent_secs: silent,
10862 probed: true,
10863 event_count: count,
10864 }
10865}
10866
10867fn direction_summary(d: &DirectedEdge) -> Value {
10868 json!({
10869 "pinned": d.pinned,
10870 "scope": d.scope,
10871 "probed": d.probed,
10872 "last_pull_at_unix": d.last_pull_at_unix,
10873 "silent_secs": d.silent_secs,
10874 "event_count": d.event_count,
10875 })
10876}
10877
10878fn probe_relay_healthz(url: &str) -> bool {
10880 let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
10881 let client = match reqwest::blocking::Client::builder()
10882 .timeout(std::time::Duration::from_millis(500))
10883 .build()
10884 {
10885 Ok(c) => c,
10886 Err(_) => return false,
10887 };
10888 match client.get(&probe_url).send() {
10889 Ok(r) => r.status().is_success(),
10890 Err(_) => false,
10891 }
10892}
10893
10894fn drive_bilateral_pair(
10909 a_home: &std::path::Path,
10910 a_name: &str,
10911 b_home: &std::path::Path,
10912 b_name: &str,
10913 _fed_host: &str,
10914 _federation_relay: &str,
10915 settle_secs: u64,
10916) -> Result<()> {
10917 use std::time::Duration;
10918 let bin = std::env::current_exe().context("locating self exe")?;
10919
10920 let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
10921 let out = std::process::Command::new(&bin)
10922 .env("WIRE_HOME", home)
10923 .env_remove("RUST_LOG")
10924 .args(args)
10925 .output()
10926 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
10927 if !out.status.success() {
10928 bail!(
10929 "`wire {}` failed: stderr={}",
10930 args.join(" "),
10931 String::from_utf8_lossy(&out.stderr).trim()
10932 );
10933 }
10934 Ok(())
10935 };
10936
10937 let read_card_handle = |home: &std::path::Path| -> Result<String> {
10942 let card_path = home.join("config").join("wire").join("agent-card.json");
10943 let bytes = std::fs::read(&card_path)
10944 .with_context(|| format!("reading agent-card at {card_path:?}"))?;
10945 let card: Value = serde_json::from_slice(&bytes)?;
10946 card.get("handle")
10947 .and_then(Value::as_str)
10948 .map(str::to_string)
10949 .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
10950 };
10951 let a_handle = read_card_handle(a_home)
10952 .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
10953 let b_handle = read_card_handle(b_home)
10954 .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
10955
10956 run(a_home, &["add", b_name, "--local-sister", "--json"])
10960 .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
10961
10962 std::thread::sleep(Duration::from_secs(settle_secs));
10964
10965 run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
10968 run(b_home, &["accept", &a_handle, "--json"]).with_context(|| {
10969 format!("step 5/8: {b_name} `wire accept {a_handle}` (a session={a_name})")
10970 })?;
10971 run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
10972
10973 std::thread::sleep(Duration::from_secs(settle_secs));
10975
10976 run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
10978 let _ = &b_handle;
10980
10981 Ok(())
10982}
10983
10984fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
10985 let name = resolve_session_name(name_arg)?;
10986 let session_home = crate::session::session_dir(&name)?;
10987 if !session_home.exists() {
10988 bail!(
10989 "no session named {name:?} on this machine. `wire session list` to enumerate, \
10990 `wire session new {name}` to create."
10991 );
10992 }
10993 if as_json {
10994 println!(
10995 "{}",
10996 serde_json::to_string(&json!({
10997 "name": name,
10998 "home_dir": session_home.to_string_lossy(),
10999 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
11000 }))?
11001 );
11002 } else {
11003 println!("export WIRE_HOME={}", session_home.to_string_lossy());
11004 }
11005 Ok(())
11006}
11007
11008fn cmd_session_current(as_json: bool) -> Result<()> {
11009 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
11010 let registry = crate::session::read_registry().unwrap_or_default();
11011 let cwd_key = crate::session::normalize_cwd_key(&cwd);
11012 let name = registry
11017 .by_cwd
11018 .get(&cwd_key)
11019 .or_else(|| {
11020 registry
11021 .by_cwd
11022 .iter()
11023 .find(|(k, _)| {
11024 crate::session::normalize_cwd_key(std::path::Path::new(k)) == cwd_key
11025 })
11026 .map(|(_, v)| v)
11027 })
11028 .cloned();
11029 if as_json {
11030 println!(
11031 "{}",
11032 serde_json::to_string(&json!({
11033 "cwd": cwd_key,
11034 "session": name,
11035 }))?
11036 );
11037 } else if let Some(n) = name {
11038 println!("{n}");
11039 } else {
11040 println!("(no session registered for this cwd)");
11041 }
11042 Ok(())
11043}
11044
11045fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
11046 let name = crate::session::sanitize_name(name_arg);
11047 let session_home = crate::session::session_dir(&name)?;
11048 if !session_home.exists() {
11049 if as_json {
11050 println!(
11051 "{}",
11052 serde_json::to_string(&json!({
11053 "name": name,
11054 "destroyed": false,
11055 "reason": "no such session",
11056 }))?
11057 );
11058 } else {
11059 println!("no session named {name:?} — nothing to destroy.");
11060 }
11061 return Ok(());
11062 }
11063 if !force {
11064 bail!(
11065 "destroying session {name:?} would delete its keypair + state irrecoverably. \
11066 Pass --force to confirm."
11067 );
11068 }
11069
11070 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
11072 if let Ok(bytes) = std::fs::read(&pidfile) {
11073 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
11074 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
11075 } else {
11076 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
11077 };
11078 if let Some(p) = pid {
11079 let _ = std::process::Command::new("kill")
11080 .args(["-TERM", &p.to_string()])
11081 .output();
11082 }
11083 }
11084
11085 std::fs::remove_dir_all(&session_home)
11086 .with_context(|| format!("removing session dir {session_home:?}"))?;
11087
11088 let mut registry = crate::session::read_registry().unwrap_or_default();
11090 registry.by_cwd.retain(|_, v| v != &name);
11091 crate::session::write_registry(®istry)?;
11092
11093 if as_json {
11094 println!(
11095 "{}",
11096 serde_json::to_string(&json!({
11097 "name": name,
11098 "destroyed": true,
11099 }))?
11100 );
11101 } else {
11102 println!("destroyed session {name:?}.");
11103 }
11104 Ok(())
11105}
11106
11107fn cmd_diag(action: DiagAction) -> Result<()> {
11110 let state = config::state_dir()?;
11111 let knob = state.join("diag.enabled");
11112 let log_path = state.join("diag.jsonl");
11113 match action {
11114 DiagAction::Tail { limit, json } => {
11115 let entries = crate::diag::tail(limit);
11116 if json {
11117 for e in entries {
11118 println!("{}", serde_json::to_string(&e)?);
11119 }
11120 } else if entries.is_empty() {
11121 println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
11122 } else {
11123 for e in entries {
11124 let ts = e["ts"].as_u64().unwrap_or(0);
11125 let ty = e["type"].as_str().unwrap_or("?");
11126 let pid = e["pid"].as_u64().unwrap_or(0);
11127 let payload = e["payload"].to_string();
11128 println!("[{ts}] pid={pid} {ty} {payload}");
11129 }
11130 }
11131 }
11132 DiagAction::Enable => {
11133 config::ensure_dirs()?;
11134 std::fs::write(&knob, "1")?;
11135 println!("wire diag: enabled at {knob:?}");
11136 }
11137 DiagAction::Disable => {
11138 if knob.exists() {
11139 std::fs::remove_file(&knob)?;
11140 }
11141 println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
11142 }
11143 DiagAction::Status { json } => {
11144 let enabled = crate::diag::is_enabled();
11145 let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
11146 if json {
11147 println!(
11148 "{}",
11149 serde_json::to_string(&serde_json::json!({
11150 "enabled": enabled,
11151 "log_path": log_path,
11152 "log_size_bytes": size,
11153 }))?
11154 );
11155 } else {
11156 println!("wire diag status");
11157 println!(" enabled: {enabled}");
11158 println!(" log: {log_path:?}");
11159 println!(" log size: {size} bytes");
11160 }
11161 }
11162 }
11163 Ok(())
11164}
11165
11166fn cmd_service(action: ServiceAction) -> Result<()> {
11169 let kind = |local_relay: bool| {
11170 if local_relay {
11171 crate::service::ServiceKind::LocalRelay
11172 } else {
11173 crate::service::ServiceKind::Daemon
11174 }
11175 };
11176 let (report, as_json) = match action {
11177 ServiceAction::Install { local_relay, json } => {
11178 (crate::service::install_kind(kind(local_relay))?, json)
11179 }
11180 ServiceAction::Uninstall { local_relay, json } => {
11181 (crate::service::uninstall_kind(kind(local_relay))?, json)
11182 }
11183 ServiceAction::Status { local_relay, json } => {
11184 (crate::service::status_kind(kind(local_relay))?, json)
11185 }
11186 };
11187 if as_json {
11188 println!("{}", serde_json::to_string(&report)?);
11189 } else {
11190 println!("wire service {}", report.action);
11191 println!(" platform: {}", report.platform);
11192 println!(" unit: {}", report.unit_path);
11193 println!(" status: {}", report.status);
11194 println!(" detail: {}", report.detail);
11195 }
11196 Ok(())
11197}
11198
11199const CRATE_NAME: &str = "slancha-wire";
11202
11203fn release_asset_triple() -> Option<(&'static str, &'static str)> {
11207 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
11208 {
11209 return Some(("x86_64-pc-windows-msvc", ".exe"));
11210 }
11211 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
11212 {
11213 return Some(("aarch64-apple-darwin", ""));
11214 }
11215 #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
11216 {
11217 return Some(("x86_64-apple-darwin", ""));
11218 }
11219 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
11220 {
11221 return Some(("x86_64-unknown-linux-musl", ""));
11222 }
11223 #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
11224 {
11225 return Some(("aarch64-unknown-linux-musl", ""));
11226 }
11227 #[allow(unreachable_code)]
11228 None
11229}
11230
11231fn fetch_latest_published_version() -> Result<String> {
11233 let url = format!("https://crates.io/api/v1/crates/{CRATE_NAME}");
11234 let client = reqwest::blocking::Client::builder()
11235 .timeout(std::time::Duration::from_secs(20))
11236 .build()?;
11237 let resp = client
11238 .get(&url)
11239 .header(
11241 "User-Agent",
11242 format!("wire/{} (self-update)", env!("CARGO_PKG_VERSION")),
11243 )
11244 .send()?;
11245 if !resp.status().is_success() {
11246 bail!("crates.io returned {} for {CRATE_NAME}", resp.status());
11247 }
11248 let v: Value = resp.json()?;
11249 v.get("crate")
11250 .and_then(|c| {
11251 c.get("max_stable_version")
11252 .or_else(|| c.get("newest_version"))
11253 })
11254 .and_then(Value::as_str)
11255 .map(str::to_string)
11256 .ok_or_else(|| anyhow!("crates.io response missing crate.max_stable_version"))
11257}
11258
11259fn version_is_newer(latest: &str, current: &str) -> bool {
11262 let parse = |s: &str| -> (u64, u64, u64) {
11263 let core = s.split('-').next().unwrap_or(s);
11264 let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
11265 (
11266 it.next().unwrap_or(0),
11267 it.next().unwrap_or(0),
11268 it.next().unwrap_or(0),
11269 )
11270 };
11271 parse(latest) > parse(current)
11272}
11273
11274fn cargo_on_path() -> bool {
11275 std::process::Command::new("cargo")
11276 .arg("--version")
11277 .stdout(std::process::Stdio::null())
11278 .stderr(std::process::Stdio::null())
11279 .status()
11280 .map(|s| s.success())
11281 .unwrap_or(false)
11282}
11283
11284fn self_update_from_release(latest: &str) -> Result<()> {
11287 let (triple, ext) = release_asset_triple().ok_or_else(|| {
11288 anyhow!(
11289 "no prebuilt release binary for this platform — install a Rust toolchain and re-run, \
11290 or `cargo install {CRATE_NAME}`"
11291 )
11292 })?;
11293 let base =
11294 format!("https://github.com/SlanchaAi/wire/releases/download/v{latest}/wire-{triple}{ext}");
11295 let client = reqwest::blocking::Client::builder()
11296 .timeout(std::time::Duration::from_secs(120))
11297 .build()?;
11298 let resp = client
11299 .get(&base)
11300 .header("User-Agent", "wire-self-update")
11301 .send()?;
11302 if !resp.status().is_success() {
11303 bail!("downloading {base} returned {}", resp.status());
11304 }
11305 let bytes = resp.bytes()?;
11306
11307 if let Ok(sha) = client
11309 .get(format!("{base}.sha256"))
11310 .header("User-Agent", "wire-self-update")
11311 .send()
11312 && sha.status().is_success()
11313 {
11314 let expected = sha
11315 .text()?
11316 .split_whitespace()
11317 .next()
11318 .unwrap_or("")
11319 .to_string();
11320 if !expected.is_empty() {
11321 use sha2::{Digest, Sha256};
11322 let mut h = Sha256::new();
11323 h.update(&bytes);
11324 let actual = hex::encode(h.finalize());
11325 if expected != actual {
11326 bail!(
11327 "SHA-256 mismatch — expected {expected}, got {actual} (aborting, binary NOT replaced)"
11328 );
11329 }
11330 }
11331 }
11332
11333 let exe = std::env::current_exe().context("locating current exe")?;
11334 let dir = exe
11335 .parent()
11336 .ok_or_else(|| anyhow!("current exe has no parent dir"))?;
11337 let tmp = dir.join(format!(".wire-update-{}", std::process::id()));
11338 std::fs::write(&tmp, &bytes).with_context(|| format!("writing {tmp:?}"))?;
11339 #[cfg(unix)]
11340 {
11341 use std::os::unix::fs::PermissionsExt;
11342 let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
11343 std::fs::rename(&tmp, &exe).with_context(|| format!("replacing {exe:?}"))?;
11346 }
11347 #[cfg(windows)]
11348 {
11349 let old = exe.with_extension("old");
11352 let _ = std::fs::remove_file(&old);
11353 std::fs::rename(&exe, &old)
11354 .with_context(|| format!("renaming running exe {exe:?} aside"))?;
11355 std::fs::rename(&tmp, &exe).with_context(|| format!("installing new exe at {exe:?}"))?;
11356 }
11357 Ok(())
11358}
11359
11360struct UpdateOutcome {
11362 current: String,
11363 latest: String,
11364 available: bool,
11366 installed: bool,
11368 via: Option<&'static str>,
11370}
11371
11372fn self_update_step(install: bool) -> Result<UpdateOutcome> {
11376 let current = env!("CARGO_PKG_VERSION").to_string();
11377 let latest = fetch_latest_published_version().context("checking crates.io for latest wire")?;
11378 let available = version_is_newer(&latest, ¤t);
11379 if !install || !available {
11380 return Ok(UpdateOutcome {
11381 current,
11382 latest,
11383 available,
11384 installed: false,
11385 via: None,
11386 });
11387 }
11388 let via = if cargo_on_path() {
11389 eprintln!(
11390 "wire upgrade: {current} → {latest} — installing via `cargo install {CRATE_NAME}` …"
11391 );
11392 let status = std::process::Command::new("cargo")
11393 .args([
11394 "install",
11395 CRATE_NAME,
11396 "--version",
11397 &latest,
11398 "--force",
11399 "--locked",
11400 ])
11401 .status()
11402 .context("running cargo install")?;
11403 if !status.success() {
11404 bail!("`cargo install {CRATE_NAME}` failed");
11405 }
11406 "cargo install"
11407 } else {
11408 eprintln!(
11409 "wire upgrade: {current} → {latest} — no `cargo` on PATH, downloading the prebuilt release binary …"
11410 );
11411 self_update_from_release(&latest)?;
11412 "prebuilt release binary"
11413 };
11414 Ok(UpdateOutcome {
11415 current,
11416 latest,
11417 available,
11418 installed: true,
11419 via: Some(via),
11420 })
11421}
11422
11423fn upgrade_kill_set(
11444 my_pid: Option<u32>,
11445 found_daemon_pids: &[u32],
11446 owned_session_pids: &std::collections::HashSet<u32>,
11447) -> Vec<u32> {
11448 let mut k: Vec<u32> = Vec::new();
11449 if let Some(p) = my_pid {
11450 k.push(p);
11451 }
11452 for &p in found_daemon_pids {
11453 if !owned_session_pids.contains(&p) && Some(p) != my_pid {
11454 k.push(p); }
11456 }
11457 k.sort_unstable();
11458 k.dedup();
11459 k
11460}
11461
11462#[derive(Debug, Clone)]
11469struct PathWireBinary {
11470 path: std::path::PathBuf,
11473 canonical: std::path::PathBuf,
11477 sha256: Option<String>,
11480 mtime: Option<std::time::SystemTime>,
11482 path_index: usize,
11485 is_current_exe: bool,
11491}
11492
11493impl PathWireBinary {
11494 fn is_active(&self) -> bool {
11496 self.path_index == 0
11497 }
11498 fn sha256_short(&self) -> String {
11501 self.sha256
11502 .as_deref()
11503 .map(|s| s[..s.len().min(8)].to_string())
11504 .unwrap_or_else(|| "????????".to_string())
11505 }
11506 fn mtime_display(&self) -> String {
11508 let Some(ts) = self.mtime else {
11509 return "?".to_string();
11510 };
11511 let secs = match ts.duration_since(std::time::UNIX_EPOCH) {
11512 Ok(d) => d.as_secs() as i64,
11513 Err(_) => return "?".to_string(),
11514 };
11515 time::OffsetDateTime::from_unix_timestamp(secs)
11516 .ok()
11517 .and_then(|dt| {
11518 dt.format(&time::format_description::well_known::Rfc3339)
11519 .ok()
11520 })
11521 .unwrap_or_else(|| "?".to_string())
11522 }
11523}
11524
11525fn sha256_file(p: &std::path::Path) -> Result<String> {
11527 use sha2::{Digest, Sha256};
11528 let mut f = std::fs::File::open(p).with_context(|| format!("opening {}", p.display()))?;
11529 let mut h = Sha256::new();
11530 std::io::copy(&mut f, &mut h).with_context(|| format!("hashing {}", p.display()))?;
11531 Ok(hex::encode(h.finalize()))
11532}
11533
11534fn enumerate_path_wire_binaries() -> Vec<PathWireBinary> {
11548 let path = std::env::var("PATH").unwrap_or_default();
11549 let current_exe_canon: Option<std::path::PathBuf> = std::env::current_exe()
11550 .ok()
11551 .and_then(|p| p.canonicalize().ok());
11552 enumerate_path_wire_binaries_from(&path, current_exe_canon.as_deref())
11553}
11554
11555fn enumerate_path_wire_binaries_from(
11560 path: &str,
11561 current_exe_canon: Option<&std::path::Path>,
11562) -> Vec<PathWireBinary> {
11563 if path.is_empty() {
11564 return Vec::new();
11565 }
11566 let separator = if cfg!(windows) { ';' } else { ':' };
11571 let names: &[&str] = if cfg!(windows) {
11572 &["wire.exe", "wire"]
11576 } else {
11577 &["wire"]
11578 };
11579
11580 let mut seen: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
11581 let mut out: Vec<PathWireBinary> = Vec::new();
11582 for dir in path.split(separator) {
11583 if dir.is_empty() {
11584 continue;
11585 }
11586 for name in names {
11587 let candidate = std::path::PathBuf::from(dir).join(name);
11588 if !candidate.is_file() {
11591 continue;
11592 }
11593 let canon = candidate
11594 .canonicalize()
11595 .unwrap_or_else(|_| candidate.clone());
11596 if !seen.insert(canon.clone()) {
11597 break;
11600 }
11601 let meta = std::fs::metadata(&canon).ok();
11602 let mtime = meta.as_ref().and_then(|m| m.modified().ok());
11603 let sha256 = sha256_file(&canon).ok();
11604 let is_current_exe = current_exe_canon
11605 .map(|c| c == canon.as_path())
11606 .unwrap_or(false);
11607 let path_index = out.len();
11608 out.push(PathWireBinary {
11609 path: candidate,
11610 canonical: canon,
11611 sha256,
11612 mtime,
11613 path_index,
11614 is_current_exe,
11615 });
11616 break;
11619 }
11620 }
11621 out
11622}
11623
11624fn path_shadow_warning(bins: &[PathWireBinary]) -> Option<String> {
11636 let any_current = bins.iter().any(|b| b.is_current_exe);
11637 let multi = bins.len() >= 2;
11638 let off_path = !bins.is_empty() && !any_current;
11639 let none_on_path = bins.is_empty();
11640 if !multi && !off_path && !none_on_path {
11641 return None;
11642 }
11643 let mut out = String::new();
11644 if multi {
11645 out.push_str(&format!(
11646 "WARN: {} distinct `wire` binaries on PATH — older entries can shadow your fresh install:\n",
11647 bins.len()
11648 ));
11649 for b in bins {
11650 let mut tags: Vec<&str> = Vec::new();
11651 if b.is_active() {
11652 tags.push("ACTIVE (bare `wire` resolves here)");
11653 }
11654 if b.is_current_exe {
11655 tags.push("THIS upgrade ran against this binary");
11656 }
11657 let tag_str = if tags.is_empty() {
11658 String::new()
11659 } else {
11660 format!(" ← {}", tags.join("; "))
11661 };
11662 out.push_str(&format!(
11663 " [{}] {} (sha256:{} mtime:{}){}\n",
11664 b.path_index,
11665 b.path.display(),
11666 b.sha256_short(),
11667 b.mtime_display(),
11668 tag_str,
11669 ));
11670 }
11671 if !any_current {
11672 out.push_str(
11673 " NOTE: none of the PATH-resident binaries is the one running this `wire upgrade`.\n",
11674 );
11675 out.push_str(
11676 " Your upgrade will NOT affect bare `wire` calls in shells, scripts, or peer agents.\n",
11677 );
11678 } else if !bins[0].is_current_exe {
11679 out.push_str(
11680 " Bare `wire` calls (shells, scripts, daemons, peer agents) will use the\n",
11681 );
11682 out.push_str(
11683 " ACTIVE binary [0], NOT the one you just upgraded. Recommended fixes:\n",
11684 );
11685 out.push_str(&format!(
11686 " - rm {} (or symlink it to the upgraded binary)\n",
11687 bins[0].path.display(),
11688 ));
11689 out.push_str(
11690 " - or reorder PATH so the upgraded binary's directory precedes the active one\n",
11691 );
11692 out.push_str(" Verify with: which -a wire\n");
11693 }
11694 } else if off_path {
11695 let active = &bins[0];
11697 out.push_str("WARN: this `wire upgrade` is running against an off-PATH binary;\n");
11698 out.push_str(&format!(
11699 " bare `wire` resolves to {} (sha256:{}),\n",
11700 active.path.display(),
11701 active.sha256_short(),
11702 ));
11703 out.push_str(
11704 " which was NOT touched by this upgrade. Shells, scripts, and peer agents\n",
11705 );
11706 out.push_str(" will continue to invoke the old binary.\n");
11707 } else if none_on_path {
11708 out.push_str("WARN: no `wire` binary on PATH; bare `wire` will fail in future shells.\n");
11709 out.push_str(" This upgrade ran against an absolute-path invocation only.\n");
11710 }
11711 Some(out.trim_end().to_string())
11712}
11713
11714#[cfg(test)]
11715mod upgrade_tests {
11716 use super::*;
11717 use std::collections::HashSet;
11718
11719 #[test]
11720 fn upgrade_kill_set_is_session_scoped() {
11721 let owned: HashSet<u32> = [100, 200].into_iter().collect();
11723 let k = upgrade_kill_set(Some(100), &[100, 200, 999], &owned);
11725 assert!(k.contains(&100), "must kill my own daemon (to replace it)");
11726 assert!(k.contains(&999), "must sweep a true orphan");
11727 assert!(!k.contains(&200), "must SPARE a sibling session's daemon");
11728
11729 assert_eq!(
11733 upgrade_kill_set(Some(100), &[], &owned),
11734 vec![100],
11735 "own daemon killed even when the process scan is empty"
11736 );
11737
11738 assert_eq!(upgrade_kill_set(None, &[999], &HashSet::new()), vec![999]);
11740 }
11741
11742 fn write_fake_wire(dir: &std::path::Path, body: &[u8]) -> std::path::PathBuf {
11750 use std::io::Write;
11751 let p = dir.join("wire");
11752 let mut f = std::fs::File::create(&p).expect("create fake wire");
11753 f.write_all(body).expect("write fake wire");
11754 drop(f);
11755 #[cfg(unix)]
11756 {
11757 use std::os::unix::fs::PermissionsExt;
11758 let mut perm = std::fs::metadata(&p).unwrap().permissions();
11759 perm.set_mode(0o755);
11760 std::fs::set_permissions(&p, perm).unwrap();
11761 }
11762 p
11763 }
11764
11765 #[test]
11766 #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11767 fn enumerate_finds_no_binaries_when_path_empty() {
11768 let bins = enumerate_path_wire_binaries_from("", None);
11769 assert!(
11770 bins.is_empty(),
11771 "empty PATH yields no binaries, got {bins:?}"
11772 );
11773 }
11774
11775 #[test]
11776 #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11777 fn enumerate_detects_two_distinct_binaries_in_path_order() {
11778 let d1 = tempfile::tempdir().unwrap();
11779 let d2 = tempfile::tempdir().unwrap();
11780 let p1 = write_fake_wire(d1.path(), b"#!/bin/sh\necho A\n");
11781 let p2 = write_fake_wire(d2.path(), b"#!/bin/sh\necho B\n");
11782 let path = format!("{}:{}", d1.path().display(), d2.path().display());
11783
11784 let bins = enumerate_path_wire_binaries_from(&path, None);
11785 assert_eq!(bins.len(), 2, "expected two distinct binaries: {bins:?}");
11786 assert_eq!(bins[0].path_index, 0);
11787 assert_eq!(bins[1].path_index, 1);
11788 assert!(bins[0].is_active(), "first PATH entry is active");
11789 assert!(!bins[1].is_active(), "second PATH entry is not active");
11790 assert_ne!(
11792 bins[0].sha256, bins[1].sha256,
11793 "distinct contents must hash differently"
11794 );
11795 assert_eq!(bins[0].path, p1);
11797 assert_eq!(bins[1].path, p2);
11798 }
11799
11800 #[test]
11801 #[cfg_attr(windows, ignore = "PATH separator + symlink semantics differ")]
11802 fn enumerate_collapses_symlink_chains_to_one_entry() {
11803 let real_dir = tempfile::tempdir().unwrap();
11804 let link_dir = tempfile::tempdir().unwrap();
11805 let real = write_fake_wire(real_dir.path(), b"#!/bin/sh\necho real\n");
11806 let link = link_dir.path().join("wire");
11807 #[cfg(unix)]
11808 std::os::unix::fs::symlink(&real, &link).unwrap();
11809
11810 let path = format!(
11814 "{}:{}",
11815 link_dir.path().display(),
11816 real_dir.path().display()
11817 );
11818 let bins = enumerate_path_wire_binaries_from(&path, None);
11819 assert_eq!(
11820 bins.len(),
11821 1,
11822 "symlink chain must collapse to a single entry: {bins:?}"
11823 );
11824 assert!(bins[0].is_active());
11825 assert_eq!(bins[0].path, link);
11827 assert_eq!(bins[0].canonical, real.canonicalize().unwrap());
11828 }
11829
11830 #[test]
11831 #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11832 fn shadow_warning_off_path_when_current_exe_not_on_path() {
11833 let d = tempfile::tempdir().unwrap();
11836 write_fake_wire(d.path(), b"#!/bin/sh\necho only\n");
11837 let elsewhere = tempfile::tempdir().unwrap();
11838 let cur = elsewhere.path().join("not-on-path-wire");
11839 let bins = enumerate_path_wire_binaries_from(&d.path().display().to_string(), Some(&cur));
11840 assert_eq!(bins.len(), 1);
11841 assert!(!bins[0].is_current_exe);
11842 let warn = path_shadow_warning(&bins).expect("off-path single bin must warn");
11843 assert!(
11844 warn.contains("off-PATH binary"),
11845 "off-path WARN must mention off-PATH; got: {warn}"
11846 );
11847 }
11848
11849 #[test]
11850 fn shadow_warning_fires_when_no_binaries_at_all() {
11851 let bins: Vec<PathWireBinary> = Vec::new();
11852 let warn = path_shadow_warning(&bins).expect("empty must warn");
11853 assert!(warn.contains("no `wire` binary on PATH"), "got: {warn}");
11854 }
11855
11856 #[test]
11857 #[cfg_attr(windows, ignore = "PATH separator differs")]
11858 fn shadow_warning_multi_binaries_names_active_and_recommends_fix() {
11859 let d1 = tempfile::tempdir().unwrap();
11860 let d2 = tempfile::tempdir().unwrap();
11861 write_fake_wire(d1.path(), b"published\n");
11862 write_fake_wire(d2.path(), b"head\n");
11863 let path = format!("{}:{}", d1.path().display(), d2.path().display());
11864 let bins = enumerate_path_wire_binaries_from(&path, None);
11865 let warn = path_shadow_warning(&bins).expect("two distinct bins must warn");
11866 assert!(warn.contains("2 distinct"), "got: {warn}");
11867 assert!(warn.contains("ACTIVE"), "must mark the active binary");
11868 assert!(
11869 warn.contains("which -a wire") || warn.contains("none of the PATH-resident"),
11870 "must guide the operator to a fix; got: {warn}"
11871 );
11872 }
11873}
11874
11875fn cmd_upgrade(
11876 check_only: bool,
11877 local: bool,
11878 restart_mcp: bool,
11879 refresh_stale_children: bool,
11880 as_json: bool,
11881) -> Result<()> {
11882 let update: Option<UpdateOutcome> = if local {
11888 None
11889 } else {
11890 match self_update_step(!check_only) {
11891 Ok(o) => Some(o),
11892 Err(e) => {
11893 if !check_only {
11894 eprintln!("wire upgrade: update check skipped — {e:#}");
11895 }
11896 None
11897 }
11898 }
11899 };
11900 if let Some(o) = &update
11901 && o.installed
11902 {
11903 eprintln!(
11904 "wire upgrade: installed {} (was {}, via {}); restarting the daemon on the new binary.",
11905 o.latest,
11906 o.current,
11907 o.via.unwrap_or("self-update")
11908 );
11909 }
11910
11911 let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
11920 let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
11921 let mcp_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire mcp");
11929 let running_pids: Vec<u32> = daemon_pids
11930 .iter()
11931 .chain(relay_pids.iter())
11932 .copied()
11933 .collect();
11934
11935 let record = crate::ensure_up::read_pid_record("daemon");
11937 let recorded_version: Option<String> = match &record {
11938 crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
11939 _ => None,
11940 };
11941 let cli_version = env!("CARGO_PKG_VERSION").to_string();
11942
11943 let my_daemon_pid = record.pid();
11957 let owned_session_pids: std::collections::HashSet<u32> = crate::session::list_sessions()
11958 .unwrap_or_default()
11959 .iter()
11960 .filter_map(|s| crate::session::session_daemon_pid(&s.home_dir))
11961 .collect();
11962 let mut kill_set = upgrade_kill_set(my_daemon_pid, &daemon_pids, &owned_session_pids);
11963 let stale_children_killed: Vec<serde_json::Value> = if refresh_stale_children {
11974 match crate::daemon_supervisor::read_supervisor_state() {
11975 Ok(sv) => {
11976 let mut killed: Vec<serde_json::Value> = Vec::new();
11977 let cli_v = env!("CARGO_PKG_VERSION");
11978 for s in &sv.sessions {
11979 if !sv.stale_binary_sessions.contains(&s.name) {
11980 continue;
11981 }
11982 if let Some(pid) = s.daemon_pid {
11983 if !kill_set.contains(&pid) {
11987 kill_set.push(pid);
11988 }
11989 killed.push(json!({
11990 "session": s.name,
11991 "pid": pid,
11992 "prev_version": s.daemon_version,
11993 "cli_version": cli_v,
11994 }));
11995 }
11996 }
11997 if !killed.is_empty() && !as_json {
11998 eprintln!(
11999 "wire upgrade: --refresh-stale-children will kill {} stale-binary session daemon(s); supervisor respawns each on next 10s poll.",
12000 killed.len()
12001 );
12002 }
12003 killed
12004 }
12005 Err(e) => {
12006 if !as_json {
12007 eprintln!(
12008 "wire upgrade: --refresh-stale-children skipped — could not read supervisor state ({e:#}). \
12009 The flag is a no-op when no `wire daemon --all-sessions` supervisor is running."
12010 );
12011 }
12012 Vec::new()
12013 }
12014 }
12015 } else {
12016 Vec::new()
12017 };
12018
12019 if check_only {
12020 let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
12022 .unwrap_or_default()
12023 .iter()
12024 .filter(|s| s.daemon_running)
12025 .map(|s| s.name.clone())
12026 .collect();
12027 let path_bins = enumerate_path_wire_binaries();
12028 let path_dupes: Vec<String> = path_bins
12029 .iter()
12030 .map(|b| b.canonical.to_string_lossy().into_owned())
12031 .collect();
12032 let path_binaries_detail: Vec<serde_json::Value> = path_bins
12033 .iter()
12034 .map(|b| {
12035 json!({
12036 "path": b.path.to_string_lossy(),
12037 "canonical": b.canonical.to_string_lossy(),
12038 "sha256": b.sha256,
12039 "mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
12040 "path_index": b.path_index,
12041 "is_active": b.is_active(),
12042 "is_current_exe": b.is_current_exe,
12043 })
12044 })
12045 .collect();
12046 let path_warning_check = path_shadow_warning(&path_bins);
12047 let installed_service_kinds: Vec<&'static str> = [
12050 (crate::service::ServiceKind::Daemon, "daemon"),
12051 (crate::service::ServiceKind::LocalRelay, "local-relay"),
12052 ]
12053 .into_iter()
12054 .filter_map(|(k, label)| {
12055 crate::service::status_kind(k)
12056 .ok()
12057 .filter(|r| r.status != "absent")
12058 .map(|_| label)
12059 })
12060 .collect();
12061 let (update_latest, update_available) = match &update {
12062 Some(o) => (Some(o.latest.clone()), o.available),
12063 None => (None, false),
12064 };
12065 let report = json!({
12066 "running_pids": running_pids,
12067 "running_daemons": daemon_pids,
12068 "running_relay_servers": relay_pids,
12069 "running_mcp_servers": mcp_pids,
12076 "would_warn_stale_mcp_servers": !mcp_pids.is_empty() && !restart_mcp,
12077 "would_restart_mcp_servers": restart_mcp && !mcp_pids.is_empty(),
12078 "restart_mcp_requested": restart_mcp,
12079 "pidfile_version": recorded_version,
12080 "cli_version": cli_version,
12081 "latest_published": update_latest,
12082 "update_available": update_available,
12083 "would_kill": kill_set,
12084 "would_refresh_services": installed_service_kinds,
12085 "session_daemons_running": sessions_with_daemons,
12086 "path_binaries": path_dupes,
12087 "path_binaries_detail": path_binaries_detail,
12088 "path_duplicate_warning": path_dupes.len() > 1,
12089 "path_warning": path_warning_check,
12090 });
12091 if as_json {
12092 println!("{}", serde_json::to_string(&report)?);
12093 } else {
12094 println!("wire upgrade --check");
12095 println!(" cli version: {cli_version}");
12096 match (&update_latest, update_available) {
12097 (Some(l), true) => println!(" latest published: {l} (UPDATE AVAILABLE)"),
12098 (Some(l), false) => println!(" latest published: {l} (up to date)"),
12099 (None, _) => println!(" latest published: (crates.io check skipped)"),
12100 }
12101 println!(
12102 " pidfile version: {}",
12103 recorded_version.as_deref().unwrap_or("(missing)")
12104 );
12105 if running_pids.is_empty() {
12106 println!(" running daemons: none");
12107 println!(" running relays: none");
12108 } else {
12109 if daemon_pids.is_empty() {
12110 println!(" running daemons: none");
12111 } else {
12112 let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
12113 println!(" running daemons: pids {}", p.join(", "));
12114 }
12115 if relay_pids.is_empty() {
12116 println!(" running relays: none");
12117 } else {
12118 let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
12119 println!(" running relays: pids {}", p.join(", "));
12120 }
12121 println!(" would kill all + spawn fresh");
12122 }
12123 if !mcp_pids.is_empty() {
12128 let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
12129 if restart_mcp {
12130 println!(
12131 " wire mcp servers: pids {} (would be killed via --restart-mcp; host respawns on new binary)",
12132 p.join(", ")
12133 );
12134 } else {
12135 println!(
12136 " wire mcp servers: pids {} (NOT killed; each Claude tab must `/mcp` reconnect, or re-run with --restart-mcp to signal them now)",
12137 p.join(", ")
12138 );
12139 }
12140 }
12141 if !installed_service_kinds.is_empty() {
12142 println!(
12143 " would refresh: {} installed service unit(s) → new binary path",
12144 installed_service_kinds.join(", ")
12145 );
12146 }
12147 if !sessions_with_daemons.is_empty() {
12148 println!(
12149 " session daemons: {} (would respawn under new binary)",
12150 sessions_with_daemons.join(", ")
12151 );
12152 }
12153 if let Ok(sv) = crate::daemon_supervisor::read_supervisor_state()
12157 && !sv.stale_binary_sessions.is_empty()
12158 {
12159 let cli_v = env!("CARGO_PKG_VERSION");
12160 if refresh_stale_children {
12161 println!(
12162 " stale children: {} session(s) on old binary; --refresh-stale-children WOULD kill each so supervisor respawns on v{cli_v}",
12163 sv.stale_binary_sessions.len()
12164 );
12165 } else {
12166 println!(
12167 " stale children: {} session(s) on old binary (v{cli_v} is current); rerun with --refresh-stale-children to refresh them",
12168 sv.stale_binary_sessions.len()
12169 );
12170 }
12171 for name in &sv.stale_binary_sessions {
12172 let ver = sv
12173 .sessions
12174 .iter()
12175 .find(|s| &s.name == name)
12176 .and_then(|s| s.daemon_version.clone())
12177 .unwrap_or_else(|| "?".to_string());
12178 println!(" - {name} running v{ver}");
12179 }
12180 }
12181 if let Some(w) = &path_warning_check {
12182 println!(" PATH check:");
12183 for line in w.lines() {
12184 println!(" {line}");
12185 }
12186 }
12187 }
12188 return Ok(());
12189 }
12190
12191 for pid in &kill_set {
12203 let _ = crate::platform::kill_process(*pid, false); }
12205 if !kill_set.is_empty() {
12206 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
12208 while std::time::Instant::now() < deadline && kill_set.iter().any(|p| process_alive_pid(*p))
12209 {
12210 std::thread::sleep(std::time::Duration::from_millis(50));
12211 }
12212 for pid in &kill_set {
12215 if process_alive_pid(*pid) {
12216 let _ = crate::platform::kill_process(*pid, true);
12217 }
12218 }
12219 std::thread::sleep(std::time::Duration::from_millis(200)); }
12221 let killed: Vec<u32> = kill_set
12223 .iter()
12224 .copied()
12225 .filter(|p| !process_alive_pid(*p))
12226 .collect();
12227
12228 let pidfile = config::state_dir()?.join("daemon.pid");
12231 if pidfile.exists() {
12232 let _ = std::fs::remove_file(&pidfile);
12233 }
12234
12235 let path_bins = enumerate_path_wire_binaries();
12247 let path_dupes: Vec<String> = path_bins
12248 .iter()
12249 .map(|b| b.canonical.to_string_lossy().into_owned())
12250 .collect();
12251 let path_binaries_detail: Vec<Value> = path_bins
12252 .iter()
12253 .map(|b| {
12254 json!({
12255 "path": b.path.to_string_lossy(),
12256 "canonical": b.canonical.to_string_lossy(),
12257 "sha256": b.sha256,
12258 "mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
12259 "path_index": b.path_index,
12260 "is_active": b.is_active(),
12261 "is_current_exe": b.is_current_exe,
12262 })
12263 })
12264 .collect();
12265 let path_warning = path_shadow_warning(&path_bins);
12266
12267 let mut service_refreshes: Vec<Value> = Vec::new();
12281 for kind in [
12282 crate::service::ServiceKind::Daemon,
12283 crate::service::ServiceKind::LocalRelay,
12284 ] {
12285 let already_installed = crate::service::status_kind(kind)
12286 .map(|r| r.status != "absent")
12287 .unwrap_or(false);
12288 if !already_installed {
12289 continue;
12290 }
12291 match crate::service::install_kind(kind) {
12292 Ok(rep) => service_refreshes.push(json!({
12293 "kind": rep.kind,
12294 "platform": rep.platform,
12295 "status": rep.status,
12296 "unit_path": rep.unit_path,
12297 "action": "refreshed",
12298 })),
12299 Err(e) => service_refreshes.push(json!({
12300 "kind": format!("{kind:?}"),
12301 "action": "refresh_failed",
12302 "error": format!("{e:#}"),
12303 })),
12304 }
12305 }
12306
12307 let supervisor_will_spawn = service_refreshes.iter().any(|r| {
12334 let kind = r.get("kind").and_then(Value::as_str).unwrap_or("");
12335 let action = r.get("action").and_then(Value::as_str).unwrap_or("");
12336 let status = r.get("status").and_then(Value::as_str).unwrap_or("");
12337 kind == "daemon"
12338 && action == "refreshed"
12339 && matches!(
12340 status,
12341 "loaded" | "enabled" | "active" | "registered" | "running"
12342 )
12343 });
12344 let spawned = if supervisor_will_spawn {
12345 None
12348 } else {
12349 Some(crate::ensure_up::ensure_daemon_running()?)
12350 };
12351
12352 let session_respawns: Vec<Value> = Vec::new();
12357
12358 let new_record = crate::ensure_up::read_pid_record("daemon");
12359 let new_pid = new_record.pid();
12360 let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
12361 Some(d.version.clone())
12362 } else {
12363 None
12364 };
12365
12366 let killed_mcp: Vec<u32> = if restart_mcp && !mcp_pids.is_empty() {
12383 for pid in &mcp_pids {
12384 let _ = crate::platform::kill_process(*pid, false);
12385 }
12386 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
12387 while std::time::Instant::now() < deadline && mcp_pids.iter().any(|p| process_alive_pid(*p))
12388 {
12389 std::thread::sleep(std::time::Duration::from_millis(50));
12390 }
12391 for pid in &mcp_pids {
12392 if process_alive_pid(*pid) {
12393 let _ = crate::platform::kill_process(*pid, true);
12394 }
12395 }
12396 mcp_pids
12397 .iter()
12398 .copied()
12399 .filter(|p| !process_alive_pid(*p))
12400 .collect()
12401 } else {
12402 Vec::new()
12403 };
12404
12405 if as_json {
12406 println!(
12407 "{}",
12408 serde_json::to_string(&json!({
12409 "killed": killed,
12410 "found_daemons": daemon_pids,
12411 "spared_relay_servers": relay_pids,
12412 "stale_mcp_server_pids": mcp_pids,
12422 "killed_mcp_server_pids": killed_mcp,
12423 "restart_mcp_requested": restart_mcp,
12424 "stale_mcp_warning": if mcp_pids.is_empty() || restart_mcp {
12425 Value::Null
12426 } else {
12427 json!(format!(
12428 "{} `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)",
12429 mcp_pids.len()
12430 ))
12431 },
12432 "service_refreshes": service_refreshes,
12433 "spawned_fresh_daemon": spawned,
12434 "new_pid": new_pid,
12435 "new_version": new_version,
12436 "cli_version": cli_version,
12437 "session_respawns": session_respawns,
12438 "stale_children_killed": stale_children_killed,
12439 "path_binaries": path_dupes,
12440 "path_binaries_detail": path_binaries_detail,
12441 "path_warning": path_warning,
12442 }))?
12443 );
12444 } else {
12445 if killed.is_empty() {
12446 println!("wire upgrade: no stale wire processes running");
12447 } else {
12448 let killed_list = killed
12449 .iter()
12450 .map(|p| p.to_string())
12451 .collect::<Vec<_>>()
12452 .join(", ");
12453 if relay_pids.is_empty() {
12458 println!(
12459 "wire upgrade: killed {} daemon(s) [{killed_list}]",
12460 killed.len()
12461 );
12462 } else {
12463 let relay_list = relay_pids
12464 .iter()
12465 .map(|p| p.to_string())
12466 .collect::<Vec<_>>()
12467 .join(", ");
12468 println!(
12469 "wire upgrade: killed {} daemon(s) [{killed_list}]; spared {} shared relay-server(s) [{relay_list}]",
12470 killed.len(),
12471 relay_pids.len()
12472 );
12473 }
12474 }
12475 if !stale_children_killed.is_empty() {
12476 let cli_v = env!("CARGO_PKG_VERSION");
12477 println!(
12478 "wire upgrade: refreshed {} stale-binary session daemon(s) (supervisor respawns on v{cli_v} on next 10s poll):",
12479 stale_children_killed.len()
12480 );
12481 for entry in &stale_children_killed {
12482 let name = entry.get("session").and_then(Value::as_str).unwrap_or("?");
12483 let pid = entry.get("pid").and_then(Value::as_u64).unwrap_or(0);
12484 let prev = entry
12485 .get("prev_version")
12486 .and_then(Value::as_str)
12487 .unwrap_or("?");
12488 println!(" - {name} (pid {pid}, was v{prev})");
12489 }
12490 }
12491 if !service_refreshes.is_empty() {
12492 println!(
12493 "wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
12494 service_refreshes.len()
12495 );
12496 for r in &service_refreshes {
12497 let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
12498 let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
12499 let status = r.get("status").and_then(Value::as_str).unwrap_or("");
12500 let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
12501 if action == "refreshed" {
12502 println!(" - {kind}: {action} ({status}, {platform})");
12503 } else {
12504 let err = r.get("error").and_then(Value::as_str).unwrap_or("");
12505 println!(" - {kind}: {action} ({err})");
12506 }
12507 }
12508 }
12509 match spawned {
12510 Some(true) => println!(
12511 "wire upgrade: spawned fresh daemon (pid {} v{})",
12512 new_pid
12513 .map(|p| p.to_string())
12514 .unwrap_or_else(|| "?".to_string()),
12515 new_version.as_deref().unwrap_or(&cli_version),
12516 ),
12517 Some(false) => {
12518 println!("wire upgrade: daemon was already running on current binary");
12519 }
12520 None => println!(
12527 "wire upgrade: daemon refresh deferred to {} supervisor (will spawn within 10s)",
12528 if cfg!(target_os = "macos") {
12529 "launchd"
12530 } else if cfg!(target_os = "linux") {
12531 "systemd"
12532 } else if cfg!(target_os = "windows") {
12533 "Task Scheduler"
12534 } else {
12535 "OS"
12536 }
12537 ),
12538 }
12539 if !session_respawns.is_empty() {
12540 println!(
12541 "wire upgrade: refreshed {} session daemon(s):",
12542 session_respawns.len()
12543 );
12544 for r in &session_respawns {
12545 let h = r["session_home"].as_str().unwrap_or("?");
12546 let s = r["status"].as_str().unwrap_or("?");
12547 let label = std::path::Path::new(h)
12548 .file_name()
12549 .map(|f| f.to_string_lossy().into_owned())
12550 .unwrap_or_else(|| h.to_string());
12551 println!(" {label:<24} {s}");
12552 }
12553 }
12554 if let Some(msg) = &path_warning {
12555 eprintln!("wire upgrade: {msg}");
12556 }
12557 if restart_mcp {
12566 if !killed_mcp.is_empty() {
12567 let p: Vec<String> = killed_mcp.iter().map(|p| p.to_string()).collect();
12568 println!(
12569 "wire upgrade: killed {} `wire mcp` server subprocess(es) [{}]; host (Claude Code / Claude.app / Copilot CLI) will respawn on the new binary.",
12570 killed_mcp.len(),
12571 p.join(", ")
12572 );
12573 } else if mcp_pids.is_empty() {
12574 println!(
12578 "wire upgrade: --restart-mcp set, but no `wire mcp` server subprocesses were running."
12579 );
12580 } else {
12581 let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
12585 eprintln!(
12586 "wire upgrade: WARNING — --restart-mcp requested but {} `wire mcp` subprocess(es) [{}] survived signaling. Check process ownership / OS permissions.",
12587 mcp_pids.len(),
12588 p.join(", ")
12589 );
12590 }
12591 } else if !mcp_pids.is_empty() {
12592 let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
12593 eprintln!(
12594 "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.",
12595 mcp_pids.len(),
12596 p.join(", ")
12597 );
12598 }
12599 }
12600 Ok(())
12601}
12602
12603fn json_default(explicit: bool) -> bool {
12613 if explicit {
12614 return true;
12615 }
12616 if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
12617 return false;
12618 }
12619 use std::io::IsTerminal;
12620 !std::io::stdout().is_terminal()
12621}
12622
12623fn process_alive_pid(pid: u32) -> bool {
12624 crate::platform::process_alive(pid)
12629}
12630
12631fn levenshtein_ci(a: &str, b: &str) -> usize {
12637 let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
12638 let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
12639 let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
12640 let (m, n) = (a.len(), b.len());
12641 if m == 0 {
12642 return n;
12643 }
12644 let mut prev: Vec<usize> = (0..=m).collect();
12645 let mut curr = vec![0usize; m + 1];
12646 for j in 1..=n {
12647 curr[0] = j;
12648 for i in 1..=m {
12649 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
12650 curr[i] = std::cmp::min(
12651 std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
12652 prev[i - 1] + cost,
12653 );
12654 }
12655 std::mem::swap(&mut prev, &mut curr);
12656 }
12657 prev[m]
12658}
12659
12660pub fn closest_candidates(
12664 needle: &str,
12665 pool: &[String],
12666 max_distance: usize,
12667 max_results: usize,
12668) -> Vec<String> {
12669 let mut scored: Vec<(usize, &String)> = pool
12670 .iter()
12671 .map(|c| (levenshtein_ci(needle, c), c))
12672 .filter(|(d, _)| *d <= max_distance)
12673 .collect();
12674 scored.sort_by_key(|(d, _)| *d);
12675 scored
12676 .into_iter()
12677 .take(max_results)
12678 .map(|(_, c)| c.clone())
12679 .collect()
12680}
12681
12682fn known_local_names() -> Vec<String> {
12687 let mut names: Vec<String> = Vec::new();
12688 if let Ok(trust) = config::read_trust() {
12689 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
12695 for (handle, agent) in agents {
12696 names.push(handle.clone());
12697 if let Some(did) = agent.get("did").and_then(Value::as_str) {
12698 let ch = crate::character::Character::from_did(did);
12699 names.push(ch.nickname);
12700 }
12701 }
12702 }
12703 }
12704 if let Ok(sessions) = crate::session::list_sessions() {
12705 for s in sessions {
12706 names.push(s.name.clone());
12707 if let Some(h) = &s.handle {
12708 names.push(h.clone());
12709 }
12710 if let Some(ch) = &s.character {
12711 names.push(ch.nickname.clone());
12712 }
12713 }
12714 }
12715 names.sort();
12716 names.dedup();
12717 names
12718}
12719
12720#[derive(Clone, Debug, serde::Serialize)]
12724pub struct DoctorCheck {
12725 pub id: String,
12728 pub status: String,
12730 pub detail: String,
12732 #[serde(skip_serializing_if = "Option::is_none")]
12734 pub fix: Option<String>,
12735}
12736
12737impl DoctorCheck {
12738 fn pass(id: &str, detail: impl Into<String>) -> Self {
12739 Self {
12740 id: id.into(),
12741 status: "PASS".into(),
12742 detail: detail.into(),
12743 fix: None,
12744 }
12745 }
12746 fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
12747 Self {
12748 id: id.into(),
12749 status: "WARN".into(),
12750 detail: detail.into(),
12751 fix: Some(fix.into()),
12752 }
12753 }
12754 fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
12755 Self {
12756 id: id.into(),
12757 status: "FAIL".into(),
12758 detail: detail.into(),
12759 fix: Some(fix.into()),
12760 }
12761 }
12762}
12763
12764fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
12769 let checks: Vec<DoctorCheck> = vec![
12770 check_daemon_health(),
12771 check_daemon_pid_consistency(),
12772 check_relay_reachable(),
12773 check_pair_rejections(recent_rejections),
12774 check_cursor_progress(),
12775 check_peer_staleness(7),
12776 check_and_heal_self_userinfo_endpoints(),
12777 check_stale_inbound_pairs(),
12778 ];
12779
12780 let fails = checks.iter().filter(|c| c.status == "FAIL").count();
12781 let warns = checks.iter().filter(|c| c.status == "WARN").count();
12782
12783 if as_json {
12784 println!(
12785 "{}",
12786 serde_json::to_string(&json!({
12787 "checks": checks,
12788 "fail_count": fails,
12789 "warn_count": warns,
12790 "ok": fails == 0,
12791 }))?
12792 );
12793 } else {
12794 println!("wire doctor — {} checks", checks.len());
12795 for c in &checks {
12796 let bullet = match c.status.as_str() {
12797 "PASS" => "✓",
12798 "WARN" => "!",
12799 "FAIL" => "✗",
12800 _ => "?",
12801 };
12802 println!(" {bullet} [{}] {}: {}", c.status, c.id, c.detail);
12803 if let Some(fix) = &c.fix {
12804 println!(" fix: {fix}");
12805 }
12806 }
12807 println!();
12808 if fails == 0 && warns == 0 {
12809 println!("ALL GREEN");
12810 } else {
12811 println!("{fails} FAIL, {warns} WARN");
12812 }
12813 }
12814
12815 if fails > 0 {
12816 std::process::exit(1);
12817 }
12818 Ok(())
12819}
12820
12821fn check_daemon_health() -> DoctorCheck {
12828 let snap = crate::ensure_up::daemon_liveness();
12834 let pgrep_pids = &snap.pgrep_pids;
12835 let pidfile_pid = snap.pidfile_pid;
12836 let pidfile_alive = snap.pidfile_alive;
12837 let orphan_pids = &snap.orphan_pids;
12838
12839 let fmt_pids = |xs: &[u32]| -> String {
12840 xs.iter()
12841 .map(|p| p.to_string())
12842 .collect::<Vec<_>>()
12843 .join(", ")
12844 };
12845
12846 match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
12847 (0, _, _) => DoctorCheck::fail(
12848 "daemon",
12849 "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
12850 "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
12851 ),
12852 (1, true, true) => DoctorCheck::pass(
12854 "daemon",
12855 format!(
12856 "one daemon running (pid {}, matches pidfile)",
12857 pgrep_pids[0]
12858 ),
12859 ),
12860 (n, true, false) => DoctorCheck::fail(
12862 "daemon",
12863 format!(
12864 "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
12865 The orphans race the relay cursor — they advance past events your current binary can't process. \
12866 (Issue #2 exact class.)",
12867 fmt_pids(pgrep_pids),
12868 pidfile_pid.unwrap(),
12869 fmt_pids(orphan_pids),
12870 ),
12871 "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
12872 ),
12873 (n, false, _) => DoctorCheck::fail(
12875 "daemon",
12876 format!(
12877 "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
12878 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
12879 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
12880 fmt_pids(pgrep_pids),
12881 match pidfile_pid {
12882 Some(p) => format!("claims pid {p} which is dead"),
12883 None => "is missing".to_string(),
12884 },
12885 ),
12886 "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
12887 ),
12888 (n, true, true) => {
12900 let supervisor_pid: Option<u32> = crate::session::sessions_root()
12902 .ok()
12903 .map(|root| root.join("supervisor.pid"))
12904 .filter(|p| p.exists())
12905 .and_then(|p| std::fs::read_to_string(p).ok())
12906 .and_then(|s| s.trim().parse::<u32>().ok())
12907 .filter(|p| crate::ensure_up::pid_is_alive(*p));
12908 if let Some(sup) = supervisor_pid
12909 && pgrep_pids.contains(&sup)
12910 {
12911 let child_count = n.saturating_sub(1);
12912 DoctorCheck::pass(
12913 "daemon",
12914 format!(
12915 "supervisor (pid {sup}) + {child_count} session child daemon(s) — legitimate #170 `--all-sessions` topology, no orphans"
12916 ),
12917 )
12918 } else {
12919 DoctorCheck::warn(
12920 "daemon",
12921 format!(
12922 "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
12923 fmt_pids(pgrep_pids)
12924 ),
12925 "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
12926 )
12927 }
12928 }
12929 }
12930}
12931
12932fn check_daemon_pid_consistency() -> DoctorCheck {
12944 let snap = crate::ensure_up::daemon_liveness();
12945 match &snap.record {
12946 crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
12947 "daemon_pid_consistency",
12948 "no daemon.pid yet — fresh box or daemon never started",
12949 ),
12950 crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
12951 "daemon_pid_consistency",
12952 format!("daemon.pid is corrupt: {reason}"),
12953 "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
12954 ),
12955 crate::ensure_up::PidRecord::Json(d) => {
12956 if !snap.pidfile_alive {
12960 return DoctorCheck::warn(
12961 "daemon_pid_consistency",
12962 format!(
12963 "daemon.pid records pid {pid} (v{version}) but that process is not running — \
12964 pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
12965 silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
12966 pid = d.pid,
12967 version = d.version,
12968 ),
12969 "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
12970 (kills any orphan daemon advancing the cursor without coordination)",
12971 );
12972 }
12973 let mut issues: Vec<String> = Vec::new();
12974 if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
12975 issues.push(format!(
12976 "schema={} (expected {})",
12977 d.schema,
12978 crate::ensure_up::DAEMON_PID_SCHEMA
12979 ));
12980 }
12981 let cli_version = env!("CARGO_PKG_VERSION");
12982 if d.version != cli_version {
12983 issues.push(format!("version daemon={} cli={cli_version}", d.version));
12984 }
12985 if !std::path::Path::new(&d.bin_path).exists() {
12986 issues.push(format!("bin_path {} missing on disk", d.bin_path));
12987 }
12988 if let Ok(card) = config::read_agent_card()
12990 && let Some(current_did) = card.get("did").and_then(Value::as_str)
12991 && let Some(recorded_did) = &d.did
12992 && recorded_did != current_did
12993 {
12994 issues.push(format!(
12995 "did daemon={recorded_did} config={current_did} — identity drift"
12996 ));
12997 }
12998 if let Ok(state) = config::read_relay_state()
12999 && let Some(current_relay) = state
13000 .get("self")
13001 .and_then(|s| s.get("relay_url"))
13002 .and_then(Value::as_str)
13003 && let Some(recorded_relay) = &d.relay_url
13004 && recorded_relay != current_relay
13005 {
13006 issues.push(format!(
13007 "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
13008 ));
13009 }
13010 if issues.is_empty() {
13011 DoctorCheck::pass(
13012 "daemon_pid_consistency",
13013 format!(
13014 "daemon v{} bound to {} as {}",
13015 d.version,
13016 d.relay_url.as_deref().unwrap_or("?"),
13017 d.did.as_deref().unwrap_or("?")
13018 ),
13019 )
13020 } else {
13021 DoctorCheck::warn(
13022 "daemon_pid_consistency",
13023 format!("daemon pidfile drift: {}", issues.join("; ")),
13024 "`wire upgrade` to atomically restart daemon with current config".to_string(),
13025 )
13026 }
13027 }
13028 }
13029}
13030
13031fn check_relay_reachable() -> DoctorCheck {
13033 let state = match config::read_relay_state() {
13034 Ok(s) => s,
13035 Err(e) => {
13036 return DoctorCheck::fail(
13037 "relay",
13038 format!("could not read relay state: {e}"),
13039 "run `wire up <handle>@<relay>` to bootstrap",
13040 );
13041 }
13042 };
13043 let url = state
13044 .get("self")
13045 .and_then(|s| s.get("relay_url"))
13046 .and_then(Value::as_str)
13047 .unwrap_or("");
13048 if url.is_empty() {
13049 return DoctorCheck::warn(
13050 "relay",
13051 "no relay bound — wire send/pull will not work",
13052 "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
13053 );
13054 }
13055 let client = crate::relay_client::RelayClient::new(url);
13056 match client.check_healthz() {
13057 Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
13058 Err(e) => DoctorCheck::fail(
13059 "relay",
13060 format!("{url} unreachable: {e}"),
13061 format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
13062 ),
13063 }
13064}
13065
13066fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
13070 let path = match config::state_dir() {
13071 Ok(d) => d.join("pair-rejected.jsonl"),
13072 Err(e) => {
13073 return DoctorCheck::warn(
13074 "pair_rejections",
13075 format!("could not resolve state dir: {e}"),
13076 "set WIRE_HOME or fix XDG_STATE_HOME",
13077 );
13078 }
13079 };
13080 if !path.exists() {
13081 return DoctorCheck::pass(
13082 "pair_rejections",
13083 "no pair-rejected.jsonl — no recorded pair failures",
13084 );
13085 }
13086 let body = match std::fs::read_to_string(&path) {
13087 Ok(b) => b,
13088 Err(e) => {
13089 return DoctorCheck::warn(
13090 "pair_rejections",
13091 format!("could not read {path:?}: {e}"),
13092 "check file permissions",
13093 );
13094 }
13095 };
13096 let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
13097 if lines.is_empty() {
13098 return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
13099 }
13100 let total = lines.len();
13101 let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
13102 let mut summary: Vec<String> = Vec::new();
13103 for line in &recent {
13104 if let Ok(rec) = serde_json::from_str::<Value>(line) {
13105 let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
13106 let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
13107 summary.push(format!("{peer}/{code}"));
13108 }
13109 }
13110 DoctorCheck::warn(
13111 "pair_rejections",
13112 format!(
13113 "{total} pair failures recorded. recent: [{}]",
13114 summary.join(", ")
13115 ),
13116 format!(
13117 "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
13118 ),
13119 )
13120}
13121
13122fn check_and_heal_self_userinfo_endpoints() -> DoctorCheck {
13186 let mut state = match config::read_relay_state() {
13187 Ok(s) => s,
13188 Err(_) => {
13189 return DoctorCheck::pass(
13190 "self-userinfo-endpoints",
13191 "no relay state yet — nothing published to heal".to_string(),
13192 );
13193 }
13194 };
13195 let self_block = match state.get_mut("self").and_then(Value::as_object_mut) {
13196 Some(s) => s,
13197 None => {
13198 return DoctorCheck::pass(
13199 "self-userinfo-endpoints",
13200 "no self block in relay state — nothing published to heal".to_string(),
13201 );
13202 }
13203 };
13204
13205 let mut stripped: Vec<String> = Vec::new();
13206 let mut clean_seed: Option<(String, String, String)> = None;
13207
13208 if let Some(endpoints) = self_block
13209 .get_mut("endpoints")
13210 .and_then(Value::as_array_mut)
13211 {
13212 endpoints.retain(|ep| {
13213 let url = ep.get("relay_url").and_then(Value::as_str).unwrap_or("");
13214 if assert_relay_url_clean_for_publish(url).is_err() {
13218 stripped.push(url.to_string());
13219 false
13220 } else {
13221 if clean_seed.is_none() {
13222 clean_seed = Some((
13223 url.to_string(),
13224 ep.get("slot_id")
13225 .and_then(Value::as_str)
13226 .unwrap_or("")
13227 .to_string(),
13228 ep.get("slot_token")
13229 .and_then(Value::as_str)
13230 .unwrap_or("")
13231 .to_string(),
13232 ));
13233 }
13234 true
13235 }
13236 });
13237 }
13238
13239 let mut legacy_healed = false;
13244 let legacy_url = self_block
13245 .get("relay_url")
13246 .and_then(Value::as_str)
13247 .unwrap_or("")
13248 .to_string();
13249 if !legacy_url.is_empty() && assert_relay_url_clean_for_publish(&legacy_url).is_err() {
13250 if let Some((url, sid, tok)) = &clean_seed {
13251 self_block.insert("relay_url".to_string(), Value::String(url.clone()));
13252 self_block.insert("slot_id".to_string(), Value::String(sid.clone()));
13253 self_block.insert("slot_token".to_string(), Value::String(tok.clone()));
13254 legacy_healed = true;
13255 stripped.push(format!("(legacy top-level) {legacy_url}"));
13256 } else {
13257 return DoctorCheck::warn(
13262 "self-userinfo-endpoints",
13263 format!(
13264 "your published endpoint is malformed (`{legacy_url}` — handle as URL \
13265 userinfo, the bug PR #61 prevents going forward) AND no clean endpoint \
13266 exists to fall back to. Inbound POSTs to this endpoint 4xx; bilateral \
13267 pairing can't complete."
13268 ),
13269 "Bind a clean federation slot first, then re-run doctor to heal: \
13270 `wire bind-relay https://wireup.net` (or your own relay). The bind \
13271 adds a clean endpoint additively; the next `wire doctor` run then \
13272 strips the malformed one safely. Finally re-publish your card with \
13273 `wire claim <your-persona>` so the phonebook serves the clean shape."
13274 .to_string(),
13275 );
13276 }
13277 }
13278
13279 if stripped.is_empty() && !legacy_healed {
13280 return DoctorCheck::pass(
13281 "self-userinfo-endpoints",
13282 "no malformed endpoints in self-state".to_string(),
13283 );
13284 }
13285
13286 if let Err(e) = config::write_relay_state(&state) {
13290 return DoctorCheck::warn(
13291 "self-userinfo-endpoints",
13292 format!(
13293 "detected {} malformed userinfo-bearing endpoint(s) in self-state but \
13294 failed to persist the heal: {e:#}. Found: {}",
13295 stripped.len(),
13296 stripped.join(", ")
13297 ),
13298 "re-run `wire doctor` — likely a transient lock contention".to_string(),
13299 );
13300 }
13301
13302 DoctorCheck::warn(
13303 "self-userinfo-endpoints",
13304 format!(
13305 "healed {} malformed endpoint(s) in self-state on disk: {}. \
13306 These were the `https://<handle>@<host>` shape that PR #61 prevents \
13307 at the write side but couldn't retroactively scrub from existing \
13308 operators. relay.json is now clean.",
13309 stripped.len(),
13310 stripped.join(", ")
13311 ),
13312 "re-publish your agent-card to the phonebook so peers resolve to the \
13313 clean endpoint: `wire claim <your-persona>` (find your persona with \
13314 `wire whoami`)."
13315 .to_string(),
13316 )
13317}
13318
13319fn check_stale_inbound_pairs() -> DoctorCheck {
13328 let pinned_verified: std::collections::HashSet<String> = config::read_trust()
13329 .ok()
13330 .and_then(|t| t.get("agents").and_then(Value::as_object).cloned())
13331 .map(|agents| {
13332 agents
13333 .into_iter()
13334 .filter_map(|(h, a)| {
13335 let tier = a.get("tier").and_then(Value::as_str).unwrap_or("");
13336 if matches!(tier, "VERIFIED" | "ORG_VERIFIED" | "ATTESTED") {
13337 Some(h)
13338 } else {
13339 None
13340 }
13341 })
13342 .collect()
13343 })
13344 .unwrap_or_default();
13345 let stale: Vec<String> = crate::pending_inbound_pair::list_pending_inbound()
13346 .unwrap_or_default()
13347 .into_iter()
13348 .filter(|p| pinned_verified.contains(&p.peer_handle))
13349 .map(|p| p.peer_handle)
13350 .collect();
13351 if stale.is_empty() {
13352 return DoctorCheck::pass(
13353 "stale-inbound-pairs",
13354 "no pre-#171 leftover pending_inbound records for VERIFIED peers",
13355 );
13356 }
13357 let n = stale.len();
13358 let list = stale.join(", ");
13359 let fix_list = stale
13360 .iter()
13361 .map(|h| format!("wire reject {h}"))
13362 .collect::<Vec<_>>()
13363 .join(" && ");
13364 DoctorCheck::warn(
13365 "stale-inbound-pairs",
13366 format!(
13367 "{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`."
13368 ),
13369 format!("clear with `{fix_list}`"),
13370 )
13371}
13372
13373fn check_peer_staleness(max_silent_days: u64) -> DoctorCheck {
13374 let state = match config::read_relay_state() {
13375 Ok(s) => s,
13376 Err(_) => {
13377 return DoctorCheck::pass(
13378 "peer-staleness",
13379 "no relay state yet — nothing pinned to check".to_string(),
13380 );
13381 }
13382 };
13383 let peers = match state.get("peers").and_then(Value::as_object) {
13384 Some(p) => p,
13385 None => {
13386 return DoctorCheck::pass("peer-staleness", "no pinned peers".to_string());
13387 }
13388 };
13389 if peers.is_empty() {
13390 return DoctorCheck::pass("peer-staleness", "no pinned peers".to_string());
13391 }
13392 let inbox_dir = match config::inbox_dir() {
13393 Ok(d) => d,
13394 Err(_) => {
13395 return DoctorCheck::warn(
13396 "peer-staleness",
13397 "could not resolve inbox dir; skipping peer-staleness check".to_string(),
13398 "check `wire status` for state-dir resolution".to_string(),
13399 );
13400 }
13401 };
13402 let threshold_secs = max_silent_days * 24 * 60 * 60;
13403 let threshold = std::time::Duration::from_secs(threshold_secs);
13404 let now = std::time::SystemTime::now();
13405 let now_unix = now
13413 .duration_since(std::time::UNIX_EPOCH)
13414 .map(|d| d.as_secs() as i64)
13415 .unwrap_or(0);
13416 let mut stale: Vec<(String, u64, &'static str)> = Vec::new();
13417 for (peer, info) in peers {
13418 let daemon_signal_ts = info
13420 .get("last_inbound_event_at")
13421 .and_then(Value::as_str)
13422 .and_then(|s| {
13423 time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
13424 })
13425 .map(|odt| odt.unix_timestamp());
13426 if let Some(ts) = daemon_signal_ts {
13427 let age = now_unix.saturating_sub(ts) as u64;
13428 if age > threshold_secs {
13429 stale.push((peer.clone(), age / (24 * 60 * 60), "silent"));
13430 }
13431 continue;
13432 }
13433 let path = inbox_dir.join(format!("{peer}.jsonl"));
13435 let (age_days, kind) = match std::fs::metadata(&path) {
13436 Ok(meta) => match meta
13437 .modified()
13438 .ok()
13439 .and_then(|m| now.duration_since(m).ok())
13440 {
13441 Some(d) if d > threshold => (d.as_secs() / (24 * 60 * 60), "silent"),
13442 Some(_) => continue, None => (0, "unknown-mtime"),
13444 },
13445 Err(_) => (max_silent_days + 1, "no-inbox-file"),
13446 };
13447 stale.push((peer.clone(), age_days, kind));
13448 }
13449 if stale.is_empty() {
13450 return DoctorCheck::pass(
13451 "peer-staleness",
13452 format!(
13453 "all {} pinned peer(s) have inbox traffic within the last {max_silent_days} day(s)",
13454 peers.len()
13455 ),
13456 );
13457 }
13458 let detail = stale
13459 .iter()
13460 .map(|(p, d, k)| match *k {
13461 "no-inbox-file" => format!("{p} (no inbox file)"),
13462 "unknown-mtime" => format!("{p} (unknown last-event time)"),
13463 _ => format!("{p} ({d}d silent)"),
13464 })
13465 .collect::<Vec<_>>()
13466 .join(", ");
13467 DoctorCheck::warn(
13468 "peer-staleness",
13469 format!(
13470 "{} pinned peer(s) silent for >{max_silent_days}d: {detail}. \
13471 If the peer re-bound their relay slot, our pin is now stale — \
13472 we push successfully to a dead slot and they never see us \
13473 (asymmetric failure, both sides report green).",
13474 stale.len()
13475 ),
13476 "re-pair with `wire add <peer>@<relay>` to refresh the slot. \
13477 Once issue #15 lands, this also auto-resolves on 410 Gone."
13478 .to_string(),
13479 )
13480}
13481
13482fn check_cursor_progress() -> DoctorCheck {
13483 let state = match config::read_relay_state() {
13484 Ok(s) => s,
13485 Err(e) => {
13486 return DoctorCheck::warn(
13487 "cursor",
13488 format!("could not read relay state: {e}"),
13489 "check ~/Library/Application Support/wire/relay.json",
13490 );
13491 }
13492 };
13493 let cursor = state
13494 .get("self")
13495 .and_then(|s| s.get("last_pulled_event_id"))
13496 .and_then(Value::as_str)
13497 .map(|s| s.chars().take(16).collect::<String>())
13498 .unwrap_or_else(|| "<none>".to_string());
13499 DoctorCheck::pass(
13500 "cursor",
13501 format!(
13502 "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
13503 ),
13504 )
13505}
13506
13507#[cfg(test)]
13508mod doctor_tests {
13509 use super::*;
13510
13511 #[test]
13512 fn doctor_check_constructors_set_status_correctly() {
13513 let p = DoctorCheck::pass("x", "ok");
13518 assert_eq!(p.status, "PASS");
13519 assert_eq!(p.fix, None);
13520
13521 let w = DoctorCheck::warn("x", "watch out", "do this");
13522 assert_eq!(w.status, "WARN");
13523 assert_eq!(w.fix, Some("do this".to_string()));
13524
13525 let f = DoctorCheck::fail("x", "broken", "fix it");
13526 assert_eq!(f.status, "FAIL");
13527 assert_eq!(f.fix, Some("fix it".to_string()));
13528 }
13529
13530 #[test]
13531 fn check_pair_rejections_no_file_is_pass() {
13532 config::test_support::with_temp_home(|| {
13535 config::ensure_dirs().unwrap();
13536 let c = check_pair_rejections(5);
13537 assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
13538 });
13539 }
13540
13541 #[test]
13542 fn check_pair_rejections_with_entries_warns() {
13543 config::test_support::with_temp_home(|| {
13547 config::ensure_dirs().unwrap();
13548 crate::pair_invite::record_pair_rejection(
13549 "willard",
13550 "pair_drop_ack_send_failed",
13551 "POST 502",
13552 );
13553 let c = check_pair_rejections(5);
13554 assert_eq!(c.status, "WARN");
13555 assert!(c.detail.contains("1 pair failures"));
13556 assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
13557 });
13558 }
13559
13560 #[test]
13561 fn check_peer_staleness_no_peers_is_pass() {
13562 config::test_support::with_temp_home(|| {
13565 config::ensure_dirs().unwrap();
13566 let c = check_peer_staleness(7);
13567 assert_eq!(c.status, "PASS", "no peers should be PASS, got {c:?}");
13568 });
13569 }
13570
13571 #[test]
13572 fn check_peer_staleness_pinned_with_no_inbox_file_warns() {
13573 config::test_support::with_temp_home(|| {
13578 config::ensure_dirs().unwrap();
13579 let mut state = json!({
13581 "peers": {
13582 "stale-peer": {
13583 "relay_url": "https://wireup.net",
13584 "slot_id": "deadslot",
13585 "slot_token": "tok",
13586 }
13587 }
13588 });
13589 state["self"] = json!({});
13590 config::write_relay_state(&state).unwrap();
13591
13592 let c = check_peer_staleness(7);
13593 assert_eq!(
13594 c.status, "WARN",
13595 "pinned peer with no inbox file must surface: {c:?}"
13596 );
13597 assert!(
13598 c.detail.contains("stale-peer"),
13599 "WARN must name the silent peer so the operator can act: {}",
13600 c.detail
13601 );
13602 assert!(
13603 c.detail.contains("asymmetric")
13604 || c.detail.contains("stale")
13605 || c.detail.contains("dead slot"),
13606 "WARN must surface the failure-mode language so the operator \
13607 finds the diagnosis without re-tracing: {}",
13608 c.detail
13609 );
13610 assert!(
13611 c.fix
13612 .as_ref()
13613 .is_some_and(|f| f.contains("wire add") && f.contains("#15")),
13614 "fix pointer must reference both the manual re-pair AND the \
13615 follow-up issue (#15) that will automate this: {:?}",
13616 c.fix
13617 );
13618 });
13619 }
13620
13621 #[test]
13622 fn check_peer_staleness_pinned_with_fresh_inbox_is_pass() {
13623 config::test_support::with_temp_home(|| {
13627 config::ensure_dirs().unwrap();
13628 let mut state = json!({
13629 "peers": {
13630 "active-peer": {
13631 "relay_url": "https://wireup.net",
13632 "slot_id": "freshslot",
13633 "slot_token": "tok",
13634 }
13635 }
13636 });
13637 state["self"] = json!({});
13638 config::write_relay_state(&state).unwrap();
13639
13640 let inbox = config::inbox_dir().unwrap();
13641 std::fs::create_dir_all(&inbox).unwrap();
13642 std::fs::write(
13643 inbox.join("active-peer.jsonl"),
13644 "{\"event_id\":\"recent\"}\n",
13645 )
13646 .unwrap();
13647
13648 let c = check_peer_staleness(7);
13649 assert_eq!(c.status, "PASS", "fresh inbox should not warn: {c:?}");
13650 });
13651 }
13652
13653 #[test]
13654 fn check_peer_staleness_daemon_field_overrides_mtime() {
13655 config::test_support::with_temp_home(|| {
13661 config::ensure_dirs().unwrap();
13662 let mut state = json!({
13663 "peers": {
13664 "ghost-peer": {
13665 "relay_url": "https://wireup.net",
13666 "slot_id": "ghostslot",
13667 "slot_token": "tok",
13668 "last_inbound_event_at": "2026-05-01T00:00:00Z",
13669 }
13670 }
13671 });
13672 state["self"] = json!({});
13673 config::write_relay_state(&state).unwrap();
13674 let inbox = config::inbox_dir().unwrap();
13676 std::fs::create_dir_all(&inbox).unwrap();
13677 std::fs::write(inbox.join("ghost-peer.jsonl"), "{\"event_id\":\"x\"}\n").unwrap();
13678 let c = check_peer_staleness(7);
13679 assert_eq!(
13680 c.status, "WARN",
13681 "daemon-field staleness must override fresh mtime: {c:?}"
13682 );
13683 assert!(c.detail.contains("ghost-peer"), "got: {}", c.detail);
13684 });
13685 }
13686
13687 #[test]
13688 fn check_peer_staleness_daemon_field_fresh_overrides_old_mtime() {
13689 config::test_support::with_temp_home(|| {
13694 config::ensure_dirs().unwrap();
13695 let now = time::OffsetDateTime::now_utc()
13697 .format(&time::format_description::well_known::Rfc3339)
13698 .unwrap();
13699 let mut state = json!({
13700 "peers": {
13701 "active-peer": {
13702 "relay_url": "https://wireup.net",
13703 "slot_id": "freshslot",
13704 "slot_token": "tok",
13705 "last_inbound_event_at": now,
13706 }
13707 }
13708 });
13709 state["self"] = json!({});
13710 config::write_relay_state(&state).unwrap();
13711 let c = check_peer_staleness(7);
13715 assert_eq!(
13716 c.status, "PASS",
13717 "recent daemon-field stamp must PASS regardless of mtime: {c:?}"
13718 );
13719 });
13720 }
13721
13722 #[test]
13723 fn check_self_userinfo_no_state_is_pass() {
13724 config::test_support::with_temp_home(|| {
13728 let c = check_and_heal_self_userinfo_endpoints();
13730 assert_eq!(c.status, "PASS", "no state should be PASS, got {c:?}");
13731 });
13732 }
13733
13734 #[test]
13735 fn check_self_userinfo_clean_state_is_pass_no_mutation() {
13736 config::test_support::with_temp_home(|| {
13740 config::ensure_dirs().unwrap();
13741 let state = json!({
13742 "self": {
13743 "endpoints": [
13744 {
13745 "relay_url": "https://wireup.net",
13746 "scope": "Federation",
13747 "slot_id": "abc",
13748 "slot_token": "tok"
13749 }
13750 ],
13751 "relay_url": "https://wireup.net",
13752 "slot_id": "abc",
13753 "slot_token": "tok"
13754 },
13755 "peers": {}
13756 });
13757 config::write_relay_state(&state).unwrap();
13758
13759 let c = check_and_heal_self_userinfo_endpoints();
13760 assert_eq!(c.status, "PASS", "clean state should be PASS: {c:?}");
13761
13762 let after = config::read_relay_state().unwrap();
13764 assert_eq!(after, state, "PASS path must NOT mutate relay.json");
13765 });
13766 }
13767
13768 #[test]
13769 fn check_self_userinfo_heals_malformed_endpoint_and_promotes_clean() {
13770 config::test_support::with_temp_home(|| {
13777 config::ensure_dirs().unwrap();
13778 let state = json!({
13779 "self": {
13780 "endpoints": [
13781 {
13782 "relay_url": "https://copilot-agent@wireup.net",
13783 "scope": "Federation",
13784 "slot_id": "stale-id",
13785 "slot_token": "stale-token"
13786 },
13787 {
13788 "relay_url": "https://wireup.net",
13789 "scope": "Federation",
13790 "slot_id": "clean-id",
13791 "slot_token": "clean-token"
13792 }
13793 ],
13794 "relay_url": "https://copilot-agent@wireup.net",
13795 "slot_id": "stale-id",
13796 "slot_token": "stale-token"
13797 },
13798 "peers": {}
13799 });
13800 config::write_relay_state(&state).unwrap();
13801
13802 let c = check_and_heal_self_userinfo_endpoints();
13803 assert_eq!(c.status, "WARN", "heal should report WARN: {c:?}");
13804 assert!(
13805 c.detail.contains("healed") && c.detail.contains("copilot-agent@wireup.net"),
13806 "WARN must name the stripped URL so the operator sees what changed: {}",
13807 c.detail
13808 );
13809 assert!(
13810 c.fix.as_ref().is_some_and(|f| f.contains("wire claim")),
13811 "fix must point at re-publishing the agent-card so the phonebook entry \
13812 matches the healed state on disk: {:?}",
13813 c.fix
13814 );
13815
13816 let after = config::read_relay_state().unwrap();
13820 let endpoints = after["self"]["endpoints"].as_array().unwrap();
13821 assert_eq!(endpoints.len(), 1, "malformed endpoint must be removed");
13822 assert_eq!(endpoints[0]["relay_url"], "https://wireup.net");
13823 assert_eq!(after["self"]["relay_url"], "https://wireup.net");
13824 assert_eq!(after["self"]["slot_id"], "clean-id");
13825 assert_eq!(after["self"]["slot_token"], "clean-token");
13826 });
13827 }
13828
13829 #[test]
13830 fn check_self_userinfo_no_clean_fallback_warns_without_mutating() {
13831 config::test_support::with_temp_home(|| {
13837 config::ensure_dirs().unwrap();
13838 let state = json!({
13839 "self": {
13840 "endpoints": [
13841 {
13842 "relay_url": "https://copilot-agent@wireup.net",
13843 "scope": "Federation",
13844 "slot_id": "stale-id",
13845 "slot_token": "stale-token"
13846 }
13847 ],
13848 "relay_url": "https://copilot-agent@wireup.net",
13849 "slot_id": "stale-id",
13850 "slot_token": "stale-token"
13851 },
13852 "peers": {}
13853 });
13854 config::write_relay_state(&state).unwrap();
13855
13856 let c = check_and_heal_self_userinfo_endpoints();
13857 assert_eq!(c.status, "WARN");
13858 assert!(
13859 c.fix
13860 .as_ref()
13861 .is_some_and(|f| f.contains("wire bind-relay") && f.contains("wire claim")),
13862 "no-clean-fallback fix must require BOTH a clean bind AND a re-claim: {:?}",
13863 c.fix
13864 );
13865
13866 let after = config::read_relay_state().unwrap();
13869 assert_eq!(
13870 after, state,
13871 "no-clean-fallback path must NOT mutate state (would strand operator)"
13872 );
13873 });
13874 }
13875}
13876
13877fn cmd_up(
13889 relay_arg: Option<&str>,
13890 name: Option<&str>,
13891 with_local: Option<&str>,
13892 no_local: bool,
13893 as_json: bool,
13894) -> Result<()> {
13895 let relay_url = match relay_arg {
13899 Some(r) => {
13900 let r = r.trim_start_matches('@');
13901 if r.starts_with("http://") || r.starts_with("https://") {
13902 r.to_string()
13903 } else {
13904 format!("https://{r}")
13905 }
13906 }
13907 None => crate::pair_invite::DEFAULT_RELAY.to_string(),
13908 };
13909
13910 let relay_url = strip_relay_url_userinfo(&relay_url);
13917
13918 let mut report: Vec<(String, String)> = Vec::new();
13919 let mut step = |stage: &str, detail: String| {
13920 report.push((stage.to_string(), detail.clone()));
13921 if !as_json {
13922 eprintln!("wire up: {stage} — {detail}");
13923 }
13924 };
13925
13926 if config::is_initialized()? {
13929 step("init", "already initialized".to_string());
13930 } else {
13931 cmd_init(
13932 None,
13933 name,
13934 Some(&relay_url),
13935 false,
13936 false,
13937 )?;
13938 step("init", format!("created identity bound to {relay_url}"));
13939 }
13940
13941 let canonical = {
13943 let card = config::read_agent_card()?;
13944 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
13945 crate::agent_card::display_handle_from_did(did).to_string()
13946 };
13947 step("identity", format!("persona is `{canonical}`"));
13948
13949 let relay_state = config::read_relay_state()?;
13953 let bound_relay = relay_state
13954 .get("self")
13955 .and_then(|s| s.get("relay_url"))
13956 .and_then(Value::as_str)
13957 .unwrap_or("")
13958 .to_string();
13959 if bound_relay.is_empty() {
13960 cmd_bind_relay(
13964 &relay_url, None, false, false, false,
13966 )?;
13967 step("bind-relay", format!("bound to {relay_url}"));
13968 } else if bound_relay != relay_url {
13969 step(
13970 "bind-relay",
13971 format!(
13972 "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
13973 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
13974 ),
13975 );
13976 } else {
13977 step("bind-relay", format!("already bound to {bound_relay}"));
13978 }
13979
13980 match cmd_claim(
13983 &canonical,
13984 Some(&relay_url),
13985 None,
13986 false,
13987 false,
13988 ) {
13989 Ok(()) => step(
13990 "claim",
13991 format!("{canonical}@{} claimed", strip_proto(&relay_url)),
13992 ),
13993 Err(e) => step(
13994 "claim",
13995 format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
13996 ),
13997 }
13998
13999 if no_local {
14004 step("local-slot", "skipped (--no-local)".to_string());
14005 } else {
14006 let local_url = with_local
14007 .unwrap_or("http://127.0.0.1:8771")
14008 .trim_end_matches('/');
14009 let already_local = crate::endpoints::self_endpoints(
14010 &config::read_relay_state().unwrap_or_else(|_| json!({})),
14011 )
14012 .iter()
14013 .any(|e| e.relay_url == local_url);
14014 if relay_url.trim_end_matches('/') == local_url || already_local {
14015 step("local-slot", "already covered".to_string());
14016 } else if crate::relay_client::RelayClient::new(local_url)
14017 .check_healthz()
14018 .is_ok()
14019 {
14020 match cmd_bind_relay(
14021 local_url,
14022 Some("local"),
14023 false,
14024 false,
14025 false,
14026 ) {
14027 Ok(()) => step(
14028 "local-slot",
14029 format!("dual-bound local relay {local_url} for sister routing"),
14030 ),
14031 Err(e) => step("local-slot", format!("skipped local relay: {e}")),
14032 }
14033 } else {
14034 step(
14035 "local-slot",
14036 format!(
14037 "no local relay reachable at {local_url} — federation only \
14038 (sisters resolve via session-list)"
14039 ),
14040 );
14041 }
14042 }
14043
14044 match crate::ensure_up::ensure_daemon_running() {
14046 Ok(true) => step("daemon", "started fresh background daemon".to_string()),
14047 Ok(false) => step("daemon", "already running".to_string()),
14048 Err(e) => step(
14049 "daemon",
14050 format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
14051 ),
14052 }
14053
14054 let summary =
14056 "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
14057 `wire monitor` to watch incoming events."
14058 .to_string();
14059 step("ready", summary.clone());
14060
14061 if as_json {
14062 let steps_json: Vec<_> = report
14063 .iter()
14064 .map(|(k, v)| json!({"stage": k, "detail": v}))
14065 .collect();
14066 println!(
14067 "{}",
14068 serde_json::to_string(&json!({
14069 "nick": canonical,
14070 "relay": relay_url,
14071 "steps": steps_json,
14072 }))?
14073 );
14074 }
14075 Ok(())
14076}
14077
14078fn strip_proto(url: &str) -> String {
14080 url.trim_start_matches("https://")
14081 .trim_start_matches("http://")
14082 .to_string()
14083}
14084
14085pub fn error_smells_like_slot_4xx(last_err: &str) -> bool {
14162 fn is_token_boundary(b: u8) -> bool {
14163 matches!(b, b' ' | b':' | b'\t' | b'\n' | b'\r')
14164 }
14165 let bytes = last_err.as_bytes();
14166 for code in ["410", "404"] {
14167 let code_bytes = code.as_bytes();
14168 let mut search_from = 0usize;
14169 while let Some(rel) = last_err[search_from..].find(code) {
14170 let abs = search_from + rel;
14171 let end = abs + code_bytes.len();
14172 let before_ok = abs == 0 || is_token_boundary(bytes[abs - 1]);
14173 let after_ok = end == bytes.len() || is_token_boundary(bytes[end]);
14174 if before_ok && after_ok {
14175 return true;
14176 }
14177 search_from = abs + 1;
14181 }
14182 }
14183 false
14184}
14185
14186fn try_reresolve_peer_on_slot_4xx(
14221 state: &mut Value,
14222 peer_handle: &str,
14223 last_err: &str,
14224 already_tried: &std::collections::HashSet<String>,
14225) -> Result<bool> {
14226 if !error_smells_like_slot_4xx(last_err) {
14227 return Ok(false);
14229 }
14230 if already_tried.contains(peer_handle) {
14231 return Ok(false);
14233 }
14234 let peer_entry = state
14236 .get("peers")
14237 .and_then(|p| p.get(peer_handle))
14238 .ok_or_else(|| anyhow!("peer `{peer_handle}` not in relay_state"))?;
14239 let peer_relay = peer_entry
14240 .get("endpoints")
14241 .and_then(Value::as_array)
14242 .and_then(|arr| {
14243 arr.iter().find(|e| {
14244 e.get("scope").and_then(Value::as_str) == Some("federation")
14245 || e.get("scope").and_then(Value::as_str) == Some("Federation")
14246 })
14247 })
14248 .and_then(|e| e.get("relay_url").and_then(Value::as_str))
14249 .or_else(|| peer_entry.get("relay_url").and_then(Value::as_str))
14250 .ok_or_else(|| {
14251 anyhow!("peer `{peer_handle}` has no federation endpoint to re-resolve against")
14252 })?
14253 .to_string();
14254 let domain = peer_relay
14257 .trim_start_matches("https://")
14258 .trim_start_matches("http://")
14259 .split('/')
14260 .next()
14261 .unwrap_or(&peer_relay)
14262 .to_string();
14263 let handle = crate::pair_profile::Handle {
14264 nick: peer_handle.to_string(),
14265 domain,
14266 };
14267 let resolved = crate::pair_profile::resolve_handle(&handle, Some(&peer_relay))?;
14268 let new_slot_id = resolved
14269 .get("slot_id")
14270 .and_then(Value::as_str)
14271 .ok_or_else(|| anyhow!("re-resolved payload missing slot_id"))?
14272 .to_string();
14273 let peers = state
14275 .get_mut("peers")
14276 .and_then(Value::as_object_mut)
14277 .ok_or_else(|| anyhow!("relay_state.peers missing or wrong shape"))?;
14278 let peer_entry = peers
14279 .get_mut(peer_handle)
14280 .ok_or_else(|| anyhow!("peer `{peer_handle}` disappeared from state mid-resolve"))?;
14281 let current_slot_id = peer_entry
14282 .get("endpoints")
14283 .and_then(Value::as_array)
14284 .and_then(|arr| {
14285 arr.iter().find(|e| {
14286 let scope = e.get("scope").and_then(Value::as_str);
14287 scope == Some("federation") || scope == Some("Federation")
14288 })
14289 })
14290 .and_then(|e| e.get("slot_id").and_then(Value::as_str))
14291 .unwrap_or("")
14292 .to_string();
14293 if current_slot_id == new_slot_id {
14294 return Ok(false);
14296 }
14297 if let Some(endpoints) = peer_entry
14306 .get_mut("endpoints")
14307 .and_then(Value::as_array_mut)
14308 {
14309 for ep in endpoints.iter_mut() {
14310 let scope = ep.get("scope").and_then(Value::as_str);
14311 if scope == Some("federation") || scope == Some("Federation") {
14312 ep["slot_id"] = Value::String(new_slot_id.clone());
14313 ep["slot_token"] = Value::String(String::new());
14314 }
14315 }
14316 }
14317 peer_entry["slot_id"] = Value::String(new_slot_id.clone());
14320 peer_entry["slot_token"] = Value::String(String::new());
14321 eprintln!(
14322 "wire push: peer `{peer_handle}` rotated their relay slot (was `{current_slot_id}`, \
14323 now `{new_slot_id}`); pin updated in place. Re-pair via `wire add \
14324 {peer_handle}@<relay>` to refresh the slot_token."
14325 );
14326 Ok(true)
14327}
14328
14329fn reject_self_pair_after_resolution(our_did: &str, peer_did: &str) -> Result<()> {
14330 if our_did == peer_did {
14331 bail!(
14332 "refusing to self-pair: resolved peer DID `{peer_did}` matches your own \
14333 DID. Two terminals can collapse onto one wire identity when the per-\
14334 session key isn't reaching the wire process (issue #30 / #29).\n\n\
14335 Diagnose:\n \
14336 • `wire whoami` in each terminal — DIDs MUST differ.\n \
14337 • `echo $WIRE_SESSION_ID` (bash) / `echo $env:WIRE_SESSION_ID` \
14338 (PowerShell) — must be set + distinct per session.\n\n\
14339 Force distinct identities before relaunching the agent:\n \
14340 • bash/zsh: `export WIRE_SESSION_ID=\"$(uuidgen)\"`\n \
14341 • PowerShell: `$env:WIRE_SESSION_ID = [guid]::NewGuid().ToString()`"
14342 );
14343 }
14344 Ok(())
14345}
14346
14347fn strip_relay_url_userinfo(url: &str) -> String {
14348 let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
14351 let rest = &url[authority_start..];
14352 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
14353 let authority = &rest[..authority_end];
14354
14355 let Some(at_pos) = authority.find('@') else {
14356 return url.to_string();
14357 };
14358
14359 let userinfo = &authority[..at_pos];
14360 let host = &authority[at_pos + 1..];
14361 let scheme = &url[..authority_start];
14362 let tail = &rest[authority_end..];
14363 let cleaned = format!("{scheme}{host}{tail}");
14364
14365 eprintln!(
14366 "wire: ignoring `{userinfo}@` prefix on relay URL `{url}` — \
14367 in v0.11+ your handle is DID-derived (one-name rule), so the relay URL \
14368 is just the bare relay. Binding to `{cleaned}` instead."
14369 );
14370
14371 cleaned
14372}
14373
14374fn assert_relay_url_clean_for_publish(url: &str) -> Result<()> {
14382 let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
14383 let rest = &url[authority_start..];
14384 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
14385 let authority = &rest[..authority_end];
14386 if authority.contains('@') {
14387 bail!(
14388 "internal invariant violated: relay URL `{url}` still carries userinfo at \
14389 the persist/publish boundary — `strip_relay_url_userinfo` must be called \
14390 before this point. Refusing to publish a malformed endpoint."
14391 );
14392 }
14393 Ok(())
14394}
14395
14396fn cmd_claim(
14397 nick: &str,
14398 relay_override: Option<&str>,
14399 public_url: Option<&str>,
14400 hidden: bool,
14401 as_json: bool,
14402) -> Result<()> {
14403 let (_did, relay_url, slot_id, slot_token) =
14406 crate::pair_invite::ensure_self_with_relay(relay_override)?;
14407 let card = config::read_agent_card()?;
14408
14409 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
14418 let canonical = crate::agent_card::display_handle_from_did(did).to_string();
14419 if !canonical.is_empty() && nick != canonical && !as_json {
14420 eprintln!(
14421 "wire claim: typed `{nick}` ignored — one-name rule. Claiming your persona `{canonical}`."
14422 );
14423 }
14424 let nick = if canonical.is_empty() {
14425 nick
14426 } else {
14427 canonical.as_str()
14428 };
14429 if !crate::pair_profile::is_valid_nick(nick) {
14430 bail!(
14431 "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
14432 );
14433 }
14434
14435 let client = crate::relay_client::RelayClient::new(&relay_url);
14436 let discoverable = if hidden { Some(false) } else { None };
14440 let resp =
14441 client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
14442
14443 if as_json {
14444 println!(
14445 "{}",
14446 serde_json::to_string(&json!({
14447 "nick": nick,
14448 "relay": relay_url,
14449 "response": resp,
14450 }))?
14451 );
14452 } else {
14453 let domain = public_url
14457 .unwrap_or(&relay_url)
14458 .trim_start_matches("https://")
14459 .trim_start_matches("http://")
14460 .trim_end_matches('/')
14461 .split('/')
14462 .next()
14463 .unwrap_or("<this-relay-domain>")
14464 .to_string();
14465 println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
14466 println!("verify with: wire whois {nick}@{domain}");
14467 }
14468 Ok(())
14469}
14470
14471fn cmd_profile(action: ProfileAction) -> Result<()> {
14472 match action {
14473 ProfileAction::Set { field, value, json } => {
14474 let parsed: Value =
14478 serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
14479 let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
14480 let published = republish_card_to_phonebook();
14481 if json {
14482 println!(
14483 "{}",
14484 serde_json::to_string(&json!({
14485 "field": field,
14486 "profile": new_profile,
14487 "published_to": published,
14488 }))?
14489 );
14490 } else {
14491 println!("profile.{field} set");
14492 print_profile_publish_result(&published);
14493 }
14494 }
14495 ProfileAction::Get { json } => return cmd_whois(None, json, None),
14496 ProfileAction::Clear { field, json } => {
14497 let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
14498 let published = republish_card_to_phonebook();
14499 if json {
14500 println!(
14501 "{}",
14502 serde_json::to_string(&json!({
14503 "field": field,
14504 "cleared": true,
14505 "profile": new_profile,
14506 "published_to": published,
14507 }))?
14508 );
14509 } else {
14510 println!("profile.{field} cleared");
14511 print_profile_publish_result(&published);
14512 }
14513 }
14514 }
14515 Ok(())
14516}
14517
14518fn republish_card_to_phonebook() -> Vec<String> {
14526 let Ok(card) = config::read_agent_card() else {
14527 return Vec::new();
14528 };
14529 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
14530 let persona = crate::agent_card::display_handle_from_did(did).to_string();
14531 if persona.is_empty() {
14532 return Vec::new();
14533 }
14534 let Ok(state) = config::read_relay_state() else {
14535 return Vec::new();
14536 };
14537 let mut published = Vec::new();
14538 for ep in crate::endpoints::self_endpoints(&state) {
14539 if ep.scope != crate::endpoints::EndpointScope::Federation
14540 || ep.slot_id.is_empty()
14541 || ep.slot_token.is_empty()
14542 {
14543 continue;
14544 }
14545 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
14546 if client
14547 .handle_claim_v2(&persona, &ep.slot_id, &ep.slot_token, None, &card, None)
14548 .is_ok()
14549 {
14550 published.push(ep.relay_url.clone());
14551 }
14552 }
14553 published
14554}
14555
14556fn print_profile_publish_result(published: &[String]) {
14557 if published.is_empty() {
14558 println!(
14559 " (local only — not bound to a federation relay; run `wire up` to publish to the phonebook)"
14560 );
14561 } else {
14562 println!(" published to phonebook: {}", published.join(", "));
14563 }
14564}
14565
14566fn cmd_setup(apply: bool) -> Result<()> {
14569 use crate::adapters::harness::HARNESS_ADAPTERS;
14570 use std::path::PathBuf;
14571
14572 let entry = json!({
14579 "command": "wire",
14580 "args": ["mcp"]
14581 });
14582 let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
14583
14584 let mut targets: Vec<(&str, PathBuf)> = Vec::new();
14588 for adapter in HARNESS_ADAPTERS {
14589 for path in (adapter.paths_fn)() {
14590 targets.push((adapter.name, path));
14591 }
14592 }
14593
14594 println!("wire setup\n");
14595 println!("MCP server snippet (add this to your client's mcpServers):");
14596 println!();
14597 println!("{entry_pretty}");
14598 println!();
14599
14600 if !apply {
14601 println!("Probable MCP host config locations on this machine:");
14602 for (name, path) in &targets {
14603 let marker = if path.exists() {
14604 "✓ found"
14605 } else {
14606 " (would create)"
14607 };
14608 println!(" {marker:14} {name}: {}", path.display());
14609 }
14610 println!();
14611 println!("Run `wire setup --apply` to merge wire into each config above.");
14612 println!(
14613 "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
14614 );
14615 return Ok(());
14616 }
14617
14618 let mut modified: Vec<String> = Vec::new();
14619 let mut skipped: Vec<String> = Vec::new();
14620 for adapter in HARNESS_ADAPTERS {
14621 for path in (adapter.paths_fn)() {
14622 match (adapter.upsert_fn)(&path, "wire", &entry) {
14623 Ok(true) => {
14624 modified.push(format!("✓ {} ({})", adapter.name, path.display()));
14625 }
14626 Ok(false) => skipped.push(format!(
14627 " {} ({}): already configured",
14628 adapter.name,
14629 path.display()
14630 )),
14631 Err(e) => skipped.push(format!("✗ {} ({}): {e}", adapter.name, path.display())),
14632 }
14633 }
14634 }
14635 if !modified.is_empty() {
14636 println!("Modified:");
14637 for line in &modified {
14638 println!(" {line}");
14639 }
14640 println!();
14641 println!("Restart the app(s) above to load wire MCP.");
14642 }
14643 if !skipped.is_empty() {
14644 println!();
14645 println!("Skipped:");
14646 for line in &skipped {
14647 println!(" {line}");
14648 }
14649 }
14650 Ok(())
14651}
14652
14653const STATUSLINE_RENDERER: &str = include_str!("../assets/wire-statusline.sh");
14665
14666fn cmd_setup_statusline(apply: bool, remove: bool) -> Result<()> {
14672 use std::path::PathBuf;
14673 let cfg_dir: PathBuf = std::env::var_os("CLAUDE_CONFIG_DIR")
14674 .map(PathBuf::from)
14675 .or_else(|| dirs::home_dir().map(|h| h.join(".claude")))
14676 .ok_or_else(|| anyhow!("cannot locate Claude config dir (set $CLAUDE_CONFIG_DIR)"))?;
14677 let settings_path = cfg_dir.join("settings.json");
14678 let script_path = cfg_dir.join("wire-statusline.sh");
14679 let (command, command_warn) = statusline_command(&script_path);
14684
14685 println!("wire setup --statusline\n");
14686 println!("Claude config dir: {}", cfg_dir.display());
14687 println!(" renderer: {}", script_path.display());
14688 println!(" settings: {}", settings_path.display());
14689 if let Some(w) = &command_warn {
14690 println!(" ⚠ {w}");
14691 }
14692 println!();
14693
14694 if remove {
14695 if !apply {
14696 println!("Would REMOVE the statusLine key from settings.json and delete the renderer.");
14697 println!("Run `wire setup --statusline --remove --apply` to do it.");
14698 return Ok(());
14699 }
14700 let dropped = remove_statusline_entry(&settings_path)?;
14701 let script_gone = if script_path.exists() {
14702 std::fs::remove_file(&script_path).is_ok()
14703 } else {
14704 false
14705 };
14706 println!(
14707 "Removed: statusLine key {} · renderer {}",
14708 if dropped { "dropped" } else { "absent" },
14709 if script_gone { "deleted" } else { "absent" }
14710 );
14711 return Ok(());
14712 }
14713
14714 if !apply {
14715 println!("Would write the renderer above and merge into settings.json:");
14716 println!();
14717 println!(" \"statusLine\": {{ \"type\": \"command\", \"command\": \"{command}\" }}");
14718 println!();
14719 println!("Resulting statusline: ● <emoji> <nickname> · <cwd>");
14720 println!("Run `wire setup --statusline --apply` to install.");
14721 println!("(Existing settings.json keys are preserved; an invalid settings.json aborts.)");
14722 return Ok(());
14723 }
14724
14725 if let Some(parent) = script_path.parent() {
14726 std::fs::create_dir_all(parent).context("creating Claude config dir")?;
14727 }
14728 std::fs::write(&script_path, STATUSLINE_RENDERER).context("writing renderer script")?;
14729 #[cfg(unix)]
14730 {
14731 use std::os::unix::fs::PermissionsExt;
14732 if let Ok(meta) = std::fs::metadata(&script_path) {
14733 let mut perms = meta.permissions();
14734 perms.set_mode(0o755);
14735 let _ = std::fs::set_permissions(&script_path, perms);
14736 }
14737 }
14738 let changed = upsert_statusline_entry(&settings_path, &command)?;
14739 println!("✓ renderer written: {}", script_path.display());
14740 if changed {
14741 println!("✓ merged statusLine into: {}", settings_path.display());
14742 } else {
14743 println!(
14744 " settings.json already configured: {}",
14745 settings_path.display()
14746 );
14747 }
14748 println!();
14749 println!("Restart Claude Code (or reopen the session) to see your persona in the statusline.");
14750 Ok(())
14751}
14752
14753fn upsert_statusline_entry(path: &std::path::Path, command: &str) -> Result<bool> {
14757 let mut cfg: Value = if path.exists() {
14758 let body = std::fs::read_to_string(path).context("reading settings.json")?;
14759 if body.trim().is_empty() {
14760 json!({})
14761 } else {
14762 serde_json::from_str(&body).context(
14763 "settings.json exists but is not valid JSON — refusing to clobber; fix or remove it first",
14764 )?
14765 }
14766 } else {
14767 json!({})
14768 };
14769 if !cfg.is_object() {
14770 bail!("settings.json root is not a JSON object — refusing to clobber");
14771 }
14772 let desired = json!({"type": "command", "command": command});
14773 let root = cfg.as_object_mut().unwrap();
14774 if root.get("statusLine") == Some(&desired) {
14775 return Ok(false);
14776 }
14777 root.insert("statusLine".to_string(), desired);
14778 if let Some(parent) = path.parent()
14779 && !parent.as_os_str().is_empty()
14780 {
14781 std::fs::create_dir_all(parent).context("creating parent dir")?;
14782 }
14783 let out = serde_json::to_string_pretty(&cfg)? + "\n";
14784 std::fs::write(path, out).context("writing settings.json")?;
14785 Ok(true)
14786}
14787
14788fn remove_statusline_entry(path: &std::path::Path) -> Result<bool> {
14791 if !path.exists() {
14792 return Ok(false);
14793 }
14794 let body = std::fs::read_to_string(path).context("reading settings.json")?;
14795 if body.trim().is_empty() {
14796 return Ok(false);
14797 }
14798 let mut cfg: Value = serde_json::from_str(&body)
14799 .context("settings.json is not valid JSON — refusing to edit")?;
14800 let Some(root) = cfg.as_object_mut() else {
14801 return Ok(false);
14802 };
14803 if root.remove("statusLine").is_none() {
14804 return Ok(false);
14805 }
14806 let out = serde_json::to_string_pretty(&cfg)? + "\n";
14807 std::fs::write(path, out).context("writing settings.json")?;
14808 Ok(true)
14809}
14810
14811fn statusline_command(script_path: &std::path::Path) -> (String, Option<String>) {
14814 #[cfg(windows)]
14815 {
14816 match resolve_git_bash() {
14817 Some(bash) => (format!("\"{}\" \"{}\"", bash, script_path.display()), None),
14818 None => (
14819 format!("bash \"{}\"", script_path.display()),
14820 Some(
14821 "could not locate git-bash; using bare `bash`. On Windows that may resolve to \
14822 WSL (System32\\bash.exe) and the statusline will be blank — install Git for \
14823 Windows or set statusLine.command to your git-bash bash.exe path."
14824 .to_string(),
14825 ),
14826 ),
14827 }
14828 }
14829 #[cfg(unix)]
14830 {
14831 (format!("bash \"{}\"", script_path.display()), None)
14832 }
14833}
14834
14835#[cfg(windows)]
14839fn resolve_git_bash() -> Option<String> {
14840 use std::path::PathBuf;
14841 if let Ok(out) = std::process::Command::new("where.exe").arg("bash").output()
14844 && out.status.success()
14845 {
14846 for line in String::from_utf8_lossy(&out.stdout).lines() {
14847 let p = line.trim();
14848 if !p.is_empty() && !p.to_lowercase().contains("\\system32\\") {
14849 return Some(p.to_string());
14850 }
14851 }
14852 }
14853 let candidates = [
14855 std::env::var("ProgramFiles")
14856 .ok()
14857 .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
14858 std::env::var("ProgramFiles(x86)")
14859 .ok()
14860 .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
14861 std::env::var("LocalAppData")
14862 .ok()
14863 .map(|p| format!("{p}\\Programs\\Git\\bin\\bash.exe")),
14864 ];
14865 candidates
14866 .into_iter()
14867 .flatten()
14868 .find(|c| PathBuf::from(c).exists())
14869}
14870
14871#[cfg(test)]
14872mod scan_jsonl_dir_tests {
14873 use super::*;
14874
14875 #[test]
14876 fn scan_jsonl_dir_excludes_pushed_audit_files() {
14877 let dir = tempfile::tempdir().unwrap();
14883 std::fs::write(
14885 dir.path().join("alice.jsonl"),
14886 "{\"event_id\":\"a\"}\n{\"event_id\":\"b\"}\n",
14887 )
14888 .unwrap();
14889 let many: String = (0..100)
14891 .map(|i| format!("{{\"event_id\":\"x{i}\",\"ts\":\"...\"}}\n"))
14892 .collect();
14893 std::fs::write(dir.path().join("alice.pushed.jsonl"), many).unwrap();
14894 let result = scan_jsonl_dir(dir.path()).unwrap();
14895 assert_eq!(
14896 result["events"], 2,
14897 "events count must include only live outbox lines, not pushed-log audit lines"
14898 );
14899 assert_eq!(
14900 result["files"], 1,
14901 "files count must reflect 1 live outbox file (the .pushed.jsonl audit log doesn't count as a queued-events surface)"
14902 );
14903 }
14904
14905 #[test]
14906 fn scan_jsonl_dir_zero_when_only_pushed_log_present() {
14907 let dir = tempfile::tempdir().unwrap();
14912 std::fs::write(
14913 dir.path().join("alice.pushed.jsonl"),
14914 "{\"event_id\":\"a\"}\n",
14915 )
14916 .unwrap();
14917 let result = scan_jsonl_dir(dir.path()).unwrap();
14918 assert_eq!(result["events"], 0);
14919 assert_eq!(result["files"], 0);
14920 }
14921
14922 #[test]
14923 fn scan_jsonl_dir_returns_zero_for_missing_dir() {
14924 let result = scan_jsonl_dir(std::path::Path::new("/nonexistent")).unwrap();
14925 assert_eq!(result["events"], 0);
14926 assert_eq!(result["files"], 0);
14927 }
14928}
14929
14930#[cfg(test)]
14935mod statusline_tests {
14936 use super::*;
14937
14938 #[test]
14939 fn statusline_merge_preserves_keys_and_is_idempotent() {
14940 let dir = tempfile::tempdir().unwrap();
14941 let path = dir.path().join("settings.json");
14942 std::fs::write(&path, r#"{"theme":"dark","model":"opus"}"#).unwrap();
14943 assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14945 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14946 assert_eq!(v["theme"], "dark");
14947 assert_eq!(v["model"], "opus");
14948 assert_eq!(v["statusLine"]["type"], "command");
14949 assert_eq!(v["statusLine"]["command"], "bash /x.sh");
14950 assert!(!upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14952 assert!(remove_statusline_entry(&path).unwrap());
14954 let v2: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14955 assert_eq!(v2["theme"], "dark");
14956 assert!(v2.get("statusLine").is_none());
14957 assert!(!remove_statusline_entry(&path).unwrap());
14959 }
14960
14961 #[test]
14962 fn statusline_merge_refuses_to_clobber_invalid_json() {
14963 let dir = tempfile::tempdir().unwrap();
14964 let path = dir.path().join("settings.json");
14965 std::fs::write(&path, "this is not json {").unwrap();
14966 let err = upsert_statusline_entry(&path, "bash /x.sh").unwrap_err();
14967 assert!(
14968 format!("{err:#}").contains("not valid JSON"),
14969 "err: {err:#}"
14970 );
14971 assert_eq!(
14973 std::fs::read_to_string(&path).unwrap(),
14974 "this is not json {"
14975 );
14976 }
14977
14978 #[test]
14979 fn statusline_creates_settings_when_absent() {
14980 let dir = tempfile::tempdir().unwrap();
14981 let path = dir.path().join("settings.json");
14982 assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14983 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14984 assert_eq!(v["statusLine"]["command"], "bash /x.sh");
14985 }
14986}
14987
14988fn cmd_notify(
14991 interval_secs: u64,
14992 peer_filter: Option<&str>,
14993 once: bool,
14994 as_json: bool,
14995) -> Result<()> {
14996 use crate::inbox_watch::InboxWatcher;
14997 let cursor_path = config::state_dir()?.join("notify.cursor");
14998 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
14999 if !once {
15003 crate::session::warn_on_identity_collision(std::process::id(), "notify");
15004 }
15005
15006 let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
15007 let events = watcher.poll()?;
15008 for ev in events {
15009 if let Some(p) = peer_filter
15010 && ev.peer != p
15011 {
15012 continue;
15013 }
15014 if as_json {
15015 println!("{}", serde_json::to_string(&ev)?);
15016 } else {
15017 os_notify_inbox_event(&ev);
15018 }
15019 }
15020 watcher.save_cursors(&cursor_path)?;
15021 Ok(())
15022 };
15023
15024 if once {
15025 return sweep(&mut watcher);
15026 }
15027
15028 let interval = std::time::Duration::from_secs(interval_secs.max(1));
15029 loop {
15030 if let Err(e) = sweep(&mut watcher) {
15031 eprintln!("wire notify: sweep error: {e}");
15032 }
15033 std::thread::sleep(interval);
15034 }
15035}
15036
15037fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
15038 let who = persona_label(&ev.peer);
15039 let title = if ev.verified {
15040 format!("wire ← {who}")
15041 } else {
15042 format!("wire ← {who} (UNVERIFIED)")
15043 };
15044 let body = format!("{}: {}", ev.kind, ev.body_preview);
15045 let id = if ev.event_id.is_empty() {
15051 ev.body_preview.as_str()
15052 } else {
15053 ev.event_id.as_str()
15054 };
15055 let dedup_key = format!("inbox:{}:{}", ev.peer, id);
15056 crate::os_notify::toast_dedup(&dedup_key, &title, &body);
15057}
15058
15059#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
15060fn os_toast(title: &str, body: &str) {
15061 eprintln!("[wire notify] {title}\n {body}");
15062}
15063
15064#[cfg(test)]
15067mod relay_url_tests {
15068 use super::*;
15069
15070 #[test]
15071 fn strip_relay_url_userinfo_strips_handle_and_returns_cleaned() {
15072 assert_eq!(
15084 strip_relay_url_userinfo("https://copilot-agent@wireup.net"),
15085 "https://wireup.net",
15086 "https URL with handle userinfo is stripped to the bare host"
15087 );
15088 assert_eq!(
15089 strip_relay_url_userinfo("http://copilot-agent@127.0.0.1:8771"),
15090 "http://127.0.0.1:8771",
15091 "http + port + userinfo is stripped, port preserved"
15092 );
15093 assert_eq!(strip_relay_url_userinfo("https://u:p@host"), "https://host");
15095 assert_eq!(
15097 strip_relay_url_userinfo("https://nick@host:8443"),
15098 "https://host:8443"
15099 );
15100 assert_eq!(strip_relay_url_userinfo("nick@wireup.net"), "wireup.net");
15104 assert_eq!(
15106 strip_relay_url_userinfo("https://nick@wireup.net/v1/events?x=1#frag"),
15107 "https://wireup.net/v1/events?x=1#frag"
15108 );
15109 }
15110
15111 #[test]
15112 fn strip_relay_url_userinfo_passes_clean_urls_through_unchanged() {
15113 for ok in [
15115 "https://wireup.net",
15116 "http://wireup.net",
15117 "http://127.0.0.1:8771",
15118 "https://relay.example.com:9443/v1/wire",
15119 "https://wireup.net/?env=prod",
15120 "https://wireup.net/users/me@example.com",
15122 "https://wireup.net/?to=me@example.com",
15123 "https://wireup.net/#contact@me",
15125 "http://[::1]:8771",
15127 "wireup.net",
15129 "wireup.net:8443",
15130 ] {
15131 assert_eq!(
15132 strip_relay_url_userinfo(ok),
15133 ok,
15134 "clean URL `{ok}` must pass through unchanged"
15135 );
15136 }
15137 }
15138
15139 #[test]
15140 fn assert_relay_url_clean_for_publish_blocks_userinfo_at_persist_site() {
15141 assert!(assert_relay_url_clean_for_publish("https://wireup.net").is_ok());
15147 assert!(assert_relay_url_clean_for_publish("http://127.0.0.1:8771").is_ok());
15148 assert!(
15149 assert_relay_url_clean_for_publish("https://wireup.net/?to=me@example.com").is_ok()
15150 );
15151
15152 let err = assert_relay_url_clean_for_publish("https://nick@wireup.net")
15153 .unwrap_err()
15154 .to_string();
15155 assert!(
15156 err.contains("invariant violated"),
15157 "persist-site failure must be flagged as an internal invariant violation, not user error: {err}"
15158 );
15159 assert!(
15160 err.contains("strip_relay_url_userinfo"),
15161 "error must name the upstream filter so the caller can audit the bypass: {err}"
15162 );
15163 assert!(assert_relay_url_clean_for_publish("https://u:p@host").is_err());
15165 assert!(assert_relay_url_clean_for_publish("https://nick@host:8443").is_err());
15167 }
15168
15169 #[test]
15170 fn strip_proto_no_longer_doubles_handle_after_userinfo_fix() {
15171 let after_strip = strip_relay_url_userinfo("https://nick@wireup.net");
15177 assert_eq!(after_strip, "https://wireup.net");
15178 assert_eq!(strip_proto(&after_strip), "wireup.net");
15179 assert!(
15181 strip_proto("https://nick@wireup.net").contains('@'),
15182 "strip_proto preserves userinfo by design; the userinfo guard upstream is what prevents the doubled echo"
15183 );
15184 }
15185}
15186
15187#[cfg(test)]
15188mod self_pair_guard_tests {
15189 use super::*;
15190
15191 #[test]
15192 fn reject_self_pair_after_resolution_blocks_matching_dids() {
15193 let err = reject_self_pair_after_resolution(
15200 "did:wire:winter-bay-4092b577",
15201 "did:wire:winter-bay-4092b577",
15202 )
15203 .unwrap_err()
15204 .to_string();
15205 assert!(
15206 err.contains("refusing to self-pair"),
15207 "must explicitly refuse, not silently bail: {err}"
15208 );
15209 assert!(
15210 err.contains("did:wire:winter-bay-4092b577"),
15211 "must include the colliding DID so the operator can grep their `wire whoami` output: {err}"
15212 );
15213 assert!(
15214 err.contains("issue #30") || err.contains("issue #29"),
15215 "must point at the tracking issue so historical context is one search away: {err}"
15216 );
15217 assert!(
15220 err.contains("WIRE_SESSION_ID"),
15221 "remediation must name the env var operators set: {err}"
15222 );
15223 assert!(
15224 err.contains("uuidgen") || err.contains("NewGuid"),
15225 "remediation must include a concrete command to mint a unique id: {err}"
15226 );
15227 }
15228
15229 #[test]
15230 fn reject_self_pair_after_resolution_allows_distinct_dids() {
15231 reject_self_pair_after_resolution(
15236 "did:wire:winter-bay-4092b577",
15237 "did:wire:cedar-bayou-0616dc6c",
15238 )
15239 .unwrap();
15240 reject_self_pair_after_resolution("did:wire:ed25519:abc123", "did:wire:ed25519:def456")
15241 .unwrap();
15242 reject_self_pair_after_resolution(
15246 "did:wire:noble-canyon-deadbeef",
15247 "did:wire:noble-canyon-cafef00d",
15248 )
15249 .unwrap();
15250 }
15251}
15252
15253#[cfg(test)]
15254mod slot_reresolve_tests {
15255 use super::*;
15256
15257 #[test]
15278 fn try_reresolve_skips_when_error_is_not_4xx_shape() {
15279 let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
15280 let already = std::collections::HashSet::new();
15281 let res =
15284 try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "post failed: 502", &already)
15285 .unwrap();
15286 assert!(!res, "502 must NOT trigger a re-resolve");
15287
15288 let res =
15289 try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "connection refused", &already)
15290 .unwrap();
15291 assert!(!res, "transport errors must NOT trigger a re-resolve");
15292
15293 let res = try_reresolve_peer_on_slot_4xx(
15294 &mut state,
15295 "some-peer",
15296 "post failed: 401 Unauthorized",
15297 &already,
15298 )
15299 .unwrap();
15300 assert!(
15301 !res,
15302 "401 (auth) is a token problem, not a slot rotation — must NOT trigger a re-resolve"
15303 );
15304 }
15305
15306 #[test]
15307 fn try_reresolve_rate_limits_one_attempt_per_peer_per_push() {
15308 let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
15313 let mut already = std::collections::HashSet::new();
15314 already.insert("some-peer".to_string());
15315 let res = try_reresolve_peer_on_slot_4xx(
15316 &mut state,
15317 "some-peer",
15318 "post failed: 410 Gone",
15319 &already,
15320 )
15321 .unwrap();
15322 assert!(
15323 !res,
15324 "peer already in `already_tried` must NOT trigger another re-resolve in the same push"
15325 );
15326 }
15327
15328 #[test]
15329 fn try_reresolve_errors_when_peer_missing_from_state() {
15330 let mut state = json!({"peers": {}});
15334 let already = std::collections::HashSet::new();
15335 let err = try_reresolve_peer_on_slot_4xx(
15336 &mut state,
15337 "missing-peer",
15338 "post failed: 410 Gone",
15339 &already,
15340 )
15341 .unwrap_err()
15342 .to_string();
15343 assert!(
15344 err.contains("missing-peer") && err.contains("not in relay_state"),
15345 "missing-peer error must name the peer + the failure: {err}"
15346 );
15347 }
15348
15349 #[test]
15350 fn try_reresolve_errors_when_peer_has_no_federation_endpoint() {
15351 let mut state = json!({
15358 "peers": {
15359 "local-only": {
15360 "endpoints": [
15361 {
15362 "scope": "Local",
15363 "relay_url": "http://127.0.0.1:8771",
15364 "slot_id": "loc",
15365 "slot_token": "tok"
15366 }
15367 ]
15368 }
15369 }
15370 });
15371 let already = std::collections::HashSet::new();
15372 let err = try_reresolve_peer_on_slot_4xx(
15373 &mut state,
15374 "local-only",
15375 "post failed: 410 Gone",
15376 &already,
15377 )
15378 .unwrap_err()
15379 .to_string();
15380 assert!(
15381 err.contains("federation endpoint"),
15382 "no-federation error must name the problem: {err}"
15383 );
15384 }
15385
15386 #[test]
15402 fn error_smells_like_slot_4xx_matches_reqwest_status_display_shape() {
15403 assert!(error_smells_like_slot_4xx(
15406 "post_event failed: 410 Gone: slot rotated by peer"
15407 ));
15408 assert!(error_smells_like_slot_4xx(
15409 "post_event failed: 404 Not Found: handle no longer claimed"
15410 ));
15411 }
15412
15413 #[test]
15414 fn error_smells_like_slot_4xx_matches_uds_bare_u16_shape() {
15415 assert!(error_smells_like_slot_4xx(
15419 "post_event (uds /tmp/wire-relay.sock) failed: 410: gone"
15420 ));
15421 assert!(error_smells_like_slot_4xx(
15422 "post_event (uds /tmp/wire-relay.sock) failed: 404: not found"
15423 ));
15424 }
15425
15426 #[test]
15427 fn error_smells_like_slot_4xx_rejects_substring_lookalikes() {
15428 let false_positives = [
15432 "push aborted: slot 4101 expired",
15433 "post_event failed: 502 Bad Gateway: request_id=410abc-deadbeef",
15434 "post_event failed: 500: received 4040 bytes, expected envelope",
15435 "post_event failed: 500: event 0x4104 malformed",
15436 "post_event failed: 503: backlog=4102 entries pending",
15437 "post_event failed: 500: tx_id=4044beef",
15439 "post_event failed: 500: hash=abc410def",
15441 ];
15442 for case in false_positives {
15443 assert!(
15444 !error_smells_like_slot_4xx(case),
15445 "must NOT trigger re-resolve on substring lookalike: {case:?}"
15446 );
15447 }
15448 }
15449
15450 #[test]
15451 fn error_smells_like_slot_4xx_handles_edge_positions() {
15452 assert!(error_smells_like_slot_4xx("410 Gone"));
15454 assert!(error_smells_like_slot_4xx("404 Not Found"));
15455 assert!(error_smells_like_slot_4xx("got 410"));
15457 assert!(error_smells_like_slot_4xx("got 404"));
15458 assert!(error_smells_like_slot_4xx("post_event failed:\t410\tGone"));
15460 assert!(error_smells_like_slot_4xx("post_event failed:\n410\nGone"));
15461 assert!(error_smells_like_slot_4xx("410"));
15463 assert!(error_smells_like_slot_4xx("404"));
15464 assert!(!error_smells_like_slot_4xx(""));
15466 assert!(!error_smells_like_slot_4xx("no relevant status"));
15467 assert!(!error_smells_like_slot_4xx(
15470 "post_event failed: 401 Unauthorized"
15471 ));
15472 assert!(!error_smells_like_slot_4xx(
15473 "post_event failed: 403 Forbidden"
15474 ));
15475 assert!(!error_smells_like_slot_4xx(
15476 "post_event failed: 411 Length Required"
15477 ));
15478 }
15479}
15480
15481#[cfg(test)]
15484mod op_claims_surfacing_tests {
15485 use super::*;
15486
15487 #[test]
15488 fn op_claims_extracts_present_non_null_fields() {
15489 let card = json!({
15490 "did": "did:wire:foo-deadbeef",
15491 "handle": "foo",
15492 "op_did": "did:wire:op:foo-aaaa",
15493 "op_pubkey": "PKB64==",
15494 "op_cert": "SIGB64==",
15495 "org_memberships": [{"org_did": "did:wire:org:slancha-bbbb"}],
15496 "schema_version": "v3.2",
15497 });
15498 let claims = op_claims_from_card(&card);
15499 assert_eq!(claims.len(), 5);
15500 assert_eq!(
15501 claims.get("op_did").and_then(Value::as_str),
15502 Some("did:wire:op:foo-aaaa")
15503 );
15504 assert!(
15505 claims
15506 .get("org_memberships")
15507 .and_then(Value::as_array)
15508 .is_some()
15509 );
15510 }
15511
15512 #[test]
15513 fn op_claims_empty_on_pre_v014_card() {
15514 let card = json!({
15519 "did": "did:wire:bar-cafebabe",
15520 "handle": "bar",
15521 "capabilities": ["wire/v3.1"],
15522 });
15523 assert!(op_claims_from_card(&card).is_empty());
15524 }
15525
15526 #[test]
15527 fn op_claims_skips_explicit_null_fields() {
15528 let card = json!({
15532 "did": "did:wire:baz-12341234",
15533 "op_did": Value::Null,
15534 "org_memberships": Value::Null,
15535 "schema_version": "v3.2",
15536 });
15537 let claims = op_claims_from_card(&card);
15538 assert_eq!(claims.len(), 1);
15539 assert!(claims.get("op_did").is_none());
15540 assert!(claims.get("org_memberships").is_none());
15541 assert_eq!(
15542 claims.get("schema_version").and_then(Value::as_str),
15543 Some("v3.2")
15544 );
15545 }
15546}
15547
15548#[cfg(test)]
15549mod enroll_add_membership_tests {
15550 use super::*;
15551 use crate::enroll::issue_member_cert;
15552 use crate::signing::{b64encode, generate_keypair};
15553
15554 fn seed_op() -> ([u8; 32], [u8; 32], String) {
15555 let (sk, pk) = generate_keypair();
15556 crate::config::write_op_key(&sk).unwrap();
15557 crate::config::write_op_handle("opfoo").unwrap();
15558 let op_did = crate::agent_card::did_for_op("opfoo", &pk);
15559 (sk, pk, op_did)
15560 }
15561
15562 #[test]
15563 fn add_membership_happy_path_stores_and_is_idempotent() {
15564 config::test_support::with_temp_home(|| {
15565 config::ensure_dirs().unwrap();
15566 let (_op_sk, _op_pk, op_did) = seed_op();
15567 let (org_sk, org_pk) = generate_keypair();
15568 let org_did = crate::agent_card::did_for_org("acme", &org_pk);
15569 let cert = issue_member_cert(&org_sk, &op_did).unwrap();
15570 let bundle = json!({
15571 "org_did": org_did,
15572 "org_pubkey": b64encode(&org_pk),
15573 "member_cert": cert,
15574 })
15575 .to_string();
15576 cmd_enroll_add_membership(Some(bundle.clone()), None, None, None, true).unwrap();
15577 let stored = config::read_memberships().unwrap();
15578 assert_eq!(stored.len(), 1);
15579 assert_eq!(
15580 stored[0].get("org_did").and_then(Value::as_str),
15581 Some(org_did.as_str())
15582 );
15583 cmd_enroll_add_membership(Some(bundle), None, None, None, true).unwrap();
15585 assert_eq!(config::read_memberships().unwrap().len(), 1);
15586 });
15587 }
15588
15589 #[test]
15590 fn add_membership_rejects_cert_for_wrong_op_did() {
15591 config::test_support::with_temp_home(|| {
15592 config::ensure_dirs().unwrap();
15593 let (_op_sk, _op_pk, _op_did) = seed_op();
15594 let (org_sk, org_pk) = generate_keypair();
15595 let org_did = crate::agent_card::did_for_org("acme", &org_pk);
15596 let other_did = "did:wire:op:ghost-deadbeefdeadbeefdeadbeefdeadbeef";
15598 let cert = issue_member_cert(&org_sk, other_did).unwrap();
15599 let bundle = json!({
15600 "org_did": org_did,
15601 "org_pubkey": b64encode(&org_pk),
15602 "member_cert": cert,
15603 })
15604 .to_string();
15605 let err = cmd_enroll_add_membership(Some(bundle), None, None, None, true).unwrap_err();
15606 assert!(
15607 err.to_string().contains("verification failed"),
15608 "got: {err:#}"
15609 );
15610 assert!(config::read_memberships().unwrap().is_empty());
15612 });
15613 }
15614
15615 #[test]
15616 fn add_membership_rejects_when_not_enrolled() {
15617 config::test_support::with_temp_home(|| {
15618 config::ensure_dirs().unwrap();
15619 let (org_sk, org_pk) = generate_keypair();
15621 let org_did = crate::agent_card::did_for_org("acme", &org_pk);
15622 let cert = issue_member_cert(&org_sk, "did:wire:op:anybody-aaaa").unwrap();
15623 let bundle = json!({
15624 "org_did": org_did,
15625 "org_pubkey": b64encode(&org_pk),
15626 "member_cert": cert,
15627 })
15628 .to_string();
15629 let err = cmd_enroll_add_membership(Some(bundle), None, None, None, true).unwrap_err();
15630 assert!(err.to_string().contains("not enrolled"), "got: {err:#}");
15631 });
15632 }
15633
15634 #[test]
15635 fn add_membership_rejects_malformed_org_did() {
15636 config::test_support::with_temp_home(|| {
15637 config::ensure_dirs().unwrap();
15638 let _ = seed_op();
15639 let bundle = json!({
15640 "org_did": "did:wire:not-an-org",
15641 "org_pubkey": "AAAA",
15642 "member_cert": "AAAA",
15643 })
15644 .to_string();
15645 let err = cmd_enroll_add_membership(Some(bundle), None, None, None, true).unwrap_err();
15646 assert!(
15647 err.to_string().contains("not a valid organization DID"),
15648 "got: {err:#}"
15649 );
15650 });
15651 }
15652}