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
--- a/crates/fleet-base/src/host.rs
+++ b/crates/fleet-base/src/host.rs
@@ -425,9 +425,9 @@
 			warn!("failed to sign store paths: {e}");
 		}
 		let store = self.nix_store().await?;
+		let eval_store = eval_store();
 		{
 			let path = path.clone();
-			let eval_store = eval_store();
 			spawn_blocking(move || eval_store.copy_to(&store, path.as_ref()))
 				.await
 				.expect("copy_to panicked")
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

no content

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");
+	}
+}