git.delta.rocks / fleet / refs/commits / 087938500f2e

difftreelog

refactor join with remowt monorepo

zqoyktorYaroslav Bolyukin2026-06-18parent: #32d9799.patch.diff

57 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -20,9 +20,9 @@
 
 [[package]]
 name = "aead"
-version = "0.6.0"
+version = "0.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef60ac202874e574ce7a7158cc8bca7313dd344322482e4fadee288bf4a306b8"
+checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
 dependencies = [
  "crypto-common 0.2.2",
  "inout 0.2.2",
@@ -71,7 +71,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "da8c919c118108f144adecad74b425b804ad075580d605d9b33c2d6d1c62a2f8"
 dependencies = [
- "aead 0.6.0",
+ "aead 0.6.1",
  "aes 0.9.1",
  "cipher 0.5.2",
  "ctr 0.10.1",
@@ -145,6 +145,12 @@
 ]
 
 [[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
 name = "android_system_properties"
 version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -231,6 +237,12 @@
 ]
 
 [[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
 name = "arrayvec"
 version = "0.7.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -329,7 +341,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -364,7 +376,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -377,18 +389,74 @@
 ]
 
 [[package]]
+name = "async_io_stream"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c"
+dependencies = [
+ "futures",
+ "pharos",
+ "rustc_version",
+]
+
+[[package]]
 name = "atomic-waker"
 version = "1.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
 
 [[package]]
+name = "attohttpc"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9"
+dependencies = [
+ "base64 0.22.1",
+ "http",
+ "log",
+ "url",
+]
+
+[[package]]
 name = "autocfg"
 version = "1.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
 
 [[package]]
+name = "aws-lc-rs"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
+dependencies = [
+ "aws-lc-sys",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-lc-sys"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
+dependencies = [
+ "cc",
+ "cmake",
+ "dunce",
+ "fs_extra",
+]
+
+[[package]]
+name = "backon"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
+dependencies = [
+ "fastrand",
+ "gloo-timers",
+ "tokio",
+]
+
+[[package]]
 name = "base16ct"
 version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -475,7 +543,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -492,11 +560,31 @@
 
 [[package]]
 name = "bindgen"
+version = "0.69.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088"
+dependencies = [
+ "bitflags 2.13.0",
+ "cexpr",
+ "clang-sys",
+ "itertools 0.12.1",
+ "lazy_static",
+ "lazycell",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash 1.1.0",
+ "shlex 1.3.0",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "bindgen"
 version = "0.72.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "cexpr",
  "clang-sys",
  "itertools 0.13.0",
@@ -507,11 +595,17 @@
  "regex",
  "rustc-hash 2.1.2",
  "shlex 1.3.0",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
 name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
 version = "2.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
@@ -526,6 +620,20 @@
 ]
 
 [[package]]
+name = "blake3"
+version = "1.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "cc",
+ "cfg-if",
+ "constant_time_eq",
+ "cpufeatures 0.3.0",
+]
+
+[[package]]
 name = "block-buffer"
 version = "0.10.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -563,6 +671,15 @@
 ]
 
 [[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2",
+]
+
+[[package]]
 name = "blocking"
 version = "1.6.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -625,9 +742,9 @@
 
 [[package]]
 name = "bytes"
-version = "1.11.1"
+version = "1.12.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
 
 [[package]]
 name = "camino"
@@ -663,10 +780,18 @@
 checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
 dependencies = [
  "find-msvc-tools",
+ "jobserver",
+ "libc",
  "shlex 2.0.1",
 ]
 
 [[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
 name = "cexpr"
 version = "0.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -815,7 +940,7 @@
  "heck",
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -825,12 +950,30 @@
 checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
 
 [[package]]
+name = "cmake"
+version = "0.1.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
+dependencies = [
+ "cc",
+]
+
+[[package]]
 name = "cmov"
 version = "0.5.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
 
 [[package]]
+name = "cobs"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
+dependencies = [
+ "thiserror 2.0.18",
+]
+
+[[package]]
 name = "codespan-reporting"
 version = "0.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -848,6 +991,16 @@
 checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
 
 [[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
 name = "concurrent-queue"
 version = "2.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -893,6 +1046,21 @@
 checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
 
 [[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
+[[package]]
+name = "convert_case"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
 name = "cookie-factory"
 version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -902,7 +1070,27 @@
 ]
 
 [[package]]
+name = "cordyceps"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a"
+dependencies = [
+ "loom",
+ "tracing",
+]
+
+[[package]]
 name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation"
 version = "0.10.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
@@ -951,6 +1139,30 @@
 ]
 
 [[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
 name = "crossbeam-utils"
 version = "0.8.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -958,13 +1170,13 @@
 
 [[package]]
 name = "crypto-bigint"
-version = "0.7.3"
+version = "0.7.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42a0d26b245348befa0c121944541476763dcc46ede886c88f9d12e1697d27c3"
+checksum = "97bb4a855e3b10f84c4e7e895a7de01db7f9a7b7eb7f73ed9773fd52ac686451"
 dependencies = [
  "cpubits",
  "ctutils",
- "getrandom 0.4.2",
+ "getrandom 0.4.3",
  "hybrid-array",
  "num-traits",
  "rand_core 0.10.1",
@@ -990,7 +1202,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
 dependencies = [
- "getrandom 0.4.2",
+ "getrandom 0.4.3",
  "hybrid-array",
  "rand_core 0.10.1",
 ]
@@ -1048,7 +1260,6 @@
  "cfg-if",
  "cpufeatures 0.2.17",
  "curve25519-dalek-derive",
- "digest 0.10.7",
  "fiat-crypto 0.2.9",
  "rustc_version",
  "subtle",
@@ -1066,7 +1277,9 @@
  "curve25519-dalek-derive",
  "digest 0.11.3",
  "fiat-crypto 0.3.0",
+ "rand_core 0.10.1",
  "rustc_version",
+ "serde",
  "subtle",
  "zeroize",
 ]
@@ -1079,7 +1292,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -1093,7 +1306,7 @@
  "cxxbridge-cmd",
  "cxxbridge-flags",
  "cxxbridge-macro",
- "foldhash 0.2.0",
+ "foldhash",
  "link-cplusplus",
 ]
 
@@ -1109,7 +1322,7 @@
  "proc-macro2",
  "quote",
  "scratch",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -1123,7 +1336,7 @@
  "indexmap",
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -1141,16 +1354,71 @@
  "indexmap",
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
+name = "darling"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
 name = "data-encoding"
 version = "2.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
 
 [[package]]
+name = "data-encoding-macro"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c"
+dependencies = [
+ "data-encoding",
+ "data-encoding-macro-internal",
+]
+
+[[package]]
+name = "data-encoding-macro-internal"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
+dependencies = [
+ "data-encoding",
+ "syn 1.0.109",
+]
+
+[[package]]
 name = "delegate"
 version = "0.13.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1158,7 +1426,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -1200,6 +1468,60 @@
 ]
 
 [[package]]
+name = "derive_builder"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
+dependencies = [
+ "derive_builder_macro",
+]
+
+[[package]]
+name = "derive_builder_core"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "derive_builder_macro"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
+dependencies = [
+ "derive_builder_core",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.118",
+ "unicode-xid",
+]
+
+[[package]]
 name = "des"
 version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1209,6 +1531,12 @@
 ]
 
 [[package]]
+name = "diatomic-waker"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c"
+
+[[package]]
 name = "digest"
 version = "0.10.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1233,6 +1561,18 @@
 ]
 
 [[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "libc",
+ "objc2",
+]
+
+[[package]]
 name = "displaydoc"
 version = "0.2.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1240,10 +1580,27 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "dlopen2"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4"
+dependencies = [
+ "libc",
+ "once_cell",
+ "winapi",
 ]
 
 [[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
 name = "ecdsa"
 version = "0.17.0-rc.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1256,16 +1613,6 @@
  "signature 3.0.0",
  "spki 0.8.0",
  "zeroize",
-]
-
-[[package]]
-name = "ed25519"
-version = "2.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
-dependencies = [
- "pkcs8 0.10.2",
- "signature 2.2.0",
 ]
 
 [[package]]
@@ -1275,31 +1622,18 @@
 checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
 dependencies = [
  "pkcs8 0.11.0",
+ "serdect",
  "signature 3.0.0",
 ]
 
 [[package]]
 name = "ed25519-dalek"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
-dependencies = [
- "curve25519-dalek 4.1.3",
- "ed25519 2.2.3",
- "serde",
- "sha2 0.10.9",
- "subtle",
- "zeroize",
-]
-
-[[package]]
-name = "ed25519-dalek"
 version = "3.0.0-rc.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60"
 dependencies = [
  "curve25519-dalek 5.0.0-rc.0",
- "ed25519 3.0.0",
+ "ed25519",
  "rand_core 0.10.1",
  "serde",
  "sha2 0.11.0",
@@ -1338,6 +1672,18 @@
 ]
 
 [[package]]
+name = "embedded-io"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
+
+[[package]]
+name = "embedded-io"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
+
+[[package]]
 name = "encode_unicode"
 version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1350,6 +1696,28 @@
 checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
 
 [[package]]
+name = "enum-assoc"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "enum-repr"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad30c9c0fa1aaf1ae5010dab11f1117b15d35faf62cda4bbbc53b9987950f18"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
 name = "enum_dispatch"
 version = "0.3.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1358,7 +1726,7 @@
  "once_cell",
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -1379,7 +1747,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -1505,7 +1873,7 @@
  "futures",
  "human-repr",
  "indicatif",
- "itertools 0.14.0",
+ "itertools 0.15.0",
  "nix-eval",
  "opentelemetry",
  "opentelemetry-appender-tracing",
@@ -1514,7 +1882,7 @@
  "remowt-fleet",
  "serde",
  "serde_json",
- "shlex 1.3.0",
+ "shlex 2.0.1",
  "tabled",
  "tempfile",
  "tokio",
@@ -1526,7 +1894,7 @@
 
 [[package]]
 name = "fleet-base"
-version = "0.1.0"
+version = "0.1.9"
 dependencies = [
  "age",
  "anyhow",
@@ -1537,7 +1905,7 @@
  "futures",
  "hostname",
  "indoc",
- "itertools 0.14.0",
+ "itertools 0.15.0",
  "nix-eval",
  "nixlike",
  "nom 8.0.0",
@@ -1558,16 +1926,16 @@
 
 [[package]]
 name = "fleet-generator-helper"
-version = "0.1.0"
+version = "0.1.9"
 dependencies = [
  "age",
  "anyhow",
  "base64 0.22.1",
  "clap",
- "ed25519-dalek 2.2.0",
+ "ed25519-dalek",
  "fleet-shared",
  "hex",
- "hmac 0.12.1",
+ "hmac 0.13.0",
  "pbkdf2 0.12.2",
  "rand 0.10.1",
  "sha2 0.10.9",
@@ -1576,7 +1944,7 @@
 
 [[package]]
 name = "fleet-install-secrets"
-version = "0.1.0"
+version = "0.1.9"
 dependencies = [
  "age",
  "anyhow",
@@ -1592,7 +1960,7 @@
 
 [[package]]
 name = "fleet-shared"
-version = "0.1.0"
+version = "0.1.9"
 dependencies = [
  "base64 0.22.1",
  "serde",
@@ -1651,12 +2019,6 @@
 
 [[package]]
 name = "foldhash"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
-
-[[package]]
-name = "foldhash"
 version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
@@ -1671,6 +2033,12 @@
 ]
 
 [[package]]
+name = "fs_extra"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+
+[[package]]
 name = "futures"
 version = "0.3.32"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1686,6 +2054,19 @@
 ]
 
 [[package]]
+name = "futures-buffered"
+version = "0.2.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5"
+dependencies = [
+ "cordyceps",
+ "diatomic-waker",
+ "futures-core",
+ "pin-project-lite",
+ "spin 0.10.0",
+]
+
+[[package]]
 name = "futures-channel"
 version = "0.3.32"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1739,7 +2120,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -1772,6 +2153,21 @@
 ]
 
 [[package]]
+name = "generator"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "libc",
+ "log",
+ "rustversion",
+ "windows-link",
+ "windows-result",
+]
+
+[[package]]
 name = "generic-array"
 version = "0.14.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1821,17 +2217,15 @@
 
 [[package]]
 name = "getrandom"
-version = "0.4.2"
+version = "0.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
 dependencies = [
  "cfg-if",
  "js-sys",
  "libc",
  "r-efi 6.0.0",
  "rand_core 0.10.1",
- "wasip2",
- "wasip3",
  "wasm-bindgen",
 ]
 
@@ -1874,6 +2268,18 @@
 ]
 
 [[package]]
+name = "gloo-timers"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
 name = "group"
 version = "0.14.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1886,9 +2292,9 @@
 
 [[package]]
 name = "h2"
-version = "0.4.14"
+version = "0.4.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
+checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
 dependencies = [
  "atomic-waker",
  "bytes",
@@ -1905,18 +2311,14 @@
 
 [[package]]
 name = "hashbrown"
-version = "0.15.5"
+version = "0.17.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
 dependencies = [
- "foldhash 0.1.5",
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
 ]
-
-[[package]]
-name = "hashbrown"
-version = "0.17.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
 
 [[package]]
 name = "heck"
@@ -1943,6 +2345,83 @@
 checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1"
 
 [[package]]
+name = "hickory-net"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "cfg-if",
+ "data-encoding",
+ "futures-channel",
+ "futures-io",
+ "futures-util",
+ "h2",
+ "hickory-proto",
+ "http",
+ "idna",
+ "ipnet",
+ "jni 0.22.4",
+ "rand 0.10.1",
+ "rustls",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tokio",
+ "tokio-rustls",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "hickory-proto"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643"
+dependencies = [
+ "data-encoding",
+ "idna",
+ "ipnet",
+ "jni 0.22.4",
+ "once_cell",
+ "prefix-trie",
+ "rand 0.10.1",
+ "ring",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "hickory-resolver"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "hickory-net",
+ "hickory-proto",
+ "ipconfig",
+ "ipnet",
+ "jni 0.22.4",
+ "moka",
+ "ndk-context",
+ "once_cell",
+ "parking_lot",
+ "rand 0.10.1",
+ "resolv-conf",
+ "rustls",
+ "smallvec",
+ "system-configuration",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-rustls",
+ "tracing",
+]
+
+[[package]]
 name = "hkdf"
 version = "0.12.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2038,6 +2517,12 @@
 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
 
 [[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
 name = "human-repr"
 version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2069,6 +2554,7 @@
  "http",
  "http-body",
  "httparse",
+ "httpdate",
  "itoa",
  "pin-project-lite",
  "smallvec",
@@ -2086,7 +2572,6 @@
  "hyper",
  "hyper-util",
  "rustls",
- "rustls-native-certs",
  "tokio",
  "tokio-rustls",
  "tower-service",
@@ -2177,7 +2662,7 @@
  "proc-macro2",
  "quote",
  "strsim",
- "syn 2.0.117",
+ "syn 2.0.118",
  "unic-langid",
 ]
 
@@ -2191,7 +2676,7 @@
  "i18n-config",
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -2301,10 +2786,16 @@
 ]
 
 [[package]]
-name = "id-arena"
-version = "2.3.0"
+name = "ident_case"
+version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "identity-hash"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da"
 
 [[package]]
 name = "idna"
@@ -2328,15 +2819,33 @@
 ]
 
 [[package]]
+name = "igd-next"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581"
+dependencies = [
+ "attohttpc",
+ "bytes",
+ "futures",
+ "http",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "log",
+ "rand 0.10.1",
+ "tokio",
+ "url",
+ "xmltree",
+]
+
+[[package]]
 name = "indexmap"
 version = "2.14.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
 dependencies = [
  "equivalent",
- "hashbrown 0.17.1",
- "serde",
- "serde_core",
+ "hashbrown",
 ]
 
 [[package]]
@@ -2420,12 +2929,195 @@
 checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304"
 
 [[package]]
+name = "ipconfig"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
+dependencies = [
+ "socket2",
+ "widestring",
+ "windows-registry",
+ "windows-result",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
 name = "ipnet"
 version = "2.12.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+dependencies = [
+ "serde",
+]
 
 [[package]]
+name = "iroh"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6435544bb3a5c4e6ff7affaa0c0aa0d1bca45bd700226329d5059d3eb54f9dff"
+dependencies = [
+ "backon",
+ "blake3",
+ "bytes",
+ "cfg_aliases",
+ "ctutils",
+ "data-encoding",
+ "derive_more",
+ "ed25519-dalek",
+ "futures-util",
+ "getrandom 0.4.3",
+ "hickory-resolver",
+ "http",
+ "ipnet",
+ "iroh-base",
+ "iroh-dns",
+ "iroh-metrics",
+ "iroh-relay",
+ "n0-error",
+ "n0-future",
+ "n0-watcher",
+ "netwatch",
+ "noq",
+ "noq-proto",
+ "noq-udp",
+ "papaya",
+ "pin-project",
+ "portable-atomic",
+ "portmapper",
+ "rand 0.10.1",
+ "reqwest",
+ "rustc-hash 2.1.2",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "smallvec",
+ "strum",
+ "time",
+ "tokio",
+ "tokio-stream",
+ "tokio-util",
+ "tracing",
+ "url",
+ "wasm-bindgen-futures",
+]
+
+[[package]]
+name = "iroh-base"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9c95e4459d9bb828a77084277abd308aa2b58a096652b079bddfd6ef2361f53"
+dependencies = [
+ "curve25519-dalek 5.0.0-rc.0",
+ "data-encoding",
+ "data-encoding-macro",
+ "derive_more",
+ "ed25519-dalek",
+ "getrandom 0.4.3",
+ "n0-error",
+ "rand 0.10.1",
+ "serde",
+ "url",
+ "zeroize",
+]
+
+[[package]]
+name = "iroh-dns"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "754f7e0c1f67938e1d671007264ffef158f14a9f795a7cc219ea68ea09a9d4c9"
+dependencies = [
+ "arc-swap",
+ "cfg_aliases",
+ "derive_more",
+ "hickory-resolver",
+ "iroh-base",
+ "n0-error",
+ "n0-future",
+ "ndk-context",
+ "portable-atomic",
+ "rand 0.10.1",
+ "rustls",
+ "simple-dns",
+ "strum",
+ "tokio",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "iroh-metrics"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef"
+dependencies = [
+ "iroh-metrics-derive",
+ "itoa",
+ "n0-error",
+ "portable-atomic",
+ "ryu",
+ "serde",
+ "tracing",
+]
+
+[[package]]
+name = "iroh-metrics-derive"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "iroh-relay"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c12e48fef252fd04f8e6b6a8802b377baf72548d62ae4838816624cd0e06b79"
+dependencies = [
+ "blake3",
+ "bytes",
+ "cfg_aliases",
+ "data-encoding",
+ "derive_more",
+ "getrandom 0.4.3",
+ "hickory-resolver",
+ "http",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "iroh-base",
+ "iroh-dns",
+ "iroh-metrics",
+ "lru",
+ "n0-error",
+ "n0-future",
+ "noq",
+ "noq-proto",
+ "num_enum",
+ "pin-project",
+ "postcard",
+ "rand 0.10.1",
+ "reqwest",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_bytes",
+ "strum",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+ "tokio-websockets",
+ "tracing",
+ "url",
+ "vergen-gitcl",
+ "webpki-roots",
+ "ws_stream_wasm",
+]
+
+[[package]]
 name = "is_terminal_polyfill"
 version = "1.70.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2433,6 +3125,15 @@
 
 [[package]]
 name = "itertools"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itertools"
 version = "0.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
@@ -2450,12 +3151,105 @@
 ]
 
 [[package]]
+name = "itertools"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
+dependencies = [
+ "either",
+]
+
+[[package]]
 name = "itoa"
 version = "1.0.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
 
 [[package]]
+name = "jni"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+dependencies = [
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys 0.3.1",
+ "log",
+ "thiserror 1.0.69",
+ "walkdir",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys 0.4.1",
+ "log",
+ "simd_cesu8",
+ "thiserror 2.0.18",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom 0.3.4",
+ "libc",
+]
+
+[[package]]
 name = "js-sys"
 version = "0.3.102"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2492,14 +3286,14 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
 dependencies = [
- "spin",
+ "spin 0.9.8",
 ]
 
 [[package]]
-name = "leb128fmt"
-version = "0.1.0"
+name = "lazycell"
+version = "1.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
 
 [[package]]
 name = "libc"
@@ -2529,7 +3323,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "libc",
  "plain",
  "redox_syscall 0.8.1",
@@ -2584,12 +3378,40 @@
 checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
 
 [[package]]
+name = "loom"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
+dependencies = [
+ "cfg-if",
+ "generator",
+ "scoped-tls",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "lru"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9"
+dependencies = [
+ "hashbrown",
+]
+
+[[package]]
 name = "lru-slab"
 version = "0.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
 
 [[package]]
+name = "mac-addr"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
+
+[[package]]
 name = "matchers"
 version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2672,12 +3494,212 @@
 ]
 
 [[package]]
+name = "moka"
+version = "0.12.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
+dependencies = [
+ "crossbeam-channel",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "equivalent",
+ "parking_lot",
+ "portable-atomic",
+ "smallvec",
+ "tagptr",
+ "uuid",
+]
+
+[[package]]
+name = "n0-error"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895"
+dependencies = [
+ "n0-error-macros",
+ "spez",
+]
+
+[[package]]
+name = "n0-error-macros"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "n0-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe"
+dependencies = [
+ "cfg_aliases",
+ "derive_more",
+ "futures-buffered",
+ "futures-lite",
+ "futures-util",
+ "js-sys",
+ "pin-project",
+ "send_wrapper",
+ "tokio",
+ "tokio-util",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-time",
+]
+
+[[package]]
+name = "n0-watcher"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826"
+dependencies = [
+ "derive_more",
+ "n0-error",
+ "n0-future",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "netdev"
+version = "0.44.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d31e7286c21ceaf0ddb1d881964011214555ea0b317cc2eb1a1d68d861386fc"
+dependencies = [
+ "block2",
+ "dispatch2",
+ "dlopen2",
+ "ipnet",
+ "jni 0.21.1",
+ "libc",
+ "mac-addr",
+ "ndk-context",
+ "netlink-packet-core",
+ "netlink-packet-route 0.29.0",
+ "netlink-sys",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-wlan",
+ "objc2-foundation",
+ "objc2-system-configuration",
+ "once_cell",
+ "plist",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "netlink-packet-core"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4"
+dependencies = [
+ "paste",
+]
+
+[[package]]
+name = "netlink-packet-route"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6"
+dependencies = [
+ "bitflags 2.13.0",
+ "libc",
+ "log",
+ "netlink-packet-core",
+]
+
+[[package]]
+name = "netlink-packet-route"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007"
+dependencies = [
+ "bitflags 2.13.0",
+ "libc",
+ "log",
+ "netlink-packet-core",
+]
+
+[[package]]
+name = "netlink-proto"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128"
+dependencies = [
+ "bytes",
+ "futures",
+ "log",
+ "netlink-packet-core",
+ "netlink-sys",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "netlink-sys"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae"
+dependencies = [
+ "bytes",
+ "futures-util",
+ "libc",
+ "log",
+ "tokio",
+]
+
+[[package]]
+name = "netwatch"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8487d26d691cd98d5c17b2adb4b1fd4b31cccc820da1eac827d483295d7bb94a"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "cfg_aliases",
+ "derive_more",
+ "ipnet",
+ "js-sys",
+ "libc",
+ "n0-error",
+ "n0-future",
+ "n0-watcher",
+ "netdev",
+ "netlink-packet-core",
+ "netlink-packet-route 0.31.0",
+ "netlink-proto",
+ "netlink-sys",
+ "noq-udp",
+ "objc2-core-foundation",
+ "objc2-system-configuration",
+ "pin-project-lite",
+ "serde",
+ "socket2",
+ "time",
+ "tokio",
+ "tokio-util",
+ "tracing",
+ "web-sys",
+ "windows",
+ "windows-result",
+ "wmi",
+]
+
+[[package]]
 name = "nix"
 version = "0.31.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "cfg-if",
  "cfg_aliases",
  "libc",
@@ -2685,15 +3707,15 @@
 
 [[package]]
 name = "nix-eval"
-version = "0.1.0"
+version = "0.1.9"
 dependencies = [
  "anyhow",
- "bindgen",
+ "bindgen 0.72.1",
  "camino",
  "cxx",
  "cxx-build",
  "futures",
- "itertools 0.14.0",
+ "itertools 0.15.0",
  "nixlike",
  "pkg-config",
  "serde",
@@ -2707,16 +3729,16 @@
 
 [[package]]
 name = "nix-plugin-fleet"
-version = "0.1.0"
+version = "0.1.9"
 dependencies = [
  "fleet-base",
 ]
 
 [[package]]
 name = "nixlike"
-version = "0.1.0"
+version = "0.1.9"
 dependencies = [
- "itertools 0.14.0",
+ "itertools 0.15.0",
  "linked-hash-map",
  "peg",
  "serde",
@@ -2743,6 +3765,68 @@
 ]
 
 [[package]]
+name = "noq"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11f0c73794bfde94db01379c46990b9a773993fca2b61a66184ce148b7c7a187"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "derive_more",
+ "noq-proto",
+ "noq-udp",
+ "pin-project-lite",
+ "rustc-hash 2.1.2",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "noq-proto"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "775be06b8d66c2c64db60140bf54dee8410f67b73c81cc1e1e32f11dfdaae501"
+dependencies = [
+ "aes-gcm 0.10.3",
+ "bytes",
+ "derive_more",
+ "enum-assoc",
+ "getrandom 0.4.3",
+ "identity-hash",
+ "lru-slab",
+ "rand 0.10.1",
+ "rand_pcg",
+ "ring",
+ "rustc-hash 2.1.2",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "sorted-index-buffer",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "noq-udp"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd5a37756f168cf350d68a97c4f0158bdf3c76f10175123941569b09ab51f011"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
 name = "nu-ansi-term"
 version = "0.50.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2814,10 +3898,135 @@
 ]
 
 [[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "num_threads"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "dispatch2",
+ "libc",
+ "objc2",
+]
+
+[[package]]
+name = "objc2-core-wlan"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48"
+dependencies = [
+ "bitflags 2.13.0",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-security",
+ "objc2-security-foundation",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "libc",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-security"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
+dependencies = [
+ "bitflags 2.13.0",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-security-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-system-configuration"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
+dependencies = [
+ "bitflags 2.13.0",
+ "dispatch2",
+ "libc",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-security",
+]
+
+[[package]]
 name = "once_cell"
 version = "1.21.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+dependencies = [
+ "critical-section",
+ "portable-atomic",
+]
 
 [[package]]
 name = "once_cell_polyfill"
@@ -2839,9 +4048,9 @@
 
 [[package]]
 name = "opentelemetry"
-version = "0.31.0"
+version = "0.32.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0"
+checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682"
 dependencies = [
  "futures-core",
  "futures-sink",
@@ -2853,9 +4062,9 @@
 
 [[package]]
 name = "opentelemetry-appender-tracing"
-version = "0.31.1"
+version = "0.32.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef6a1ac5ca3accf562b8c306fa8483c85f4390f768185ab775f242f7fe8fdcc2"
+checksum = "2c0080f0dc1d7c786f467cd85a4e395fcab11ee852004f39a29a18ab7c25d837"
 dependencies = [
  "opentelemetry",
  "tracing",
@@ -2865,7 +4074,7 @@
 
 [[package]]
 name = "opentelemetry-exporter-env"
-version = "0.1.0"
+version = "0.1.9"
 dependencies = [
  "clap",
  "opentelemetry-otlp",
@@ -2874,9 +4083,9 @@
 
 [[package]]
 name = "opentelemetry-http"
-version = "0.31.0"
+version = "0.32.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d"
+checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331"
 dependencies = [
  "async-trait",
  "bytes",
@@ -2887,9 +4096,9 @@
 
 [[package]]
 name = "opentelemetry-otlp"
-version = "0.31.1"
+version = "0.32.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f"
+checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35"
 dependencies = [
  "http",
  "opentelemetry",
@@ -2902,14 +4111,14 @@
  "thiserror 2.0.18",
  "tokio",
  "tonic",
- "tracing",
+ "tonic-types",
 ]
 
 [[package]]
 name = "opentelemetry-proto"
-version = "0.31.0"
+version = "0.32.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
+checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638"
 dependencies = [
  "base64 0.22.1",
  "const-hex",
@@ -2917,22 +4126,22 @@
  "opentelemetry_sdk",
  "prost",
  "serde",
- "serde_json",
  "tonic",
  "tonic-prost",
 ]
 
 [[package]]
 name = "opentelemetry_sdk"
-version = "0.31.0"
+version = "0.32.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd"
+checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9"
 dependencies = [
  "futures-channel",
  "futures-executor",
  "futures-util",
  "opentelemetry",
  "percent-encoding",
+ "portable-atomic",
  "rand 0.9.4",
  "thiserror 2.0.18",
 ]
@@ -3009,10 +4218,44 @@
 ]
 
 [[package]]
+name = "pam-client"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51bd776116a7ada5ebbe31f54cdc5b1030ed7265686cf7c8a21c057a2f8dab9a"
+dependencies = [
+ "bitflags 1.3.2",
+ "enum-repr",
+ "libc",
+ "pam-sys",
+ "rpassword",
+ "rustversion",
+]
+
+[[package]]
+name = "pam-sys"
+version = "1.0.0-alpha5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce9484729b3e52c0bacdc5191cb6a6a5f31ef4c09c5e4ab1209d3340ad9e997b"
+dependencies = [
+ "bindgen 0.69.5",
+ "libc",
+]
+
+[[package]]
+name = "papaya"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7"
+dependencies = [
+ "equivalent",
+ "seize",
+]
+
+[[package]]
 name = "papergrid"
-version = "0.17.0"
+version = "0.18.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1"
+checksum = "d0984e668274d34691bc2b262ef0d115de5fa9973bcdee7ae32213f93099153e"
 dependencies = [
  "bytecount",
  "fnv",
@@ -3126,6 +4369,16 @@
 checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
 
 [[package]]
+name = "pharos"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414"
+dependencies = [
+ "futures",
+ "rustc_version",
+]
+
+[[package]]
 name = "phc"
 version = "0.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3152,7 +4405,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -3244,6 +4497,36 @@
 checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
 
 [[package]]
+name = "plist"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
+dependencies = [
+ "base64 0.22.1",
+ "indexmap",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "polkit-backend"
+version = "0.1.9"
+dependencies = [
+ "anyhow",
+ "clap",
+ "nix",
+ "pam-client",
+ "remowt-polkit-shared",
+ "remowt-ui-prompt",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "zbus",
+ "zbus_polkit",
+]
+
+[[package]]
 name = "polling"
 version = "3.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3307,6 +4590,62 @@
 version = "1.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "portmapper"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccc716c56a0a50f7e4e25f41446419599d47c6197cc5c9858174220e97c272e6"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "derive_more",
+ "hyper-util",
+ "igd-next",
+ "iroh-metrics",
+ "libc",
+ "n0-error",
+ "n0-future",
+ "netwatch",
+ "num_enum",
+ "rand 0.10.1",
+ "serde",
+ "smallvec",
+ "socket2",
+ "time",
+ "tokio",
+ "tokio-util",
+ "tower-layer",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "postcard"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
+dependencies = [
+ "cobs",
+ "embedded-io 0.4.0",
+ "embedded-io 0.6.1",
+ "postcard-derive",
+ "serde",
+]
+
+[[package]]
+name = "postcard-derive"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
 
 [[package]]
 name = "potential_utf"
@@ -3333,20 +4672,31 @@
 ]
 
 [[package]]
+name = "prefix-trie"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7"
+dependencies = [
+ "either",
+ "ipnet",
+ "num-traits",
+]
+
+[[package]]
 name = "prettyplease"
 version = "0.2.37"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
 dependencies = [
  "proc-macro2",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
 name = "primefield"
-version = "0.14.0-rc.11"
+version = "0.14.0-rc.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1d7e42f46a29abc16fb621a3466ee453358ebaae48a9e515f287e0af052ed8f"
+checksum = "8675564771a62f69a0af716b03e89b917b963c7b173b5855575e84fd4f605ca0"
 dependencies = [
  "crypto-bigint",
  "crypto-common 0.2.2",
@@ -3393,7 +4743,7 @@
  "proc-macro-error-attr2",
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -3411,7 +4761,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "num-traits",
  "rand 0.9.4",
  "rand_chacha 0.9.0",
@@ -3440,10 +4790,28 @@
  "itertools 0.14.0",
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
+name = "prost-types"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
+dependencies = [
+ "prost",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.39.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
 name = "quinn"
 version = "0.11.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3469,6 +4837,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
 dependencies = [
+ "aws-lc-rs",
  "bytes",
  "getrandom 0.3.4",
  "lru-slab",
@@ -3547,7 +4916,7 @@
 checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
 dependencies = [
  "chacha20 0.10.0",
- "getrandom 0.4.2",
+ "getrandom 0.4.3",
  "rand_core 0.10.1",
 ]
 
@@ -3596,6 +4965,15 @@
 checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
 
 [[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.1",
+]
+
+[[package]]
 name = "rand_xorshift"
 version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3610,7 +4988,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
 ]
 
 [[package]]
@@ -3619,7 +4997,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
 ]
 
 [[package]]
@@ -3652,16 +5030,40 @@
 checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
 
 [[package]]
+name = "remowt-agent"
+version = "0.1.9"
+dependencies = [
+ "anyhow",
+ "bifrostlink",
+ "bifrostlink-ports",
+ "clap",
+ "futures",
+ "nix",
+ "remowt-endpoints",
+ "remowt-link-shared",
+ "remowt-plugin",
+ "remowt-polkit-shared",
+ "remowt-ui-prompt",
+ "tempfile",
+ "tokio",
+ "tokio-util",
+ "tracing",
+ "tracing-subscriber",
+ "uuid",
+ "zbus",
+ "zbus_polkit",
+]
+
+[[package]]
 name = "remowt-client"
 version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af0ab07d961f14fa5c4933899a5253341e2044dec71db6e341f80b16beff2c20"
 dependencies = [
  "anyhow",
  "bifrostlink",
  "bytes",
  "camino",
  "futures",
+ "iroh",
  "remowt-endpoints",
  "remowt-link-shared",
  "russh",
@@ -3678,12 +5080,12 @@
 [[package]]
 name = "remowt-endpoints"
 version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a841eec11d4cc0593188062634f7ee34e87a2052a70c0fa40fbecb4aa8692c0"
 dependencies = [
  "bifrostlink",
  "camino",
+ "iroh",
  "nix",
+ "remowt-link-shared",
  "serde",
  "tempfile",
  "thiserror 2.0.18",
@@ -3694,7 +5096,7 @@
 
 [[package]]
 name = "remowt-fleet"
-version = "0.1.0"
+version = "0.1.9"
 dependencies = [
  "anyhow",
  "bifrostlink",
@@ -3712,24 +5114,28 @@
 [[package]]
 name = "remowt-link-shared"
 version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d5036c5e2d5f47bb7ecb25542268f865c298c2b747d74269ff6b2b387574071a"
 dependencies = [
+ "anyhow",
  "bifrostlink",
  "bytes",
+ "camino",
+ "futures",
+ "iroh",
+ "iroh-base",
+ "n0-watcher",
+ "noq-udp",
  "remowt-ui-prompt",
  "serde",
  "serde_json",
  "thiserror 2.0.18",
  "tokio",
+ "tokio-util",
  "tracing",
 ]
 
 [[package]]
 name = "remowt-plugin"
 version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ca599e67dc675d8dd59abfe8f08679f7f3c6fd7aa84507ab426839985a142ab"
 dependencies = [
  "anyhow",
  "bifrostlink",
@@ -3742,7 +5148,7 @@
 
 [[package]]
 name = "remowt-plugin-fleet"
-version = "0.1.0"
+version = "0.1.9"
 dependencies = [
  "anyhow",
  "remowt-fleet",
@@ -3750,10 +5156,32 @@
 ]
 
 [[package]]
+name = "remowt-polkit-shared"
+version = "0.1.9"
+dependencies = [
+ "nix",
+ "serde",
+ "zbus",
+]
+
+[[package]]
+name = "remowt-ssh"
+version = "0.1.9"
+dependencies = [
+ "anyhow",
+ "clap",
+ "nix",
+ "remowt-client",
+ "remowt-link-shared",
+ "remowt-ui-prompt",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
 name = "remowt-ui-prompt"
 version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2610933566f9b057dc47145b7bfc5876c4139eb766fc2fe9f2ce60baf1856c8c"
 dependencies = [
  "anyhow",
  "bifrostlink",
@@ -3767,9 +5195,9 @@
 
 [[package]]
 name = "reqwest"
-version = "0.12.28"
+version = "0.13.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
 dependencies = [
  "base64 0.22.1",
  "bytes",
@@ -3788,24 +5216,29 @@
  "pin-project-lite",
  "quinn",
  "rustls",
- "rustls-native-certs",
  "rustls-pki-types",
- "serde",
- "serde_json",
- "serde_urlencoded",
+ "rustls-platform-verifier",
  "sync_wrapper",
  "tokio",
  "tokio-rustls",
+ "tokio-util",
  "tower",
  "tower-http",
  "tower-service",
  "url",
  "wasm-bindgen",
  "wasm-bindgen-futures",
+ "wasm-streams",
  "web-sys",
 ]
 
 [[package]]
+name = "resolv-conf"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
+
+[[package]]
 name = "rfc6979"
 version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3830,6 +5263,18 @@
 ]
 
 [[package]]
+name = "rpassword"
+version = "6.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bf099a1888612545b683d2661a1940089f6c2e5a8e38979b2159da876bfd956"
+dependencies = [
+ "libc",
+ "serde",
+ "serde_json",
+ "winapi",
+]
+
+[[package]]
 name = "rsa"
 version = "0.9.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3875,7 +5320,7 @@
 checksum = "bbf893f64684e58da8a68d56a5e84d1cf0440226274c515770fe267707a7d0b0"
 dependencies = [
  "aes 0.9.1",
- "bitflags",
+ "bitflags 2.13.0",
  "block-padding 0.4.2",
  "byteorder",
  "bytes",
@@ -3889,13 +5334,13 @@
  "der 0.8.0",
  "digest 0.11.3",
  "ecdsa",
- "ed25519-dalek 3.0.0-rc.0",
+ "ed25519-dalek",
  "elliptic-curve",
  "enum_dispatch",
  "flate2",
  "futures",
  "generic-array 1.4.3",
- "getrandom 0.4.2",
+ "getrandom 0.4.3",
  "ghash 0.6.0",
  "hex-literal",
  "hmac 0.13.0",
@@ -3998,7 +5443,7 @@
  "proc-macro2",
  "quote",
  "rust-embed-utils",
- "syn 2.0.117",
+ "syn 2.0.118",
  "walkdir",
 ]
 
@@ -4039,7 +5484,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "errno",
  "libc",
  "linux-raw-sys 0.4.15",
@@ -4052,7 +5497,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "errno",
  "libc",
  "linux-raw-sys 0.12.1",
@@ -4065,6 +5510,8 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
 dependencies = [
+ "aws-lc-rs",
+ "log",
  "once_cell",
  "ring",
  "rustls-pki-types",
@@ -4096,11 +5543,39 @@
 ]
 
 [[package]]
+name = "rustls-platform-verifier"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
+dependencies = [
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "jni 0.22.4",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-platform-verifier-android",
+ "rustls-webpki",
+ "security-framework",
+ "security-framework-sys",
+ "webpki-root-certs",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls-platform-verifier-android"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
+
+[[package]]
 name = "rustls-webpki"
 version = "0.103.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
 dependencies = [
+ "aws-lc-rs",
  "ring",
  "rustls-pki-types",
  "untrusted",
@@ -4156,6 +5631,12 @@
 ]
 
 [[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[package]]
 name = "scopeguard"
 version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4219,8 +5700,8 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
 dependencies = [
- "bitflags",
- "core-foundation",
+ "bitflags 2.13.0",
+ "core-foundation 0.10.1",
  "core-foundation-sys",
  "libc",
  "security-framework-sys",
@@ -4237,6 +5718,16 @@
 ]
 
 [[package]]
+name = "seize"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
 name = "self_cell"
 version = "0.10.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4258,6 +5749,12 @@
 checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
 
 [[package]]
+name = "send_wrapper"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
+
+[[package]]
 name = "serde"
 version = "1.0.228"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4268,6 +5765,16 @@
 ]
 
 [[package]]
+name = "serde_bytes"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
 name = "serde_core"
 version = "1.0.228"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4284,7 +5791,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -4308,19 +5815,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "serde_urlencoded"
-version = "0.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
-dependencies = [
- "form_urlencoded",
- "itoa",
- "ryu",
- "serde",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -4345,6 +5840,12 @@
 ]
 
 [[package]]
+name = "sha1_smol"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
+
+[[package]]
 name = "sha2"
 version = "0.10.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4434,6 +5935,31 @@
 checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
 
 [[package]]
+name = "simd_cesu8"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "simple-dns"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4"
+dependencies = [
+ "bitflags 2.13.0",
+]
+
+[[package]]
 name = "slab"
 version = "0.4.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4456,12 +5982,35 @@
 ]
 
 [[package]]
+name = "sorted-index-buffer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06"
+
+[[package]]
+name = "spez"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
 name = "spin"
 version = "0.9.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
 
 [[package]]
+name = "spin"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
+
+[[package]]
 name = "spki"
 version = "0.7.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4487,7 +6036,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "10db6f219196a8528f9ec904d9d45cdad692d65b0e57e72be4dedd1c5fddce36"
 dependencies = [
- "aead 0.6.0",
+ "aead 0.6.1",
  "aes 0.9.1",
  "aes-gcm 0.11.0-rc.4",
  "cbc 0.2.1",
@@ -4525,7 +6074,7 @@
  "argon2",
  "bcrypt-pbkdf 0.11.0",
  "ctutils",
- "ed25519-dalek 3.0.0-rc.0",
+ "ed25519-dalek",
  "hex",
  "hmac 0.13.0",
  "p256",
@@ -4549,12 +6098,39 @@
 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
 
 [[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
 name = "strsim"
 version = "0.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
 
 [[package]]
+name = "strum"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
 name = "subtle"
 version = "2.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4573,9 +6149,9 @@
 
 [[package]]
 name = "syn"
-version = "2.0.117"
+version = "2.0.118"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -4599,14 +6175,35 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
+name = "system-configuration"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
+dependencies = [
+ "bitflags 2.13.0",
+ "core-foundation 0.9.4",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
 name = "tabled"
-version = "0.20.0"
+version = "0.21.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d"
+checksum = "b5dc662e6da844ad6e428ad16b57967c9d33c82e16bb1c258326c0c078605dff"
 dependencies = [
  "papergrid",
  "tabled_derive",
@@ -4623,17 +6220,23 @@
  "proc-macro-error2",
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
+name = "tagptr"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
+
+[[package]]
 name = "tempfile"
 version = "3.27.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
 dependencies = [
  "fastrand",
- "getrandom 0.4.2",
+ "getrandom 0.4.3",
  "once_cell",
  "rustix 1.1.4",
  "windows-sys 0.61.2",
@@ -4677,7 +6280,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -4686,7 +6289,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "944ad38adcbb71eaa682c56bceeb079e4ca82b4b3edc2a0fde5cb297b77dac8d"
 dependencies = [
- "syn 2.0.117",
+ "syn 2.0.118",
  "test-log-core",
 ]
 
@@ -4725,7 +6328,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -4736,7 +6339,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -4755,7 +6358,10 @@
 checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469"
 dependencies = [
  "deranged",
+ "js-sys",
+ "libc",
  "num-conv",
+ "num_threads",
  "powerfmt",
  "serde_core",
  "time-core",
@@ -4817,6 +6423,7 @@
  "signal-hook-registry",
  "socket2",
  "tokio-macros",
+ "tracing",
  "windows-sys 0.61.2",
 ]
 
@@ -4828,7 +6435,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -4850,6 +6457,7 @@
  "futures-core",
  "pin-project-lite",
  "tokio",
+ "tokio-util",
 ]
 
 [[package]]
@@ -4861,11 +6469,35 @@
  "bytes",
  "futures-core",
  "futures-sink",
+ "futures-util",
  "pin-project-lite",
  "tokio",
 ]
 
 [[package]]
+name = "tokio-websockets"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "getrandom 0.4.3",
+ "http",
+ "httparse",
+ "rand 0.10.1",
+ "ring",
+ "rustls-pki-types",
+ "sha1_smol",
+ "simdutf8",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+]
+
+[[package]]
 name = "toml"
 version = "0.5.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4943,6 +6575,17 @@
 ]
 
 [[package]]
+name = "tonic-types"
+version = "0.14.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8"
+dependencies = [
+ "prost",
+ "prost-types",
+ "tonic",
+]
+
+[[package]]
 name = "tower"
 version = "0.5.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4967,7 +6610,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "bytes",
  "futures-util",
  "http",
@@ -4997,6 +6640,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
 dependencies = [
+ "log",
  "pin-project-lite",
  "tracing-attributes",
  "tracing-core",
@@ -5010,7 +6654,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -5048,9 +6692,9 @@
 
 [[package]]
 name = "tracing-opentelemetry"
-version = "0.32.1"
+version = "0.33.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc"
+checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26"
 dependencies = [
  "js-sys",
  "opentelemetry",
@@ -5150,6 +6794,12 @@
 checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
 
 [[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
 name = "unicode-width"
 version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5209,6 +6859,7 @@
  "idna",
  "percent-encoding",
  "serde",
+ "serde_derive",
 ]
 
 [[package]]
@@ -5229,7 +6880,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
 dependencies = [
- "getrandom 0.4.2",
+ "getrandom 0.4.3",
  "js-sys",
  "serde_core",
  "wasm-bindgen",
@@ -5242,6 +6893,43 @@
 checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
 
 [[package]]
+name = "vergen"
+version = "9.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75"
+dependencies = [
+ "anyhow",
+ "derive_builder",
+ "rustversion",
+ "vergen-lib",
+]
+
+[[package]]
+name = "vergen-gitcl"
+version = "9.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9"
+dependencies = [
+ "anyhow",
+ "derive_builder",
+ "rustversion",
+ "time",
+ "vergen",
+ "vergen-lib",
+]
+
+[[package]]
+name = "vergen-lib"
+version = "9.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569"
+dependencies = [
+ "anyhow",
+ "derive_builder",
+ "rustversion",
+]
+
+[[package]]
 name = "version_check"
 version = "0.9.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5265,7 +6953,7 @@
 checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
 dependencies = [
  "arrayvec",
- "bitflags",
+ "bitflags 2.13.0",
  "cursor-icon",
  "log",
  "memchr",
@@ -5302,16 +6990,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
 dependencies = [
- "wit-bindgen 0.57.1",
-]
-
-[[package]]
-name = "wasip3"
-version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
-dependencies = [
- "wit-bindgen 0.51.0",
+ "wit-bindgen",
 ]
 
 [[package]]
@@ -5362,7 +7041,7 @@
  "bumpalo",
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
  "wasm-bindgen-shared",
 ]
 
@@ -5376,57 +7055,54 @@
 ]
 
 [[package]]
-name = "wasm-encoder"
-version = "0.244.0"
+name = "wasm-streams"
+version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
 dependencies = [
- "leb128fmt",
- "wasmparser",
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
 ]
 
 [[package]]
-name = "wasm-metadata"
-version = "0.244.0"
+name = "web-sys"
+version = "0.3.102"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
 dependencies = [
- "anyhow",
- "indexmap",
- "wasm-encoder",
- "wasmparser",
+ "js-sys",
+ "wasm-bindgen",
 ]
 
 [[package]]
-name = "wasmparser"
-version = "0.244.0"
+name = "web-time"
+version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
 dependencies = [
- "bitflags",
- "hashbrown 0.15.5",
- "indexmap",
- "semver",
+ "js-sys",
+ "wasm-bindgen",
 ]
 
 [[package]]
-name = "web-sys"
-version = "0.3.102"
+name = "webpki-root-certs"
+version = "1.0.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
+checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
 dependencies = [
- "js-sys",
- "wasm-bindgen",
+ "rustls-pki-types",
 ]
 
 [[package]]
-name = "web-time"
-version = "1.1.0"
+name = "webpki-roots"
+version = "1.0.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
 dependencies = [
- "js-sys",
- "wasm-bindgen",
+ "rustls-pki-types",
 ]
 
 [[package]]
@@ -5453,6 +7129,28 @@
 ]
 
 [[package]]
+name = "widestring"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
 name = "winapi-util"
 version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5462,6 +7160,12 @@
 ]
 
 [[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
 name = "windows"
 version = "0.62.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5514,7 +7218,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -5525,7 +7229,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -5545,6 +7249,17 @@
 ]
 
 [[package]]
+name = "windows-registry"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
+dependencies = [
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
 name = "windows-result"
 version = "0.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5564,6 +7279,15 @@
 
 [[package]]
 name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
+[[package]]
+name = "windows-sys"
 version = "0.52.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
@@ -5600,6 +7324,21 @@
 
 [[package]]
 name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
+[[package]]
+name = "windows-targets"
 version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
@@ -5642,6 +7381,12 @@
 
 [[package]]
 name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
 version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
@@ -5654,6 +7399,12 @@
 
 [[package]]
 name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+[[package]]
+name = "windows_aarch64_msvc"
 version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
@@ -5666,6 +7417,12 @@
 
 [[package]]
 name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
+[[package]]
+name = "windows_i686_gnu"
 version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
@@ -5690,6 +7447,12 @@
 
 [[package]]
 name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+[[package]]
+name = "windows_i686_msvc"
 version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
@@ -5702,6 +7465,12 @@
 
 [[package]]
 name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+[[package]]
+name = "windows_x86_64_gnu"
 version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
@@ -5714,6 +7483,12 @@
 
 [[package]]
 name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
 version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
@@ -5726,6 +7501,12 @@
 
 [[package]]
 name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+[[package]]
+name = "windows_x86_64_msvc"
 version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
@@ -5747,103 +7528,49 @@
 
 [[package]]
 name = "wit-bindgen"
-version = "0.51.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
-dependencies = [
- "wit-bindgen-rust-macro",
-]
-
-[[package]]
-name = "wit-bindgen"
 version = "0.57.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
 
 [[package]]
-name = "wit-bindgen-core"
-version = "0.51.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
-dependencies = [
- "anyhow",
- "heck",
- "wit-parser",
-]
-
-[[package]]
-name = "wit-bindgen-rust"
-version = "0.51.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
-dependencies = [
- "anyhow",
- "heck",
- "indexmap",
- "prettyplease",
- "syn 2.0.117",
- "wasm-metadata",
- "wit-bindgen-core",
- "wit-component",
-]
-
-[[package]]
-name = "wit-bindgen-rust-macro"
-version = "0.51.0"
+name = "wmi"
+version = "0.18.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64"
 dependencies = [
- "anyhow",
- "prettyplease",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
- "wit-bindgen-core",
- "wit-bindgen-rust",
+ "chrono",
+ "futures",
+ "log",
+ "serde",
+ "thiserror 2.0.18",
+ "windows",
+ "windows-core",
 ]
 
 [[package]]
-name = "wit-component"
-version = "0.244.0"
+name = "writeable"
+version = "0.6.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
-dependencies = [
- "anyhow",
- "bitflags",
- "indexmap",
- "log",
- "serde",
- "serde_derive",
- "serde_json",
- "wasm-encoder",
- "wasm-metadata",
- "wasmparser",
- "wit-parser",
-]
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
 
 [[package]]
-name = "wit-parser"
-version = "0.244.0"
+name = "ws_stream_wasm"
+version = "0.7.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc"
 dependencies = [
- "anyhow",
- "id-arena",
- "indexmap",
+ "async_io_stream",
+ "futures",
+ "js-sys",
  "log",
- "semver",
- "serde",
- "serde_derive",
- "serde_json",
- "unicode-xid",
- "wasmparser",
+ "pharos",
+ "rustc_version",
+ "send_wrapper",
+ "thiserror 2.0.18",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
 ]
-
-[[package]]
-name = "writeable"
-version = "0.6.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
 
 [[package]]
 name = "wsl"
@@ -5864,6 +7591,21 @@
 ]
 
 [[package]]
+name = "xml-rs"
+version = "0.8.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
+
+[[package]]
+name = "xmltree"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb"
+dependencies = [
+ "xml-rs",
+]
+
+[[package]]
 name = "yoke"
 version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5882,7 +7624,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
  "synstructure",
 ]
 
@@ -5911,6 +7653,7 @@
  "rustix 1.1.4",
  "serde",
  "serde_repr",
+ "tokio",
  "tracing",
  "uds_windows",
  "uuid",
@@ -5930,7 +7673,7 @@
  "proc-macro-crate",
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
  "zbus_names",
  "zvariant",
  "zvariant_utils",
@@ -5948,6 +7691,19 @@
 ]
 
 [[package]]
+name = "zbus_polkit"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad23d5c4d198c7e2641b33e6e0d1f866f117408ba66fe80bbe52e289eeb77c52"
+dependencies = [
+ "enumflags2",
+ "serde",
+ "serde_repr",
+ "static_assertions",
+ "zbus",
+]
+
+[[package]]
 name = "zerocopy"
 version = "0.8.52"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5964,7 +7720,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -5984,7 +7740,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
  "synstructure",
 ]
 
@@ -6005,7 +7761,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -6039,7 +7795,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
 ]
 
 [[package]]
@@ -6071,7 +7827,7 @@
  "proc-macro-crate",
  "proc-macro2",
  "quote",
- "syn 2.0.117",
+ "syn 2.0.118",
  "zvariant_utils",
 ]
 
@@ -6084,6 +7840,6 @@
  "proc-macro2",
  "quote",
  "serde",
- "syn 2.0.117",
+ "syn 2.0.118",
  "winnow",
 ]
modifiedCargo.tomldiffbeforeafterboth
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,9 +1,10 @@
 [workspace]
-members = ["crates/*", "cmds/*"]
-resolver = "2"
-package.version = "0.1.0"
+members = ["crates/*", "cmds/*", "remowt/crates/*", "remowt/cmds/*"]
+resolver = "3"
+package.version = "0.1.9"
 package.edition = "2024"
-package.rust-version = "1.89.0"
+package.rust-version = "1.95.0"
+package.license = "MIT"
 
 [workspace.dependencies]
 fleet-base = { path = "./crates/fleet-base" }
@@ -13,42 +14,56 @@
 opentelemetry-exporter-env = { path = "./crates/opentelemetry-exporter-env" }
 remowt-fleet = { path = "./crates/remowt-fleet" }
 
-remowt-client = "0.1.3"
-remowt-endpoints = "0.1.3"
-remowt-link-shared = "0.1.3"
-remowt-plugin = "0.1.3"
-remowt-ui-prompt = "0.1.3"
+remowt-client = { version = "0.1.9", path = "remowt/crates/remowt-client" }
+remowt-polkit-shared = { version = "0.1.9", path = "remowt/crates/polkit-shared" }
+remowt-link-shared = { version = "0.1.9", path = "remowt/crates/remowt-link-shared" }
+remowt-plugin = { version = "0.1.9", path = "remowt/crates/remowt-plugin" }
+remowt-ui-prompt = { version = "0.1.9", path = "remowt/crates/remowt-ui-prompt" }
+remowt-endpoints = { version = "0.1.9", path = "remowt/crates/remowt-endpoints" }
 
-bifrostlink = "0.2.3"
+bifrostlink = "0.2.0"
+bifrostlink-macros = "0.2.0"
+bifrostlink-ports = "0.2.0"
 
-uuid = { version = "1", features = ["v4"] }
+iroh = { version = "1.0.0", features = ["unstable-custom-transports"] }
+iroh-base = "1.0.0"
+noq-udp = { version = "1.0.0", default-features = false }
+n0-watcher = "1.0.0"
 
+uuid = { version = "1", features = ["v4"] }
+russh = { version = "0.61.2", default-features = false, features = [
+        "ring",
+        "flate2",
+        "rsa",
+] }
+russh-config = "0.58.0"
 age = { version = "0.11", features = ["plugin", "ssh"] }
 anyhow = "1.0"
 base64 = "0.22.1"
 bindgen = "0.72.0"
+bytes = "1.11.0"
 camino = "1.2.2"
 chrono = { version = "0.4.41", features = ["serde"] }
 clap = { version = "4.5", features = ["derive", "env", "unicode", "wrap_help"] }
 clap_complete = "4.5"
 cxx = "1.0.168"
 cxx-build = "1.0.168"
-ed25519-dalek = "2.1"
+ed25519-dalek = "3.0.0-rc.0"
 futures = "0.3.31"
 hex = "0.4.3"
-hmac = "0.12"
+hmac = "0.13.0"
 hostname = "0.4.1"
 human-repr = "1.1"
 indicatif = "0.18"
 indoc = "2.0.6"
-itertools = "0.14.0"
+itertools = "0.15.0"
 linked-hash-map = "0.5.6"
 nix = { version = "0.31.2", features = ["fs", "user"] }
 nom = "8.0.0"
-opentelemetry = "0.31.0"
-opentelemetry-appender-tracing = "0.31.1"
-opentelemetry-otlp = { version = "0.31.0", features = ["grpc-tonic", "gzip-tonic", "http-json", "reqwest-rustls"] }
-opentelemetry_sdk = "0.31.0"
+opentelemetry = "0.32.0"
+opentelemetry-appender-tracing = "0.32.0"
+opentelemetry-otlp = { version = "0.32.0", features = ["grpc-tonic", "gzip-tonic", "http-json", "reqwest-rustls"] }
+opentelemetry_sdk = "0.32.0"
 pbkdf2 = "0.12"
 peg = "0.8.5"
 pkg-config = "0.3.30"
@@ -56,8 +71,8 @@
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
 sha2 = "0.10"
-shlex = "1.3"
-tabled = "0.20.0"
+shlex = "2.0.1"
+tabled = "0.21.0"
 tempfile = "3.20"
 test-log = { version = "0.2.19", features = ["trace"] }
 thiserror = "2.0.12"
@@ -65,12 +80,16 @@
 tokio = { version = "1.45.1", features = ["fs", "macros", "rt", "rt-multi-thread", "sync", "time"] }
 tracing = "0.1"
 tracing-indicatif = "0.3.13"
-tracing-opentelemetry = "0.32.1"
+tracing-opentelemetry = "0.33.0"
 # For fixed coloring needs to be updated to https://github.com/tokio-rs/tracing/pull/3484
 tracing-subscriber = { version = "0.3.19", features = ["env-filter", "fmt"] }
 unicode_categories = "0.1.1"
 vte = { version = "0.15.0", features = ["ansi"] }
 x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
+zbus = "5.16.0"
+zbus_polkit = "5.0.0"
+tokio-util = "0.7.11"
+pam-client = "0.5.0"
 
 [profile.dev]
 panic = "abort"
modifiedcmds/remowt-plugin-fleet/src/main.rsdiffbeforeafterboth
--- a/cmds/remowt-plugin-fleet/src/main.rs
+++ b/cmds/remowt-plugin-fleet/src/main.rs
@@ -1,4 +1,4 @@
-use remowt_fleet::{Nix, init_libraries};
+use remowt_fleet::{init_libraries, Nix};
 
 fn main() -> anyhow::Result<()> {
 	remowt_plugin::run(|rpc| {
modifiedcrates/fleet-base/src/host.rsdiffbeforeafterboth
before · crates/fleet-base/src/host.rs
1use std::{2	collections::{BTreeMap, BTreeSet, HashSet},3	future::Future,4	io::Write,5	ops::Deref,6	path::PathBuf,7	pin::Pin,8	str::FromStr,9	sync::{Arc, OnceLock},10};1112use anyhow::{Context, Result, anyhow, bail, ensure};13use camino::{Utf8Path, Utf8PathBuf};14use chrono::{DateTime, Utc};15use fleet_shared::SecretData;16use nix_eval::{Store, Value, eval_store, nix_go, nix_go_json, util::assert_warn};17use remowt_client::{AgentBundle, Remowt};18use remowt_endpoints::fs::FsClient;19use remowt_link_shared::Address;20use remowt_ui_prompt::auto::AutoPrompter;21use remowt_ui_prompt::bifrost::PromptEndpoints;22use remowt_ui_prompt::{PrependSourcePrompter, Source};23use tabled::Tabled;24use tempfile::NamedTempFile;25use time::UtcDateTime;26use tokio::task::spawn_blocking;27use tracing::warn;2829use crate::fleetdata::{30	FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretPart, SecretOwner,31};3233pub struct FleetConfigInternals {34	pub prefer_identities: BTreeSet<SecretOwner>,35	pub now: DateTime<Utc>,3637	/// Fleet project directory, containing fleet.nix file.38	pub directory: PathBuf,39	/// builtins.currentSystem40	pub local_system: String,41	pub data: Arc<FleetData>,42	/// fleet_config.config43	pub config_field: Value,44	/// flake.output45	pub flake_outputs: Value,46	// TODO: Remove with connectivity refactor47	pub localhost: String,4849	/// import nixpkgs {system = local};50	pub default_pkgs: Value,51	/// inputs.nixpkgs52	pub nixpkgs: Value,5354	pub local_host: OnceLock<Arc<ConfigHost>>,55}5657// TODO: Make field not pub58#[derive(Clone)]59pub struct Config(pub Arc<FleetConfigInternals>);6061impl Deref for Config {62	type Target = FleetConfigInternals;6364	fn deref(&self) -> &Self::Target {65		&self.066	}67}6869#[derive(Clone, PartialEq, Copy, Debug)]70pub enum DeployKind {71	/// NixOS => NixOS managed by fleet72	UpgradeToFleet,73	/// NixOS managed by fleet => NixOS managed by fleet74	Fleet,75	/// Remote host has /mnt, /mnt/boot mounted,76	/// generated config is added to fleet configuration.77	NixosInstall,78	/// Remote host has some system and nix installed in multi-user mode (/nix is owned by root),79	/// generated config is added to fleet configuration,80	/// and /etc/NIXOS_LUSTRATE exists, fleet will perform the rest.81	NixosLustrate,82}8384impl FromStr for DeployKind {85	type Err = anyhow::Error;86	fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {87		match s {88			"upgrade-to-fleet" => Ok(Self::UpgradeToFleet),89			"fleet" => Ok(Self::Fleet),90			"nixos-install" => Ok(Self::NixosInstall),91			"nixos-lustrate" => Ok(Self::NixosLustrate),92			v => bail!(93				"unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""94			),95		}96	}97}98pub struct ConfigHost {99	config: Config,100	pub name: String,101	groups: OnceLock<Vec<String>>,102103	// TODO: Both of those values are taken from host opts, there should be a cleaner way to specify it104	deploy_kind: OnceLock<DeployKind>,105	session_destination: OnceLock<String>,106	legacy_ssh_store: OnceLock<bool>,107108	pub host_config: Option<Value>,109	pub nixos_config: OnceLock<Value>,110	pub nixos_unchecked_config: OnceLock<Value>,111	pub pkgs_override: Option<Value>,112113	// TODO: Move command helpers away with connectivity refactor114	pub local: bool,115	pub remowt: OnceLock<Remowt>,116	nix_store: OnceLock<Arc<Store>>,117	nix_plugin: tokio::sync::OnceCell<()>,118}119120const NIX_PLUGIN_ID: u16 = 2;121122fn agents_dir() -> Result<PathBuf> {123	std::env::var_os("REMOWT_AGENTS_DIR")124		.map(PathBuf::from)125		.or_else(|| option_env!("REMOWT_AGENTS_DIR").map(PathBuf::from))126		.ok_or_else(|| {127			anyhow!("no remowt-agents bundle; set REMOWT_AGENTS_DIR to a remowt-agents output")128		})129}130131fn agent_bundle() -> Result<AgentBundle> {132	AgentBundle::from_dir(agents_dir()?)133}134135#[derive(Debug, Clone, Copy)]136pub enum GenerationStorage {137	Deployer,138	Machine,139	Pusher,140}141impl GenerationStorage {142	fn prefix(&self) -> &'static str {143		match self {144			GenerationStorage::Deployer => "deployer.",145			GenerationStorage::Machine => "",146			GenerationStorage::Pusher => "pusher.",147		}148	}149}150151#[derive(Tabled, Debug)]152pub struct Generation {153	#[tabled(rename = "ID", format("{}", self.rollback_id()))]154	pub id: u32,155	#[tabled(rename = "Current")]156	pub current: bool,157	#[tabled(rename = "Created at")]158	pub datetime: UtcDateTime,159	#[tabled(format = "{:?}")]160	pub store_path: Utf8PathBuf,161	#[tabled(skip)]162	pub location: GenerationStorage,163}164impl Generation {165	pub fn rollback_id(&self) -> String {166		format!("{}{}", self.location.prefix(), self.id)167	}168}169170impl ConfigHost {171	pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {172		let plugin_id = self.ensure_nix_plugin().await?;173		let nix = self174			.remowt()175			.await?176			.plugin_endpoints::<remowt_fleet::NixClient<_>>(plugin_id);177		let raw = nix178			.list_generations(profile.to_owned())179			.await180			.map_err(|e| anyhow!("{e:?}"))?181			.map_err(|e| anyhow!("{e}"))?;182		raw.into_iter()183			.map(|g| {184				let id: u32 =185					g.id.try_into()186						.with_context(|| format!("generation id {} doesn't fit in u32", g.id))?;187				let datetime = UtcDateTime::from_unix_timestamp(g.creation_time_unix)188					.with_context(|| {189						format!("invalid generation timestamp {}", g.creation_time_unix)190					})?;191				Ok(Generation {192					id,193					current: g.current,194					datetime,195					store_path: g.store_path,196					location: GenerationStorage::Machine,197				})198			})199			.collect()200	}201202	pub fn set_session_destination(&self, dest: String) {203		self.session_destination204			.set(dest)205			.expect("session destination is already set")206	}207	pub fn set_deploy_kind(&self, kind: DeployKind) {208		self.deploy_kind209			.set(kind)210			.expect("deploy kind is already set");211	}212	pub fn set_legacy_ssh_store(&self, legacy: bool) {213		self.legacy_ssh_store214			.set(legacy)215			.expect("legacy ssh store is already set")216	}217	pub async fn deploy_kind(&self) -> Result<DeployKind> {218		if let Some(kind) = self.deploy_kind.get() {219			return Ok(*kind);220		}221		let remowt = self.remowt().await?;222		let fs = remowt.endpoints::<FsClient<_>>();223		let is_fleet_managed = match fs.file_exists(Utf8PathBuf::from("/etc/FLEET_HOST")).await {224			Ok(v) => v,225			Err(e) => {226				bail!("failed to query remote system kind: {e}");227			}228		};229		if !is_fleet_managed {230			bail!(231				"{}",232				indoc::indoc! {"233				host is not marked as managed by fleet234				if you're not trying to lustrate/install system from scratch,235				you should either236					1. manually create /etc/FLEET_HOST file on the target host,237					2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet238					3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos239				for installation use ?deploy_kind=nixos_install / ?deploy_kind=nixos_lustrate 240			"}241			);242		}243		// TOCTOU is possible244		let _ = self.deploy_kind.set(DeployKind::Fleet);245		Ok(*self.deploy_kind.get().expect("deploy kind is just set"))246	}247	async fn connection(&self) -> Result<Remowt> {248		if let Some(conn) = self.remowt.get() {249			return Ok(conn.clone());250		}251		let bundle = agent_bundle()?;252		let conn = if self.local {253			Remowt::connect_local(&bundle, "remowt-fleet".to_owned())254				.await255				.context("starting local remowt agent")?256		} else {257			let dest = self258				.session_destination259				.get()260				.cloned()261				.unwrap_or_else(|| self.name.clone());262			Remowt::connect(&dest, &bundle, "remowt-fleet".to_owned())263				.await264				.map_err(|e| anyhow!("remowt error while connecting to {}: {e:#?}", self.name))?265		};266		PromptEndpoints(PrependSourcePrompter {267			prompter: AutoPrompter::new().await,268			source: if self.local {269				vec![]270			} else {271				vec![Source(std::borrow::Cow::Owned(format!(272					"ssh host: {}",273					self.name274				)))]275			},276			description: "".to_owned(),277		})278		.register_endpoints(&mut conn.rpc());279		let _ = self.remowt.set(conn);280		Ok(self.remowt.get().expect("just set").clone())281	}282283	/// Client for this host's unprivileged agent.284	pub async fn remowt(&self) -> Result<Remowt> {285		self.connection().await286	}287288	pub fn ensure_nix_plugin(&self) -> Pin<Box<dyn Future<Output = Result<u16>> + Send + '_>> {289		Box::pin(async {290			self.nix_plugin291				.get_or_try_init(|| async {292					let pkgs = self.pkgs()?;293					let name = "remowt-plugin-fleet";294					let plugin = nix_go!(pkgs[{ name }]);295					let built = plugin296						.build("out")297						.context("failed to build the fleet nix plugin")?;298					let copied = self299						.remote_derivation(&built)300						.await301						.context("failed to copy the fleet nix plugin to the host store")?;302					let bin = copied.join("bin/remowt-plugin-fleet");303					self.remowt()304						.await?305						.run0_load_plugin_path(NIX_PLUGIN_ID, bin.as_str())306						.await307						.context("failed to load the fleet nix plugin")?;308					self.remowt()309						.await?310						.rpc()311						.wait_for_connection_to(Address::Plugin(NIX_PLUGIN_ID))312						.await313						.map_err(|_| anyhow!("failed to wait for plugin"))?;314					anyhow::Ok(())315				})316				.await?;317			Ok(NIX_PLUGIN_ID)318		})319	}320321	async fn nix_store(&self) -> Result<Arc<Store>> {322		if let Some(store) = self.nix_store.get() {323			return Ok(store.clone());324		}325		let conn = self.connection().await?;326		let socket = match self.deploy_kind().await? {327			DeployKind::NixosInstall => {328				remowt_fleet::nix_store_socket(conn, "/mnt?require-sigs=false").await?329			}330			_ => remowt_fleet::nix_store_socket(conn, "auto").await?,331		};332		let uri = format!("unix://{}", socket.display());333		let store = Arc::new(Store::open(&uri)?);334		let _ = self.nix_store.set(store);335		Ok(self.nix_store.get().expect("just set").clone())336	}337338	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {339		ensure!(data.encrypted, "secret is not encrypted");340		let remowt = self.remowt().await?;341		let mut cmd = remowt.cmd("fleet-install-secrets");342		cmd.arg("decrypt").eqarg("--secret", data.to_string());343		let encoded = cmd344			.sudo()345			.run_string()346			.await347			.context("failed to call remote host for decrypt")?;348		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;349		ensure!(!data.encrypted, "secret came out encrypted");350		Ok(data.data)351	}352	pub async fn reencrypt_distribution(353		&self,354		data: &FleetSecretDistribution,355		targets: BTreeSet<SecretOwner>,356		now: DateTime<Utc>,357	) -> Result<FleetSecretDistribution> {358		let mut parts = BTreeMap::new();359		for (part_name, part) in &data.secret.parts {360			parts.insert(361				part_name.clone(),362				if part.raw.encrypted {363					FleetSecretPart {364						raw: self.reencrypt(part.raw.clone(), targets.clone()).await?,365					}366				} else {367					part.clone()368				},369			);370		}371		let secret = FleetSecretData {372			created_at: data.secret.created_at,373			expires_at: data.secret.expires_at,374			generation_data: data.secret.generation_data.clone(),375			parts,376		};377		Ok(FleetSecretDistribution::new(targets, secret, now))378	}379	pub async fn reencrypt(380		&self,381		data: SecretData,382		targets: BTreeSet<SecretOwner>,383	) -> Result<SecretData> {384		let remowt = self.remowt().await?;385		ensure!(data.encrypted, "secret is not encrypted");386		let mut cmd = remowt.cmd("fleet-install-secrets");387		cmd.arg("reencrypt").eqarg("--secret", data.to_string());388		for target in targets {389			let key = self.config.key(&target).await?;390			cmd.eqarg("--targets", key);391		}392		let encoded = cmd393			.sudo()394			.run_string()395			.await396			.context("failed to call remote host for decrypt")?;397		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;398		ensure!(data.encrypted, "secret came out not encrypted");399		Ok(data)400	}401	/// Returns path for futureproofing, as path might change i.e on conversion to CA402	pub async fn remote_derivation(&self, path: impl AsRef<Utf8Path>) -> Result<Utf8PathBuf> {403		let path = path.as_ref().to_owned();404		if self.local {405			// Path is located locally, thus already trusted.406			return Ok(path);407		}408		let sign: Pin<Box<dyn Future<Output = Result<()>> + Send>> = {409			let path = path.clone();410			Box::pin(async move {411				let local = self.config.local_host();412				let plugin_id = local.ensure_nix_plugin().await?;413				let nix = local414					.remowt()415					.await?416					.plugin_endpoints::<remowt_fleet::NixClient<_>>(plugin_id);417				nix.sign_closure(path, Utf8PathBuf::from("/etc/nix/private-key"))418					.await419					.map_err(|e| anyhow!("{e:?}"))?420					.map_err(|e| anyhow!("{e}"))?;421				Ok(())422			})423		};424		if let Err(e) = sign.await {425			warn!("failed to sign store paths: {e}");426		}427		let store = self.nix_store().await?;428		{429			let path = path.clone();430			let eval_store = eval_store();431			spawn_blocking(move || eval_store.copy_to(&store, path.as_ref()))432				.await433				.expect("copy_to panicked")434				.context("copying closure to remote store")?;435		}436		Ok(path)437	}438}439440struct HostSecretDefinition(Value);441442impl ConfigHost {443	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,444	// assuming getting tags always returns the same value.445	pub fn tags(&self) -> Result<Vec<String>> {446		if let Some(v) = self.groups.get() {447			return Ok(v.clone());448		}449		let Some(host_config) = &self.host_config else {450			return Ok(vec![]);451		};452		let tags: Vec<String> = nix_go_json!(host_config.tags);453454		let _ = self.groups.set(tags.clone());455456		Ok(tags)457	}458	pub fn nixos_config(&self) -> Result<Value> {459		if let Some(v) = self.nixos_config.get() {460			return Ok(v.clone());461		}462		let Some(host_config) = &self.host_config else {463			bail!("local host has no nixos_config");464		};465		let nixos_config = nix_go!(host_config.nixos.config);466		assert_warn("nixos config evaluation", &nixos_config)?;467468		let _ = self.nixos_config.set(nixos_config.clone());469470		Ok(nixos_config)471	}472	pub fn nixos_unchecked_config(&self) -> Result<Value> {473		if let Some(v) = self.nixos_unchecked_config.get() {474			return Ok(v.clone());475		}476		let Some(host_config) = &self.host_config else {477			bail!("local host has no nixos_config");478		};479		let nixos_config = nix_go!(host_config.nixos_unchecked.config);480481		let _ = self.nixos_unchecked_config.set(nixos_config.clone());482483		Ok(nixos_config)484	}485486	pub fn list_defined_secrets(&self) -> Result<Vec<String>> {487		let nixos = self.nixos_unchecked_config()?;488		let secrets = nix_go!(nixos.secrets);489		secrets.list_fields()490	}491492	/// Packages for this host, resolved with nixpkgs overlays493	pub fn pkgs(&self) -> Result<Value> {494		if let Some(value) = &self.pkgs_override {495			return Ok(value.clone());496		}497		let Some(host_config) = &self.host_config else {498			bail!("local host has no host_config");499		};500		// TODO: Should nixos.options be cached?501		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))502	}503}504505#[derive(Clone)]506pub struct SharedSecretDefinition(Value);507impl SharedSecretDefinition {508	pub fn expected_owners(&self) -> Result<BTreeSet<SecretOwner>> {509		let secret = &self.0;510		Ok(nix_go_json!(secret.expectedOwners))511	}512	pub fn allow_different(&self) -> Result<bool> {513		let secret = &self.0;514		Ok(nix_go_json!(secret.allowDifferent))515	}516	pub fn regenerate_on_owner_added(&self) -> Result<bool> {517		let secret = &self.0;518		Ok(nix_go_json!(secret.regenerateOnOwnerAdded))519	}520	pub fn regenerate_on_owner_removed(&self) -> Result<bool> {521		let secret = &self.0;522		Ok(nix_go_json!(secret.regenerateOnOwnerRemoved))523	}524	pub fn generator(&self) -> Result<Value> {525		let secret = &self.0;526		Ok(nix_go!(secret.generator))527	}528}529530impl Config {531	pub fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {532		let config = &self.config_field;533		let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);534		Ok(tagged)535	}536	pub fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {537		let mut out = BTreeSet::new();538		for owner in owners {539			if let Some(tag) = owner.strip_prefix('@') {540				let hosts = self.tagged_hostnames(tag)?;541				out.extend(hosts);542			} else {543				out.insert(owner);544			}545		}546		Ok(out)547	}548	pub fn local_host(&self) -> Arc<ConfigHost> {549		self.local_host550			.get_or_init(|| {551				Arc::new(ConfigHost {552					config: self.clone(),553					name: "<virtual localhost>".to_owned(),554					host_config: None,555					nixos_config: OnceLock::new(),556					nixos_unchecked_config: OnceLock::new(),557					groups: {558						let cell = OnceLock::new();559						let _ = cell.set(vec![]);560						cell561					},562					pkgs_override: Some(self.default_pkgs.clone()),563564					local: true,565					remowt: OnceLock::new(),566					nix_store: OnceLock::new(),567					nix_plugin: tokio::sync::OnceCell::new(),568					deploy_kind: OnceLock::new(),569					session_destination: OnceLock::new(),570					legacy_ssh_store: OnceLock::new(),571				})572			})573			.clone()574	}575576	pub fn preferred_hosts(577		&self,578		filter: impl Fn(&str) -> bool,579	) -> Result<impl Iterator<Item = Result<ConfigHost>>> {580		let prefer = self581			.prefer_identities582			.iter()583			.filter_map(|v| v.as_host())584			.collect::<HashSet<_>>();585		let config = &self.config_field;586		let mut names = nix_go!(config.hosts).list_fields()?;587		names.retain(|s| filter(s));588		names.sort_by_key(|h| prefer.contains(h.as_str()));589590		Ok(names.into_iter().map(|h| self.host(&h)))591	}592593	pub fn host(&self, name: &str) -> Result<ConfigHost> {594		let config = &self.config_field;595		let host_config = nix_go!(config.hosts[{ name }]);596597		Ok(ConfigHost {598			config: self.clone(),599			name: name.to_owned(),600			host_config: Some(host_config),601			nixos_config: OnceLock::new(),602			nixos_unchecked_config: OnceLock::new(),603			groups: OnceLock::new(),604			pkgs_override: None,605606			// TODO: Remove with connectivit refactor607			local: self.localhost == name,608			remowt: OnceLock::new(),609			nix_store: OnceLock::new(),610			nix_plugin: tokio::sync::OnceCell::new(),611			deploy_kind: OnceLock::new(),612			session_destination: OnceLock::new(),613			legacy_ssh_store: OnceLock::new(),614		})615	}616	pub fn list_hosts(&self) -> Result<Vec<ConfigHost>> {617		let config = &self.config_field;618		let names = nix_go!(config.hosts).list_fields()?;619		let mut out = vec![];620		for name in names {621			out.push(self.host(&name)?);622		}623		Ok(out)624	}625	// TODO: Replace usages with .host().nixos_config626	pub fn system_config(&self, host: &str) -> Result<Value> {627		let fleet_field = &self.config_field;628		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))629	}630631	pub fn secret_definition(&self, secret: &str) -> Result<Option<SharedSecretDefinition>> {632		let config = &self.config_field;633		let shared_secrets = nix_go!(config.secrets);634		if !shared_secrets.has_field(secret)? {635			return Ok(None);636		}637		Ok(Some(SharedSecretDefinition(nix_go!(638			shared_secrets[secret]639		))))640	}641642	pub fn save(&self) -> Result<()> {643		let mut tempfile = NamedTempFile::new_in(self.directory.clone()).context("failed to create updated version of fleet.nix in the same directory as original.\nDo you have write access to it? Access only to the fleet.nix won't be enough, the directory is used for atomic overwrite operation.\nIt is not recommended to use fleet by root anyway, move fleet project to your home directory.")?;644		let data = nixlike::serialize(&*self.data)?;645		tempfile.write_all(646			format!(647				"# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"648			)649			.as_bytes(),650		)?;651		let mut fleet_data_path = self.directory.clone();652		fleet_data_path.push("fleet.nix");653		tempfile.persist(fleet_data_path)?;654		Ok(())655	}656}
modifiedcrates/nix-eval/src/lib.rsdiffbeforeafterboth
--- a/crates/nix-eval/src/lib.rs
+++ b/crates/nix-eval/src/lib.rs
@@ -565,7 +565,7 @@
 	#[instrument(skip(self, dst))]
 	pub fn copy_to(&self, dst: &Store, path: &Utf8Path) -> Result<()> {
 		let sp = self
-			.parse_path(&path)
+			.parse_path(path)
 			.context("failed to parse store path")?;
 		let rc = with_default_context(|c, _| unsafe {
 			store_copy_closure(c, self.as_ptr(), dst.0, sp.as_ptr())
addedremowt/README.adocdiffbeforeafterboth
--- /dev/null
+++ b/remowt/README.adoc
@@ -0,0 +1,3 @@
+= Remowt
+
+A framework for executing code on remote machines.
addedremowt/cmds/polkit-dbus-helper/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/polkit-dbus-helper/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "polkit-backend"
+description = "polkit/PAM D-Bus authentication backend for remowt"
+version.workspace = true
+edition = "2021"
+license.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+clap = { workspace = true, features = ["derive"] }
+nix.workspace = true
+pam-client.workspace = true
+remowt-polkit-shared.workspace = true
+tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] }
+tracing.workspace = true
+tracing-subscriber.workspace = true
+remowt-ui-prompt.workspace = true
+zbus = { workspace = true, features = ["tokio"] }
+zbus_polkit = { workspace = true, features = ["tokio"] }
addedremowt/cmds/polkit-dbus-helper/README.adocdiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/polkit-dbus-helper/README.adoc
@@ -0,0 +1,8 @@
+== What is it?
+
+Usually, there is a `polkit-agent-helper-1` suid binary installed on the system with polkit package.
+
+This, however, an alternative to that approach, a system daemon listening for dbus requests, which works
+without using suid binaries.
+
+In future it will provide some additional features.
addedremowt/cmds/polkit-dbus-helper/etc/systemd/system/remowt-polkit-helper.servicediffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/polkit-dbus-helper/etc/systemd/system/remowt-polkit-helper.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=Remowt polkit helper service
+
+[Service]
+Type=dbus
+BusName=lach.polkit.helper1
+ExecStart=@libexecdir@/polkit-backend
+# TODO: Hardening
+
+[Install]
+WantedBy=multi-user.target
+Alias=dbus-lach.polkit.helper1.service
addedremowt/cmds/polkit-dbus-helper/share/dbus-1/system-services/lach.polkit.helper1.confdiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/polkit-dbus-helper/share/dbus-1/system-services/lach.polkit.helper1.conf
@@ -0,0 +1,5 @@
+[D-BUS Service]
+Name=lach.polkit.helper1
+Exec=/bin/false
+User=root
+SystemdService=dbus-lach.polkit.helper1.service
addedremowt/cmds/polkit-dbus-helper/share/dbus-1/system.d/lach.polkit.helper1.confdiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/polkit-dbus-helper/share/dbus-1/system.d/lach.polkit.helper1.conf
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN" "https://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
+<busconfig>
+	<policy user="root">
+		<allow own = "lach.polkit.helper1"/>
+		<allow send_interface="lach.PolkitInputHandler"/>
+	</policy>
+	<policy context="default">
+		<allow send_destination="lach.polkit.helper1"/>
+		<deny send_interface="lach.PolkitInputHandler"/>
+	</policy>
+</busconfig>
addedremowt/cmds/polkit-dbus-helper/src/main.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/polkit-dbus-helper/src/main.rs
@@ -0,0 +1,236 @@
+use std::collections::{HashMap, HashSet};
+use std::ffi::{CStr, CString};
+use std::future::pending;
+use std::sync::LazyLock;
+
+use anyhow::Context as _;
+use clap::Parser;
+use nix::unistd::{setuid, Uid, User};
+use pam_client::{Context, ConversationHandler, ErrorCode, Flag};
+use remowt_polkit_shared::BackendRequest;
+use remowt_ui_prompt::dbus::DbusPrompterProxyBlocking;
+use remowt_ui_prompt::BlockingPrompter;
+use tokio::task::{block_in_place, spawn_blocking};
+use tracing::trace;
+use zbus::fdo;
+use zbus::message::Header;
+use zbus::zvariant::OwnedValue;
+use zbus::{blocking, interface, proxy, Connection};
+
+struct Helper {
+	connection: Connection,
+	blocking_connection: blocking::Connection,
+}
+
+static ALLOWED_ENVIRONMENT: LazyLock<HashSet<&str>> = LazyLock::new(|| {
+	[
+		// pam ssh agent auth
+		"SSH_AUTH_SOCK",
+		// ssh itself provides this when running PAM
+		"SSH_AUTH_INFO_0",
+		// contains user which ran sudo
+		"SUDO_USER",
+	]
+	.into_iter()
+	.collect()
+});
+
+struct Conversation<P>(P);
+impl<P: BlockingPrompter> Conversation<P> {
+	fn prompt_inner(&self, echo: bool, prompt: &CStr) -> Result<CString, ErrorCode> {
+		trace!("do prompt");
+		let out = self
+			.0
+			.prompt_text(echo, &prompt.to_string_lossy(), "PAM prompt request", &[])
+			.map_err(|e| {
+				trace!("prompt error: {e}");
+				ErrorCode::CONV_ERR
+			})?;
+		CString::new(out).map_err(|_| ErrorCode::CONV_AGAIN)
+	}
+	fn text_inner(&self, error: bool, msg: &CStr) {
+		trace!("do text");
+		let msg = msg.to_string_lossy();
+		let _ = self.0.display_text(error, &msg, &[]);
+	}
+}
+impl<P: BlockingPrompter> ConversationHandler for Conversation<P> {
+	fn prompt_echo_on(&mut self, prompt: &CStr) -> Result<CString, ErrorCode> {
+		self.prompt_inner(true, prompt)
+	}
+
+	fn prompt_echo_off(&mut self, prompt: &CStr) -> Result<CString, ErrorCode> {
+		self.prompt_inner(false, prompt)
+	}
+
+	fn text_info(&mut self, msg: &CStr) {
+		self.text_inner(false, msg)
+	}
+
+	fn error_msg(&mut self, msg: &CStr) {
+		self.text_inner(true, msg)
+	}
+
+	fn radio_prompt(&mut self, prompt: &CStr) -> Result<bool, ErrorCode> {
+		let prompt = prompt.to_string_lossy();
+		let result = self
+			.0
+			.prompt_radio(&prompt, "PAM prompt request", &[])
+			.map_err(|_| ErrorCode::CONV_ERR)?;
+		Ok(result)
+	}
+}
+
+#[proxy(
+	default_service = "org.freedesktop.DBus",
+	default_path = "/org/freedesktop/DBus"
+)]
+trait DBus {
+	fn get_connection_credentials(&self, body: &str) -> zbus::Result<HashMap<String, OwnedValue>>;
+}
+
+#[interface(name = "lach.PolkitHelper")]
+impl Helper {
+	async fn init_conversation(
+		&self,
+		request: BackendRequest,
+		#[zbus(header)] hdr: Header<'_>,
+	) -> fdo::Result<()> {
+		let Some(sender) = hdr.sender().map(|v| v.to_owned()) else {
+			trace!("missing sender");
+			return Err(fdo::Error::AuthFailed("missing sender".to_owned()));
+		};
+
+		let dbus = DBusProxy::new(&self.connection).await?;
+
+		// TOCTOU: sender might be already disconnected, and there might be another
+		// user with different user id here, but does it matters?
+		let reply = dbus.get_connection_credentials(&sender).await?;
+		let connection_uid: u32 = (&reply["UnixUserID"]).try_into().unwrap();
+
+		let identity = request.identity.clone();
+		let blocking_connection = self.blocking_connection.clone();
+		let thread_result: fdo::Result<()> = block_in_place(move || {
+			trace!("find user");
+			let Some(identity_uid) = identity.uid() else {
+				return Err(fdo::Error::AuthFailed("can't process identity".to_owned()));
+			};
+			let user = User::from_uid(identity_uid)
+				.map_err(|_| fdo::Error::AuthFailed("error querying user".to_owned()))?
+				.ok_or_else(|| fdo::Error::AuthFailed("uid not found".to_owned()))?;
+
+			let responder = DbusPrompterProxyBlocking::new(
+				&blocking_connection,
+				sender,
+				request.prompter_path,
+			)?;
+			let conversation = Conversation(responder);
+			trace!("run context for {}", &user.name);
+			let mut ctx = Context::new(
+				// TODO: Should another scope be used?
+				"login",
+				Some(&user.name),
+				conversation,
+			)
+			.map_err(|_| fdo::Error::Failed("pam context init failed".to_owned()))?;
+
+			trace!("fill env");
+			for (k, v) in request.environment {
+				if k.contains('=') || !ALLOWED_ENVIRONMENT.contains(k.as_str()) {
+					continue;
+				}
+				let _ = ctx.putenv(format!("{k}={v}"));
+			}
+
+			trace!("authenticate");
+			ctx.authenticate(Flag::NONE)
+				.map_err(|_| fdo::Error::AuthFailed("pam authentication failed".to_owned()))?;
+
+			trace!("acct mgmt");
+			ctx.acct_mgmt(Flag::NONE)
+				.map_err(|_| fdo::Error::AuthFailed("pam acct mgmt failed".to_owned()))?;
+
+			Ok(())
+		});
+
+		thread_result?;
+
+		trace!("respond");
+		let proxy = zbus_polkit::policykit1::AuthorityProxy::new(&self.connection).await?;
+
+		let identity_details = request
+			.identity
+			.details
+			.iter()
+			.map(|(k, v)| (k.as_str(), (**v).try_clone().expect("success")))
+			.collect::<HashMap<_, _>>();
+		proxy
+			.authentication_agent_response2(
+				connection_uid,
+				&request.cookie,
+				&zbus_polkit::policykit1::Identity {
+					identity_kind: &request.identity.kind,
+					identity_details: &identity_details,
+				},
+			)
+			.await?;
+		Ok(())
+	}
+}
+
+const OBJ_PATH: &str = "/lach/PolkitHelper";
+
+#[derive(Parser)]
+struct Opts {
+	/// Not recommended: start as a session connection, then use escalation
+	/// to respond to polkit requests.
+	#[arg(long)]
+	session: bool,
+}
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+	tracing_subscriber::fmt::init();
+	let opts = Opts::parse();
+	let connection = if opts.session {
+		Connection::session().await
+	} else {
+		Connection::system().await
+	}
+	.context("failed to open connection")?;
+
+	let session = opts.session;
+	let blocking_connection: anyhow::Result<blocking::Connection> = spawn_blocking(move || {
+		Ok(if session {
+			blocking::Connection::session()?
+		} else {
+			blocking::Connection::system()?
+		})
+	})
+	.await?;
+	let blocking_connection = blocking_connection.context("failed to open blocking connection")?;
+
+	if opts.session {
+		setuid(Uid::from_raw(0))
+			.context("polkit-backend needs to be suid if run in session mode")?;
+	}
+
+	connection
+		.object_server()
+		.at(
+			OBJ_PATH,
+			Helper {
+				connection: connection.clone(),
+				blocking_connection,
+			},
+		)
+		.await
+		.context("failed listen path")?;
+
+	connection
+		.request_name("lach.polkit.helper1")
+		.await
+		.context("failed to request name")?;
+
+	pending().await
+}
addedremowt/cmds/remowt-agent/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/remowt-agent/Cargo.toml
@@ -0,0 +1,35 @@
+[package]
+name = "remowt-agent"
+description = "remowt on-host agent serving fs/pty/systemd endpoints over bifrostlink"
+version.workspace = true
+edition = "2021"
+license.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+bifrostlink.workspace = true
+bifrostlink-ports.workspace = true
+clap = { workspace = true, features = ["derive"] }
+futures.workspace = true
+nix.workspace = true
+remowt-polkit-shared.workspace = true
+remowt-link-shared.workspace = true
+remowt-plugin.workspace = true
+tempfile.workspace = true
+tokio = { workspace = true, features = [
+	"rt",
+	"fs",
+	"macros",
+	"net",
+	"io-util",
+	"time",
+	"process",
+] }
+tokio-util = { workspace = true, features = ["codec"] }
+tracing.workspace = true
+tracing-subscriber.workspace = true
+remowt-ui-prompt.workspace = true
+uuid = { workspace = true, features = ["v4"] }
+zbus = { workspace = true, features = ["tokio"] }
+zbus_polkit = { workspace = true, features = ["tokio"] }
+remowt-endpoints.workspace = true
addedremowt/cmds/remowt-agent/src/askpass.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/remowt-agent/src/askpass.rs
@@ -0,0 +1,51 @@
+use std::borrow::Cow;
+use std::io::Write as _;
+
+use anyhow::Context as _;
+use remowt_ui_prompt::dbus::{DbusPrompterInterface, DbusPrompterProxy, BUS_NAME, PROMPTER_PATH};
+use remowt_ui_prompt::{Prompter, Source};
+use tracing::debug;
+use zbus::Connection;
+
+pub async fn serve<P>(conn: &Connection, prompter: P) -> anyhow::Result<()>
+where
+	P: Prompter + 'static,
+{
+	conn.object_server()
+		.at(PROMPTER_PATH, DbusPrompterInterface(prompter))
+		.await?;
+	match conn.request_name(BUS_NAME).await {
+		Ok(()) => {}
+		Err(zbus::Error::NameTaken) => {
+			debug!("{BUS_NAME} already owned, chaining to upstream");
+		}
+		Err(e) => return Err(e.into()),
+	}
+	Ok(())
+}
+
+pub async fn ask(prompt: &str, description: String) -> anyhow::Result<()> {
+	let conn = Connection::session()
+		.await
+		.context("connecting to the session bus (DBUS_SESSION_BUS_ADDRESS)")?;
+	let proxy = DbusPrompterProxy::builder(&conn)
+		.destination(BUS_NAME)?
+		.path(PROMPTER_PATH)?
+		.build()
+		.await?;
+
+	let password = proxy
+		.prompt_text(
+			false,
+			prompt,
+			&description,
+			&[Source(Cow::Borrowed("remowt-askpass"))],
+		)
+		.await?;
+
+	let mut out = std::io::stdout().lock();
+	out.write_all(password.as_bytes())?;
+	out.write_all(b"\n")?;
+	out.flush()?;
+	Ok(())
+}
addedremowt/cmds/remowt-agent/src/bus.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/remowt-agent/src/bus.rs
@@ -0,0 +1,40 @@
+use std::process::Stdio;
+
+use anyhow::Context as _;
+use futures::StreamExt as _;
+use tokio::process::{Child, Command};
+use tokio_util::codec::{FramedRead, LinesCodec};
+use zbus::Connection;
+
+pub struct PrivateBus {
+	pub address: String,
+	pub conn: Connection,
+	_child: Child,
+}
+
+pub async fn spawn() -> anyhow::Result<PrivateBus> {
+	let mut child = Command::new("dbus-daemon")
+		.args(["--session", "--nofork", "--print-address"])
+		.stdout(Stdio::piped())
+		.kill_on_drop(true)
+		.spawn()
+		.context("spawning dbus-daemon for the private bus")?;
+
+	let stdout = child.stdout.take().expect("piped");
+	let address = FramedRead::new(stdout, LinesCodec::new())
+		.next()
+		.await
+		.context("dbus-daemon exited before printing its address")?
+		.context("reading dbus-daemon address")?;
+
+	let conn = zbus::connection::Builder::address(address.as_str())?
+		.build()
+		.await
+		.context("connecting to the private bus")?;
+
+	Ok(PrivateBus {
+		address,
+		conn,
+		_child: child,
+	})
+}
addedremowt/cmds/remowt-agent/src/editor.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/remowt-agent/src/editor.rs
@@ -0,0 +1,158 @@
+use std::env::{current_dir, temp_dir};
+use std::path::Path;
+use std::time::Duration;
+use std::{fs, io};
+
+use anyhow::{bail, Context as _};
+use nix::libc;
+use remowt_link_shared::editor::EditorEndpointsClient;
+use tokio::process::Command;
+use zbus::{fdo, interface, proxy, Connection};
+
+use remowt_link_shared::BifConfig;
+
+const BUS_NAME: &str = "lach.RemowtEditor";
+const SERVICE_PATH: &str = "/lach/Editor";
+
+pub struct EditorService {
+	editor: EditorEndpointsClient<BifConfig>,
+}
+
+#[interface(name = "lach.RemowtEditor")]
+impl EditorService {
+	/// Attach the User's GUI to the nvim server at `socket_path` (on the remote),
+	/// blocking until the user is done.
+	async fn edit(&self, socket_path: String) -> fdo::Result<()> {
+		self.editor
+			.open_editor(socket_path)
+			.await
+			.map_err(|e| fdo::Error::Failed(format!("requesting editor on the User: {e}")))?
+			.map_err(|e| fdo::Error::Failed(format!("editor failed: {e}")))?;
+		Ok(())
+	}
+
+	async fn forward_tcp(&self, addr: String) -> fdo::Result<u16> {
+		let local = self
+			.editor
+			.expose_tcp(addr)
+			.await
+			.map_err(|e| fdo::Error::Failed(format!("requesting tcp forward on the User: {e}")))?
+			.map_err(|e| fdo::Error::Failed(format!("tcp forward failed: {e}")))?;
+		Ok(local)
+	}
+
+	async fn forward_udp(&self, addr: String) -> fdo::Result<u16> {
+		let local = self
+			.editor
+			.expose_udp(addr)
+			.await
+			.map_err(|e| fdo::Error::Failed(format!("requesting udp forward on the User: {e}")))?
+			.map_err(|e| fdo::Error::Failed(format!("udp forward failed: {e}")))?;
+		Ok(local)
+	}
+}
+
+pub async fn serve(
+	conn: &Connection,
+	editor: EditorEndpointsClient<BifConfig>,
+) -> anyhow::Result<()> {
+	conn.object_server()
+		.at(SERVICE_PATH, EditorService { editor })
+		.await?;
+	conn.request_name(BUS_NAME).await?;
+	Ok(())
+}
+
+#[proxy(interface = "lach.RemowtEditor")]
+trait RemowtEditor {
+	async fn edit(&self, socket_path: &str) -> fdo::Result<()>;
+	async fn forward_tcp(&self, addr: &str) -> fdo::Result<u16>;
+	async fn forward_udp(&self, addr: &str) -> fdo::Result<u16>;
+}
+
+pub async fn forward(udp: bool, addr: String) -> anyhow::Result<()> {
+	let conn = Connection::session()
+		.await
+		.context("connecting to the session bus (DBUS_SESSION_BUS_ADDRESS)")?;
+	let proxy = RemowtEditorProxy::builder(&conn)
+		.destination(BUS_NAME)?
+		.path(SERVICE_PATH)?
+		.build()
+		.await?;
+	let local = if udp {
+		proxy.forward_udp(&addr).await?
+	} else {
+		proxy.forward_tcp(&addr).await?
+	};
+	println!("{local}");
+	Ok(())
+}
+
+pub async fn edit(path: String) -> anyhow::Result<()> {
+	let path = Path::new(&path);
+	let abs = if path.is_absolute() {
+		path.to_path_buf()
+	} else {
+		current_dir()?.join(path)
+	};
+
+	let sock = temp_dir().join(format!("remowt-nvim-{}.sock", uuid::Uuid::new_v4()));
+	let sock_str = sock
+		.to_str()
+		.context("temp socket path is not utf-8")?
+		.to_owned();
+
+	let mut child = Command::new("nvim");
+	child
+		.arg("--headless")
+		.arg("--listen")
+		.arg(&sock)
+		.arg("--")
+		.arg(&abs)
+		.kill_on_drop(true);
+	// SAFETY: only an async-signal-safe `prctl` call.
+	unsafe {
+		child.pre_exec(|| {
+			if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_ulong) != 0 {
+				return Err(io::Error::last_os_error());
+			}
+			Ok(())
+		});
+	}
+	let mut child = child.spawn().context("spawning nvim")?;
+
+	wait_for_socket(&sock)
+		.await
+		.context("nvim did not start its server")?;
+
+	let conn = Connection::session()
+		.await
+		.context("connecting to the session bus (DBUS_SESSION_BUS_ADDRESS)")?;
+	let proxy = RemowtEditorProxy::builder(&conn)
+		.destination(BUS_NAME)?
+		.path(SERVICE_PATH)?
+		.build()
+		.await?;
+	let result = proxy.edit(&sock_str).await;
+
+	if tokio::time::timeout(Duration::from_secs(2), child.wait())
+		.await
+		.is_err()
+	{
+		let _ = child.kill().await;
+	}
+	let _ = fs::remove_file(&sock);
+
+	result?;
+	Ok(())
+}
+
+async fn wait_for_socket(path: &Path) -> anyhow::Result<()> {
+	for _ in 0..200 {
+		if tokio::fs::try_exists(path).await.unwrap_or(false) {
+			return Ok(());
+		}
+		tokio::time::sleep(Duration::from_millis(50)).await;
+	}
+	bail!("timed out waiting for {}", path.display())
+}
addedremowt/cmds/remowt-agent/src/helper/dbus.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/remowt-agent/src/helper/dbus.rs
@@ -0,0 +1,81 @@
+use std::collections::HashMap;
+use std::marker::PhantomData;
+
+use remowt_polkit_shared::{BackendRequest, Identity};
+use remowt_ui_prompt::dbus::DbusPrompterInterface;
+use remowt_ui_prompt::Prompter;
+use zbus::Connection;
+
+use crate::PolkitHelperProxy;
+
+use super::Helper;
+
+struct TemporaryPrompterInterface<P: Prompter + 'static> {
+	connection: Connection,
+	path: String,
+	_marker: PhantomData<P>,
+}
+impl<P: Prompter + 'static> TemporaryPrompterInterface<P> {
+	async fn new(connection: Connection, prompter: P) -> Self {
+		let path = format!(
+			"/remowt/prompters/{}",
+			uuid::Uuid::new_v4().to_string().replace("-", "_")
+		);
+		let _ = connection
+			.object_server()
+			.at(path.clone(), DbusPrompterInterface(prompter))
+			.await;
+		Self {
+			connection,
+			path,
+			_marker: PhantomData,
+		}
+	}
+}
+impl<P: Prompter + Send + Sync + 'static> Drop for TemporaryPrompterInterface<P> {
+	fn drop(&mut self) {
+		// Removal is async because of async RwLock used inside...
+		// We should not care about its reuse
+		let connection = self.connection.clone();
+		let path = std::mem::take(&mut self.path);
+		tokio::spawn(async move {
+			let _ = connection
+				.object_server()
+				.remove::<DbusPrompterInterface<P>, String>(path)
+				.await;
+		});
+	}
+}
+
+#[derive(Clone)]
+pub struct DbusHelper {
+	connection: Connection,
+	helper: PolkitHelperProxy<'static>,
+}
+impl DbusHelper {
+	pub async fn new(connection: Connection) -> zbus::Result<Self> {
+		let helper = PolkitHelperProxy::new(&connection).await?;
+		Ok(Self { connection, helper })
+	}
+}
+impl Helper for DbusHelper {
+	async fn help_me<P: Prompter + Send + Sync + 'static>(
+		&self,
+		cookie: &str,
+		prompter: P,
+		identity: Identity,
+	) -> anyhow::Result<()> {
+		let prompter = TemporaryPrompterInterface::new(self.connection.clone(), prompter).await;
+		self.helper
+			.init_conversation(
+				BackendRequest {
+					cookie: cookie.to_owned(),
+					environment: HashMap::new(),
+					prompter_path: prompter.path.clone(),
+					identity,
+				}, // cookie.to_owned(), HashMap::new(), prompter.path.clone()
+			)
+			.await?;
+		Ok(())
+	}
+}
addedremowt/cmds/remowt-agent/src/helper/mod.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/remowt-agent/src/helper/mod.rs
@@ -0,0 +1,21 @@
+use futures::Future;
+use remowt_polkit_shared::Identity;
+use remowt_ui_prompt::Prompter;
+
+mod dbus;
+mod protocol;
+mod socket;
+mod suid;
+
+pub use dbus::DbusHelper;
+pub use socket::SocketHelper;
+pub use suid::SuidHelper;
+
+pub trait Helper {
+	fn help_me<P: Prompter + Send + Sync + 'static>(
+		&self,
+		cookie: &str,
+		prompt: P,
+		identity: Identity,
+	) -> impl Future<Output = anyhow::Result<()>> + Send;
+}
addedremowt/cmds/remowt-agent/src/helper/protocol.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/remowt-agent/src/helper/protocol.rs
@@ -0,0 +1,50 @@
+use std::pin::pin;
+
+use anyhow::bail;
+use futures::stream::Peekable;
+use futures::StreamExt as _;
+use remowt_ui_prompt::Prompter;
+use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt as _};
+use tokio::select;
+use tokio_util::codec::{FramedRead, LinesCodec};
+
+pub async fn run_conversation<R, W, P>(reader: R, mut writer: W, prompt: P) -> anyhow::Result<()>
+where
+	R: AsyncRead,
+	W: AsyncWrite + Unpin,
+	P: Prompter,
+{
+	let mut lines = pin!(FramedRead::new(reader, LinesCodec::new()).peekable());
+
+	while let Some(line) = lines.next().await {
+		let line = line?;
+		let res = if let Some(prompt_text) = line.strip_prefix("PAM_PROMPT_ECHO_OFF ") {
+			prompt.prompt_text(false, prompt_text, "", &[]).await?
+		} else if let Some(prompt_text) = line.strip_prefix("PAM_PROMPT_ECHO_ON ") {
+			prompt.prompt_text(true, prompt_text, "", &[]).await?
+		} else if let Some(msg_text) = line.strip_prefix("PAM_ERROR_MSG ") {
+			prompt.display_text(true, msg_text, &[]).await?;
+			String::new()
+		} else if let Some(msg_text) = line.strip_prefix("PAM_TEXT_INFO ") {
+			select! {
+				_ = Peekable::peek(lines.as_mut()) => {},
+				r = prompt.display_text(false, msg_text, &[]) => {r?}
+			}
+			String::new()
+		} else if line == "SUCCESS" {
+			return Ok(());
+		} else if line == "FAILURE" {
+			bail!("helper reported failure")
+		} else {
+			bail!("unknown agent request: {line}")
+		};
+
+		if res.contains('\n') {
+			bail!("response should not include newline")
+		}
+
+		writer.write_all(res.as_bytes()).await?;
+		writer.write_all(b"\n").await?;
+	}
+	bail!("agent finished unexpectedly")
+}
addedremowt/cmds/remowt-agent/src/helper/socket.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/remowt-agent/src/helper/socket.rs
@@ -0,0 +1,53 @@
+use anyhow::{anyhow, bail};
+use nix::unistd::User;
+use remowt_polkit_shared::Identity;
+use remowt_ui_prompt::Prompter;
+use tokio::io::AsyncWriteExt as _;
+use tokio::net::UnixStream;
+use tracing::debug;
+
+use super::protocol::run_conversation;
+use super::Helper;
+
+/// Polkit 127 introduced an alternative backend similar to `lach.PolkitHelper`
+const SOCKET_PATH: &str = "/run/polkit/agent-helper.socket";
+
+#[derive(Clone)]
+pub struct SocketHelper<F> {
+	pub fallback: F,
+}
+
+impl<F: Helper + Sync> Helper for SocketHelper<F> {
+	async fn help_me<P: Prompter + Send + Sync + 'static>(
+		&self,
+		cookie: &str,
+		prompt: P,
+		identity: Identity,
+	) -> anyhow::Result<()> {
+		let Some(uid) = identity.uid() else {
+			bail!("can't process identity");
+		};
+
+		let stream = match UnixStream::connect(SOCKET_PATH).await {
+			Ok(stream) => stream,
+			Err(e) => {
+				debug!("agent-helper.socket unavailable ({e}), using fallback helper");
+				return self.fallback.help_me(cookie, prompt, identity).await;
+			}
+		};
+
+		let user = User::from_uid(uid)
+			.map_err(|e| anyhow!("error querying user: {e}"))?
+			.ok_or_else(|| anyhow!("user not found"))?;
+
+		assert!(!cookie.contains('\n'));
+		let (reader, mut writer) = stream.into_split();
+
+		writer.write_all(user.name.as_bytes()).await?;
+		writer.write_all(b"\n").await?;
+		writer.write_all(cookie.as_bytes()).await?;
+		writer.write_all(b"\n").await?;
+
+		run_conversation(reader, writer, prompt).await
+	}
+}
addedremowt/cmds/remowt-agent/src/helper/suid.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/remowt-agent/src/helper/suid.rs
@@ -0,0 +1,46 @@
+use std::process::Stdio;
+
+use anyhow::{anyhow, bail};
+use nix::unistd::User;
+use remowt_polkit_shared::Identity;
+use remowt_ui_prompt::Prompter;
+use tokio::io::AsyncWriteExt as _;
+use tokio::process::Command;
+
+use super::protocol::run_conversation;
+use super::Helper;
+
+#[derive(Clone)]
+pub struct SuidHelper;
+impl Helper for SuidHelper {
+	async fn help_me<P: Prompter + 'static>(
+		&self,
+		cookie: &str,
+		prompt: P,
+		identity: Identity,
+	) -> anyhow::Result<()> {
+		let Some(uid) = identity.uid() else {
+			bail!("can't process identity");
+		};
+		let user = User::from_uid(uid)
+			.map_err(|e| anyhow!("error querying user: {e}"))?
+			.ok_or_else(|| anyhow!("user not found"))?;
+
+		let mut cmd = Command::new("polkit-agent-helper-1");
+		cmd.arg(user.name);
+		cmd.stdin(Stdio::piped());
+		cmd.stdout(Stdio::piped());
+		cmd.kill_on_drop(true);
+		let mut child = cmd.spawn()?;
+		let mut stdin = child.stdin.take().expect("piped");
+		let stdout = child.stdout.take().expect("piped");
+
+		assert!(!cookie.contains('\n'));
+		stdin.write_all(cookie.as_bytes()).await?;
+		stdin.write_all(b"\n").await?;
+
+		let res = run_conversation(stdout, stdin, prompt).await;
+		drop(child);
+		res
+	}
+}
addedremowt/cmds/remowt-agent/src/main.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/remowt-agent/src/main.rs
@@ -0,0 +1,437 @@
+use std::borrow::Cow;
+use std::collections::{BTreeMap, HashMap};
+use std::fs::Permissions;
+use std::future::pending;
+use std::io;
+use std::os::unix::fs::PermissionsExt as _;
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex, OnceLock};
+
+use bifrostlink::declarative::RemoteEndpoints;
+use bifrostlink::Rpc;
+use bifrostlink_ports::stdio::from_stdio;
+use bifrostlink_ports::unix_socket::from_socket;
+use clap::Parser;
+use remowt_endpoints::{
+	forward::Forward, fs::Fs, iroh_tunnel::IrohTunnel, nix_daemon::NixDaemon, pty::Pty,
+	subprocess::Subprocess, systemd::Systemd,
+};
+use remowt_link_shared::iroh_tunnel::TunnelDialer;
+use remowt_link_shared::{editor::EditorEndpointsClient, Address, BifConfig};
+use remowt_polkit_shared::{emphasize, BackendRequest, Identity, PidDisplay};
+use remowt_ui_prompt::bifrost::PromptEndpointsClient;
+use remowt_ui_prompt::rofi::RofiPrompter;
+use remowt_ui_prompt::{PrependSourcePrompter, Prompter, Source};
+use tokio::fs;
+use tokio::net::UnixStream;
+use tokio::runtime::Builder;
+use tokio::task::AbortHandle;
+use tracing::{debug, trace};
+use zbus::fdo;
+use zbus::zvariant::{OwnedValue, Str};
+use zbus::{interface, proxy, Connection};
+use zbus_polkit::policykit1::Subject;
+
+use self::helper::{Helper, SocketHelper, SuidHelper};
+
+pub mod askpass;
+pub mod bus;
+pub mod editor;
+pub mod helper;
+
+struct CancelTaskOnDrop {
+	tasks: Arc<Mutex<HashMap<String, AbortHandle>>>,
+	handle: String,
+}
+impl Drop for CancelTaskOnDrop {
+	fn drop(&mut self) {
+		debug!("cancel on drop");
+		if let Some(task) = self
+			.tasks
+			.lock()
+			.expect("not poisoned")
+			.remove(&self.handle)
+		{
+			task.abort();
+		}
+	}
+}
+
+struct Agent<H, P> {
+	tasks: Arc<Mutex<HashMap<String, AbortHandle>>>,
+	helper: H,
+	prompter: P,
+}
+impl<H, P> Agent<H, P> {
+	fn new(helper: H, prompter: P) -> Self {
+		Agent {
+			tasks: Arc::new(Mutex::new(HashMap::new())),
+			helper,
+			prompter,
+		}
+	}
+}
+
+#[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")]
+impl<H, P> Agent<H, P>
+where
+	H: Helper + Clone + Send + Sync + 'static,
+	P: Prompter + Clone + Send + Sync + 'static,
+{
+	/// BeginAuthentication method
+	#[allow(clippy::too_many_arguments)]
+	async fn begin_authentication(
+		&self,
+		action_id: String,
+		message: String,
+		_icon_name: String,
+		mut details: BTreeMap<String, String>,
+		cookie: String,
+		identities: Vec<Identity>,
+	) -> zbus::fdo::Result<()> {
+		use std::fmt::Write;
+		debug!("begin auth");
+		let _cancel_guard = Arc::new(OnceLock::new());
+		let task = {
+			let helper = self.helper.clone();
+			let prompter = self.prompter.clone();
+			let cookie = cookie.clone();
+			let _cancel_guard = _cancel_guard.clone();
+			tokio::task::spawn(async move {
+				let _cancel_guard = _cancel_guard.clone();
+				trace!("conversation task");
+				let mut description = format!("{message}\n\n<b>Action id:</b> {action_id}",);
+				if let Some(subject) = details.remove("polkit.caller-pid") {
+					let _ = write!(description, "\n<b>Caller:</b> ");
+					if let Ok(pid) = subject.parse::<u32>() {
+						let _ = write!(description, "{}", PidDisplay(pid));
+					} else {
+						let _ = write!(description, "{}", emphasize("invalid pid"));
+					}
+				}
+				if let Some(subject) = details.remove("polkit.subject-pid") {
+					let _ = write!(description, "\n<b>Subject:</b> ");
+					if let Ok(pid) = subject.parse::<u32>() {
+						let _ = write!(description, "{}", PidDisplay(pid));
+					} else {
+						let _ = write!(description, "{}", emphasize("invalid pid"));
+					}
+				}
+				let mut prompter = PrependSourcePrompter {
+					source: vec![Source(Cow::Borrowed("polkit agent"))],
+					description: description.clone(),
+					prompter,
+				};
+
+				let identity_displays: Vec<String> =
+					identities.iter().map(|v| v.to_string()).collect();
+				let identity_displays: Vec<&str> =
+					identity_displays.iter().map(|v| v.as_str()).collect();
+				debug!("choose identity");
+				let choosen_identity = match identity_displays.len() {
+					0 => {
+						return Err(fdo::Error::AuthFailed(
+							"no identity to authenticate as".to_owned(),
+						))
+					}
+					1 => 0,
+					_ => {
+						prompter
+							.prompt_enum(
+								"Identity",
+								"Select identity to use for polkit authorization",
+								&identity_displays,
+								&[],
+							)
+							.await?
+					}
+				};
+				debug!("identity chosen");
+
+				let _ = write!(
+					description,
+					"\n<b>Identity:</b> {}",
+					identities[choosen_identity as usize]
+				);
+				prompter.description = description;
+
+				prompter.source.push(Source(Cow::Borrowed("polkit daemon")));
+
+				helper
+					.help_me(
+						&cookie,
+						prompter,
+						identities[choosen_identity as usize].clone(),
+					)
+					.await
+					.map_err(|e| fdo::Error::Failed(e.to_string()))?;
+				// let connection = Connection::system().await?;
+				// let helper = PolkitHelperProxy::new(&connection).await?;
+
+				Ok(())
+			})
+		};
+		self.tasks
+			.lock()
+			.unwrap()
+			.insert(cookie.clone(), task.abort_handle());
+		debug!("abort handle stored");
+		let _ = _cancel_guard.set(CancelTaskOnDrop {
+			tasks: self.tasks.clone(),
+			handle: cookie.clone(),
+		});
+
+		let _ = task.await;
+
+		Ok(())
+	}
+
+	/// CancelAuthentication method
+	async fn cancel_authentication(&self, cookie: &str) -> zbus::fdo::Result<()> {
+		debug!("auth cancelled");
+		if let Some(abort) = self.tasks.lock().unwrap().remove(cookie) {
+			debug!("abort handle found");
+			abort.abort();
+		}
+		// debug!("Authentication cancled ! {cookie}");
+		Ok(())
+	}
+}
+
+const OBJ_PATH: &str = "/org/freedesktop/PolicyKit1/AuthenticationAgent";
+
+#[proxy(
+	interface = "lach.PolkitHelper",
+	default_service = "lach.polkit.helper1",
+	default_path = "/lach/PolkitHelper"
+)]
+trait PolkitHelper {
+	fn init_conversation(&self, request: BackendRequest) -> zbus::Result<()>;
+}
+
+#[derive(Parser)]
+enum Opts {
+	AskPass {
+		prompt: String,
+		description: String,
+	},
+	Editor {
+		/// Argument to nvim
+		path: String,
+	},
+	/// Expose a remote TCP/UDP address on the local (User) machine, printing the local port.
+	Forward {
+		/// Protocol: `tcp` (ssh or iroh) or `udp` (iroh-only).
+		proto: ForwardProto,
+		/// Remote socket address to expose locally, e.g. `127.0.0.1:8080`.
+		addr: String,
+	},
+	RealAgent {
+		#[arg(long)]
+		path: Option<PathBuf>,
+		/// Expect own address to be AgentPrivileged, skip installing polkit agent
+		#[arg(long)]
+		privileged: bool,
+		#[arg(long)]
+		local: bool,
+	},
+	LocalAgent,
+}
+
+#[derive(Clone, Copy, clap::ValueEnum)]
+enum ForwardProto {
+	Tcp,
+	Udp,
+}
+
+fn main() -> anyhow::Result<()> {
+	tracing_subscriber::fmt()
+		.with_writer(io::stderr)
+		.without_time()
+		.init();
+	let opts = Opts::parse();
+
+	let runtime = Builder::new_current_thread().enable_all().build()?;
+
+	match opts {
+		Opts::AskPass {
+			prompt,
+			description,
+		} => runtime.block_on(askpass::ask(&prompt, description)),
+		Opts::LocalAgent => runtime.block_on(main_real()),
+		Opts::Editor { path } => runtime.block_on(editor::edit(path)),
+		Opts::Forward { proto, addr } => {
+			runtime.block_on(editor::forward(matches!(proto, ForwardProto::Udp), addr))
+		}
+		Opts::RealAgent {
+			path,
+			privileged,
+			local,
+		} => runtime.block_on(main_real_agent(path, privileged, local)),
+	}
+}
+async fn main_real() -> anyhow::Result<()> {
+	let system_conn = Connection::system().await?;
+	let helper = SocketHelper {
+		fallback: SuidHelper,
+	};
+	register_auth_agent(&system_conn, Agent::new(helper, RofiPrompter)).await?;
+
+	let session_conn = Connection::session().await?;
+	askpass::serve(&session_conn, RofiPrompter).await?;
+
+	let _keep_alive = (system_conn, session_conn);
+	pending().await
+}
+async fn main_real_agent(
+	path: Option<PathBuf>,
+	privileged: bool,
+	local: bool,
+) -> anyhow::Result<()> {
+	let address = if privileged {
+		Address::AgentPrivileged
+	} else {
+		Address::Agent
+	};
+	let mut rpc = Rpc::<BifConfig>::new(address);
+
+	let dialer = Arc::new(TunnelDialer::new());
+	Fs::new().register_endpoints(&mut rpc);
+	Systemd.register_endpoints(&mut rpc);
+	Pty::new(dialer.clone()).register_endpoints(&mut rpc);
+	Subprocess::new(dialer.clone()).register_endpoints(&mut rpc);
+	NixDaemon::new(dialer.clone()).register_endpoints(&mut rpc);
+	IrohTunnel::new(dialer.clone()).register_endpoints(&mut rpc);
+	Forward::new(dialer.clone()).register_endpoints(&mut rpc);
+
+	remowt_plugin::host::serve(&mut rpc);
+
+	let user_prompter = PromptEndpointsClient::wrap(rpc.remote(Address::User));
+	let editor_client = EditorEndpointsClient::wrap(rpc.remote(Address::User));
+
+	let bus = bus::spawn().await?;
+	askpass::serve(&bus.conn, user_prompter.clone()).await?;
+	editor::serve(&bus.conn, editor_client).await?;
+
+	let helpers = tempfile::Builder::new().prefix("remowt-path.").tempdir()?;
+	let exe = std::env::current_exe()?;
+	let askpass_helper = helpers.path().join("remowt-askpass");
+	let editor_helper = helpers.path().join("remowt-editor");
+	let forward_helper = helpers.path().join("remowt-forward");
+	{
+		let script = format!(
+			"#!/bin/sh\nexec {} ask-pass \"password\" \"$1\"\n",
+			sh_quote(&exe.to_string_lossy())
+		);
+		fs::write(&askpass_helper, script).await?;
+		fs::set_permissions(&askpass_helper, Permissions::from_mode(0o755)).await?;
+	}
+	{
+		let script = format!(
+			"#!/bin/sh\nexec {} editor \"$1\"\n",
+			sh_quote(&exe.to_string_lossy())
+		);
+		fs::write(&editor_helper, script).await?;
+		fs::set_permissions(&editor_helper, Permissions::from_mode(0o755)).await?;
+	}
+	{
+		let script = format!(
+			"#!/bin/sh\nexec {} forward \"$@\"\n",
+			sh_quote(&exe.to_string_lossy())
+		);
+		fs::write(&forward_helper, script).await?;
+		fs::set_permissions(&forward_helper, Permissions::from_mode(0o755)).await?;
+	}
+
+	// Safety: Hoping tokio own threads won't read any of those...
+	unsafe {
+		prepend_path(helpers.path());
+		std::env::set_var("SUDO_ASKPASS", &askpass_helper);
+		std::env::set_var("SSH_ASKPASS", &askpass_helper);
+		std::env::set_var("SSH_ASKPASS_REQUIRE", "force");
+		std::env::set_var("EDITOR", &editor_helper);
+		std::env::set_var("VISUAL", &editor_helper);
+		std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &bus.address);
+	}
+
+	let port = match path {
+		Some(path) => from_socket(UnixStream::connect(path).await?),
+		None => from_stdio(),
+	};
+	rpc.add_direct(Address::User, port, bifrostlink::Rtt(0));
+
+	let polkit_conn = if !privileged && !local {
+		// The unprivileged agent doubles as a polkit authentication agent so
+		// `run0` (e.g. our own elevation) routes its prompt to the User over
+		// bifrost instead of failing on a tty-less session.
+		let conn = Connection::system().await?;
+		let helper = SocketHelper {
+			fallback: SuidHelper,
+		};
+		register_auth_agent(&conn, Agent::new(helper, user_prompter)).await?;
+		Some(conn)
+	} else {
+		None
+	};
+
+	let _keep_alive = (bus, helpers, polkit_conn);
+	pending().await
+}
+
+async fn register_auth_agent<H, P>(conn: &Connection, agent: Agent<H, P>) -> anyhow::Result<()>
+where
+	H: Helper + Clone + Send + Sync + 'static,
+	P: Prompter + Clone + Send + Sync + 'static,
+{
+	let proxy = zbus_polkit::policykit1::AuthorityProxy::new(conn).await?;
+	conn.object_server().at(OBJ_PATH, agent).await?;
+
+	let subject = auth_agent_subject()?;
+	proxy
+		.register_authentication_agent(&subject, "C", OBJ_PATH)
+		.await?;
+	debug!(kind = subject.subject_kind, "registered polkit agent");
+	Ok(())
+}
+
+fn auth_agent_subject() -> anyhow::Result<Subject> {
+	let mut details = HashMap::new();
+	if let Ok(session_id) = std::env::var("XDG_SESSION_ID") {
+		let val: OwnedValue = Str::from(session_id).into();
+		details.insert("session-id".to_string(), val);
+		return Ok(Subject {
+			subject_kind: "unix-session".to_string(),
+			subject_details: details,
+		});
+	}
+
+	details.insert("pid".to_string(), OwnedValue::from(std::process::id()));
+	Ok(Subject {
+		subject_kind: "unix-process".to_string(),
+		subject_details: details,
+	})
+}
+
+fn sh_quote(s: &str) -> String {
+	format!("'{}'", s.replace('\'', "'\\''"))
+}
+
+/// Prepend `dir` to the process `PATH`.
+///
+/// # SAFETY
+///
+/// Same as `set_var`
+unsafe fn prepend_path(dir: &std::path::Path) {
+	let value = match std::env::var_os("PATH") {
+		Some(existing) => {
+			let mut v = dir.as_os_str().to_owned();
+			v.push(":");
+			v.push(existing);
+			v
+		}
+		None => dir.as_os_str().to_owned(),
+	};
+	unsafe {
+		std::env::set_var("PATH", value);
+	}
+}
addedremowt/cmds/remowt-ssh/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/remowt-ssh/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "remowt-ssh"
+description = "SSH transport client for connecting to a remowt agent"
+version.workspace = true
+edition = "2021"
+license.workspace = true
+
+[dependencies]
+clap = { workspace = true, features = ["derive"] }
+tracing-subscriber.workspace = true
+remowt-link-shared.workspace = true
+remowt-client.workspace = true
+tokio = { workspace = true, features = [
+	"macros",
+	"fs",
+	"net",
+	"io-util",
+	"rt",
+	"signal",
+] }
+nix = { workspace = true, features = ["term"] }
+anyhow.workspace = true
+tracing.workspace = true
+remowt-ui-prompt.workspace = true
addedremowt/cmds/remowt-ssh/src/main.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/cmds/remowt-ssh/src/main.rs
@@ -0,0 +1,230 @@
+use std::borrow::Cow;
+use std::env::VarError;
+use std::io;
+use std::os::fd::{AsRawFd, RawFd};
+use std::path::PathBuf;
+use std::pin::Pin;
+use std::task::{Context, Poll};
+
+use anyhow::anyhow;
+use clap::Parser;
+use nix::libc;
+use nix::sys::termios::{self, SetArg, Termios};
+use remowt_client::editor::SshEditor;
+use remowt_client::{AgentBundle, Remowt};
+use remowt_link_shared::editor::serve_editor;
+use remowt_ui_prompt::auto::AutoPrompter;
+use remowt_ui_prompt::bifrost::serve_prompts;
+use remowt_ui_prompt::{PrependSourcePrompter, Source};
+use tokio::io::unix::AsyncFd;
+use tokio::io::{AsyncRead, ReadBuf};
+use tokio::signal::unix::{signal, SignalKind};
+use tracing::debug;
+
+#[derive(Parser)]
+enum Opts {
+	/// Connect to remote host with remowt agent.
+	Ssh {
+		host: String,
+		#[arg(long)]
+		escalate: bool,
+	},
+	/// Connect to local host for testing the connectivity.
+	Local {
+		#[arg(long)]
+		escalate: bool,
+	},
+}
+
+fn agents_dir() -> anyhow::Result<PathBuf> {
+	std::env::var_os("REMOWT_AGENTS_DIR")
+		.map(PathBuf::from)
+		.or_else(|| option_env!("REMOWT_AGENTS_DIR").map(PathBuf::from))
+		.ok_or_else(|| anyhow!("no remowt-agents bundle"))
+}
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> anyhow::Result<()> {
+	tracing_subscriber::fmt()
+		.with_writer(std::io::stderr)
+		.without_time()
+		.init();
+	let opts = Opts::parse();
+
+	let bundle = AgentBundle::from_dir(agents_dir()?)?;
+	let (conn, escalate) = match &opts {
+		Opts::Ssh { host, escalate } => (
+			Remowt::connect(host, &bundle, "remowt-ssh".to_owned()).await?,
+			*escalate,
+		),
+		Opts::Local { escalate } => (
+			Remowt::connect_local(&bundle, "remowt-ssh".to_owned()).await?,
+			*escalate,
+		),
+	};
+	let mut rpc = conn.rpc();
+
+	serve_prompts(
+		&mut rpc,
+		PrependSourcePrompter {
+			prompter: AutoPrompter::new().await,
+			source: match opts {
+				Opts::Ssh { host, .. } => vec![Source(Cow::Owned(format!("ssh host: {}", host)))],
+				Opts::Local { .. } => vec![],
+			},
+			description: "".to_owned(),
+		},
+	);
+	if let Some(sess) = conn.ssh() {
+		serve_editor(
+			&mut rpc,
+			SshEditor {
+				sess,
+				conn: conn.clone(),
+			},
+		);
+	}
+
+	debug!("entering shell");
+	run_shell(&conn, escalate).await?;
+	debug!("shell ended");
+
+	Ok(())
+}
+
+async fn run_shell(conn: &Remowt, escalate: bool) -> anyhow::Result<()> {
+	let term = match std::env::var("TERM") {
+		Ok(v) => v,
+		Err(VarError::NotPresent) => "xterm-256color".to_owned(),
+		Err(e) => return Err(e.into()),
+	};
+	let (cols, rows) = term_size().unwrap_or((80, 24));
+
+	let shell = conn.open_shell(&term, cols, rows, escalate).await?;
+	let resizer = shell.resizer();
+	let stream = shell.stream;
+
+	let _raw = RawMode::enable();
+
+	if let Ok(mut winch) = signal(SignalKind::window_change()) {
+		tokio::spawn(async move {
+			while winch.recv().await.is_some() {
+				if let Some((cols, rows)) = term_size() {
+					let _ = resizer.resize(cols, rows).await;
+				}
+			}
+		});
+	}
+
+	let (mut from_remote, mut to_remote) = tokio::io::split(stream);
+	let mut stdin = AsyncStdin::new()?;
+	let mut stdout = tokio::io::stdout();
+
+	tokio::select! {
+		r = tokio::io::copy(&mut from_remote, &mut stdout) => { r?; }
+		_ = tokio::io::copy(&mut stdin, &mut to_remote) => {}
+	}
+
+	Ok(())
+}
+
+struct AsyncStdin {
+	fd: AsyncFd<RawFd>,
+	original_flags: i32,
+}
+
+impl AsyncStdin {
+	fn new() -> io::Result<Self> {
+		let raw = libc::STDIN_FILENO;
+		// SAFETY: F_GETFL/F_SETFL round-trip on a valid fd.
+		let original_flags = unsafe { libc::fcntl(raw, libc::F_GETFL) };
+		if original_flags < 0 {
+			return Err(io::Error::last_os_error());
+		}
+		if unsafe { libc::fcntl(raw, libc::F_SETFL, original_flags | libc::O_NONBLOCK) } < 0 {
+			return Err(io::Error::last_os_error());
+		}
+		Ok(Self {
+			fd: AsyncFd::new(raw)?,
+			original_flags,
+		})
+	}
+}
+
+impl Drop for AsyncStdin {
+	fn drop(&mut self) {
+		// SAFETY: restoring the flags we saved on a valid fd.
+		unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, self.original_flags) };
+	}
+}
+
+impl AsyncRead for AsyncStdin {
+	fn poll_read(
+		self: Pin<&mut Self>,
+		cx: &mut Context<'_>,
+		buf: &mut ReadBuf<'_>,
+	) -> Poll<io::Result<()>> {
+		let this = self.get_mut();
+		loop {
+			let mut guard = match this.fd.poll_read_ready(cx) {
+				Poll::Ready(Ok(g)) => g,
+				Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
+				Poll::Pending => return Poll::Pending,
+			};
+			let unfilled = buf.initialize_unfilled();
+			let res = guard.try_io(|inner| {
+				let fd = *inner.get_ref();
+				// SAFETY: writing into `unfilled`'s own backing storage.
+				let n = unsafe { libc::read(fd, unfilled.as_mut_ptr().cast(), unfilled.len()) };
+				if n < 0 {
+					Err(io::Error::last_os_error())
+				} else {
+					Ok(n as usize)
+				}
+			});
+			match res {
+				Ok(Ok(n)) => {
+					buf.advance(n);
+					return Poll::Ready(Ok(()));
+				}
+				Ok(Err(e)) => return Poll::Ready(Err(e)),
+				Err(_would_block) => continue,
+			}
+		}
+	}
+}
+
+fn term_size() -> Option<(u16, u16)> {
+	let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
+	let rc = unsafe { libc::ioctl(libc::STDIN_FILENO, libc::TIOCGWINSZ, &mut ws) };
+	if rc != 0 || ws.ws_col == 0 {
+		None
+	} else {
+		Some((ws.ws_col, ws.ws_row))
+	}
+}
+
+struct RawMode {
+	original: Termios,
+}
+
+impl RawMode {
+	fn enable() -> Option<Self> {
+		let stdin = std::io::stdin();
+		// SAFETY: trivial libc call on a borrowed fd.
+		if unsafe { libc::isatty(stdin.as_raw_fd()) } != 1 {
+			return None;
+		}
+		let original = termios::tcgetattr(&stdin).ok()?;
+		let mut raw = original.clone();
+		termios::cfmakeraw(&mut raw);
+		termios::tcsetattr(&stdin, SetArg::TCSANOW, &raw).ok()?;
+		Some(Self { original })
+	}
+}
+
+impl Drop for RawMode {
+	fn drop(&mut self) {
+		let _ = termios::tcsetattr(std::io::stdin(), SetArg::TCSANOW, &self.original);
+	}
+}
addedremowt/crates/polkit-shared/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/polkit-shared/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "remowt-polkit-shared"
+description = "Shared polkit/PAM types for remowt"
+version.workspace = true
+edition = "2021"
+license.workspace = true
+
+[dependencies]
+nix = { workspace = true, features = ["user"] }
+serde = { workspace = true, features = ["derive"] }
+zbus.workspace = true
addedremowt/crates/polkit-shared/src/lib.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/polkit-shared/src/lib.rs
@@ -0,0 +1,113 @@
+use std::collections::HashMap;
+use std::{fmt, fs};
+
+use nix::unistd::{Uid, User};
+use serde::{Deserialize, Serialize};
+use zbus::zvariant::{OwnedValue, Type, Value};
+
+pub fn emphasize(s: impl AsRef<str>) -> String {
+	format!("<span style=\"italic\">&lt;{}&gt;</span>", escape(s),)
+}
+fn command(s: impl AsRef<str>) -> String {
+	format!("<u><tt>{}</tt></u>", s.as_ref())
+}
+fn escape(s: impl AsRef<str>) -> String {
+	s.as_ref()
+		.replace("&", "&quot;")
+		.replace("<", "&lt;")
+		.replace(">", "&gt;")
+}
+
+pub struct PidDisplay(pub u32);
+impl fmt::Display for PidDisplay {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		if self.0 == 1 {
+			emphasize("init").fmt(f)
+		} else if let Ok(proc) = fs::read_to_string(format!("/proc/{}/cmdline", self.0)) {
+			write!(
+				f,
+				"command: {}",
+				command(
+					proc.replace("\0", " ")
+						.strip_suffix(" ")
+						.expect("cmdline should end with NUL")
+				)
+			)
+		} else if let Ok(proc) = fs::read_to_string(format!("/proc/{}/comm", self.0)) {
+			write!(f, "process: {}", command(proc.replace("\0", " ")))
+		} else {
+			emphasize("unknown process").fmt(f)
+		}
+	}
+}
+
+#[derive(Serialize, Deserialize, Type, PartialEq, Debug)]
+pub struct Identity {
+	pub kind: String,
+	pub details: HashMap<String, OwnedValue>,
+}
+
+impl Identity {
+	pub fn uid(&self) -> Option<Uid> {
+		if self.kind != "unix-user" {
+			return None;
+		}
+		let uid = self.details.get("uid")?;
+		let Value::U32(uid) = &**uid else {
+			return None;
+		};
+		Some(Uid::from_raw(*uid))
+	}
+}
+
+impl fmt::Display for Identity {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		match self.kind.as_str() {
+			"unix-user" => match self.details.get("uid").map(|v| &**v) {
+				Some(Value::U32(uid)) => match User::from_uid(Uid::from_raw(*uid)) {
+					Ok(Some(u)) => write!(
+						f,
+						"user: {} {} {}",
+						u.name,
+						u.uid,
+						if u.gecos.is_empty() {
+							"".to_owned()
+						} else {
+							format!(": {}", escape(u.gecos.to_string_lossy()))
+						}
+					),
+					Ok(None) => emphasize("not found").fmt(f),
+					Err(e) => {
+						let user = format!("could not get user: {e}");
+						emphasize(&user).fmt(f)?;
+						Ok(())
+					}
+				},
+
+				_ => emphasize("unknown uid").fmt(f),
+			},
+			_ => emphasize(format!("identity of unknown kind: {}", self.kind)).fmt(f),
+		}
+	}
+}
+
+impl Clone for Identity {
+	fn clone(&self) -> Self {
+		Self {
+			kind: self.kind.clone(),
+			details: self
+				.details
+				.iter()
+				.map(|(k, v)| (k.clone(), v.try_clone().expect("no fds are expected")))
+				.collect(),
+		}
+	}
+}
+
+#[derive(Serialize, Deserialize, Type, PartialEq, Debug)]
+pub struct BackendRequest {
+	pub cookie: String,
+	pub environment: HashMap<String, String>,
+	pub prompter_path: String,
+	pub identity: Identity,
+}
addedremowt/crates/remowt-client/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-client/Cargo.toml
@@ -0,0 +1,32 @@
+[package]
+name = "remowt-client"
+description = "russh-based client connection to a remowt agent"
+version.workspace = true
+edition = "2021"
+license.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+bifrostlink.workspace = true
+bytes.workspace = true
+camino.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+remowt-link-shared.workspace = true
+russh.workspace = true
+russh-config.workspace = true
+tempfile.workspace = true
+tokio = { workspace = true, features = [
+	"net",
+	"io-util",
+	"rt",
+	"sync",
+	"macros",
+	"process",
+] }
+tracing.workspace = true
+uuid = { workspace = true, features = ["v4"] }
+remowt-endpoints.workspace = true
+tokio-util = { workspace = true, features = ["codec"] }
+futures.workspace = true
+iroh.workspace = true
addedremowt/crates/remowt-client/src/editor.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-client/src/editor.rs
@@ -0,0 +1,183 @@
+use std::collections::HashMap;
+use std::net::SocketAddr;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex};
+
+use remowt_endpoints::forward::ForwardClient;
+use remowt_link_shared::editor::{EditorBackend, Error};
+use remowt_link_shared::BifConfig;
+use russh::client::Handle;
+use tokio::net::{TcpListener, UdpSocket, UnixListener};
+use tracing::error;
+
+use crate::{Remowt, SshHandler};
+
+pub struct SshEditor {
+	pub sess: Arc<Handle<SshHandler>>,
+	pub conn: Remowt,
+}
+impl EditorBackend for SshEditor {
+	async fn open_editor(&self, socket_path: String) -> Result<(), Error> {
+		let local = std::env::temp_dir().join(format!("remowt-nvim-{}.sock", uuid::Uuid::new_v4()));
+		let _ = std::fs::remove_file(&local);
+		let listener = UnixListener::bind(&local).map_err(|e| Error::Failed(e.to_string()))?;
+
+		let sess = self.sess.clone();
+		let forward = tokio::spawn(async move {
+			loop {
+				let Ok((mut stream, _)) = listener.accept().await else {
+					break;
+				};
+				let sess = sess.clone();
+				let remote = socket_path.clone();
+				tokio::spawn(async move {
+					match sess.channel_open_direct_streamlocal(remote).await {
+						Ok(ch) => {
+							let mut remote = ch.into_stream();
+							let _ = tokio::io::copy_bidirectional(&mut stream, &mut remote).await;
+						}
+						Err(e) => error!("opening direct-streamlocal to nvim failed: {e}"),
+					}
+				});
+			}
+		});
+
+		let status = tokio::process::Command::new("neovide")
+			.arg("--no-fork")
+			.arg("--server")
+			.arg(&local)
+			.status()
+			.await
+			.map_err(|e| Error::Failed(format!("spawning neovide: {e}")));
+
+		forward.abort();
+		let _ = std::fs::remove_file(&local);
+
+		match status? {
+			s if s.success() => Ok(()),
+			s => Err(Error::Failed(format!("neovide exited with {s}"))),
+		}
+	}
+
+	async fn expose_tcp(&self, addr: String) -> Result<u16, Error> {
+		let listener = TcpListener::bind(("127.0.0.1", 0))
+			.await
+			.map_err(|e| Error::Failed(e.to_string()))?;
+		let local = listener
+			.local_addr()
+			.map_err(|e| Error::Failed(e.to_string()))?
+			.port();
+
+		let conn = self.conn.clone();
+		tokio::spawn(async move {
+			loop {
+				let Ok((mut tcp, _)) = listener.accept().await else {
+					break;
+				};
+				let conn = conn.clone();
+				let addr = addr.clone();
+				tokio::spawn(async move {
+					let (forwarded, tunnel) = match conn.bind_fast_tunnel("forward", false).await {
+						Ok(v) => v,
+						Err(e) => {
+							error!("forward: bind tunnel failed: {e}");
+							return;
+						}
+					};
+					let fclient: ForwardClient<BifConfig> = conn.endpoints();
+					match fclient.connect_tcp(tunnel, addr).await {
+						Ok(Ok(())) => {}
+						Ok(Err(e)) => {
+							error!("forward: agent connect_tcp failed: {e}");
+							return;
+						}
+						Err(e) => {
+							error!("forward: connect_tcp rpc failed: {e}");
+							return;
+						}
+					}
+					match forwarded.accept().await {
+						Ok(mut stream) => {
+							let _ = tokio::io::copy_bidirectional(&mut tcp, &mut stream).await;
+						}
+						Err(e) => error!("forward: accept tunnel failed: {e}"),
+					}
+				});
+			}
+		});
+
+		Ok(local)
+	}
+
+	async fn expose_udp(&self, addr: String) -> Result<u16, Error> {
+		let router = self.conn.datagram_router().ok_or_else(|| {
+			Error::Failed(
+				"udp forward requires the iroh fast path, which is not established".into(),
+			)
+		})?;
+
+		let fclient: ForwardClient<BifConfig> = self.conn.endpoints();
+		let session = fclient
+			.open_udp(addr)
+			.await
+			.map_err(|e| Error::Failed(format!("open_udp rpc: {e}")))?
+			.map_err(|e| Error::Failed(format!("agent open_udp: {e}")))?;
+
+		let sock = Arc::new(
+			UdpSocket::bind(("127.0.0.1", 0))
+				.await
+				.map_err(|e| Error::Failed(e.to_string()))?,
+		);
+		let local = sock
+			.local_addr()
+			.map_err(|e| Error::Failed(e.to_string()))?
+			.port();
+
+		let sub_for_source: Arc<Mutex<HashMap<SocketAddr, u64>>> =
+			Arc::new(Mutex::new(HashMap::new()));
+		let source_for_sub: Arc<Mutex<HashMap<u64, SocketAddr>>> =
+			Arc::new(Mutex::new(HashMap::new()));
+		let next_sub = Arc::new(AtomicU64::new(0));
+		let mut rx = router.register(session);
+
+		let up_sock = sock.clone();
+		let up_router = router.clone();
+		let down_source_for_sub = source_for_sub.clone();
+		tokio::spawn(async move {
+			let mut buf = vec![0u8; 65535];
+			loop {
+				let (n, src) = match up_sock.recv_from(&mut buf).await {
+					Ok(v) => v,
+					Err(_) => break,
+				};
+				let sub = {
+					let mut by_src = sub_for_source.lock().expect("lock");
+					if let Some(&sub) = by_src.get(&src) {
+						sub
+					} else {
+						let sub = next_sub.fetch_add(1, Ordering::Relaxed);
+						by_src.insert(src, sub);
+						source_for_sub.lock().expect("lock").insert(sub, src);
+						sub
+					}
+				};
+				if up_router.send(session, sub, &buf[..n]).is_err() {
+					break;
+				}
+			}
+			up_router.unregister(session);
+		});
+
+		let down_sock = sock.clone();
+		tokio::spawn(async move {
+			while let Some((sub, payload)) = rx.recv().await {
+				let dst = down_source_for_sub.lock().expect("lock").get(&sub).copied();
+				if let Some(dst) = dst {
+					let _ = down_sock.send_to(&payload, dst).await;
+				}
+			}
+		});
+
+		Ok(local)
+	}
+}
addedremowt/crates/remowt-client/src/forwarded.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-client/src/forwarded.rs
@@ -0,0 +1,89 @@
+use std::io;
+use std::pin::Pin;
+use std::task::{Context, Poll};
+
+use anyhow::{anyhow, Result};
+use camino::Utf8PathBuf;
+use remowt_link_shared::iroh_tunnel::IrohBiStream;
+use russh::client::Msg;
+use russh::{Channel, ChannelStream};
+use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
+use tokio::net::{UnixListener, UnixStream};
+use tokio::sync::oneshot;
+
+pub enum RemowtListener {
+	Ssh(oneshot::Receiver<Channel<Msg>>),
+	Local(UnixListener, Utf8PathBuf),
+	Iroh(oneshot::Receiver<RemowtStream>),
+}
+
+impl RemowtListener {
+	pub async fn accept(self) -> Result<RemowtStream> {
+		match self {
+			RemowtListener::Ssh(rx) => {
+				let ch = rx
+					.await
+					.map_err(|_| anyhow!("agent never connected the forwarded socket"))?;
+				Ok(RemowtStream::Ssh(ch.into_stream()))
+			}
+			RemowtListener::Local(listener, path) => {
+				let (stream, _) = listener.accept().await?;
+				let _ = std::fs::remove_file(&path);
+				Ok(RemowtStream::Local(stream))
+			}
+			RemowtListener::Iroh(rx) => rx
+				.await
+				.map_err(|_| anyhow!("agent never opened the iroh tunnel")),
+		}
+	}
+}
+
+pub enum RemowtStream {
+	Ssh(ChannelStream<Msg>),
+	Local(UnixStream),
+	Iroh(IrohBiStream),
+}
+
+impl AsyncRead for RemowtStream {
+	fn poll_read(
+		self: Pin<&mut Self>,
+		cx: &mut Context<'_>,
+		buf: &mut ReadBuf<'_>,
+	) -> Poll<io::Result<()>> {
+		match self.get_mut() {
+			RemowtStream::Ssh(s) => Pin::new(s).poll_read(cx, buf),
+			RemowtStream::Local(s) => Pin::new(s).poll_read(cx, buf),
+			RemowtStream::Iroh(s) => Pin::new(s).poll_read(cx, buf),
+		}
+	}
+}
+
+impl AsyncWrite for RemowtStream {
+	fn poll_write(
+		self: Pin<&mut Self>,
+		cx: &mut Context<'_>,
+		buf: &[u8],
+	) -> Poll<io::Result<usize>> {
+		match self.get_mut() {
+			RemowtStream::Ssh(s) => Pin::new(s).poll_write(cx, buf),
+			RemowtStream::Local(s) => Pin::new(s).poll_write(cx, buf),
+			RemowtStream::Iroh(s) => Pin::new(s).poll_write(cx, buf),
+		}
+	}
+
+	fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
+		match self.get_mut() {
+			RemowtStream::Ssh(s) => Pin::new(s).poll_flush(cx),
+			RemowtStream::Local(s) => Pin::new(s).poll_flush(cx),
+			RemowtStream::Iroh(s) => Pin::new(s).poll_flush(cx),
+		}
+	}
+
+	fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
+		match self.get_mut() {
+			RemowtStream::Ssh(s) => Pin::new(s).poll_shutdown(cx),
+			RemowtStream::Local(s) => Pin::new(s).poll_shutdown(cx),
+			RemowtStream::Iroh(s) => Pin::new(s).poll_shutdown(cx),
+		}
+	}
+}
addedremowt/crates/remowt-client/src/lib.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-client/src/lib.rs
@@ -0,0 +1,714 @@
+use std::collections::HashMap;
+use std::env;
+use std::path::PathBuf;
+use std::sync::atomic::AtomicU64;
+use std::sync::{Arc, Mutex};
+
+use anyhow::{anyhow, bail, ensure, Context as _, Result};
+use bifrostlink::declarative::RemoteEndpoints;
+use bifrostlink::{Remote, Rpc, Rtt};
+use camino::{Utf8Path, Utf8PathBuf};
+use remowt_link_shared::iroh_tunnel::{DatagramRouter, IrohBiStream, TunnelAddr};
+use remowt_link_shared::plugin::PluginEndpointsClient;
+use remowt_link_shared::port::child_port;
+use remowt_link_shared::{Address, BifConfig};
+use russh::client::{connect, Config, Handle, Handler, Msg, Session};
+use russh::keys::agent::client::AgentClient;
+use russh::keys::agent::AgentIdentity;
+use russh::keys::check_known_hosts;
+use russh::keys::ssh_key::PublicKey;
+use russh::Channel;
+use tempfile::TempDir;
+use tokio::io::AsyncRead;
+use tokio::net::UnixListener;
+use tokio::sync::oneshot;
+use tokio::task::JoinHandle;
+use tokio::{
+	fs,
+	io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader},
+};
+use tracing::{debug, info, warn};
+use uuid::Uuid;
+
+pub mod editor;
+mod forwarded;
+mod shell;
+mod ssh_exec;
+mod subprocess;
+
+use self::ssh_exec::SshExecChild;
+pub use self::subprocess::{RemowtChild, SpawnOptions, StderrMode, StdioMode};
+pub use forwarded::{RemowtListener, RemowtStream};
+pub use shell::{RemowtShell, RemowtShellResizer};
+
+type Subs = Arc<Mutex<HashMap<Utf8PathBuf, oneshot::Sender<Channel<Msg>>>>>;
+
+fn sh_quote(s: impl AsRef<str>) -> String {
+	format!("'{}'", s.as_ref().replace('\'', "'\\''"))
+}
+
+const ESCALATORS: [(&str, &[&str]); 2] = [("run0", &["--background=", "--pipe"]), ("sudo", &[])];
+
+pub struct AgentBundle {
+	dir: PathBuf,
+	hashes: HashMap<String, String>,
+}
+
+impl AgentBundle {
+	pub fn from_dir(dir: impl Into<PathBuf>) -> Result<Self> {
+		let dir = dir.into();
+		let hashes_path = dir.join("hashes");
+		let raw = std::fs::read_to_string(&hashes_path)
+			.with_context(|| format!("reading agent hashes at {}", hashes_path.display()))?;
+		let mut hashes = HashMap::new();
+		for line in raw.lines() {
+			let line = line.trim();
+			if line.is_empty() {
+				continue;
+			}
+			let (arch, hash) = line
+				.split_once(char::is_whitespace)
+				.ok_or_else(|| anyhow!("malformed hashes line: {line:?}"))?;
+			hashes.insert(arch.to_owned(), hash.trim().to_owned());
+		}
+		ensure!(
+			!hashes.is_empty(),
+			"agent bundle {} has no hashes",
+			dir.display()
+		);
+		Ok(Self { dir, hashes })
+	}
+
+	fn binary(&self, arch: &str) -> PathBuf {
+		self.dir.join(format!("remowt-agent-{arch}"))
+	}
+
+	fn local_binary(&self) -> Result<PathBuf> {
+		let arch = env::consts::ARCH;
+		let path = self.binary(arch);
+		ensure!(
+			path.is_file(),
+			"no local remowt-agent build for arch {arch} in bundle {}",
+			self.dir.display()
+		);
+		Ok(path)
+	}
+}
+
+async fn run(sess: &Handle<SshHandler>, cmd: &str) -> Result<(Option<u32>, Vec<u8>)> {
+	let ch = sess.channel_open_session().await?;
+	ch.exec(true, cmd).await?;
+
+	let mut child = SshExecChild::from_exec(ch);
+	drop(child.stdin);
+	drain_to_tracing(child.stderr, cmd.to_owned(), true);
+
+	let mut out = Vec::new();
+	child.stdout.read_to_end(&mut out).await?;
+	let code = child.exit.await.ok().flatten();
+	Ok((code, out))
+}
+
+async fn run_string_ok(sess: &Handle<SshHandler>, cmd: &str) -> Result<String> {
+	let (code, mut out) = run(sess, cmd).await?;
+	ensure!(
+		code == Some(0),
+		"remote command failed (exit {code:?}): {cmd}"
+	);
+	if !out.is_empty() {
+		ensure!(
+			out.ends_with(b"\n"),
+			"remote command was not newline-terminated: {cmd}: {out:?}"
+		);
+		out.pop();
+	}
+	String::from_utf8(out).context("expected utf8 output for command")
+}
+
+async fn deploy_agent(sess: &Handle<SshHandler>, bundle: &AgentBundle) -> Result<Utf8PathBuf> {
+	debug!("uname -a");
+	let arch = run_string_ok(sess, "uname -m").await?;
+	let hash = bundle
+		.hashes
+		.get(&arch)
+		.ok_or_else(|| anyhow!("no remowt-agent build for remote arch {arch:?}"))?;
+
+	debug!("get dir");
+	let cache = run_string_ok(sess, "echo \"$XDG_CACHE_HOME\"").await?;
+	let dir = if cache.is_empty() {
+		let home = run_string_ok(sess, "echo \"$HOME\"").await?;
+		ensure!(
+			!home.is_empty(),
+			"remote $HOME and $XDG_CACHE_HOME both empty"
+		);
+		Utf8PathBuf::from(home).join(".cache/remowt")
+	} else {
+		Utf8PathBuf::from(cache).join("remowt")
+	};
+	let path = dir.join(hash);
+
+	debug!("presence");
+	let (present, _) = run(sess, &format!("test -x {}", sh_quote(&path))).await?;
+	if present != Some(0) {
+		let bin = bundle.binary(&arch);
+		debug!("read");
+		let bytes = fs::read(&bin)
+			.await
+			.with_context(|| format!("reading agent binary {}", bin.display()))?;
+		debug!("upload");
+		upload_agent(sess, &dir, &path, bytes).await?;
+	}
+	Ok(path)
+}
+
+async fn upload_agent(
+	sess: &Handle<SshHandler>,
+	dir: &Utf8Path,
+	path: &Utf8Path,
+	bytes: Vec<u8>,
+) -> Result<()> {
+	debug!("mkdirp");
+	run_string_ok(sess, &format!("mkdir -p {}", sh_quote(dir))).await?;
+
+	let tmp = dir.join(format!("tmp.{}", Uuid::new_v4()));
+	let ch = sess.channel_open_session().await?;
+	debug!("cat");
+	ch.exec(true, format!("cat > {}", sh_quote(&tmp))).await?;
+
+	let mut child = SshExecChild::from_exec(ch);
+	child
+		.stdin
+		.write_all(&bytes)
+		.await
+		.context("sending agent binary")?;
+	child
+		.stdin
+		.shutdown()
+		.await
+		.context("sending agent binary")?;
+	let code = child.wait().await;
+	ensure!(code == Some(0), "agent upload failed (exit {code:?})");
+
+	debug!("chmod");
+	run_string_ok(sess, &format!("chmod 0755 {}", sh_quote(&tmp))).await?;
+	run_string_ok(
+		sess,
+		&format!("mv -f {} {}", sh_quote(&tmp), sh_quote(path)),
+	)
+	.await?;
+	Ok(())
+}
+
+pub struct SshHandler {
+	host: String,
+	port: u16,
+	subs: Subs,
+}
+impl Handler for SshHandler {
+	type Error = russh::Error;
+	async fn check_server_key(
+		&mut self,
+		server_public_key: &PublicKey,
+	) -> Result<bool, Self::Error> {
+		Ok(check_known_hosts(&self.host, self.port, server_public_key)?)
+	}
+	async fn server_channel_open_forwarded_streamlocal(
+		&mut self,
+		channel: Channel<Msg>,
+		socket_path: &str,
+		_session: &mut Session,
+	) -> Result<(), Self::Error> {
+		let Some(ch) = self
+			.subs
+			.lock()
+			.expect("lock")
+			.remove(&Utf8PathBuf::from(socket_path))
+		else {
+			return Err(russh::Error::WrongChannel);
+		};
+		let _ = ch.send(channel);
+		Ok(())
+	}
+}
+
+enum Transport {
+	Ssh {
+		sess: Arc<Handle<SshHandler>>,
+		subs: Subs,
+		runtime_dir: Utf8PathBuf,
+		agent_path: Utf8PathBuf,
+	},
+	Local {
+		agent_path: PathBuf,
+		runtime_dir: Utf8PathBuf,
+	},
+}
+
+struct RemowtInner {
+	transport: Transport,
+	rpc: Rpc<BifConfig>,
+	elevated: tokio::sync::OnceCell<()>,
+	#[allow(dead_code)]
+	children: Mutex<Vec<tokio::process::Child>>,
+	_runtime_tmp: Option<TempDir>,
+	user: String,
+	iroh: IrohState,
+}
+
+#[derive(Default)]
+struct IrohState {
+	conn: tokio::sync::OnceCell<iroh::endpoint::Connection>,
+	#[allow(dead_code)]
+	endpoint: Mutex<Option<iroh::Endpoint>>,
+	subs: Arc<Mutex<HashMap<u64, oneshot::Sender<RemowtStream>>>>,
+	next_token: AtomicU64,
+	router: Mutex<Option<Arc<DatagramRouter>>>,
+}
+
+#[derive(Clone)]
+pub struct Remowt(Arc<RemowtInner>);
+
+pub type RemowtRemote = Remote<BifConfig>;
+
+impl Remowt {
+	/// Connect to the remote host over ssh, detect the architecture and deploy the required
+	/// agent binary.
+	pub async fn connect(host: &str, bundle: &AgentBundle, remowt_user: String) -> Result<Self> {
+		let conf = russh_config::parse_home(host)?;
+		let port = conf.host_config.port.or(conf.port).unwrap_or(22);
+		let hostname = conf
+			.host_config
+			.hostname
+			.clone()
+			.unwrap_or_else(|| conf.host_name.clone());
+		let user = conf
+			.user
+			.clone()
+			.unwrap_or_else(|| env::var("USER").unwrap_or_else(|_| "root".to_owned()));
+
+		let subs: Subs = Arc::new(Mutex::new(HashMap::new()));
+		let mut sess = connect(
+			Arc::new(Config::default()),
+			(hostname.clone(), port),
+			SshHandler {
+				host: hostname,
+				port,
+				subs: subs.clone(),
+			},
+		)
+		.await?;
+
+		let mut agent = AgentClient::connect_env().await?;
+		let rsa_hash = sess.best_supported_rsa_hash().await?.flatten();
+		let mut authenticated = false;
+		for ident in agent.request_identities().await? {
+			let AgentIdentity::PublicKey { key, .. } = ident else {
+				continue;
+			};
+			if sess
+				.authenticate_publickey_with(user.clone(), key, rsa_hash, &mut agent)
+				.await?
+				.success()
+			{
+				authenticated = true;
+				break;
+			}
+		}
+		ensure!(authenticated, "ssh authentication failed");
+
+		let sess = Arc::new(sess);
+
+		debug!("deploying agent");
+		let agent_path = deploy_agent(&sess, bundle).await?;
+
+		debug!("runtime dir");
+		let runtime_dir = remote_runtime_dir(&sess).await?;
+
+		let rpc = Rpc::<BifConfig>::new(Address::User);
+
+		let cmd_chan = sess.channel_open_session().await?;
+		debug!("starting agent");
+		cmd_chan
+			.exec(true, format!("{} real-agent", sh_quote(&agent_path)))
+			.await?;
+
+		let child = SshExecChild::from_exec(cmd_chan);
+		drain_to_tracing(child.stderr, "agent".to_owned(), true);
+		rpc.add_direct(
+			Address::Agent,
+			child_port(child.stdout, child.stdin),
+			Rtt(0),
+		);
+
+		let remowt = Self(Arc::new(RemowtInner {
+			transport: Transport::Ssh {
+				sess,
+				subs,
+				runtime_dir,
+				agent_path,
+			},
+			rpc,
+			elevated: tokio::sync::OnceCell::new(),
+			children: Mutex::new(Vec::new()),
+			_runtime_tmp: None,
+			user: remowt_user,
+			iroh: IrohState::default(),
+		}));
+		remowt.setup_iroh().await;
+		Ok(remowt)
+	}
+
+	/// "Connect" to the local machine's agent, by starting the agent binary locally.
+	pub async fn connect_local(bundle: &AgentBundle, user: String) -> Result<Self> {
+		let agent_path = bundle.local_binary()?;
+		let mut child = tokio::process::Command::new(&agent_path)
+			.arg("real-agent")
+			.arg("--local")
+			.stdin(std::process::Stdio::piped())
+			.stdout(std::process::Stdio::piped())
+			.kill_on_drop(true)
+			.spawn()
+			.with_context(|| format!("spawning agent binary {}", agent_path.display()))?;
+		let stdin = child.stdin.take().expect("stdin piped");
+		let stdout = child.stdout.take().expect("stdout piped");
+
+		let rpc = Rpc::<BifConfig>::new(Address::User);
+		rpc.add_direct(Address::Agent, child_port(stdout, stdin), Rtt(0));
+
+		let (runtime_dir, runtime_tmp) = local_runtime_dir()?;
+
+		Ok(Self(Arc::new(RemowtInner {
+			transport: Transport::Local {
+				agent_path,
+				runtime_dir,
+			},
+			rpc,
+			elevated: tokio::sync::OnceCell::new(),
+			children: Mutex::new(vec![child]),
+			_runtime_tmp: runtime_tmp,
+			user,
+			iroh: IrohState::default(),
+		})))
+	}
+
+	/// Get the handle to the underlying russh session handle.
+	pub fn ssh(&self) -> Option<Arc<Handle<SshHandler>>> {
+		match &self.0.transport {
+			Transport::Ssh { sess, .. } => Some(sess.clone()),
+			Transport::Local { .. } => None,
+		}
+	}
+
+	pub fn rpc(&self) -> Rpc<BifConfig> {
+		self.0.rpc.clone()
+	}
+
+	pub async fn load_plugin(&self, id: u16, name: &str) -> Result<()> {
+		let client: PluginEndpointsClient<BifConfig> = self.endpoints();
+		client
+			.load_plugin(id, name.to_owned())
+			.await?
+			.map_err(|e| anyhow!("agent failed to load plugin: {e}"))
+	}
+	pub async fn run0_load_plugin_path(&self, id: u16, path: &str) -> Result<()> {
+		self.ensure_escalated().await?;
+		let client: PluginEndpointsClient<BifConfig> =
+			PluginEndpointsClient::wrap(self.0.rpc.remote(Address::AgentPrivileged));
+		client
+			.load_plugin_path(id, path.to_owned())
+			.await?
+			.map_err(|e| anyhow!("privileged agent failed to load plugin: {e}"))
+	}
+	pub fn plugin_endpoints<R: RemoteEndpoints<BifConfig>>(&self, id: u16) -> R {
+		R::wrap(self.0.rpc.remote(Address::Plugin(id)))
+	}
+
+	pub fn endpoints<R: RemoteEndpoints<BifConfig>>(&self) -> R {
+		R::wrap(self.0.rpc.remote(Address::Agent))
+	}
+	pub async fn run0_endpoints<R: RemoteEndpoints<BifConfig>>(&self) -> Result<R> {
+		self.ensure_escalated().await?;
+		Ok(R::wrap(self.0.rpc.remote(Address::AgentPrivileged)))
+	}
+
+	async fn ensure_escalated(&self) -> Result<()> {
+		self.0
+			.elevated
+			.get_or_try_init(|| async {
+				let (agent_path, local) = match &self.0.transport {
+					Transport::Ssh { agent_path, .. } => (agent_path.as_str().to_owned(), false),
+					Transport::Local { agent_path, .. } => (
+						agent_path
+							.to_str()
+							.ok_or_else(|| anyhow!("local agent path is not utf-8"))?
+							.to_owned(),
+						true,
+					),
+				};
+
+				let (tool, flags) = self.detect_escalation().await?;
+				let mut args: Vec<String> = Vec::new();
+				args.push("-w".to_owned());
+				args.push(tool.to_owned());
+				args.extend(flags.iter().copied().map(str::to_owned));
+				if tool == "run0" {
+					args.push(format!(
+						"--unit={}-{}.service",
+						self.0.user,
+						Uuid::new_v4().simple()
+					));
+				}
+				args.push(agent_path);
+				args.push("real-agent".to_owned());
+				args.push("--privileged".to_owned());
+				if local {
+					args.push("--local".to_owned());
+				}
+
+				let child = self
+					.spawn(SpawnOptions {
+						program: "setsid".to_owned(),
+						args,
+						stdin: StdioMode::Pipe,
+						stdout: StdioMode::Pipe,
+						stderr: StderrMode::Inherit,
+						..Default::default()
+					})
+					.await
+					.context("spawning privileged agent")?;
+
+				let stdin = child
+					.stdin
+					.ok_or_else(|| anyhow!("privileged agent stdin missing"))?;
+				let stdout = child
+					.stdout
+					.ok_or_else(|| anyhow!("privileged agent stdout missing"))?;
+
+				let port = child_port(stdout, stdin);
+				self.0
+					.rpc
+					.add_direct(Address::AgentPrivileged, port, Rtt(0));
+				anyhow::Ok(())
+			})
+			.await?;
+		Ok(())
+	}
+
+	async fn detect_escalation(&self) -> Result<(&'static str, &'static [&'static str])> {
+		for (tool, flags) in ESCALATORS {
+			let probe = self
+				.spawn(SpawnOptions {
+					program: (*tool).to_owned(),
+					args: vec!["--version".to_owned()],
+					stdout: StdioMode::Null,
+					stderr: StderrMode::Null,
+					..Default::default()
+				})
+				.await;
+			if let Ok(child) = probe {
+				let _ = child.wait().await;
+				return Ok((tool, flags));
+			}
+		}
+		bail!("no escalation tool found")
+	}
+
+	/// XDG_RUNTIME_DIR on the remote machine.
+	pub fn runtime_dir(&self) -> Utf8PathBuf {
+		match &self.0.transport {
+			Transport::Ssh { runtime_dir, .. } => runtime_dir.clone(),
+			Transport::Local { runtime_dir, .. } => runtime_dir.clone(),
+		}
+	}
+
+	/// Bind unix listener socket on the remote machine with auto-generated path, returning the path.
+	pub async fn bind_runtime_unix(&self, hint: &str) -> Result<(RemowtListener, Utf8PathBuf)> {
+		let sock = self
+			.runtime_dir()
+			.join(format!("remowt-{hint}-{}.sock", Uuid::new_v4()));
+		let listener = self.bind_unix(&sock).await?;
+		Ok((listener, sock))
+	}
+
+	/// Bind unix listener socket on the remote machine on the specified path.
+	pub async fn bind_unix(&self, path: &Utf8Path) -> Result<RemowtListener> {
+		match &self.0.transport {
+			Transport::Ssh { sess, subs, .. } => {
+				let (tx, rx) = oneshot::channel();
+				subs.lock().expect("lock").insert(path.to_owned(), tx);
+				sess.streamlocal_forward(path.to_owned()).await?;
+				Ok(RemowtListener::Ssh(rx))
+			}
+			Transport::Local { .. } => {
+				let _ = std::fs::remove_file(path);
+				Ok(RemowtListener::Local(
+					UnixListener::bind(path)?,
+					path.to_owned(),
+				))
+			}
+		}
+	}
+
+	/// Bind a data tunnel, preferring the iroh fast path when it is up. Escalated tunnels
+	/// (the privileged agent, a separate process with no iroh connection of its own) and the
+	/// local transport always use the ssh/unix path.
+	pub async fn bind_fast_tunnel(
+		&self,
+		hint: &str,
+		escalated: bool,
+	) -> Result<(RemowtListener, TunnelAddr)> {
+		if !escalated && self.0.iroh.conn.get().is_some() {
+			let token = self
+				.0
+				.iroh
+				.next_token
+				.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+			let (tx, rx) = oneshot::channel();
+			self.0.iroh.subs.lock().expect("lock").insert(token, tx);
+			return Ok((RemowtListener::Iroh(rx), TunnelAddr::Iroh { token }));
+		}
+		let (listener, path) = self.bind_runtime_unix(hint).await?;
+		Ok((listener, TunnelAddr::Unix(path)))
+	}
+
+	async fn setup_iroh(&self) {
+		if std::env::var_os("REMOWT_NO_IROH").is_some() {
+			debug!("REMOWT_NO_IROH set, skipping iroh fast tunnel");
+			return;
+		}
+		if !matches!(self.0.transport, Transport::Ssh { .. }) {
+			return;
+		}
+		if let Err(e) = self.try_setup_iroh().await {
+			warn!("iroh fast tunnel unavailable, using ssh: {e}");
+		}
+	}
+
+	async fn try_setup_iroh(&self) -> Result<()> {
+		use remowt_endpoints::iroh_tunnel::IrohTunnelClient;
+		use remowt_link_shared::iroh_tunnel::{build_endpoint, ssh_custom_addr, REMOWT_ALPN};
+
+		let (listener, sock) = self.bind_runtime_unix("iroh-xport").await?;
+		let secret = iroh::SecretKey::generate();
+		let client_id = secret.public();
+
+		let client: IrohTunnelClient<BifConfig> =
+			IrohTunnelClient::wrap(self.0.rpc.remote(Address::Agent));
+		let (accepted, agent_id) = tokio::join!(listener.accept(), client.setup(client_id, sock));
+		let stream = accepted?;
+		let agent_id = agent_id?.map_err(|e| anyhow!("agent iroh setup failed: {e}"))?;
+
+		let ep = build_endpoint(secret, stream, agent_id, false).await?;
+		let addr = iroh::EndpointAddr::from_parts(
+			agent_id,
+			[iroh::TransportAddr::Custom(ssh_custom_addr(agent_id))],
+		);
+		let conn = ep.connect(addr, REMOWT_ALPN).await?;
+		ensure!(conn.remote_id() == agent_id, "iroh peer identity mismatch");
+		info!("iroh fast tunnel established");
+
+		let subs = self.0.iroh.subs.clone();
+		let accept_conn = conn.clone();
+		tokio::spawn(async move {
+			loop {
+				match accept_conn.accept_bi().await {
+					Ok((send, mut recv)) => {
+						let subs = subs.clone();
+						tokio::spawn(async move {
+							let mut buf = [0u8; 8];
+							if tokio::io::AsyncReadExt::read_exact(&mut recv, &mut buf)
+								.await
+								.is_err()
+							{
+								return;
+							}
+							let token = u64::from_be_bytes(buf);
+							let tx = subs.lock().expect("lock").remove(&token);
+							if let Some(tx) = tx {
+								let _ = tx.send(RemowtStream::Iroh(IrohBiStream::new(send, recv)));
+							}
+						});
+					}
+					Err(e) => {
+						debug!("iroh accept loop ended: {e}");
+						break;
+					}
+				}
+			}
+		});
+
+		let log_conn = conn.clone();
+		tokio::spawn(async move {
+			tokio::time::sleep(std::time::Duration::from_secs(2)).await;
+			for p in log_conn.paths().iter() {
+				if p.is_selected() {
+					info!(rtt = ?p.rtt(), "iroh selected path: {}", p.remote_addr());
+				}
+			}
+		});
+
+		*self.0.iroh.router.lock().expect("lock") = Some(DatagramRouter::spawn(conn.clone()));
+		*self.0.iroh.endpoint.lock().expect("lock") = Some(ep);
+		let _ = self.0.iroh.conn.set(conn);
+		Ok(())
+	}
+
+	pub fn datagram_router(&self) -> Option<Arc<DatagramRouter>> {
+		self.0.iroh.router.lock().expect("lock").clone()
+	}
+}
+
+pub(crate) fn drain_to_tracing(
+	stream: impl AsyncRead + Unpin + 'static + Send,
+	context: String,
+	stderr: bool,
+) -> JoinHandle<()> {
+	tokio::spawn(async move {
+		let mut reader = BufReader::new(stream);
+		let mut buf = Vec::with_capacity(4096);
+		loop {
+			buf.clear();
+			match reader.read_until(b'\n', &mut buf).await {
+				Ok(0) => break,
+				Ok(_) => {
+					let line = String::from_utf8_lossy(buf.strip_suffix(b"\n").unwrap_or(&buf));
+					if stderr {
+						warn!(context = %context, "{line}");
+					} else {
+						info!(context = %context, "{line}");
+					}
+				}
+				Err(e) => {
+					warn!(context = %context, "child stdio read failed: {e}");
+					break;
+				}
+			}
+		}
+	})
+}
+
+fn local_runtime_dir() -> Result<(Utf8PathBuf, Option<TempDir>)> {
+	if let Ok(dir) = env::var("XDG_RUNTIME_DIR") {
+		if !dir.is_empty() {
+			return Ok((Utf8PathBuf::from(dir), None));
+		}
+	}
+	let tmp = tempfile::Builder::new()
+		.prefix("remowt.")
+		.rand_bytes(12)
+		.tempdir()?;
+	let dir = Utf8PathBuf::from_path_buf(tmp.path().to_owned())
+		.map_err(|p| anyhow!("temp dir {} is not utf-8", p.display()))?;
+	Ok((dir, Some(tmp)))
+}
+
+async fn remote_runtime_dir(sess: &Handle<SshHandler>) -> Result<Utf8PathBuf> {
+	let dir = run_string_ok(sess, "echo \"$XDG_RUNTIME_DIR\"").await?;
+	let dir = dir.trim();
+	if dir.is_empty() {
+		let tmp = run_string_ok(sess, "mktemp -d remowt.XXXXXXXXXXXX --tmpdir").await?;
+		Ok(Utf8PathBuf::from(tmp))
+	} else {
+		Ok(Utf8PathBuf::from(dir))
+	}
+}
addedremowt/crates/remowt-client/src/shell.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-client/src/shell.rs
@@ -0,0 +1,65 @@
+use anyhow::{anyhow, Result};
+use bifrostlink::declarative::RemoteEndpoints as _;
+use bifrostlink::Remote;
+use remowt_endpoints::pty::{PtyClient, ShellId};
+use remowt_link_shared::{Address, BifConfig};
+
+use crate::forwarded::RemowtStream;
+use crate::Remowt;
+
+pub struct RemowtShell {
+	pub id: ShellId,
+	pub stream: RemowtStream,
+	remote: Remote<BifConfig>,
+}
+impl RemowtShell {
+	pub fn resizer(&self) -> RemowtShellResizer {
+		RemowtShellResizer {
+			remote: self.remote.clone(),
+			id: self.id,
+		}
+	}
+}
+
+#[derive(Clone)]
+pub struct RemowtShellResizer {
+	remote: Remote<BifConfig>,
+	id: ShellId,
+}
+
+impl RemowtShellResizer {
+	pub async fn resize(&self, cols: u16, rows: u16) -> Result<()> {
+		PtyClient::wrap(self.remote.clone())
+			.resize(self.id, cols, rows)
+			.await?
+			.map_err(|e| anyhow!("failed to resize remote shell: {e}"))
+	}
+}
+
+impl Remowt {
+	pub async fn open_shell(
+		&self,
+		term: &str,
+		cols: u16,
+		rows: u16,
+		escalate: bool,
+	) -> Result<RemowtShell> {
+		let (forwarded, tunnel) = self.bind_fast_tunnel("shell", escalate).await?;
+		let client: PtyClient<BifConfig> = if escalate {
+			self.run0_endpoints().await?
+		} else {
+			self.endpoints()
+		};
+		let id = client
+			.open_shell(tunnel, term.to_owned(), cols, rows)
+			.await?
+			.map_err(|e| anyhow!("agent failed to open shell: {e}"))?;
+		let stream = forwarded.accept().await?;
+
+		Ok(RemowtShell {
+			id,
+			stream,
+			remote: self.0.rpc.remote(Address::Agent),
+		})
+	}
+}
addedremowt/crates/remowt-client/src/ssh_exec.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-client/src/ssh_exec.rs
@@ -0,0 +1,82 @@
+use bytes::Bytes;
+use russh::client::Msg;
+use russh::{Channel, ChannelMsg};
+use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _, DuplexStream};
+use tokio::sync::oneshot;
+
+const BUF: usize = 64 * 1024;
+
+pub(crate) struct SshExecChild {
+	pub stdin: DuplexStream,
+	pub stdout: DuplexStream,
+	pub stderr: DuplexStream,
+	pub exit: oneshot::Receiver<Option<u32>>,
+}
+
+impl SshExecChild {
+	/// Manage channel returned by russh exec().
+	pub(crate) fn from_exec(ch: Channel<Msg>) -> Self {
+		let (stdin, mut stdin_r) = tokio::io::duplex(BUF);
+		let (mut out_w, stdout) = tokio::io::duplex(BUF);
+		let (mut err_w, stderr) = tokio::io::duplex(BUF);
+		let (exit_tx, exit) = oneshot::channel();
+
+		tokio::spawn(async move {
+			let (mut read, write) = ch.split();
+
+			let stdin_pump = tokio::spawn(async move {
+				let mut buf = vec![0u8; BUF];
+				loop {
+					match stdin_r.read(&mut buf).await {
+						Ok(0) | Err(_) => break,
+						Ok(n) => {
+							if write
+								.data_bytes(Bytes::copy_from_slice(&buf[..n]))
+								.await
+								.is_err()
+							{
+								return;
+							}
+						}
+					}
+				}
+				let _ = write.eof().await;
+			});
+
+			let mut code = None;
+			while let Some(msg) = read.wait().await {
+				match msg {
+					ChannelMsg::Data { data } => {
+						if out_w.write_all(&data).await.is_err() {
+							break;
+						}
+					}
+					ChannelMsg::ExtendedData { data, .. } => {
+						if err_w.write_all(&data).await.is_err() {
+							break;
+						}
+					}
+					ChannelMsg::ExitStatus { exit_status } => code = Some(exit_status),
+					_ => {}
+				}
+			}
+
+			stdin_pump.abort();
+			let _ = out_w.shutdown().await;
+			let _ = err_w.shutdown().await;
+			let _ = exit_tx.send(code);
+		});
+
+		SshExecChild {
+			stdin,
+			stdout,
+			stderr,
+			exit,
+		}
+	}
+
+	/// Wait for the process to finish, returning its exit status.
+	pub(crate) async fn wait(self) -> Option<u32> {
+		self.exit.await.ok().flatten()
+	}
+}
addedremowt/crates/remowt-client/src/subprocess.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-client/src/subprocess.rs
@@ -0,0 +1,439 @@
+use std::pin::pin;
+
+use anyhow::{anyhow, bail, Result};
+use camino::Utf8PathBuf;
+use futures::StreamExt as _;
+use remowt_endpoints::subprocess::{ProcId, SpawnSpec, StderrSpec, StdioSpec, SubprocessClient};
+use remowt_link_shared::iroh_tunnel::TunnelAddr;
+use remowt_link_shared::BifConfig;
+use serde::de::DeserializeOwned;
+use tokio::io::AsyncWriteExt as _;
+use tokio::select;
+use tokio_util::codec::{BytesCodec, FramedRead, LinesCodec};
+use tracing::{debug, info, warn};
+
+use crate::forwarded::{RemowtListener, RemowtStream};
+use crate::{drain_to_tracing, Remowt};
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum StdioMode {
+	#[default]
+	Null,
+	Pipe,
+	Inherit,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum StderrMode {
+	#[default]
+	Null,
+	Pipe,
+	Inherit,
+	MergeWithStdout,
+}
+
+#[derive(Default)]
+pub struct SpawnOptions {
+	pub program: String,
+	pub args: Vec<String>,
+	pub env: Vec<(String, String)>,
+	pub env_clear: bool,
+	pub cwd: Option<Utf8PathBuf>,
+	pub escalated: bool,
+	pub stdin: StdioMode,
+	pub stdout: StdioMode,
+	pub stderr: StderrMode,
+}
+
+pub struct RemowtChild {
+	pub stdin: Option<RemowtStream>,
+	pub stdout: Option<RemowtStream>,
+	pub stderr: Option<RemowtStream>,
+	id: ProcId,
+	client: SubprocessClient<BifConfig>,
+}
+
+impl RemowtChild {
+	pub async fn wait(self) -> Result<Option<i32>> {
+		let RemowtChild {
+			stdin,
+			stdout,
+			stderr,
+			id,
+			client,
+		} = self;
+		drop(stdin);
+		let drain_out = async move {
+			if let Some(s) = stdout {
+				let _ = drain_to_tracing(s, "<child stdout>".to_owned(), false).await;
+			}
+		};
+		let drain_err = async move {
+			if let Some(s) = stderr {
+				let _ = drain_to_tracing(s, "<child stderr>".to_owned(), true).await;
+			}
+		};
+		let wait = async move {
+			client
+				.wait(id)
+				.await?
+				.map_err(|e| anyhow!("agent wait failed: {e}"))
+		};
+		let (code, _, _) = tokio::join!(wait, drain_out, drain_err);
+		code
+	}
+
+	pub async fn kill(&self, signal: i32) -> Result<()> {
+		self.client
+			.kill(self.id, signal)
+			.await?
+			.map_err(|e| anyhow!("agent kill failed: {e}"))
+	}
+}
+
+fn needs_socket(m: StdioMode) -> bool {
+	matches!(m, StdioMode::Pipe | StdioMode::Inherit)
+}
+
+fn stderr_needs_socket(m: StderrMode) -> bool {
+	matches!(m, StderrMode::Pipe | StderrMode::Inherit)
+}
+
+impl Remowt {
+	pub async fn spawn(&self, opts: SpawnOptions) -> Result<RemowtChild> {
+		let SpawnOptions {
+			program,
+			args,
+			env,
+			env_clear,
+			cwd,
+			escalated,
+			stdin,
+			stdout,
+			stderr,
+		} = opts;
+
+		if matches!(stderr, StderrMode::MergeWithStdout) && !needs_socket(stdout) {
+			bail!("stderr=MergeWithStdout requires stdout=Pipe or Inherit");
+		}
+
+		let stdin_bound = if needs_socket(stdin) {
+			Some(self.bind_fast_tunnel("proc-stdin", escalated).await?)
+		} else {
+			None
+		};
+		let stdout_bound = if needs_socket(stdout) {
+			Some(self.bind_fast_tunnel("proc-stdout", escalated).await?)
+		} else {
+			None
+		};
+		let stderr_bound = if stderr_needs_socket(stderr) {
+			Some(self.bind_fast_tunnel("proc-stderr", escalated).await?)
+		} else {
+			None
+		};
+
+		let stdin_spec = match &stdin_bound {
+			Some((_, t)) => StdioSpec::Tunnel(t.clone()),
+			None => StdioSpec::Null,
+		};
+		let stdout_spec = match &stdout_bound {
+			Some((_, t)) => StdioSpec::Tunnel(t.clone()),
+			None => StdioSpec::Null,
+		};
+		let stderr_spec = match (&stderr, &stderr_bound) {
+			(StderrMode::Pipe | StderrMode::Inherit, Some((_, t))) => StderrSpec::Tunnel(t.clone()),
+			(StderrMode::MergeWithStdout, _) => StderrSpec::MergeWithStdout,
+			_ => StderrSpec::Null,
+		};
+
+		let client: SubprocessClient<BifConfig> = if escalated {
+			// Boxed to break the async-fn type cycle
+			Box::pin(self.run0_endpoints::<SubprocessClient<BifConfig>>()).await?
+		} else {
+			self.endpoints()
+		};
+
+		let spec = SpawnSpec {
+			program: program.clone(),
+			args,
+			env,
+			env_clear,
+			cwd,
+			stdin: stdin_spec,
+			stdout: stdout_spec,
+			stderr: stderr_spec,
+		};
+		let id = client
+			.spawn(spec)
+			.await?
+			.map_err(|e| anyhow!("agent spawn failed: {e}"))?;
+
+		let (stdin_res, stdout_res, stderr_res) = tokio::join!(
+			accept(stdin_bound),
+			accept(stdout_bound),
+			accept(stderr_bound),
+		);
+
+		let stdin_stream = handle_stdin(stdin, stdin_res?, &program);
+		let stdout_stream = handle_output(stdout, stdout_res?, &program);
+		let stderr_stream = handle_output_err(stderr, stderr_res?, &program);
+
+		Ok(RemowtChild {
+			stdin: stdin_stream,
+			stdout: stdout_stream,
+			stderr: stderr_stream,
+			id,
+			client,
+		})
+	}
+
+	pub fn cmd(&self, program: impl AsRef<str>) -> RemowtCommand {
+		let program = program.as_ref().to_owned();
+		RemowtCommand {
+			program,
+			args: vec![],
+			env: vec![],
+			remowt: self.clone(),
+			escalated: false,
+		}
+	}
+}
+
+async fn accept(b: Option<(RemowtListener, TunnelAddr)>) -> Result<Option<RemowtStream>> {
+	match b {
+		Some((l, _)) => Ok(Some(l.accept().await?)),
+		None => Ok(None),
+	}
+}
+
+fn handle_stdin(mode: StdioMode, s: Option<RemowtStream>, program: &str) -> Option<RemowtStream> {
+	match mode {
+		StdioMode::Pipe => s,
+		StdioMode::Inherit => {
+			if let Some(s) = s {
+				let program = program.to_owned();
+				tokio::spawn(async move {
+					let mut stdin = tokio::io::stdin();
+					let mut s = s;
+					if let Err(e) = tokio::io::copy(&mut stdin, &mut s).await {
+						warn!(program, "stdin forward ended: {e}");
+					}
+					let _ = s.shutdown().await;
+				});
+			}
+			None
+		}
+		StdioMode::Null => None,
+	}
+}
+
+fn handle_output(mode: StdioMode, s: Option<RemowtStream>, program: &str) -> Option<RemowtStream> {
+	match mode {
+		StdioMode::Pipe => s,
+		StdioMode::Inherit => {
+			if let Some(s) = s {
+				let program = program.to_owned();
+				tokio::spawn(drain_to_tracing(s, program, false));
+			}
+			None
+		}
+		StdioMode::Null => None,
+	}
+}
+
+fn handle_output_err(
+	mode: StderrMode,
+	s: Option<RemowtStream>,
+	program: &str,
+) -> Option<RemowtStream> {
+	match mode {
+		StderrMode::Pipe => s,
+		StderrMode::Inherit => {
+			if let Some(s) = s {
+				let program = program.to_owned();
+				tokio::spawn(drain_to_tracing(s, program, true));
+			}
+			None
+		}
+		StderrMode::MergeWithStdout | StderrMode::Null => None,
+	}
+}
+
+fn escape_bash(input: &str, out: &mut String) {
+	const TO_ESCAPE: &str = "$ !\"#&'()*,;<>?[\\]^`{|}";
+	if input.chars().all(|c| !TO_ESCAPE.contains(c)) {
+		out.push_str(input);
+		return;
+	}
+	out.push('\'');
+	for (i, v) in input.split('\'').enumerate() {
+		if i != 0 {
+			out.push_str("'\"'\"'");
+		}
+		out.push_str(v);
+	}
+	out.push('\'');
+}
+
+#[derive(Clone)]
+pub struct RemowtCommand {
+	program: String,
+	args: Vec<String>,
+	env: Vec<(String, String)>,
+	remowt: Remowt,
+	escalated: bool,
+}
+
+impl RemowtCommand {
+	pub fn arg(&mut self, arg: impl AsRef<str>) -> &mut Self {
+		self.args.push(arg.as_ref().to_owned());
+		self
+	}
+	pub fn args<V: AsRef<str>>(&mut self, args: impl IntoIterator<Item = V>) -> &mut Self {
+		for arg in args {
+			self.args.push(arg.as_ref().to_owned());
+		}
+		self
+	}
+	pub fn eqarg(&mut self, key: impl AsRef<str>, value: impl AsRef<str>) -> &mut Self {
+		self.args
+			.push(format!("{}={}", key.as_ref(), value.as_ref()));
+		self
+	}
+	pub fn comparg(&mut self, key: impl AsRef<str>, value: impl AsRef<str>) -> &mut Self {
+		self.args.push(key.as_ref().to_owned());
+		self.args.push(value.as_ref().to_owned());
+		self
+	}
+	pub fn env(&mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> &mut Self {
+		self.env
+			.push((name.as_ref().to_owned(), value.as_ref().to_owned()));
+		self
+	}
+
+	pub fn sudo(mut self) -> Self {
+		self.escalated = true;
+		self
+	}
+
+	/// Only for display.
+	fn shell_line(&self) -> String {
+		let mut out = String::new();
+		if self.escalated {
+			out.push_str("run0 ");
+		}
+		if !self.env.is_empty() {
+			out.push_str("env");
+			for (k, v) in &self.env {
+				out.push(' ');
+				assert!(!k.contains('='));
+				escape_bash(k, &mut out);
+				out.push('=');
+				escape_bash(v, &mut out);
+			}
+			out.push(' ');
+		}
+		escape_bash(&self.program, &mut out);
+		for arg in &self.args {
+			out.push(' ');
+			escape_bash(arg, &mut out);
+		}
+		out
+	}
+
+	fn into_spawn_options(self) -> (Remowt, SpawnOptions, String) {
+		let line = self.shell_line();
+		let opts = SpawnOptions {
+			program: self.program,
+			args: self.args,
+			env: self.env,
+			env_clear: false,
+			cwd: None,
+			escalated: self.escalated,
+			stdin: StdioMode::Null,
+			stdout: StdioMode::Pipe,
+			stderr: StderrMode::Pipe,
+		};
+		(self.remowt, opts, line)
+	}
+
+	pub async fn run(self) -> Result<()> {
+		run_inner(self, false).await.map(|_| ())
+	}
+	pub async fn run_string(self) -> Result<String> {
+		let bytes = run_inner(self, true).await?.expect("want_stdout");
+		Ok(String::from_utf8(bytes)?)
+	}
+	pub async fn run_value<T: DeserializeOwned>(self) -> Result<T> {
+		let s = self.run_string().await?;
+		Ok(serde_json::from_str(&s)?)
+	}
+}
+
+async fn run_inner(cmd: RemowtCommand, want_stdout: bool) -> Result<Option<Vec<u8>>> {
+	let (remowt, opts, line) = cmd.into_spawn_options();
+	debug!("running command {line:?} over remowt");
+	let program = opts.program.clone();
+	let mut child = remowt.spawn(opts).await?;
+	let stderr = child.stderr.take().expect("stderr=Pipe");
+	let stdout = child.stdout.take().expect("stdout=Pipe");
+
+	let mut err = FramedRead::new(stderr, LinesCodec::new());
+	let (mut out_bytes, mut out_lines) = if want_stdout {
+		(Some(FramedRead::new(stdout, BytesCodec::new())), None)
+	} else {
+		(None, Some(FramedRead::new(stdout, LinesCodec::new())))
+	};
+
+	let mut buf = if want_stdout { Some(Vec::new()) } else { None };
+
+	let mut wait = pin!(child.wait());
+	let exit = loop {
+		select! {
+			biased;
+
+			Some(e) = err.next() => {
+				let e = e?;
+				warn!(program = %program, "{e}");
+			}
+			Some(o) = async { out_bytes.as_mut()?.next().await }, if want_stdout => {
+				buf.as_mut().expect("want_stdout").extend_from_slice(&o?);
+			}
+			Some(o) = async { out_lines.as_mut()?.next().await }, if !want_stdout => {
+				let o = o?;
+				info!(program = %program, "{o}");
+			}
+			res = &mut wait => {
+				break res?;
+			}
+		}
+	};
+
+	while let Some(e) = err.next().await {
+		if let Ok(line) = e {
+			warn!(program = %program, "{line}");
+		}
+	}
+	if want_stdout {
+		if let Some(out_bytes) = out_bytes.as_mut() {
+			while let Some(o) = out_bytes.next().await {
+				if let Ok(chunk) = o {
+					buf.as_mut().expect("want_stdout").extend_from_slice(&chunk);
+				}
+			}
+		}
+	} else if let Some(out_lines) = out_lines.as_mut() {
+		while let Some(o) = out_lines.next().await {
+			if let Ok(line) = o {
+				info!(program = %program, "{line}");
+			}
+		}
+	}
+
+	match exit {
+		Some(0) => Ok(buf),
+		Some(c) => bail!("command '{line}' failed with status {c}"),
+		None => Err(anyhow!("command '{line}' ended without an exit status")),
+	}
+}
addedremowt/crates/remowt-endpoints/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-endpoints/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "remowt-endpoints"
+description = "Nix daemon proxy"
+version.workspace = true
+edition = "2021"
+license.workspace = true
+
+[dependencies]
+bifrostlink.workspace = true
+camino.workspace = true
+serde = { workspace = true }
+tempfile.workspace = true
+thiserror.workspace = true
+tokio = { workspace = true, features = [
+	"net",
+	"io-util",
+	"rt",
+	"process",
+	"io-std",
+] }
+tracing.workspace = true
+nix = { workspace = true, features = ["process", "signal", "term"] }
+zbus.workspace = true
+remowt-link-shared.workspace = true
+iroh.workspace = true
addedremowt/crates/remowt-endpoints/src/forward.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-endpoints/src/forward.rs
@@ -0,0 +1,117 @@
+use std::collections::HashMap;
+use std::net::SocketAddr;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex};
+
+use bifrostlink::declarative::endpoints;
+use bifrostlink::Config;
+use remowt_link_shared::iroh_tunnel::{TunnelAddr, TunnelDialer};
+use serde::{Deserialize, Serialize};
+use std::result::Result;
+use tokio::net::{TcpStream, UdpSocket};
+use tracing::warn;
+
+#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
+pub enum Error {
+	#[error("tunnel unavailable: {0}")]
+	Tunnel(String),
+	#[error("connect to {0} failed: {1}")]
+	Connect(String, String),
+	#[error("invalid target address {0:?}")]
+	BadAddr(String),
+	#[error("udp forward requires the iroh fast path, which is not established")]
+	NoIroh,
+}
+
+#[derive(Clone)]
+pub struct Forward {
+	dialer: Arc<TunnelDialer>,
+	next_session: Arc<AtomicU64>,
+}
+
+impl Forward {
+	pub fn new(dialer: Arc<TunnelDialer>) -> Self {
+		Self {
+			dialer,
+			next_session: Arc::new(AtomicU64::new(0)),
+		}
+	}
+}
+
+#[endpoints(ns = 12)]
+impl Forward {
+	#[endpoints(id = 1)]
+	async fn connect_tcp(&self, tunnel: TunnelAddr, addr: String) -> Result<(), Error> {
+		let stream = self
+			.dialer
+			.connect_tunnel(&tunnel)
+			.await
+			.map_err(|e| Error::Tunnel(e.to_string()))?;
+		let tcp = TcpStream::connect(&addr)
+			.await
+			.map_err(|e| Error::Connect(addr, e.to_string()))?;
+		tokio::spawn(async move {
+			let mut stream = stream;
+			let mut tcp = tcp;
+			let _ = tokio::io::copy_bidirectional(&mut stream, &mut tcp).await;
+		});
+		Ok(())
+	}
+
+	#[endpoints(id = 2)]
+	async fn open_udp(&self, addr: String) -> Result<u64, Error> {
+		let target: SocketAddr = addr.parse().map_err(|_| Error::BadAddr(addr.clone()))?;
+		let router = self.dialer.router().ok_or(Error::NoIroh)?;
+		let session = self.next_session.fetch_add(1, Ordering::Relaxed);
+		let mut rx = router.register(session);
+
+		let sockets: Arc<Mutex<HashMap<u64, Arc<UdpSocket>>>> =
+			Arc::new(Mutex::new(HashMap::new()));
+		tokio::spawn(async move {
+			while let Some((sub, payload)) = rx.recv().await {
+				let existing = sockets.lock().expect("lock").get(&sub).cloned();
+				let sock = match existing {
+					Some(s) => s,
+					None => {
+						let sock = match UdpSocket::bind(unspecified_for(&target)).await {
+							Ok(s) => s,
+							Err(e) => {
+								warn!("udp forward: bind failed: {e}");
+								continue;
+							}
+						};
+						if let Err(e) = sock.connect(target).await {
+							warn!("udp forward: connect {target} failed: {e}");
+							continue;
+						}
+						let sock = Arc::new(sock);
+						sockets.lock().expect("lock").insert(sub, sock.clone());
+						// Reply reader: datagrams from the target go back on (session, sub).
+						let router = router.clone();
+						let reply_sock = sock.clone();
+						tokio::spawn(async move {
+							let mut buf = vec![0u8; 65535];
+							while let Ok(n) = reply_sock.recv(&mut buf).await {
+								if router.send(session, sub, &buf[..n]).is_err() {
+									break;
+								}
+							}
+						});
+						sock
+					}
+				};
+				let _ = sock.send(&payload).await;
+			}
+			router.unregister(session);
+		});
+
+		Ok(session)
+	}
+}
+
+fn unspecified_for(target: &SocketAddr) -> SocketAddr {
+	match target {
+		SocketAddr::V4(_) => SocketAddr::from(([0, 0, 0, 0], 0)),
+		SocketAddr::V6(_) => SocketAddr::from(([0u16; 8], 0)),
+	}
+}
addedremowt/crates/remowt-endpoints/src/fs.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-endpoints/src/fs.rs
@@ -0,0 +1,105 @@
+use std::io::ErrorKind;
+use std::str::FromStr;
+use std::sync::Mutex;
+
+use bifrostlink::declarative::endpoints;
+use bifrostlink::Config;
+use camino::Utf8PathBuf;
+use serde::{Deserialize, Serialize};
+use tempfile::TempDir;
+
+#[derive(Default)]
+pub struct Fs {
+	tempdirs: Mutex<Vec<TempDir>>,
+}
+
+impl Fs {
+	pub fn new() -> Self {
+		Self::default()
+	}
+}
+
+#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
+pub enum Error {
+	#[error("file not found")]
+	NotFound,
+	#[error("file name/contents is not utf8")]
+	InvalidUtf8,
+	#[error("unknown fs error")]
+	Unknown,
+}
+
+#[endpoints(ns = 1)]
+impl Fs {
+	#[endpoints(id = 1)]
+	async fn read_file_tiny(&self, path: Utf8PathBuf) -> Result<Vec<u8>, Error> {
+		match tokio::fs::read(path).await {
+			Ok(v) => Ok(v),
+			Err(e) if e.kind() == ErrorKind::NotFound => Err(Error::NotFound),
+			_ => Err(Error::Unknown),
+		}
+	}
+	#[endpoints(id = 2)]
+	async fn file_exists(&self, path: Utf8PathBuf) -> bool {
+		tokio::fs::try_exists(path).await.unwrap_or(false)
+	}
+	#[endpoints(id = 3)]
+	async fn read_dir_raw(&self, path: Utf8PathBuf) -> Result<Vec<Utf8PathBuf>, Error> {
+		let mut dir = match tokio::fs::read_dir(path).await {
+			Ok(dir) => dir,
+			Err(e) if e.kind() == ErrorKind::NotFound => return Err(Error::NotFound),
+			Err(_) => return Err(Error::Unknown),
+		};
+		let mut out = Vec::new();
+		while let Ok(Some(entry)) = dir.next_entry().await {
+			let name = Utf8PathBuf::try_from(entry.file_name()).map_err(|_| Error::InvalidUtf8)?;
+			out.push(name);
+		}
+		Ok(out)
+	}
+	#[endpoints(id = 4)]
+	async fn mktemp_dir_raw(&self) -> Result<Utf8PathBuf, Error> {
+		let dir = tempfile::Builder::new()
+			.prefix("remowt.")
+			.tempdir()
+			.map_err(|_| Error::Unknown)?;
+		let mut tempdirs = self.tempdirs.lock().expect("not poisoned");
+		let path = Utf8PathBuf::try_from(dir.path().to_owned()).map_err(|_| Error::InvalidUtf8);
+		tempdirs.push(dir);
+		path
+	}
+	#[endpoints(id = 5)]
+	async fn rm_file(&self, path: Utf8PathBuf) -> Result<(), Error> {
+		match tokio::fs::remove_file(path).await {
+			Ok(()) => Ok(()),
+			Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
+			Err(_) => Err(Error::Unknown),
+		}
+	}
+}
+
+impl<C: Config> FsClient<C> {
+	pub async fn read_file_text(&self, path: impl Into<Utf8PathBuf>) -> Result<String, Error> {
+		let v = self
+			.read_file_tiny(path.into())
+			.await
+			.map_err(|_| Error::Unknown)?;
+		let v = v?;
+		String::from_utf8(v).map_err(|_| Error::InvalidUtf8)
+	}
+	pub async fn read_file_value<T: FromStr>(
+		&self,
+		path: impl Into<Utf8PathBuf>,
+	) -> Result<Result<T, T::Err>, Error> {
+		let text = self.read_file_text(path).await?;
+		Ok(T::from_str(&text))
+	}
+	pub async fn mktemp_dir(&self) -> Result<Utf8PathBuf, Error> {
+		self.mktemp_dir_raw().await.map_err(|_| Error::Unknown)?
+	}
+	pub async fn read_dir(&self, path: impl Into<Utf8PathBuf>) -> Result<Vec<Utf8PathBuf>, Error> {
+		self.read_dir_raw(path.into())
+			.await
+			.map_err(|_| Error::Unknown)?
+	}
+}
addedremowt/crates/remowt-endpoints/src/iroh_tunnel.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-endpoints/src/iroh_tunnel.rs
@@ -0,0 +1,85 @@
+use std::sync::{Arc, Mutex};
+
+use bifrostlink::declarative::endpoints;
+use bifrostlink::Config;
+use camino::Utf8PathBuf;
+use iroh::{Endpoint, EndpointId, SecretKey};
+use remowt_link_shared::iroh_tunnel::{build_endpoint, TunnelDialer};
+use serde::{Deserialize, Serialize};
+use std::result::Result;
+use tokio::net::UnixStream;
+use tracing::{debug, warn};
+
+#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
+pub enum Error {
+	#[error("iroh transport pipe unavailable: {0}")]
+	Pipe(String),
+	#[error("iroh endpoint failed: {0}")]
+	Iroh(String),
+}
+
+#[derive(Clone)]
+pub struct IrohTunnel {
+	dialer: Arc<TunnelDialer>,
+	endpoint: Arc<Mutex<Option<Endpoint>>>,
+}
+
+impl IrohTunnel {
+	pub fn new(dialer: Arc<TunnelDialer>) -> Self {
+		Self {
+			dialer,
+			endpoint: Arc::new(Mutex::new(None)),
+		}
+	}
+}
+
+#[endpoints(ns = 11)]
+impl IrohTunnel {
+	#[endpoints(id = 1)]
+	async fn setup(
+		&self,
+		client_id: EndpointId,
+		xport_socket: Utf8PathBuf,
+	) -> Result<EndpointId, Error> {
+		let stream = UnixStream::connect(&xport_socket)
+			.await
+			.map_err(|e| Error::Pipe(e.to_string()))?;
+
+		let secret = SecretKey::generate();
+		let agent_id = secret.public();
+		let ep = build_endpoint(secret, stream, client_id, true)
+			.await
+			.map_err(|e| Error::Iroh(e.to_string()))?;
+
+		let dialer = self.dialer.clone();
+		let accept_ep = ep.clone();
+		tokio::spawn(async move {
+			while let Some(incoming) = accept_ep.accept().await {
+				let dialer = dialer.clone();
+				match incoming.accept() {
+					Ok(accepting) => {
+						tokio::spawn(async move {
+							match accepting.await {
+								Ok(conn) => {
+									if conn.remote_id() != client_id {
+										warn!("iroh: rejecting connection from unexpected peer");
+										conn.close(0u32.into(), b"unexpected peer");
+										return;
+									}
+									debug!("iroh tunnel connection accepted");
+									dialer.set_conn(conn);
+								}
+								Err(e) => warn!("iroh accept failed: {e}"),
+							}
+						});
+					}
+					Err(e) => warn!("iroh incoming rejected: {e}"),
+				}
+			}
+		});
+
+		*self.endpoint.lock().expect("not poisoned") = Some(ep);
+
+		Ok(agent_id)
+	}
+}
addedremowt/crates/remowt-endpoints/src/lib.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-endpoints/src/lib.rs
@@ -0,0 +1,7 @@
+pub mod forward;
+pub mod fs;
+pub mod iroh_tunnel;
+pub mod nix_daemon;
+pub mod pty;
+pub mod subprocess;
+pub mod systemd;
addedremowt/crates/remowt-endpoints/src/nix_daemon.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-endpoints/src/nix_daemon.rs
@@ -0,0 +1,59 @@
+use std::process::Stdio;
+use std::sync::Arc;
+
+use bifrostlink::declarative::endpoints;
+use bifrostlink::Config;
+use remowt_link_shared::iroh_tunnel::{TunnelAddr, TunnelDialer};
+use serde::{Deserialize, Serialize};
+use std::result::Result;
+use tokio::process::Command;
+
+#[derive(Clone)]
+pub struct NixDaemon {
+	dialer: Arc<TunnelDialer>,
+}
+
+impl NixDaemon {
+	pub fn new(dialer: Arc<TunnelDialer>) -> Self {
+		Self { dialer }
+	}
+}
+
+#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
+pub enum Error {
+	#[error("nix daemon unavailable: {0}")]
+	DaemonUnavailable(String),
+	#[error("tunnel socket unavailable: {0}")]
+	Tunnel(String),
+}
+
+#[endpoints(ns = 4)]
+impl NixDaemon {
+	#[endpoints(id = 2)]
+	async fn serve_store(&self, store: String, tunnel: TunnelAddr) -> Result<(), Error> {
+		let mut child = Command::new("nix-daemon")
+			.arg("--stdio")
+			.arg("--store")
+			.arg(&store)
+			.stdin(Stdio::piped())
+			.stdout(Stdio::piped())
+			.spawn()
+			.map_err(|e| Error::DaemonUnavailable(e.to_string()))?;
+		let tunnel = self
+			.dialer
+			.connect_tunnel(&tunnel)
+			.await
+			.map_err(|e| Error::Tunnel(e.to_string()))?;
+		let mut stdin = child.stdin.take().expect("piped");
+		let mut stdout = child.stdout.take().expect("piped");
+		tokio::spawn(async move {
+			let (mut tr, mut tw) = tokio::io::split(tunnel);
+			let _ = tokio::join!(
+				tokio::io::copy(&mut tr, &mut stdin),
+				tokio::io::copy(&mut stdout, &mut tw),
+			);
+			let _ = child.wait().await;
+		});
+		Ok(())
+	}
+}
addedremowt/crates/remowt-endpoints/src/pty.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-endpoints/src/pty.rs
@@ -0,0 +1,260 @@
+use std::collections::HashMap;
+use std::io;
+use std::os::fd::{AsRawFd, OwnedFd};
+use std::pin::Pin;
+use std::process::Stdio;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex};
+use std::task::{Context, Poll};
+
+use bifrostlink::declarative::endpoints;
+use bifrostlink::Config;
+use nix::libc;
+use nix::pty::{openpty, OpenptyResult, Winsize};
+use remowt_link_shared::iroh_tunnel::{TunnelAddr, TunnelDialer};
+use serde::{Deserialize, Serialize};
+use tokio::io::unix::AsyncFd;
+use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
+use tracing::{debug, info, warn};
+
+pub type ShellId = u64;
+
+#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
+pub enum Error {
+	#[error("openpty failed: {0}")]
+	Open(String),
+	#[error("failed to spawn shell: {0}")]
+	Spawn(String),
+	#[error("failed to connect to forwarded socket: {0}")]
+	Connect(String),
+	#[error("no shell with that id")]
+	NoSuchShell,
+	#[error("resize failed: {0}")]
+	Resize(String),
+	#[error("io error: {0}")]
+	Io(String),
+}
+
+impl From<io::Error> for Error {
+	fn from(e: io::Error) -> Self {
+		Error::Io(e.to_string())
+	}
+}
+
+#[derive(Clone)]
+pub struct Pty {
+	shells: Arc<Mutex<HashMap<ShellId, OwnedFd>>>,
+	next_id: Arc<AtomicU64>,
+	dialer: Arc<TunnelDialer>,
+}
+
+impl Pty {
+	pub fn new(dialer: Arc<TunnelDialer>) -> Self {
+		Self {
+			shells: Default::default(),
+			next_id: Default::default(),
+			dialer,
+		}
+	}
+}
+
+#[endpoints(ns = 7)]
+impl Pty {
+	#[endpoints(id = 1)]
+	async fn open_shell(
+		&self,
+		tunnel: TunnelAddr,
+		term: String,
+		cols: u16,
+		rows: u16,
+	) -> Result<ShellId, Error> {
+		let ws = Winsize {
+			ws_row: rows,
+			ws_col: cols,
+			ws_xpixel: 0,
+			ws_ypixel: 0,
+		};
+		let OpenptyResult { master, slave } =
+			openpty(Some(&ws), None).map_err(|e| Error::Open(e.to_string()))?;
+
+		let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned());
+
+		let slave_in = slave.try_clone()?;
+		let slave_out = slave.try_clone()?;
+		let slave_err = slave;
+
+		let mut cmd = tokio::process::Command::new(&shell);
+		cmd.env("TERM", &term);
+		if let Ok(home) = std::env::var("HOME") {
+			cmd.current_dir(home);
+		}
+		cmd.stdin(Stdio::from(slave_in));
+		cmd.stdout(Stdio::from(slave_out));
+		cmd.stderr(Stdio::from(slave_err));
+		// SAFETY: only async-signal-safe calls (setsid, ioctl) before exec.
+		unsafe {
+			cmd.pre_exec(|| {
+				nix::unistd::setsid().map_err(|e| io::Error::from_raw_os_error(e as i32))?;
+				if libc::ioctl(0, libc::TIOCSCTTY as _, 0) < 0 {
+					return Err(io::Error::last_os_error());
+				}
+				Ok(())
+			});
+		}
+
+		let mut child = cmd.spawn().map_err(|e| Error::Spawn(e.to_string()))?;
+
+		let resize_fd = master.try_clone()?;
+		let id = self.next_id.fetch_add(1, Ordering::Relaxed);
+		self.shells
+			.lock()
+			.expect("not poisoned")
+			.insert(id, resize_fd);
+
+		let sock = match self.dialer.connect_tunnel(&tunnel).await {
+			Ok(s) => s,
+			Err(e) => {
+				self.shells.lock().expect("not poisoned").remove(&id);
+				let _ = child.kill().await;
+				return Err(Error::Connect(e.to_string()));
+			}
+		};
+		let pty = AsyncPty::new(master)?;
+
+		debug!(id, shell, "shell opened");
+		let shells = self.shells.clone();
+		tokio::spawn(async move {
+			let mut pty = pty;
+			let mut sock = sock;
+			if let Err(e) = tokio::io::copy_bidirectional(&mut pty, &mut sock).await {
+				warn!(id, "shell pump ended: {e}");
+			}
+			let _ = child.kill().await;
+			shells.lock().expect("not poisoned").remove(&id);
+			info!(id, "shell closed");
+		});
+
+		Ok(id)
+	}
+
+	#[endpoints(id = 2)]
+	async fn resize(&self, id: ShellId, cols: u16, rows: u16) -> Result<(), Error> {
+		let ws = libc::winsize {
+			ws_row: rows,
+			ws_col: cols,
+			ws_xpixel: 0,
+			ws_ypixel: 0,
+		};
+		let shells = self.shells.lock().expect("not poisoned");
+		let fd = shells.get(&id).ok_or(Error::NoSuchShell)?;
+		// SAFETY: `fd` is a live PTY master
+		let rc = unsafe { libc::ioctl(fd.as_raw_fd(), libc::TIOCSWINSZ as _, &ws) };
+		if rc < 0 {
+			return Err(Error::Resize(io::Error::last_os_error().to_string()));
+		}
+		Ok(())
+	}
+}
+
+struct AsyncPty {
+	fd: AsyncFd<OwnedFd>,
+}
+
+impl AsyncPty {
+	fn new(fd: OwnedFd) -> io::Result<Self> {
+		let raw = fd.as_raw_fd();
+		// SAFETY: standard F_GETFL/F_SETFL round-trip on a valid fd.
+		unsafe {
+			let flags = libc::fcntl(raw, libc::F_GETFL);
+			if flags < 0 {
+				return Err(io::Error::last_os_error());
+			}
+			if libc::fcntl(raw, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
+				return Err(io::Error::last_os_error());
+			}
+		}
+		Ok(Self {
+			fd: AsyncFd::new(fd)?,
+		})
+	}
+}
+
+impl AsyncRead for AsyncPty {
+	fn poll_read(
+		self: Pin<&mut Self>,
+		cx: &mut Context<'_>,
+		buf: &mut ReadBuf<'_>,
+	) -> Poll<io::Result<()>> {
+		let this = self.get_mut();
+		loop {
+			let mut guard = match this.fd.poll_read_ready(cx) {
+				Poll::Ready(Ok(g)) => g,
+				Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
+				Poll::Pending => return Poll::Pending,
+			};
+			let unfilled = buf.initialize_unfilled();
+			let res = guard.try_io(|inner| {
+				let fd = inner.get_ref().as_raw_fd();
+				// SAFETY: writing into `unfilled`'s own backing storage.
+				let n = unsafe { libc::read(fd, unfilled.as_mut_ptr().cast(), unfilled.len()) };
+				if n < 0 {
+					let err = io::Error::last_os_error();
+					if err.raw_os_error() == Some(libc::EIO) {
+						Ok(0)
+					} else {
+						Err(err)
+					}
+				} else {
+					Ok(n as usize)
+				}
+			});
+			match res {
+				Ok(Ok(n)) => {
+					buf.advance(n);
+					return Poll::Ready(Ok(()));
+				}
+				Ok(Err(e)) => return Poll::Ready(Err(e)),
+				Err(_would_block) => continue,
+			}
+		}
+	}
+}
+
+impl AsyncWrite for AsyncPty {
+	fn poll_write(
+		self: Pin<&mut Self>,
+		cx: &mut Context<'_>,
+		buf: &[u8],
+	) -> Poll<io::Result<usize>> {
+		let this = self.get_mut();
+		loop {
+			let mut guard = match this.fd.poll_write_ready(cx) {
+				Poll::Ready(Ok(g)) => g,
+				Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
+				Poll::Pending => return Poll::Pending,
+			};
+			let res = guard.try_io(|inner| {
+				let fd = inner.get_ref().as_raw_fd();
+				// SAFETY: reading from `buf` for `buf.len()` bytes.
+				let n = unsafe { libc::write(fd, buf.as_ptr().cast(), buf.len()) };
+				if n < 0 {
+					Err(io::Error::last_os_error())
+				} else {
+					Ok(n as usize)
+				}
+			});
+			match res {
+				Ok(r) => return Poll::Ready(r),
+				Err(_would_block) => continue,
+			}
+		}
+	}
+
+	fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
+		Poll::Ready(Ok(()))
+	}
+
+	fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
+		Poll::Ready(Ok(()))
+	}
+}
addedremowt/crates/remowt-endpoints/src/subprocess.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-endpoints/src/subprocess.rs
@@ -0,0 +1,293 @@
+use std::collections::HashMap;
+use std::io;
+use std::process::Stdio;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex};
+
+use bifrostlink::declarative::endpoints;
+use bifrostlink::Config;
+use camino::Utf8PathBuf;
+use nix::sys::signal::{self, Signal};
+use nix::unistd::Pid;
+use remowt_link_shared::iroh_tunnel::{TunnelAddr, TunnelDialer, TunnelStream};
+use serde::{Deserialize, Serialize};
+use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
+use tokio::process::{ChildStderr, ChildStdout, Command};
+use tokio::sync::{mpsc, watch};
+use tracing::{debug, warn};
+
+pub type ProcId = u64;
+
+#[derive(Serialize, Deserialize, Debug)]
+pub enum StdioSpec {
+	Null,
+	Tunnel(TunnelAddr),
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+pub enum StderrSpec {
+	Null,
+	Tunnel(TunnelAddr),
+	MergeWithStdout,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct SpawnSpec {
+	pub program: String,
+	pub args: Vec<String>,
+	pub env: Vec<(String, String)>,
+	pub env_clear: bool,
+	pub cwd: Option<Utf8PathBuf>,
+	pub stdin: StdioSpec,
+	pub stdout: StdioSpec,
+	pub stderr: StderrSpec,
+}
+
+#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
+pub enum Error {
+	#[error("spawn failed: {0}")]
+	Spawn(String),
+	#[error("connect to forwarded socket failed: {0}")]
+	Connect(String),
+	#[error("no process with that id")]
+	NoSuchProcess,
+	#[error("MergeWithStdout requires stdout=Socket")]
+	BadMerge,
+	#[error("invalid signal: {0}")]
+	BadSignal(i32),
+	#[error("kill failed: {0}")]
+	Kill(String),
+	#[error("io error: {0}")]
+	Io(String),
+}
+
+impl From<io::Error> for Error {
+	fn from(e: io::Error) -> Self {
+		Error::Io(e.to_string())
+	}
+}
+
+struct ChildState {
+	pid: u32,
+	exit_rx: watch::Receiver<Option<Option<i32>>>,
+}
+
+#[derive(Clone)]
+pub struct Subprocess {
+	children: Arc<Mutex<HashMap<ProcId, ChildState>>>,
+	next_id: Arc<AtomicU64>,
+	dialer: Arc<TunnelDialer>,
+}
+
+impl Subprocess {
+	pub fn new(dialer: Arc<TunnelDialer>) -> Self {
+		Self {
+			children: Default::default(),
+			next_id: Default::default(),
+			dialer,
+		}
+	}
+}
+
+#[endpoints(ns = 10)]
+impl Subprocess {
+	#[endpoints(id = 1)]
+	async fn spawn(&self, spec: SpawnSpec) -> Result<ProcId, Error> {
+		let SpawnSpec {
+			program,
+			args,
+			env,
+			env_clear,
+			cwd,
+			stdin,
+			stdout,
+			stderr,
+		} = spec;
+
+		if matches!(stderr, StderrSpec::MergeWithStdout) && !matches!(stdout, StdioSpec::Tunnel(_))
+		{
+			return Err(Error::BadMerge);
+		}
+
+		let mut cmd = Command::new(&program);
+		cmd.args(&args);
+		if env_clear {
+			cmd.env_clear();
+		}
+		for (k, v) in &env {
+			cmd.env(k, v);
+		}
+		if let Some(cwd) = &cwd {
+			cmd.current_dir(cwd);
+		}
+		cmd.stdin(match &stdin {
+			StdioSpec::Tunnel(_) => Stdio::piped(),
+			StdioSpec::Null => Stdio::null(),
+		});
+		cmd.stdout(match &stdout {
+			StdioSpec::Tunnel(_) => Stdio::piped(),
+			StdioSpec::Null => Stdio::null(),
+		});
+		cmd.stderr(match &stderr {
+			StderrSpec::Tunnel(_) | StderrSpec::MergeWithStdout => Stdio::piped(),
+			StderrSpec::Null => Stdio::null(),
+		});
+		cmd.kill_on_drop(false);
+
+		let mut child = cmd.spawn().map_err(|e| Error::Spawn(e.to_string()))?;
+		let pid = child
+			.id()
+			.ok_or_else(|| Error::Spawn("child exited before pid available".to_owned()))?;
+
+		if let StdioSpec::Tunnel(addr) = &stdin {
+			let sock = self
+				.dialer
+				.connect_tunnel(addr)
+				.await
+				.map_err(|e| Error::Connect(e.to_string()))?;
+			let mut stdin_w = child.stdin.take().expect("piped");
+			tokio::spawn(async move {
+				let (mut sr, _) = tokio::io::split(sock);
+				let _ = tokio::io::copy(&mut sr, &mut stdin_w).await;
+				let _ = stdin_w.shutdown().await;
+			});
+		}
+
+		let stdout_handle = child.stdout.take();
+		let stderr_handle = child.stderr.take();
+
+		match (&stdout, &stderr, stdout_handle, stderr_handle) {
+			(StdioSpec::Tunnel(out_addr), StderrSpec::MergeWithStdout, Some(out), Some(err)) => {
+				let sock = self
+					.dialer
+					.connect_tunnel(out_addr)
+					.await
+					.map_err(|e| Error::Connect(e.to_string()))?;
+				tokio::spawn(merge_to_sock(out, err, sock));
+			}
+			(StdioSpec::Tunnel(out_addr), _, Some(out), err_opt) => {
+				let sock = self
+					.dialer
+					.connect_tunnel(out_addr)
+					.await
+					.map_err(|e| Error::Connect(e.to_string()))?;
+				tokio::spawn(pump_to_sock(out, sock));
+				if let (StderrSpec::Tunnel(err_addr), Some(err)) = (&stderr, err_opt) {
+					let err_sock = self
+						.dialer
+						.connect_tunnel(err_addr)
+						.await
+						.map_err(|e| Error::Connect(e.to_string()))?;
+					tokio::spawn(pump_to_sock(err, err_sock));
+				}
+			}
+			(StdioSpec::Null, StderrSpec::Tunnel(err_addr), _, Some(err)) => {
+				let sock = self
+					.dialer
+					.connect_tunnel(err_addr)
+					.await
+					.map_err(|e| Error::Connect(e.to_string()))?;
+				tokio::spawn(pump_to_sock(err, sock));
+			}
+			_ => {}
+		}
+
+		let (exit_tx, exit_rx) = watch::channel(None);
+		let id = self.next_id.fetch_add(1, Ordering::Relaxed);
+		self.children
+			.lock()
+			.expect("not poisoned")
+			.insert(id, ChildState { pid, exit_rx });
+
+		debug!(id, pid, program, "subprocess spawned");
+		tokio::spawn(async move {
+			let result = child.wait().await;
+			let code = match result {
+				Ok(status) => status.code(),
+				Err(e) => {
+					warn!(id, "child.wait failed: {e}");
+					None
+				}
+			};
+			let _ = exit_tx.send(Some(code));
+		});
+
+		Ok(id)
+	}
+
+	#[endpoints(id = 2)]
+	async fn wait(&self, id: ProcId) -> Result<Option<i32>, Error> {
+		let mut rx = {
+			let map = self.children.lock().expect("not poisoned");
+			let entry = map.get(&id).ok_or(Error::NoSuchProcess)?;
+			entry.exit_rx.clone()
+		};
+		rx.wait_for(|v| v.is_some())
+			.await
+			.map_err(|_| Error::Io("exit channel closed".to_owned()))?;
+		let code = rx.borrow().flatten();
+		self.children.lock().expect("not poisoned").remove(&id);
+		Ok(code)
+	}
+
+	#[endpoints(id = 3)]
+	async fn kill(&self, id: ProcId, signal: i32) -> Result<(), Error> {
+		let pid = {
+			let map = self.children.lock().expect("not poisoned");
+			let entry = map.get(&id).ok_or(Error::NoSuchProcess)?;
+			entry.pid
+		};
+		let sig = Signal::try_from(signal).map_err(|_| Error::BadSignal(signal))?;
+		signal::kill(Pid::from_raw(pid as i32), sig).map_err(|e| Error::Kill(e.to_string()))?;
+		Ok(())
+	}
+}
+
+async fn pump_to_sock<R>(mut from: R, sock: TunnelStream)
+where
+	R: tokio::io::AsyncRead + Unpin,
+{
+	let (_, mut sw) = tokio::io::split(sock);
+	let _ = tokio::io::copy(&mut from, &mut sw).await;
+	let _ = sw.shutdown().await;
+}
+
+async fn merge_to_sock(mut stdout: ChildStdout, mut stderr: ChildStderr, sock: TunnelStream) {
+	let (_, mut sw) = tokio::io::split(sock);
+	let (tx, mut rx) = mpsc::channel::<Vec<u8>>(64);
+	let tx_out = tx.clone();
+	let out_pump = tokio::spawn(async move {
+		let mut buf = vec![0u8; 4096];
+		loop {
+			match stdout.read(&mut buf).await {
+				Ok(0) | Err(_) => break,
+				Ok(n) => {
+					if tx_out.send(buf[..n].to_vec()).await.is_err() {
+						break;
+					}
+				}
+			}
+		}
+	});
+	let err_pump = tokio::spawn(async move {
+		let mut buf = vec![0u8; 4096];
+		loop {
+			match stderr.read(&mut buf).await {
+				Ok(0) | Err(_) => break,
+				Ok(n) => {
+					if tx.send(buf[..n].to_vec()).await.is_err() {
+						break;
+					}
+				}
+			}
+		}
+	});
+	while let Some(chunk) = rx.recv().await {
+		if sw.write_all(&chunk).await.is_err() {
+			break;
+		}
+	}
+	let _ = out_pump.await;
+	let _ = err_pump.await;
+	let _ = sw.shutdown().await;
+}
addedremowt/crates/remowt-endpoints/src/systemd.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-endpoints/src/systemd.rs
@@ -0,0 +1,54 @@
+use bifrostlink::declarative::endpoints;
+use bifrostlink::Config;
+use serde::{Deserialize, Serialize};
+use zbus::proxy;
+use zbus::zvariant::OwnedObjectPath;
+
+pub struct Systemd;
+
+#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
+pub enum Error {
+	#[error("systemd request failed: {0}")]
+	Failed(String),
+}
+
+#[proxy(
+	interface = "org.freedesktop.systemd1.Manager",
+	default_service = "org.freedesktop.systemd1",
+	default_path = "/org/freedesktop/systemd1"
+)]
+trait Manager {
+	fn start_unit(&self, name: &str, mode: &str) -> zbus::Result<OwnedObjectPath>;
+	fn stop_unit(&self, name: &str, mode: &str) -> zbus::Result<OwnedObjectPath>;
+}
+
+async fn manager() -> Result<ManagerProxy<'static>, Error> {
+	let conn = zbus::Connection::system()
+		.await
+		.map_err(|e| Error::Failed(e.to_string()))?;
+	ManagerProxy::new(&conn)
+		.await
+		.map_err(|e| Error::Failed(e.to_string()))
+}
+
+#[endpoints(ns = 5)]
+impl Systemd {
+	#[endpoints(id = 1)]
+	async fn start(&self, unit: String) -> Result<(), Error> {
+		manager()
+			.await?
+			.start_unit(&unit, "replace")
+			.await
+			.map_err(|e| Error::Failed(e.to_string()))?;
+		Ok(())
+	}
+	#[endpoints(id = 2)]
+	async fn stop(&self, unit: String) -> Result<(), Error> {
+		manager()
+			.await?
+			.stop_unit(&unit, "replace")
+			.await
+			.map_err(|e| Error::Failed(e.to_string()))?;
+		Ok(())
+	}
+}
addedremowt/crates/remowt-link-shared/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-link-shared/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "remowt-link-shared"
+description = "Shared bifrostlink endpoint wiring for remowt"
+version.workspace = true
+edition = "2021"
+license.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+bifrostlink.workspace = true
+bytes.workspace = true
+camino = { workspace = true }
+serde = { workspace = true, features = ["derive"] }
+serde_json.workspace = true
+thiserror.workspace = true
+tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "sync", "rt"] }
+tokio-util = { workspace = true, features = ["codec"] }
+tracing.workspace = true
+remowt-ui-prompt.workspace = true
+
+iroh.workspace = true
+iroh-base.workspace = true
+noq-udp.workspace = true
+n0-watcher.workspace = true
+futures.workspace = true
addedremowt/crates/remowt-link-shared/src/editor.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-link-shared/src/editor.rs
@@ -0,0 +1,45 @@
+use std::future::Future;
+
+use bifrostlink::declarative::endpoints;
+use bifrostlink::{Config, Rpc};
+use serde::{Deserialize, Serialize};
+
+#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
+pub enum Error {
+	#[error("editor failed: {0}")]
+	Failed(String),
+}
+
+pub trait EditorBackend: Send + Sync {
+	fn open_editor(&self, socket_path: String) -> impl Future<Output = Result<(), Error>> + Send;
+	fn expose_tcp(&self, addr: String) -> impl Future<Output = Result<u16, Error>> + Send;
+	fn expose_udp(&self, addr: String) -> impl Future<Output = Result<u16, Error>> + Send;
+}
+
+pub struct EditorEndpoints<E>(pub E);
+
+#[endpoints(ns = 8)]
+impl<E: EditorBackend + 'static> EditorEndpoints<E> {
+	#[endpoints(id = 1)]
+	async fn open_editor(&self, socket_path: String) -> Result<(), Error> {
+		self.0.open_editor(socket_path).await
+	}
+
+	#[endpoints(id = 2)]
+	async fn expose_tcp(&self, addr: String) -> Result<u16, Error> {
+		self.0.expose_tcp(addr).await
+	}
+
+	#[endpoints(id = 3)]
+	async fn expose_udp(&self, addr: String) -> Result<u16, Error> {
+		self.0.expose_udp(addr).await
+	}
+}
+
+pub fn serve_editor<E, C>(rpc: &mut Rpc<C>, editor: E)
+where
+	E: EditorBackend + Send + Sync + 'static,
+	C: Config,
+{
+	EditorEndpoints(editor).register_endpoints(rpc);
+}
addedremowt/crates/remowt-link-shared/src/iroh_tunnel.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-link-shared/src/iroh_tunnel.rs
@@ -0,0 +1,536 @@
+use std::collections::HashMap;
+use std::pin::Pin;
+use std::sync::{Arc, Mutex};
+use std::task::{Context, Poll};
+use std::time::Duration;
+use std::{fmt, io};
+
+use anyhow::Result;
+use bytes::{Bytes, BytesMut};
+use camino::Utf8PathBuf;
+use futures::{SinkExt as _, Stream};
+use iroh::endpoint::transports::{
+	CustomEndpoint, CustomSender, CustomTransport, RecvInfo, Transmit,
+};
+use iroh::endpoint::{presets, Connection, RecvStream, SendStream};
+use iroh::{Endpoint, EndpointId, SecretKey};
+use iroh_base::CustomAddr;
+use serde::{Deserialize, Serialize};
+use tokio::io::{AsyncRead, AsyncWrite, ReadBuf, ReadHalf, WriteHalf};
+use tokio::net::UnixStream;
+use tokio::sync::{mpsc, watch};
+use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
+use tracing::{debug, warn};
+
+// "rmwtssh"
+pub const REMOWT_SSH_TRANSPORT_ID: u64 = 0x_72_6d_77_74_73_73_68;
+
+pub const REMOWT_ALPN: &[u8] = b"remowt/tunnel/0";
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub enum TunnelAddr {
+	Unix(Utf8PathBuf),
+	Iroh { token: u64 },
+}
+
+pub fn ssh_custom_addr(id: EndpointId) -> CustomAddr {
+	CustomAddr::from((REMOWT_SSH_TRANSPORT_ID, &id.as_bytes()[..]))
+}
+
+pub async fn build_endpoint<S>(
+	secret: SecretKey,
+	stream: S,
+	remote: EndpointId,
+	accept: bool,
+) -> Result<Endpoint>
+where
+	S: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static,
+{
+	let local = secret.public();
+	let transport = Arc::new(SshTransport::new(stream, local, remote));
+	let mut builder = Endpoint::builder(presets::N0)
+		.secret_key(secret)
+		.add_custom_transport(transport);
+	if accept {
+		builder = builder.alpns(vec![REMOWT_ALPN.to_vec()]);
+	}
+	Ok(builder.bind().await?)
+}
+
+struct SshTransport {
+	local: CustomAddr,
+	remote: CustomAddr,
+	addrs: n0_watcher::Watchable<Vec<CustomAddr>>,
+	endpoint: Mutex<Option<SshEndpointParts>>,
+	sender: Arc<SshSender>,
+}
+
+struct SshEndpointParts {
+	reader: Pin<Box<dyn FramedDatagrams>>,
+}
+
+trait FramedDatagrams: Stream<Item = io::Result<BytesMut>> + Send + Sync {}
+impl<T: Stream<Item = io::Result<BytesMut>> + Send + Sync> FramedDatagrams for T {}
+
+impl fmt::Debug for SshTransport {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		f.debug_struct("SshTransport").finish_non_exhaustive()
+	}
+}
+
+impl SshTransport {
+	fn new<S>(stream: S, local: EndpointId, remote: EndpointId) -> Self
+	where
+		S: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static,
+	{
+		let (read, write): (ReadHalf<S>, WriteHalf<S>) = tokio::io::split(stream);
+		let reader = FramedRead::new(read, LengthDelimitedCodec::new());
+
+		let (tx, rx) = mpsc::unbounded_channel::<Bytes>();
+		tokio::spawn(writer_task(rx, write));
+
+		let local = ssh_custom_addr(local);
+		Self {
+			addrs: n0_watcher::Watchable::new(vec![local.clone()]),
+			local,
+			remote: ssh_custom_addr(remote),
+			endpoint: Mutex::new(Some(SshEndpointParts {
+				reader: Box::pin(reader),
+			})),
+			sender: Arc::new(SshSender { tx }),
+		}
+	}
+}
+
+async fn writer_task<W: AsyncWrite + Unpin>(mut rx: mpsc::UnboundedReceiver<Bytes>, write: W) {
+	let mut framed = FramedWrite::new(write, LengthDelimitedCodec::new());
+	while let Some(datagram) = rx.recv().await {
+		if let Err(e) = framed.send(datagram).await {
+			debug!("ssh transport writer ended: {e}");
+			break;
+		}
+	}
+}
+
+impl CustomTransport for SshTransport {
+	fn bind(&self) -> io::Result<Box<dyn CustomEndpoint>> {
+		let parts = self
+			.endpoint
+			.lock()
+			.expect("not poisoned")
+			.take()
+			.ok_or_else(|| io::Error::other("ssh transport already bound"))?;
+		Ok(Box::new(SshEndpoint {
+			local: self.local.clone(),
+			remote: self.remote.clone(),
+			addrs: self.addrs.clone(),
+			sender: self.sender.clone(),
+			reader: parts.reader,
+		}))
+	}
+}
+
+struct SshEndpoint {
+	local: CustomAddr,
+	remote: CustomAddr,
+	addrs: n0_watcher::Watchable<Vec<CustomAddr>>,
+	sender: Arc<SshSender>,
+	reader: Pin<Box<dyn FramedDatagrams>>,
+}
+
+impl fmt::Debug for SshEndpoint {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		f.debug_struct("SshEndpoint").finish_non_exhaustive()
+	}
+}
+
+impl CustomEndpoint for SshEndpoint {
+	fn watch_local_addrs(&self) -> n0_watcher::Direct<Vec<CustomAddr>> {
+		self.addrs.watch()
+	}
+
+	fn create_sender(&self) -> Arc<dyn CustomSender> {
+		self.sender.clone()
+	}
+
+	fn poll_recv(
+		&mut self,
+		cx: &mut Context,
+		bufs: &mut [io::IoSliceMut<'_>],
+		metas: &mut [noq_udp::RecvMeta],
+		recv_infos: &mut [RecvInfo],
+	) -> Poll<io::Result<usize>> {
+		assert_eq!(bufs.len(), metas.len());
+		assert_eq!(bufs.len(), recv_infos.len());
+		if bufs.is_empty() {
+			return Poll::Ready(Ok(0));
+		}
+		let mut count = 0;
+		while count < bufs.len() {
+			match self.reader.as_mut().poll_next(cx) {
+				Poll::Ready(Some(Ok(frame))) => {
+					let buf = &mut bufs[count];
+					if buf.len() < frame.len() {
+						warn!("ssh transport datagram {} > buf {}", frame.len(), buf.len());
+						if count > 0 {
+							break;
+						}
+						return Poll::Ready(Err(io::Error::other("datagram exceeds recv buffer")));
+					}
+					buf[..frame.len()].copy_from_slice(&frame);
+					metas[count].len = frame.len();
+					metas[count].stride = frame.len();
+					recv_infos[count] =
+						RecvInfo::new(self.remote.clone(), Some(self.local.clone()));
+					count += 1;
+				}
+				Poll::Ready(Some(Err(e))) => {
+					if count > 0 {
+						break;
+					}
+					return Poll::Ready(Err(e));
+				}
+				Poll::Ready(None) => {
+					if count > 0 {
+						break;
+					}
+					return Poll::Ready(Err(io::Error::other("ssh transport closed")));
+				}
+				Poll::Pending => {
+					if count > 0 {
+						break;
+					}
+					return Poll::Pending;
+				}
+			}
+		}
+		Poll::Ready(Ok(count))
+	}
+}
+
+#[derive(Debug)]
+struct SshSender {
+	tx: mpsc::UnboundedSender<Bytes>,
+}
+
+impl CustomSender for SshSender {
+	fn is_valid_send_addr(&self, addr: &CustomAddr) -> bool {
+		addr.id() == REMOWT_SSH_TRANSPORT_ID
+	}
+
+	fn poll_send(
+		&self,
+		_cx: &mut Context,
+		_dst: &CustomAddr,
+		_src: Option<&CustomAddr>,
+		transmit: &Transmit<'_>,
+	) -> Poll<io::Result<()>> {
+		let segment = transmit.segment_size.unwrap_or(transmit.contents.len());
+		if segment == 0 || transmit.contents.is_empty() {
+			return Poll::Ready(Ok(()));
+		}
+		for chunk in transmit.contents.chunks(segment) {
+			if self.tx.send(Bytes::copy_from_slice(chunk)).is_err() {
+				return Poll::Ready(Err(io::Error::other("ssh transport writer gone")));
+			}
+		}
+		Poll::Ready(Ok(()))
+	}
+}
+
+pub struct IrohBiStream {
+	send: SendStream,
+	recv: RecvStream,
+}
+
+impl IrohBiStream {
+	pub fn new(send: SendStream, recv: RecvStream) -> Self {
+		Self { send, recv }
+	}
+}
+
+impl AsyncRead for IrohBiStream {
+	fn poll_read(
+		self: Pin<&mut Self>,
+		cx: &mut Context<'_>,
+		buf: &mut ReadBuf<'_>,
+	) -> Poll<io::Result<()>> {
+		AsyncRead::poll_read(Pin::new(&mut self.get_mut().recv), cx, buf)
+	}
+}
+
+impl AsyncWrite for IrohBiStream {
+	fn poll_write(
+		self: Pin<&mut Self>,
+		cx: &mut Context<'_>,
+		buf: &[u8],
+	) -> Poll<io::Result<usize>> {
+		AsyncWrite::poll_write(Pin::new(&mut self.get_mut().send), cx, buf)
+	}
+	fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
+		AsyncWrite::poll_flush(Pin::new(&mut self.get_mut().send), cx)
+	}
+	fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
+		AsyncWrite::poll_shutdown(Pin::new(&mut self.get_mut().send), cx)
+	}
+}
+
+pub enum TunnelStream {
+	Unix(UnixStream),
+	Iroh(IrohBiStream),
+}
+
+impl AsyncRead for TunnelStream {
+	fn poll_read(
+		self: Pin<&mut Self>,
+		cx: &mut Context<'_>,
+		buf: &mut ReadBuf<'_>,
+	) -> Poll<io::Result<()>> {
+		match self.get_mut() {
+			TunnelStream::Unix(s) => Pin::new(s).poll_read(cx, buf),
+			TunnelStream::Iroh(s) => Pin::new(s).poll_read(cx, buf),
+		}
+	}
+}
+
+impl AsyncWrite for TunnelStream {
+	fn poll_write(
+		self: Pin<&mut Self>,
+		cx: &mut Context<'_>,
+		buf: &[u8],
+	) -> Poll<io::Result<usize>> {
+		match self.get_mut() {
+			TunnelStream::Unix(s) => Pin::new(s).poll_write(cx, buf),
+			TunnelStream::Iroh(s) => Pin::new(s).poll_write(cx, buf),
+		}
+	}
+	fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
+		match self.get_mut() {
+			TunnelStream::Unix(s) => Pin::new(s).poll_flush(cx),
+			TunnelStream::Iroh(s) => Pin::new(s).poll_flush(cx),
+		}
+	}
+	fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
+		match self.get_mut() {
+			TunnelStream::Unix(s) => Pin::new(s).poll_shutdown(cx),
+			TunnelStream::Iroh(s) => Pin::new(s).poll_shutdown(cx),
+		}
+	}
+}
+
+#[derive(Clone)]
+pub struct TunnelDialer {
+	conn: watch::Sender<Option<Connection>>,
+	router: Arc<Mutex<Option<Arc<DatagramRouter>>>>,
+}
+
+impl Default for TunnelDialer {
+	fn default() -> Self {
+		Self::new()
+	}
+}
+
+impl TunnelDialer {
+	pub fn new() -> Self {
+		let (conn, _) = watch::channel(None);
+		Self {
+			conn,
+			router: Arc::new(Mutex::new(None)),
+		}
+	}
+
+	pub fn set_conn(&self, conn: Connection) {
+		*self.router.lock().expect("lock") = Some(DatagramRouter::spawn(conn.clone()));
+		self.conn.send_replace(Some(conn));
+	}
+
+	pub fn router(&self) -> Option<Arc<DatagramRouter>> {
+		self.router.lock().expect("lock").clone()
+	}
+
+	pub async fn connect_tunnel(&self, addr: &TunnelAddr) -> io::Result<TunnelStream> {
+		match addr {
+			TunnelAddr::Unix(path) => Ok(TunnelStream::Unix(UnixStream::connect(path).await?)),
+			TunnelAddr::Iroh { token } => {
+				let mut rx = self.conn.subscribe();
+				let conn =
+					tokio::time::timeout(Duration::from_secs(5), rx.wait_for(|c| c.is_some()))
+						.await
+						.map_err(|_| io::Error::other("timed out waiting for iroh connection"))?
+						.map_err(|_| io::Error::other("iroh connection channel closed"))?
+						.clone()
+						.expect("is_some");
+				let (mut send, recv) = conn.open_bi().await.map_err(io::Error::other)?;
+				send.write_all(&token.to_be_bytes())
+					.await
+					.map_err(io::Error::other)?;
+				Ok(TunnelStream::Iroh(IrohBiStream::new(send, recv)))
+			}
+		}
+	}
+}
+
+const DGRAM_HEADER: usize = 16;
+
+type DatagramRoutes = Arc<Mutex<HashMap<u64, mpsc::UnboundedSender<(u64, Bytes)>>>>;
+
+pub struct DatagramRouter {
+	conn: Connection,
+	routes: DatagramRoutes,
+}
+
+impl DatagramRouter {
+	pub fn spawn(conn: Connection) -> Arc<Self> {
+		let routes: DatagramRoutes = Arc::new(Mutex::new(HashMap::new()));
+		let read_routes = routes.clone();
+		let read_conn = conn.clone();
+		tokio::spawn(async move {
+			loop {
+				match read_conn.read_datagram().await {
+					Ok(dg) if dg.len() >= DGRAM_HEADER => {
+						let session = u64::from_be_bytes(dg[0..8].try_into().expect("8 bytes"));
+						let sub = u64::from_be_bytes(dg[8..16].try_into().expect("8 bytes"));
+						let payload = dg.slice(DGRAM_HEADER..);
+						let tx = read_routes.lock().expect("lock").get(&session).cloned();
+						if let Some(tx) = tx {
+							let _ = tx.send((sub, payload));
+						}
+					}
+					Ok(_) => {}
+					Err(e) => {
+						debug!("datagram read loop ended: {e}");
+						break;
+					}
+				}
+			}
+		});
+		Arc::new(Self { conn, routes })
+	}
+
+	pub fn register(&self, session: u64) -> mpsc::UnboundedReceiver<(u64, Bytes)> {
+		let (tx, rx) = mpsc::unbounded_channel();
+		self.routes.lock().expect("lock").insert(session, tx);
+		rx
+	}
+
+	pub fn unregister(&self, session: u64) {
+		self.routes.lock().expect("lock").remove(&session);
+	}
+
+	pub fn send(&self, session: u64, sub: u64, payload: &[u8]) -> io::Result<()> {
+		let mut buf = BytesMut::with_capacity(DGRAM_HEADER + payload.len());
+		buf.extend_from_slice(&session.to_be_bytes());
+		buf.extend_from_slice(&sub.to_be_bytes());
+		buf.extend_from_slice(payload);
+		self.conn
+			.send_datagram(buf.freeze())
+			.map_err(io::Error::other)
+	}
+}
+
+#[cfg(test)]
+mod tests {
+	use super::*;
+	use iroh::endpoint::presets;
+	use iroh::{EndpointAddr, RelayMode, TransportAddr};
+
+	async fn endpoint<S>(secret: SecretKey, stream: S, remote: EndpointId, accept: bool) -> Endpoint
+	where
+		S: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static,
+	{
+		let local = secret.public();
+		let transport = Arc::new(SshTransport::new(stream, local, remote));
+		let mut builder = Endpoint::builder(presets::N0)
+			.secret_key(secret)
+			.relay_mode(RelayMode::Disabled)
+			.clear_ip_transports()
+			.add_custom_transport(transport);
+		if accept {
+			builder = builder.alpns(vec![REMOWT_ALPN.to_vec()]);
+		}
+		builder.bind().await.expect("bind")
+	}
+
+	#[tokio::test]
+	async fn echo_over_ssh_transport() {
+		let (client_pipe, agent_pipe) = tokio::io::duplex(256 * 1024);
+		let client_secret = SecretKey::generate();
+		let agent_secret = SecretKey::generate();
+		let client_id = client_secret.public();
+		let agent_id = agent_secret.public();
+
+		let client = endpoint(client_secret, client_pipe, agent_id, false).await;
+		let agent = endpoint(agent_secret, agent_pipe, client_id, true).await;
+
+		let server = tokio::spawn(async move {
+			let incoming = agent.accept().await.expect("incoming");
+			let conn = incoming
+				.accept()
+				.expect("accept")
+				.await
+				.expect("connecting");
+			assert_eq!(conn.remote_id(), client_id);
+			let (mut send, mut recv) = conn.accept_bi().await.expect("accept_bi");
+			let mut buf = [0u8; 5];
+			recv.read_exact(&mut buf).await.expect("read");
+			send.write_all(&buf).await.expect("write");
+			send.finish().expect("finish");
+			conn.closed().await;
+		});
+
+		let addr =
+			EndpointAddr::from_parts(agent_id, [TransportAddr::Custom(ssh_custom_addr(agent_id))]);
+		let conn = client.connect(addr, REMOWT_ALPN).await.expect("connect");
+		assert_eq!(conn.remote_id(), agent_id);
+		let (mut send, mut recv) = conn.open_bi().await.expect("open_bi");
+		send.write_all(b"hello").await.expect("write");
+		send.finish().expect("finish");
+		let mut buf = [0u8; 5];
+		recv.read_exact(&mut buf).await.expect("read");
+		assert_eq!(&buf, b"hello");
+
+		conn.close(0u32.into(), b"done");
+		let _ = server.await;
+	}
+
+	#[tokio::test]
+	async fn dgram_round_trip() {
+		let (client_pipe, agent_pipe) = tokio::io::duplex(256 * 1024);
+		let client_secret = SecretKey::generate();
+		let agent_secret = SecretKey::generate();
+		let client_id = client_secret.public();
+		let agent_id = agent_secret.public();
+
+		let client = endpoint(client_secret, client_pipe, agent_id, false).await;
+		let agent = endpoint(agent_secret, agent_pipe, client_id, true).await;
+
+		let server = tokio::spawn(async move {
+			let incoming = agent.accept().await.expect("incoming");
+			let conn = incoming
+				.accept()
+				.expect("accept")
+				.await
+				.expect("connecting");
+			let router = DatagramRouter::spawn(conn);
+			let mut rx = router.register(7);
+			if let Some((sub, payload)) = rx.recv().await {
+				router.send(7, sub, &payload).expect("send reply");
+			}
+			tokio::time::sleep(Duration::from_millis(200)).await;
+		});
+
+		let addr =
+			EndpointAddr::from_parts(agent_id, [TransportAddr::Custom(ssh_custom_addr(agent_id))]);
+		let conn = client.connect(addr, REMOWT_ALPN).await.expect("connect");
+		let router = DatagramRouter::spawn(conn);
+		let mut rx = router.register(7);
+		router.send(7, 42, b"ping").expect("send");
+		let (sub, payload) = tokio::time::timeout(Duration::from_secs(5), rx.recv())
+			.await
+			.expect("datagram timed out")
+			.expect("router closed");
+		assert_eq!(sub, 42);
+		assert_eq!(&payload[..], b"ping");
+		let _ = server.await;
+	}
+}
addedremowt/crates/remowt-link-shared/src/lib.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-link-shared/src/lib.rs
@@ -0,0 +1,89 @@
+use bifrostlink::error::{ErrorT, ListenerForYourRequestHasBeenDeadError, ResponseError};
+use bifrostlink::notification;
+use bifrostlink::packet::OpaquePacketWrapper;
+use bifrostlink::AddressT;
+use bytes::Bytes;
+use serde::de::DeserializeOwned;
+use serde::{Deserialize, Serialize};
+
+pub mod editor;
+pub mod iroh_tunnel;
+pub mod port;
+
+#[derive(Clone, Serialize, Hash, Eq, Debug, PartialEq, Deserialize)]
+pub enum Address {
+	User,
+	Agent,
+	AgentPrivileged,
+	Plugin(u16),
+}
+impl AddressT for Address {}
+
+pub mod plugin;
+
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+	#[error("listener is dead")]
+	ListenerDead,
+	#[error("response: {0}")]
+	Response(String),
+
+	#[error(transparent)]
+	Ui(#[from] remowt_ui_prompt::Error),
+}
+
+impl From<ListenerForYourRequestHasBeenDeadError> for Error {
+	fn from(_value: ListenerForYourRequestHasBeenDeadError) -> Self {
+		Self::ListenerDead
+	}
+}
+impl From<serde_json::Error> for Error {
+	fn from(_value: serde_json::Error) -> Self {
+		Self::ListenerDead
+	}
+}
+impl From<Error> for ResponseError {
+	fn from(val: Error) -> Self {
+		ResponseError(val.to_string())
+	}
+}
+impl From<ResponseError> for Error {
+	fn from(value: ResponseError) -> Self {
+		Self::Response(value.0)
+	}
+}
+impl ErrorT for Error {}
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct TestNotification {
+	pub value: u32,
+}
+notification!((0x0100) TestNotification);
+
+pub struct BifConfig;
+impl bifrostlink::Config for BifConfig {
+	type Address = Address;
+
+	type Error = Error;
+
+	type EncodedData = Vec<u8>;
+
+	fn decode_headers(
+		data_with_headers: Bytes,
+	) -> Result<(OpaquePacketWrapper<Self::Address>, Self::EncodedData), Self::Error> {
+		let (header, data): (OpaquePacketWrapper<Self::Address>, Vec<u8>) =
+			serde_json::from_slice(&data_with_headers)?;
+		Ok((header, data))
+	}
+
+	fn decode_data<T: DeserializeOwned>(data: Self::EncodedData) -> Result<T, Self::Error> {
+		let v: T = serde_json::from_slice(&data)?;
+		Ok(v)
+	}
+
+	fn encode_data<T: Serialize>(headers: OpaquePacketWrapper<Self::Address>, data: T) -> Bytes {
+		let data = serde_json::to_vec(&data).expect("serialization shouldn't fail");
+		let o = serde_json::to_vec(&(headers, data)).expect("serialization shouldn't fail");
+		o.into()
+	}
+}
addedremowt/crates/remowt-link-shared/src/plugin.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-link-shared/src/plugin.rs
@@ -0,0 +1,39 @@
+use std::future::Future;
+
+use bifrostlink::declarative::endpoints;
+use bifrostlink::Config;
+use serde::{Deserialize, Serialize};
+
+#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
+pub enum Error {
+	#[error("plugin name must be a bare file name")]
+	BadName,
+	#[error("spawning plugin failed: {0}")]
+	Spawn(String),
+	#[error("agent is shutting down")]
+	Gone,
+}
+
+pub trait PluginHost: Send + Sync {
+	fn load_plugin(&self, id: u16, name: String) -> impl Future<Output = Result<(), Error>> + Send;
+
+	fn load_plugin_path(
+		&self,
+		id: u16,
+		path: String,
+	) -> impl Future<Output = Result<(), Error>> + Send;
+}
+
+pub struct PluginEndpoints<H>(pub H);
+
+#[endpoints(ns = 9)]
+impl<H: PluginHost + 'static> PluginEndpoints<H> {
+	#[endpoints(id = 1)]
+	async fn load_plugin(&self, id: u16, name: String) -> Result<(), Error> {
+		self.0.load_plugin(id, name).await
+	}
+	#[endpoints(id = 2)]
+	async fn load_plugin_path(&self, id: u16, path: String) -> Result<(), Error> {
+		self.0.load_plugin_path(id, path).await
+	}
+}
addedremowt/crates/remowt-link-shared/src/port.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-link-shared/src/port.rs
@@ -0,0 +1,82 @@
+use std::io;
+
+use bifrostlink::Port;
+use bytes::{Bytes, BytesMut};
+use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _};
+use tokio::select;
+
+pub fn child_port<R, W>(mut reader: R, mut writer: W) -> Port
+where
+	R: AsyncRead + Unpin + Send + 'static,
+	W: AsyncWrite + Unpin + Send + 'static,
+{
+	Port::new(|mut rx, tx| async move {
+		let read_task = async move {
+			loop {
+				let len = match reader.read_u32().await {
+					Ok(len) => len,
+					Err(e) => {
+						log_read_end(&e);
+						break;
+					}
+				};
+				let mut buf = BytesMut::zeroed(len as usize);
+				if let Err(e) = reader.read_exact(&mut buf).await {
+					log_read_end(&e);
+					break;
+				}
+				if tx.send(buf.freeze()).is_err() {
+					break;
+				}
+			}
+		};
+		let write_task = async move {
+			while let Some(msg) = rx.recv().await {
+				if let Err(e) = write_frame(&mut writer, msg).await {
+					log_write_end(&e);
+					break;
+				}
+			}
+		};
+		select! {
+			_ = read_task => {},
+			_ = write_task => {},
+		}
+	})
+}
+
+fn log_read_end(e: &io::Error) {
+	if matches!(
+		e.kind(),
+		io::ErrorKind::UnexpectedEof
+			| io::ErrorKind::BrokenPipe
+			| io::ErrorKind::ConnectionReset
+			| io::ErrorKind::ConnectionAborted
+	) {
+		tracing::debug!("child read ended: {e}");
+	} else {
+		tracing::error!("child read failed: {e}");
+	}
+}
+
+fn log_write_end(e: &io::Error) {
+	if matches!(
+		e.kind(),
+		io::ErrorKind::BrokenPipe
+			| io::ErrorKind::ConnectionReset
+			| io::ErrorKind::ConnectionAborted
+	) {
+		tracing::debug!("child write ended: {e}");
+	} else {
+		tracing::error!("child write failed: {e}");
+	}
+}
+
+async fn write_frame<W: AsyncWrite + Unpin>(writer: &mut W, msg: Bytes) -> io::Result<()> {
+	let len = u32::try_from(msg.len())
+		.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "message larger than 4GB"))?;
+	writer.write_u32(len).await?;
+	writer.write_all(&msg).await?;
+	writer.flush().await?;
+	Ok(())
+}
addedremowt/crates/remowt-plugin/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-plugin/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "remowt-plugin"
+description = "Plugin host and protocol for remowt agents"
+version.workspace = true
+edition = "2021"
+license.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+bifrostlink.workspace = true
+bifrostlink-ports.workspace = true
+remowt-link-shared.workspace = true
+serde_json.workspace = true
+tokio = { workspace = true, features = [
+	"rt",
+	"net",
+	"io-std",
+	"io-util",
+	"macros",
+	"time",
+	"process",
+] }
+tracing-subscriber.workspace = true
addedremowt/crates/remowt-plugin/src/host.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-plugin/src/host.rs
@@ -0,0 +1,70 @@
+use std::ffi::OsStr;
+use std::process::Stdio;
+use std::sync::Mutex;
+
+use bifrostlink::{Rpc, Rtt, WeakRpc};
+use tokio::process::{Child, Command};
+
+use remowt_link_shared::plugin::{Error, PluginEndpoints, PluginHost};
+use remowt_link_shared::port::child_port;
+use remowt_link_shared::{Address, BifConfig};
+
+pub fn serve(rpc: &mut Rpc<BifConfig>) {
+	let host = Host {
+		me: rpc.me(),
+		rpc: rpc.clone().downgrade(),
+		children: Mutex::new(Vec::new()),
+	};
+	PluginEndpoints(host).register_endpoints(rpc);
+}
+
+struct Host {
+	me: Address,
+	rpc: WeakRpc<BifConfig>,
+	children: Mutex<Vec<Child>>,
+}
+
+impl Host {
+	fn spawn(&self, id: u16, path: impl AsRef<OsStr>) -> Result<(), Error> {
+		let rpc = self.rpc.clone().upgrade().ok_or(Error::Gone)?;
+
+		let mut child = Command::new(path)
+			.arg(id.to_string())
+			.arg(serde_json::to_string(&self.me).expect("address serializes"))
+			.stdin(Stdio::piped())
+			.stdout(Stdio::piped())
+			.kill_on_drop(true)
+			.spawn()
+			.map_err(|e| Error::Spawn(e.to_string()))?;
+		let stdin = child.stdin.take().expect("stdin piped");
+		let stdout = child.stdout.take().expect("stdout piped");
+
+		let addr = Address::Plugin(id);
+		rpc.add_direct(addr, child_port(stdout, stdin), Rtt(0));
+		self.children.lock().expect("not poisoned").push(child);
+		Ok(())
+	}
+}
+
+impl PluginHost for Host {
+	async fn load_plugin(&self, id: u16, name: String) -> Result<(), Error> {
+		// TODO: Right now loads plugin next to the binary...
+		// But with our CA addressed schema, the plugins should be located in content-addressed subdir...
+		// Maybe it should just be scrapped in favor of load_plugin_path.
+		if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\0']) {
+			return Err(Error::BadName);
+		}
+		let exe = std::env::current_exe().map_err(|e| Error::Spawn(e.to_string()))?;
+		let dir = exe
+			.parent()
+			.ok_or_else(|| Error::Spawn("primary agent has no parent directory".to_owned()))?;
+		self.spawn(id, dir.join(&name))
+	}
+
+	async fn load_plugin_path(&self, id: u16, path: String) -> Result<(), Error> {
+		if path.is_empty() || path.contains('\0') {
+			return Err(Error::BadName);
+		}
+		self.spawn(id, path)
+	}
+}
addedremowt/crates/remowt-plugin/src/lib.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-plugin/src/lib.rs
@@ -0,0 +1,46 @@
+use std::future::pending;
+
+use anyhow::Result;
+use bifrostlink::{Rpc, Rtt};
+use bifrostlink_ports::stdio::from_stdio;
+use tokio::runtime::Builder;
+
+pub mod host;
+
+pub use bifrostlink;
+pub use remowt_link_shared::{self, Address, BifConfig};
+
+pub fn plugin_index() -> Result<u16> {
+	let arg = std::env::args()
+		.nth(1)
+		.ok_or_else(|| anyhow::anyhow!("missing plugin index argument"))?;
+	arg.parse()
+		.map_err(|e| anyhow::anyhow!("invalid plugin index {arg:?}: {e}"))
+}
+
+pub fn host_address() -> Result<Address> {
+	let arg = std::env::args()
+		.nth(2)
+		.ok_or_else(|| anyhow::anyhow!("missing host address argument"))?;
+	serde_json::from_str(&arg).map_err(|e| anyhow::anyhow!("invalid host address {arg:?}: {e}"))
+}
+
+pub fn run<F>(register: F) -> Result<()>
+where
+	F: FnOnce(&mut Rpc<BifConfig>),
+{
+	tracing_subscriber::fmt()
+		.with_writer(std::io::stderr)
+		.init();
+
+	let index = plugin_index()?;
+	let host = host_address()?;
+	let runtime = Builder::new_current_thread().enable_all().build()?;
+	runtime.block_on(async move {
+		let mut rpc = Rpc::<BifConfig>::new(Address::Plugin(index));
+		rpc.add_direct(host, from_stdio(), Rtt(0));
+		register(&mut rpc);
+		let _rpc = rpc;
+		pending::<Result<()>>().await
+	})
+}
addedremowt/crates/remowt-ui-prompt/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-ui-prompt/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "remowt-ui-prompt"
+description = "Interactive UI prompt endpoint for remowt (D-Bus)"
+version.workspace = true
+edition = "2021"
+license.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+bifrostlink.workspace = true
+bifrostlink-macros.workspace = true
+serde.workspace = true
+thiserror.workspace = true
+tokio = { workspace = true, features = ["io-util", "macros", "process", "rt"] }
+tracing.workspace = true
+zbus = { workspace = true, optional = true }
+
+[features]
+default = ["dbus"]
+dbus = ["dep:zbus"]
addedremowt/crates/remowt-ui-prompt/src/auto.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-ui-prompt/src/auto.rs
@@ -0,0 +1,83 @@
+use anyhow::bail;
+use tracing::debug;
+use zbus::fdo::DBusProxy;
+use zbus::names::BusName;
+
+use crate::dbus::{DbusPrompterProxy, BUS_NAME, PROMPTER_PATH};
+use crate::rofi::RofiPrompter;
+use crate::{Prompter, Result, Source};
+
+pub struct AutoPrompter {
+	dbus: Option<DbusPrompterProxy<'static>>,
+	fallback: RofiPrompter,
+}
+
+impl AutoPrompter {
+	pub async fn new() -> Self {
+		let dbus = match Self::try_dbus().await {
+			Ok(p) => Some(p),
+			Err(e) => {
+				debug!("dbus prompter unavailable, falling back to rofi: {e}");
+				None
+			}
+		};
+		Self {
+			dbus,
+			fallback: RofiPrompter,
+		}
+	}
+
+	async fn try_dbus() -> anyhow::Result<DbusPrompterProxy<'static>> {
+		let conn = zbus::Connection::session().await?;
+		let dbus = DBusProxy::new(&conn).await?;
+		let name = BusName::try_from(BUS_NAME)?;
+		if !dbus.name_has_owner(name).await? {
+			bail!("{BUS_NAME} not registered on session bus");
+		}
+		let proxy = DbusPrompterProxy::builder(&conn)
+			.destination(BUS_NAME)?
+			.path(PROMPTER_PATH)?
+			.build()
+			.await?;
+		Ok(proxy)
+	}
+}
+
+impl Prompter for AutoPrompter {
+	async fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> Result<u32> {
+		if let Some(dbus) = &self.dbus {
+			return Prompter::prompt_enum(dbus, prompt, description, variants, source).await;
+		}
+		self.fallback
+			.prompt_enum(prompt, description, variants, source)
+			.await
+	}
+
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<String> {
+		if let Some(dbus) = &self.dbus {
+			return Prompter::prompt_text(dbus, echo, prompt, description, source).await;
+		}
+		self.fallback
+			.prompt_text(echo, prompt, description, source)
+			.await
+	}
+
+	async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
+		if let Some(dbus) = &self.dbus {
+			return Prompter::display_text(dbus, error, description, source).await;
+		}
+		self.fallback.display_text(error, description, source).await
+	}
+}
addedremowt/crates/remowt-ui-prompt/src/bifrost.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-ui-prompt/src/bifrost.rs
@@ -0,0 +1,109 @@
+use bifrostlink::{Config, Rpc};
+use bifrostlink_macros::endpoints;
+use serde::{Deserialize, Serialize};
+
+use crate::{Error, Prompter, Source};
+
+pub struct PromptEndpoints<P>(pub P);
+
+#[endpoints(ns = 2)]
+impl<P> PromptEndpoints<P>
+where
+	P: Prompter + Send + Sync + 'static,
+{
+	#[endpoints(id = 1, cancel)]
+	async fn prompt_enum(
+		&self,
+		prompt: String,
+		description: String,
+		variants: Vec<String>,
+		source: Vec<Source>,
+	) -> Result<u32, Error> {
+		let variants: Vec<&str> = variants.iter().map(|v| v.as_str()).collect();
+		self.0
+			.prompt_enum(&prompt, &description, &variants, &source)
+			.await
+	}
+
+	#[endpoints(id = 2, cancel)]
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: String,
+		description: String,
+		source: Vec<Source>,
+	) -> Result<String, Error> {
+		self.0
+			.prompt_text(echo, &prompt, &description, &source)
+			.await
+	}
+
+	#[endpoints(id = 3, cancel)]
+	async fn display_text(
+		&self,
+		error: bool,
+		description: String,
+		source: Vec<Source>,
+	) -> Result<(), Error> {
+		self.0.display_text(error, &description, &source).await
+	}
+}
+
+impl<C: Config> Prompter for PromptEndpointsClient<C>
+where
+	Error: ToString,
+{
+	async fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> crate::Result<u32> {
+		self.prompt_enum(
+			prompt.to_owned(),
+			description.to_owned(),
+			variants.iter().map(|v| (*v).to_owned()).collect(),
+			source.to_vec(),
+		)
+		.await
+		.map_err(|e| Error::Remote(e.to_string()))?
+	}
+
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> crate::Result<String> {
+		self.prompt_text(
+			echo,
+			prompt.to_owned(),
+			description.to_owned(),
+			source.to_vec(),
+		)
+		.await
+		.map_err(|e| Error::Remote(e.to_string()))?
+	}
+
+	async fn display_text(
+		&self,
+		error: bool,
+		description: &str,
+		source: &[Source],
+	) -> crate::Result<()> {
+		self.display_text(error, description.to_owned(), source.to_vec())
+			.await
+			.map_err(|e| Error::Remote(e.to_string()))?
+	}
+}
+
+pub fn serve_prompts<P, C>(rpc: &mut Rpc<C>, prompt: P)
+where
+	P: Prompter + Send + Sync + 'static,
+	C: Config,
+	C::Error: From<Error>,
+{
+	PromptEndpoints(prompt).register_endpoints(rpc);
+}
addedremowt/crates/remowt-ui-prompt/src/dbus.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-ui-prompt/src/dbus.rs
@@ -0,0 +1,143 @@
+use zbus::interface;
+use zbus::{fdo, proxy};
+
+use crate::Source;
+use crate::{BlockingPrompter, Result};
+use crate::{Error, Prompter};
+
+pub const BUS_NAME: &str = "lach.RemowtAskpass";
+pub const PROMPTER_PATH: &str = "/lach/Askpass";
+
+pub struct DbusPrompterInterface<P>(pub P);
+
+#[interface(name = "lach.PolkitInputHandler")]
+impl<P: Prompter + Send + Sync + 'static> DbusPrompterInterface<P> {
+	async fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: Vec<String>,
+		source: Vec<Source>,
+	) -> fdo::Result<u32> {
+		let variants: Vec<&str> = variants.iter().map(|v| v.as_str()).collect();
+		Ok(self
+			.0
+			.prompt_enum(prompt, description, &variants, &source)
+			.await?)
+	}
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: Vec<Source>,
+	) -> fdo::Result<String> {
+		Ok(self
+			.0
+			.prompt_text(echo, prompt, description, &source)
+			.await?)
+	}
+	async fn display_text(
+		&self,
+		error: bool,
+		description: &str,
+		source: Vec<Source>,
+	) -> fdo::Result<()> {
+		Ok(self.0.display_text(error, description, &source).await?)
+	}
+}
+
+#[proxy(interface = "lach.PolkitInputHandler")]
+pub trait DbusPrompter {
+	async fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> fdo::Result<u32>;
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> fdo::Result<String>;
+	async fn display_text(
+		&self,
+		error: bool,
+		description: &str,
+		source: &[Source],
+	) -> fdo::Result<()>;
+}
+
+impl Prompter for DbusPrompterProxy<'_> {
+	async fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> Result<u32> {
+		Ok(self
+			.prompt_enum(prompt, description, variants, source)
+			.await?)
+	}
+
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<String> {
+		Ok(self.prompt_text(echo, prompt, description, source).await?)
+	}
+
+	async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
+		Ok(self.display_text(error, description, source).await?)
+	}
+}
+impl BlockingPrompter for DbusPrompterProxyBlocking<'_> {
+	fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> Result<u32> {
+		Ok(self.prompt_enum(prompt, description, variants, source)?)
+	}
+
+	fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<String> {
+		Ok(self.prompt_text(echo, prompt, description, source)?)
+	}
+
+	fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
+		Ok(self.display_text(error, description, source)?)
+	}
+}
+
+impl From<fdo::Error> for Error {
+	fn from(value: fdo::Error) -> Self {
+		if matches!(value, fdo::Error::NoReply(_)) {
+			return Self::Cancel;
+		}
+		Self::InputError(format!("{value}"))
+	}
+}
+impl From<Error> for fdo::Error {
+	fn from(value: Error) -> Self {
+		match value {
+			Error::Cancel => fdo::Error::NoReply("input was cancelled".to_owned()),
+			Error::Remote(e) => fdo::Error::NoReply(format!("remote error occured: {e}")),
+			Error::InputError(e) => fdo::Error::Failed(e),
+		}
+	}
+}
addedremowt/crates/remowt-ui-prompt/src/lib.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-ui-prompt/src/lib.rs
@@ -0,0 +1,202 @@
+use core::fmt;
+use std::borrow::Cow;
+use std::future::Future;
+use std::result;
+
+pub mod auto;
+pub mod bifrost;
+pub mod dbus;
+pub mod rofi;
+
+#[derive(thiserror::Error, Debug, serde::Serialize, serde::Deserialize)]
+pub enum Error {
+	#[error("user has cancelled input")]
+	Cancel,
+	#[error("input error: {0}")]
+	InputError(String),
+	#[error("unknown remote error: {0}")]
+	Remote(String),
+}
+
+pub type Result<T, E = Error> = result::Result<T, E>;
+
+#[cfg_attr(feature = "dbus", derive(zbus::zvariant::Type))]
+#[derive(serde::Serialize, serde::Deserialize, Clone)]
+pub struct Source(pub Cow<'static, str>);
+impl fmt::Display for Source {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		write!(f, "<u>{}</u>", self.0)
+	}
+}
+
+pub trait Prompter: Send + Sync {
+	fn prompt_radio(
+		&self,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<bool>> + Send {
+		let fut = self.prompt_enum(prompt, description, &["No", "Yes"], source);
+		async { fut.await.map(|v| v == 1) }
+	}
+	fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> impl Future<Output = Result<u32>> + Send;
+	fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<String>> + Send;
+	fn display_text(
+		&self,
+		error: bool,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<()>> + Send;
+}
+pub trait BlockingPrompter {
+	fn prompt_radio(&self, prompt: &str, description: &str, source: &[Source]) -> Result<bool> {
+		self.prompt_enum(prompt, description, &["No", "Yes"], source)
+			.map(|v| v == 1)
+	}
+	fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> Result<u32>;
+	fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<String>;
+	fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()>;
+}
+impl<P> Prompter for &P
+where
+	P: Prompter,
+{
+	fn prompt_radio(
+		&self,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<bool>> + Send {
+		(*self).prompt_radio(prompt, description, source)
+	}
+
+	fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> impl Future<Output = Result<u32>> + Send {
+		(*self).prompt_enum(prompt, description, variants, source)
+	}
+
+	fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<String>> + Send {
+		(*self).prompt_text(echo, prompt, description, source)
+	}
+
+	fn display_text(
+		&self,
+		error: bool,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<()>> + Send {
+		(*self).display_text(error, description, source)
+	}
+}
+
+pub struct PrependSourcePrompter<P> {
+	pub prompter: P,
+	pub source: Vec<Source>,
+	pub description: String,
+}
+impl<P> PrependSourcePrompter<P> {
+	fn source(&self, input: &[Source]) -> Vec<Source> {
+		let mut out = self.source.clone();
+		out.extend(input.iter().cloned());
+		out
+	}
+	fn description(&self, input: &str) -> String {
+		if self.description.is_empty() {
+			input.to_owned()
+		} else if input.is_empty() {
+			self.description.to_owned()
+		} else {
+			format!("{input}\n\n{}", self.description)
+		}
+	}
+}
+impl<P> Prompter for PrependSourcePrompter<P>
+where
+	P: Prompter + Sync,
+{
+	async fn prompt_radio(
+		&self,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<bool> {
+		self.prompter
+			.prompt_radio(prompt, &self.description(description), &self.source(source))
+			.await
+	}
+
+	async fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> Result<u32> {
+		self.prompter
+			.prompt_enum(
+				prompt,
+				&self.description(description),
+				variants,
+				&self.source(source),
+			)
+			.await
+	}
+
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<String> {
+		self.prompter
+			.prompt_text(
+				echo,
+				prompt,
+				&self.description(description),
+				&self.source(source),
+			)
+			.await
+	}
+
+	async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
+		self.prompter
+			.display_text(error, &self.description(description), &self.source(source))
+			.await
+	}
+}
addedremowt/crates/remowt-ui-prompt/src/rofi.rsdiffbeforeafterboth
--- /dev/null
+++ b/remowt/crates/remowt-ui-prompt/src/rofi.rs
@@ -0,0 +1,226 @@
+use std::process::Stdio;
+
+use tokio::io::AsyncWriteExt;
+use tokio::process::Command;
+use tracing::trace;
+
+use crate::{Error, Prompter, Result, Source};
+
+#[derive(Clone)]
+pub struct RofiPrompter;
+
+fn fixup_prompt(prompt: &str) -> &str {
+	// Rofi always appends such suffix
+	prompt.strip_suffix(": ").unwrap_or(prompt)
+}
+
+fn rofi_command() -> Command {
+	Command::new(option_env!("ROFI").unwrap_or("rofi"))
+}
+
+impl Prompter for RofiPrompter {
+	async fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> Result<u32> {
+		trace!("rofi radio");
+		let mut cmd = rofi_command();
+		let mesg = if source.is_empty() {
+			description.to_owned()
+		} else {
+			let mut out = format!("{description}\n\n<b>Requested on ",);
+			for (i, s) in source.iter().enumerate() {
+				if i != 0 {
+					out.push_str(" -> ");
+				}
+				out.push_str(&s.to_string());
+			}
+			out.push_str("</b>");
+			out
+		};
+		cmd.args([
+			"-dmenu",
+			"-mesg",
+			&mesg,
+			"-sync",
+			"-no-custom",
+			"-p",
+			fixup_prompt(prompt),
+			"-format",
+			"i",
+			"-markup-rows",
+		]);
+		cmd.stdin(Stdio::piped());
+		cmd.stdout(Stdio::piped());
+		cmd.kill_on_drop(true);
+		let mut child = cmd
+			.spawn()
+			.map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?;
+
+		let mut stdin = child.stdin.take().expect("stdin is piped");
+		for var in variants {
+			stdin
+				.write_all(var.replace('\n', " ").as_bytes())
+				.await
+				.map_err(|e| Error::InputError(format!("failed to write rofi variants: {e}")))?;
+			stdin
+				.write_all(b"\n")
+				.await
+				.map_err(|e| Error::InputError(format!("failed to write rofi variants: {e}")))?;
+		}
+		// write_all already flushes, just to be sure.
+		let _ = stdin.flush().await;
+		drop(stdin);
+
+		let out = child
+			.wait_with_output()
+			.await
+			.map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?;
+		match out.status.code() {
+			Some(0) => {}
+			Some(1) => return Err(Error::Cancel),
+			other => {
+				return Err(Error::InputError(format!(
+					"rofi exited with status {other:?}"
+				)));
+			}
+		}
+		let stdout = out
+			.stdout
+			.strip_suffix(b"\n")
+			.unwrap_or(&out.stdout)
+			.to_owned();
+
+		let id: u32 = String::from_utf8(stdout)
+			.map_err(|e| Error::InputError(format!("rofi produced invalid output: {e}")))?
+			.parse()
+			.map_err(|e| Error::InputError(format!("rofi produced invalid output: {e}")))?;
+		if id as usize >= variants.len() {
+			return Err(Error::InputError("invalid rofi response".to_owned()));
+		}
+
+		Ok(id)
+	}
+
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<String> {
+		trace!("rofi text");
+		let mut cmd = rofi_command();
+		let mesg = if source.is_empty() {
+			description.to_owned()
+		} else {
+			let mut out = format!("{description}\n\n<b>Requested on ",);
+			for (i, s) in source.iter().enumerate() {
+				if i != 0 {
+					out.push_str(" -> ");
+				}
+				out.push_str(&s.to_string());
+			}
+			out.push_str("</b>");
+			out
+		};
+		cmd.args(["-dmenu", "-mesg", &mesg, "-p", fixup_prompt(prompt)]);
+		if !echo {
+			cmd.arg("-password");
+		}
+		cmd.stdin(Stdio::null());
+		cmd.stdout(Stdio::piped());
+		cmd.kill_on_drop(true);
+		let child = cmd
+			.spawn()
+			.map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?;
+
+		let out = child
+			.wait_with_output()
+			.await
+			.map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?;
+		match out.status.code() {
+			Some(0) => {}
+			Some(1) => return Err(Error::Cancel),
+			other => {
+				return Err(Error::InputError(format!(
+					"rofi exited with status {other:?}"
+				)));
+			}
+		}
+		let stdout = out
+			.stdout
+			.strip_suffix(b"\n")
+			.unwrap_or(&out.stdout)
+			.to_owned();
+
+		Ok(String::from_utf8_lossy(&stdout).to_string())
+	}
+
+	async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
+		trace!("rofi display");
+		let mut cmd = rofi_command();
+		let mut mesg = if source.is_empty() {
+			description.to_owned()
+		} else {
+			let mut out = format!("{description}\n\n<b>Coming from ",);
+			for s in source.iter() {
+				out.push_str(&s.to_string());
+			}
+			out.push_str("</b>");
+			out
+		};
+		if error {
+			mesg.insert_str(0, "<span color=\"red\">");
+			mesg.push_str("</span>");
+		}
+		cmd.args(["-e", &mesg, "-markup"]);
+		cmd.stdin(Stdio::null());
+		cmd.stdout(Stdio::null());
+		cmd.kill_on_drop(true);
+		let mut child = cmd
+			.spawn()
+			.map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?;
+
+		child
+			.wait()
+			.await
+			.map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?;
+
+		Ok(())
+	}
+}
+
+#[cfg(test)]
+mod tests {
+	use std::borrow::Cow;
+
+	use crate::rofi::RofiPrompter;
+	use crate::{PrependSourcePrompter, Prompter as _, Source};
+
+	// #[tokio::test]
+	#[tokio::test]
+	#[ignore = "interactive"]
+	async fn test() {
+		let prompter = PrependSourcePrompter {
+			prompter: RofiPrompter,
+			description: "test".to_owned(),
+			source: vec![Source(Cow::Borrowed("ssh"))],
+		};
+		prompter
+			.prompt_radio("Enable", "Polkit needs access", &[])
+			.await
+			.expect("rofi");
+		prompter
+			.prompt_text(false, "Password", "Polkit needs access", &[])
+			.await
+			.expect("rofi");
+		prompter
+			.display_text(true, "Polkit needs access", &[])
+			.await
+			.expect("rofi");
+	}
+}