diff --git a/.gitignore b/.gitignore index b64cf0d..c168bd1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ data/instruments/ data/wechat-contexts.json .idea .pi -__pycache__/ \ No newline at end of file +__pycache__/ +.workflow diff --git a/Cargo.lock b/Cargo.lock index 539e85b..d941df5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,6 +56,12 @@ dependencies = [ "memchr", ] +[[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" @@ -90,11 +96,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" dependencies = [ "include_dir", - "itertools", + "itertools 0.10.5", "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -125,7 +131,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -136,7 +142,16 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", ] [[package]] @@ -151,12 +166,91 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "1.3.2" @@ -212,6 +306,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.60" @@ -242,7 +345,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -325,7 +428,30 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", ] [[package]] @@ -384,7 +510,7 @@ dependencies = [ "clap", "criterion-plot", "is-terminal", - "itertools", + "itertools 0.10.5", "num-traits", "once_cell", "oorandom", @@ -405,7 +531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ "cast", - "itertools", + "itertools 0.10.5", ] [[package]] @@ -442,6 +568,33 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "derive_more 2.1.1", + "document-features", + "mio 1.2.0", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -458,6 +611,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + [[package]] name = "darling" version = "0.23.0" @@ -478,7 +641,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -489,9 +652,21 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "deranged" version = "0.5.8" @@ -508,7 +683,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" dependencies = [ - "derive_more-impl", + "derive_more-impl 1.0.0", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl 2.1.1", ] [[package]] @@ -519,10 +703,23 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "unicode-xid", ] +[[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.117", +] + [[package]] name = "digest" version = "0.10.7" @@ -562,7 +759,16 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", ] [[package]] @@ -619,6 +825,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -631,12 +846,33 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.27" @@ -654,12 +890,36 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[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" + [[package]] name = "foreign-types" version = "0.3.2" @@ -749,7 +1009,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -802,6 +1062,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -810,8 +1082,8 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", - "rand_core", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -848,7 +1120,18 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -917,12 +1200,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -936,6 +1231,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1165,6 +1461,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inotify" version = "0.9.6" @@ -1194,6 +1499,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1230,6 +1548,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1248,6 +1575,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -1268,6 +1606,12 @@ dependencies = [ "libc", ] +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.0" @@ -1295,7 +1639,7 @@ dependencies = [ "bitflags 2.11.1", "libc", "plain", - "redox_syscall", + "redox_syscall 0.7.4", ] [[package]] @@ -1309,6 +1653,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "linkme" version = "0.3.36" @@ -1326,7 +1679,7 @@ checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1341,12 +1694,36 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lucarne" version = "0.4.3" @@ -1410,10 +1787,40 @@ dependencies = [ ] [[package]] -name = "lucarne-telegram" +name = "lucarne-remote" version = "0.4.3" dependencies = [ - "agent-sessions", + "async-trait", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "lucarne-rmux" +version = "0.4.3" +dependencies = [ + "bitflags 2.11.1", + "dirs", + "rmux-sdk", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "lucarne-telegram" +version = "0.4.3" +dependencies = [ + "agent-sessions", "async-trait", "base64", "dotenvy", @@ -1433,6 +1840,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "lucarne-termgw" +version = "0.4.3" +dependencies = [ + "async-trait", + "axum", + "dirs", + "futures", + "http-body-util", + "lucarne", + "lucarne-rmux", + "rand 0.8.6", + "serde", + "serde_json", + "subtle", + "tempfile", + "tokio", + "tokio-tungstenite", + "tower", + "tower-http", + "tracing", +] + [[package]] name = "lucarne-wechat" version = "0.4.3" @@ -1462,12 +1892,19 @@ name = "lucarned" version = "0.4.3" dependencies = [ "async-trait", + "axum", + "crossterm", "dotenvy", "lucarne", "lucarne-adapter", + "lucarne-remote", + "lucarne-rmux", "lucarne-telegram", + "lucarne-termgw", "lucarne-wechat", "lucarned-ctl", + "qrcode", + "ratatui", "reqwest", "serde", "serde_json", @@ -1490,6 +1927,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md-5" version = "0.10.6" @@ -1506,6 +1959,21 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1522,6 +1990,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "0.8.11" @@ -1541,6 +2015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1572,6 +2047,17 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", ] [[package]] @@ -1608,6 +2094,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1617,6 +2114,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "object" version = "0.37.3" @@ -1660,7 +2166,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1687,12 +2193,139 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -1710,7 +2343,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1759,6 +2392,12 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1774,6 +2413,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1781,7 +2429,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -1839,12 +2487,39 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.1" @@ -1853,7 +2528,45 @@ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -1862,6 +2575,91 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.11.1", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools 0.14.0", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rayon" version = "1.12.0" @@ -1891,6 +2689,15 @@ dependencies = [ "erasable", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "redox_syscall" version = "0.7.4" @@ -1928,7 +2735,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1968,6 +2775,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", "futures-util", "http", @@ -1985,28 +2793,92 @@ dependencies = [ "rustls-pki-types", "serde", "serde_json", - "serde_urlencoded", - "sync_wrapper", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "rmux-ipc" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fad9f6a295f8dd2e124d18284985d5307861cf66629552a2d2b56cac3a9fbdc" +dependencies = [ + "libc", + "rmux-os", + "rustix", + "tokio", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "rmux-os" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fca6ded314a09605ca551c385e2be3ac67af7751c16d48f6c3332fe7b738a09" +dependencies = [ + "libc", + "rmux-types", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "rmux-proto" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24732ca60f7a674e1c9fa5eb11aa7d70958db894c5216ab72bbeb0d8b71ccbcc" +dependencies = [ + "bincode", + "rmux-types", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "rmux-sdk" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "081a9824727a8e487beb6ca74d301be5e73acf71d1ff58d44f9ce8bfa1b485f4" +dependencies = [ + "libc", + "rmux-ipc", + "rmux-os", + "rmux-proto", + "rustix", + "serde", + "serde_json", "tokio", - "tokio-native-tls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", + "windows-sys 0.61.2", ] [[package]] -name = "rgb" -version = "0.8.53" +name = "rmux-types" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +checksum = "f6475eee45f7a1c6ac690634f8ac4dc9c3cc63dd0ba610b670f53e6972b510aa" dependencies = [ - "bytemuck", + "serde", ] [[package]] @@ -2023,6 +2895,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -2161,7 +3042,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2177,6 +3058,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -2185,7 +3077,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2228,7 +3120,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2244,6 +3136,28 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2259,6 +3173,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 1.2.0", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2269,6 +3204,12 @@ dependencies = [ "libc", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -2320,18 +3261,62 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "symlink" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -2360,7 +3345,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2404,7 +3389,7 @@ checksum = "84992abeed3ae42e8401b25d266d12bcba1def0abe59d22f6b9781167545f71e" dependencies = [ "aquamarine", "bytes", - "derive_more", + "derive_more 1.0.0", "dptree", "either", "futures", @@ -2430,7 +3415,7 @@ dependencies = [ "bitflags 2.11.1", "bytes", "chrono", - "derive_more", + "derive_more 1.0.0", "either", "futures", "log", @@ -2466,6 +3451,69 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.11.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2492,7 +3540,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2503,7 +3551,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2523,7 +3571,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -2590,7 +3640,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2614,6 +3664,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2640,6 +3702,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2650,14 +3713,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.11.1", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2678,6 +3751,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2704,7 +3778,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2748,12 +3822,34 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", +] + [[package]] name = "typenum" version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.9.0" @@ -2766,6 +3862,29 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2803,12 +3922,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ + "atomic", "getrandom 0.4.2", "js-sys", "serde_core", @@ -2833,6 +3959,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -2918,7 +4053,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -3001,7 +4136,7 @@ dependencies = [ "futures-util", "hex", "md-5", - "rand", + "rand 0.10.1", "reqwest", "serde", "serde_json", @@ -3013,15 +4148,109 @@ dependencies = [ "uuid", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[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" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] +[[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-core" version = "0.62.2" @@ -3043,7 +4272,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3054,7 +4283,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3203,7 +4432,7 @@ dependencies = [ "heck", "indexmap 2.14.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3219,7 +4448,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3286,7 +4515,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3307,7 +4536,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3327,7 +4556,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3367,7 +4596,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 22ac734..fbcc610 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,9 @@ default-members = [ "crates/lucarne", "crates/lucarne-adapter", "crates/lucarne-channel", + "crates/lucarne-rmux", + "crates/lucarne-termgw", + "crates/lucarne-remote", "crates/lucarned-ctl", "crates/lucarne-telegram", "crates/lucarne-wechat", @@ -15,6 +18,9 @@ members = [ "crates/lucarne-adapter", "crates/lucarne-channel", "crates/lucarne-fakeagent", + "crates/lucarne-rmux", + "crates/lucarne-termgw", + "crates/lucarne-remote", "crates/lucarned-ctl", "crates/lucarne-telegram", "crates/lucarne-wechat", @@ -56,6 +62,9 @@ reqwest = { version = "0.12", default-features = false, features = [ # Also let daemon HTTP clients discover OS proxy settings. "system-proxy", ] } +# Shared by gateway/daemon crates. Hoisted to keep the version aligned; +# consumers add their own features (e.g. `ws`) at the crate level. +axum = { version = "0.8" } [profile.release] opt-level = "z" diff --git a/README.cn.md b/README.cn.md index 15ddcbd..703d3f9 100644 --- a/README.cn.md +++ b/README.cn.md @@ -176,6 +176,54 @@ Telegram 入口 chat 需要开启 Topics/Thread mode。可以直接使用 Bot Telegram workspace 映射为 Forum Topic。一个项目一个 topic;一个 topic 可绑定一个 live Agent session。 - Telegram支持WeChat所有功能 +### 终端监控与 `lucarned tui` 控制台 + +终端监控子系统通过 `lucarned` 的产品 bundle feature(`product-terminal`)交付。 +默认源码构建仍保持 base daemon;release packaging 会显式启用 bundle,所以安装用户仍然 +只有一个 `lucarned` 入口。终端 bundle 会把系统级 rmux daemon 上的会话镜像到 web +终端视图,并允许把某个会话弹出到本地终端、再收回,而远程镜像保持运行。外部 Web app +直接消费同一套 gateway API;Lucarne 内部不再维护独立 web-chat runtime crate。 + +`lucarned tui` 是本地操作者的唯一交互入口:一个全屏、方向键导航的控制台,替代了 +旧的独立 `term` CLI。启动: + +```bash +lucarned tui # 启动全屏控制台 +``` + +源码构建/运行这个能力时需要显式 feature: + +```bash +cargo +nightly run -Zbuild-dir-new-layout -p lucarned --features product-terminal -- tui +``` + +控制台有三个面板(`Tab` / `←` `→` 切换面板,`↑` `↓` 移动选项,`q` / `Esc` 退出): + +- **Sessions(会话)** — 列出系统级 rmux 会话;`Enter` attach(把会话弹出到本地终端, + detach 后回到控制台),`d` detach,`k` / `Del` kill,`a` archive 到共享 archive + store,`r` 刷新。 +- **Go Public(对外暴露)** — `s` 启动远程接入隧道,`x` 停止,`r` 查询状态,`Enter` + 弹出登录 URL 的高对比二维码模态,让手机能远程访问终端 gateway(终端太小无法画出 + 可扫码二维码时回退为纯 URL)。 +- **Config(配置)** — 编辑远程接入 provider 字段(Cloudflared 优先),密钥字段掩码 + 显示且永不回显,保存时写回 `lucarned.yaml` 并生成带时间戳的备份。 + +脚本和 SSH 场景使用等价的 headless 入口: + +```bash +lucarned remote start +lucarned remote status --json +lucarned remote stop +``` + +`remote.enabled: true` 表示 daemon 启动时自动拉起隧道;`remote.enabled: false` +时,`lucarned` 仍会提供 loopback control plane,等待 `lucarned remote start` 或 TUI +Go Public 面板启动隧道。 + +Cloudflared token 留空时使用 Cloudflare Quick Tunnel:它会生成临时 +`trycloudflare.com` URL,仅适合测试/开发。敏感或需要稳定访问的场景应配置 named +tunnel(`token` + `public_url`)。 + --- ## 架构概览 @@ -198,6 +246,27 @@ Telegram workspace 映射为 Forum Topic。一个项目一个 topic;一个 top ┌──────┬──────┬──────┬──────┐ Claude Codex Gemini Copilot Pi ← Agent CLI 进程 ``` + +### 终端监控子系统 + +一个并行子系统:把用户系统级 rmux 终端会话通过 gateway API 镜像到 web 客户端, +并可通过隧道远程访问。 + +``` + ┌──────────────┐ + │ web client │ ← 外部 Web app / 浏览器终端 + └──────┬───────┘ + │ + lucarne-termgw ← Axum ws/HTTP gateway API + │ + lucarne-rmux ← 终端词汇 + 归档 + 实时 rmux-sdk 绑定 + │ + ┌──────┴───────┐ + │ 系统 rmux │ lucarne-remote ← go-public 隧道注册表(Cloudflared 优先) + │ daemon │ + └──────────────┘ lucarned tui / remote CLI ← sessions / go-public / config +``` + --- ## Agent 能力矩阵 @@ -221,7 +290,7 @@ Telegram workspace 映射为 Forum Topic。一个项目一个 topic;一个 top ```bash git clone https://github.com/tuchg/Lucarne.git -cd agents +cd Lucarne cargo +nightly check -Zbuild-dir-new-layout cargo +nightly test -Zbuild-dir-new-layout ``` @@ -232,8 +301,8 @@ cargo +nightly test -Zbuild-dir-new-layout - [x] Linux 支持:补齐安装说明、服务管理、发布包与 smoke test - [x] Windows 支持:补齐安装说明、后台运行、路径 / 进程兼容与发布包 - [ ] 消息模式 steer/queue -- [ ] agent-sessions 整理为独立crate -- [ ] 支持远程 agent 环境 +- [x] `agent-sessions` 保持为独立 workspace crate +- [x] 支持远程 agent 环境:rmux 终端监控 + gateway API + go-public 隧道 - [ ] 更多 Agent Provider:Cursor、opencode 等 - [ ] More channels:Discord、Slack、飞书、钉钉、Matrix、QQ 等更多入口 - [ ] .... diff --git a/README.md b/README.md index 47fd59c..0c27920 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,64 @@ Telegram needs Topics/thread mode for the entry chat. A private chat with the bo Telegram workspaces map to Forum Topics. One project gets one topic; one topic can bind one live agent session. - Telegram supports every WeChat feature. +### Terminal monitor and the `lucarned tui` dashboard + +The terminal-monitor subsystem is delivered through the `lucarned` product +bundle feature (`product-terminal`). The default source build remains the base +daemon; release packaging opts into the bundle so installed users still get the +single `lucarned` entry. The terminal bundle mirrors sessions on your +system-wide rmux daemon to a web terminal view and lets a session be popped into +your local terminal and retracted again while the remote mirror keeps running. +External Web apps consume the same gateway API; Lucarne does not ship a separate +internal web-chat runtime crate. + +`lucarned tui` is the single interactive entry for the local operator. It is a +full-screen, arrow-key-navigable dashboard and replaces the old standalone +`term` CLI. Launch it with: + +```bash +lucarned tui # launch the full-screen dashboard +``` + +From source, build or run this capability with an explicit feature: + +```bash +cargo +nightly run -Zbuild-dir-new-layout -p lucarned --features product-terminal -- tui +``` + +The dashboard has three panels (switch with `Tab` / `←` `→`, navigate items with +`↑` `↓`, quit with `q` / `Esc`): + +- **Sessions** — lists your system-wide rmux sessions; `Enter` attaches (pops the + session into your terminal and returns to the dashboard on detach), `d` detaches, + `k` / `Del` kills, `a` archives to the shared archive store, `r` refreshes. +- **Go Public** — `s` starts the remote-access tunnel, `x` stops it, `r` checks + status, and `Enter` shows a high-contrast QR modal of the login URL so a phone + can reach the terminal gateway remotely (it falls back to the plain URL + when the terminal is too small to draw a scannable QR). +- **Config** — edits the remote-access provider fields (Cloudflared-first) with + secret fields masked and never echoed, and saves back to `lucarned.yaml` with a + timestamped backup. + +For scripts and SSH sessions, use the equivalent headless entry: + +```bash +lucarned remote start +lucarned remote status --json +lucarned remote stop +``` + +`remote.enabled: true` means autostart the tunnel at daemon boot. With +`remote.enabled: false`, `lucarned` still serves the loopback control plane and +waits for `lucarned remote start` or the TUI Go Public panel. + +Leaving the Cloudflared token blank uses Cloudflare Quick Tunnel: an ephemeral +`trycloudflare.com` URL intended for testing/development. Use a named tunnel +(`token` + `public_url`) for sensitive or repeatable remote access. + +See [`docs/tui.md`](docs/tui.md) for the full TUI guide (panels, keybindings, +the daemon requirement for Go Public, and `term` → `lucarned tui` migration). + --- ## Architecture Overview @@ -198,6 +256,27 @@ Telegram workspaces map to Forum Topics. One project gets one topic; one topic c ┌──────┬──────┬──────┬──────┐ Claude Codex Gemini Copilot Pi ← Agent CLI processes ``` + +### Terminal-monitor subsystem + +A parallel subsystem that mirrors the user's system-wide rmux terminal sessions +to web clients through the gateway API — reachable remotely through a tunnel. + +``` + ┌──────────────┐ + │ web client │ ← External Web app / browser terminal + └──────┬───────┘ + │ + lucarne-termgw ← Axum ws/HTTP gateway API + │ + lucarne-rmux ← Terminal vocabulary + archive + live rmux-sdk binding + │ + ┌──────┴───────┐ + │ system rmux │ lucarne-remote ← go-public tunnel registry (Cloudflared-first) + │ daemon │ + └──────────────┘ lucarned tui / remote CLI ← sessions / go-public / config +``` + --- ## Agent Capability Matrix @@ -221,7 +300,7 @@ Telegram workspaces map to Forum Topics. One project gets one topic; one topic c ```bash git clone https://github.com/tuchg/Lucarne.git -cd agents +cd Lucarne cargo +nightly check -Zbuild-dir-new-layout cargo +nightly test -Zbuild-dir-new-layout ``` @@ -232,8 +311,8 @@ cargo +nightly test -Zbuild-dir-new-layout - [x] Linux support: installation docs, service management, release packages, and smoke tests - [x] Windows support: installation docs, background execution, path / process compatibility, and release packages - [ ] Message modes: steer / queue -- [ ] Split `agent-sessions` into an independent crate -- [ ] Support remote agent environments +- [x] Keep `agent-sessions` as an independent workspace crate +- [x] Support remote agent environments: rmux terminal monitor + gateway API + go-public tunnel - [ ] More agent providers: Cursor, opencode, and more - [ ] More channels: Discord, Slack, Feishu, DingTalk, Matrix, QQ, and more - [ ] .... diff --git a/crates/lucarne-adapter/src/lib.rs b/crates/lucarne-adapter/src/lib.rs index 290ac14..70e20eb 100644 --- a/crates/lucarne-adapter/src/lib.rs +++ b/crates/lucarne-adapter/src/lib.rs @@ -1,3 +1,5 @@ +#![allow(clippy::explicit_auto_deref, clippy::too_many_arguments)] + use std::{ collections::{BTreeMap, VecDeque}, path::Path, diff --git a/crates/lucarne-remote/Cargo.toml b/crates/lucarne-remote/Cargo.toml new file mode 100644 index 0000000..68800e1 --- /dev/null +++ b/crates/lucarne-remote/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "lucarne-remote" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Pluggable remote-access tunnel provider registry (Cloudflared-first)." +rust-version.workspace = true +repository.workspace = true + +[dependencies] +async-trait.workspace = true +tokio = { workspace = true } +thiserror.workspace = true +tracing.workspace = true +url = "2" + +[dev-dependencies] +# SEC-003 binary-pinning tests: create vetted / world-writable temp dirs to +# exercise `resolve_cloudflared_binary` without a real cloudflared install. +tempfile = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/lucarne-remote/src/cloudflared.rs b/crates/lucarne-remote/src/cloudflared.rs new file mode 100644 index 0000000..5b8ba6d --- /dev/null +++ b/crates/lucarne-remote/src/cloudflared.rs @@ -0,0 +1,1076 @@ +//! Cloudflare Tunnel ([`cloudflared`]) remote-access provider. +//! +//! Implements [`RemoteAccessProvider`] over the `cloudflared` binary. Two modes +//! are supported and selected purely by configuration (Free decision F3, quick +//! is the default): +//! +//! * **Quick tunnel** (no `token`): spawns +//! `cloudflared tunnel --url http://127.0.0.1:`, reads the binary's +//! stderr, and extracts the first `https://.trycloudflare.com` URL it +//! prints. Zero-config first run with a random ephemeral domain. +//! * **Named tunnel** (`token` present): spawns +//! `cloudflared tunnel run --token `. Named tunnels have a fixed, +//! pre-configured public hostname, so the public URL is taken from the +//! `public_url` config field rather than parsed from stderr. +//! +//! The tunnel binary spawning is isolated from the parsing logic +//! ([`parse_quick_url`]) so the latter is unit-testable without `cloudflared` +//! installed. A missing binary is surfaced as [`RemoteError::Spawn`] — never a +//! panic. +//! +//! Per the provider boundary (Locked decision L2) this is a **pure tunnel**: it +//! only knows local addr → public URL, stop, and health. No auth. + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::Duration; + +use async_trait::async_trait; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::{Child, Command}; +use tracing::{debug, warn}; + +use crate::{ + ProviderConfig, RemoteAccessProvider, RemoteError, RemoteResult, RequiredField, TunnelHandle, + TunnelStatus, +}; + +/// Stable provider id. +const PROVIDER_ID: &str = "cloudflared"; +/// Human-readable provider name. +const PROVIDER_NAME: &str = "Cloudflare Tunnel"; + +/// Config field carrying the named-tunnel token. When present and non-empty the +/// provider runs in named mode; otherwise it runs in quick mode (the default). +const FIELD_TOKEN: &str = "token"; +/// Config field carrying the fixed public URL for a named tunnel. +const FIELD_PUBLIC_URL: &str = "public_url"; +/// Config field carrying an explicit absolute path to the `cloudflared` binary +/// (SEC-003). When set it is used verbatim (after the same safety checks); +/// otherwise the binary is resolved to an absolute path via `$PATH`. +const FIELD_BINARY_PATH: &str = "binary_path"; +/// Deprecated `remote.cloudflare` config section accepted as a provider-owned +/// compatibility alias for `remote.providers.cloudflared`. +const COMPAT_SECTION_CLOUDFLARE: &str = "cloudflare"; + +/// Bare binary name resolved on `$PATH` when no `binary_path` is configured. +const CLOUDFLARED_BIN: &str = "cloudflared"; + +/// How long [`start`](Cloudflared::start) waits for a quick tunnel to print its +/// `trycloudflare.com` URL before giving up. +const QUICK_URL_TIMEOUT: Duration = Duration::from_secs(30); + +/// The fields this provider advertises for CLI prompting. The token is optional: +/// leaving it empty selects the zero-config quick tunnel. `public_url` is +/// conditionally required (M7): only when a named-tunnel `token` is present (a +/// named tunnel has a fixed hostname, so the URL cannot be parsed from stderr). +/// `binary_path` is an optional absolute path used to pin the `cloudflared` +/// binary (SEC-003); leaving it empty resolves the binary on `$PATH` to an +/// absolute path. +static REQUIRED_FIELDS: &[RequiredField] = &[ + RequiredField { + key: FIELD_TOKEN, + label: "Cloudflare Tunnel Token (named tunnel; leave empty for quick)", + secret: true, + required: false, + required_when: None, + }, + RequiredField { + key: FIELD_PUBLIC_URL, + label: "Named-tunnel public URL (e.g. https://term.example.com)", + secret: false, + required: false, + // M7: required only when a (named-tunnel) token is configured. + required_when: Some((FIELD_TOKEN, crate::ANY_VALUE)), + }, + RequiredField { + key: FIELD_BINARY_PATH, + label: "Absolute path to the cloudflared binary (leave empty to resolve via PATH)", + secret: false, + required: false, + required_when: None, + }, +]; + +/// Cloudflare Tunnel provider backed by the `cloudflared` binary. +/// +/// Holds an internal registry mapping a [`TunnelHandle::opaque`] key to the +/// spawned child process (plus any secret token file written for it, L3) so it +/// can later be stopped and health-checked. +#[derive(Default)] +pub struct Cloudflared { + /// Spawned children keyed by the opaque handle id. + children: Mutex>, +} + +/// A running cloudflared child plus the on-disk token file (if any) written for +/// it (L3). Keeping the [`TokenFile`] here ties the secret file's lifetime to the +/// child: it is removed when the tunnel is stopped (handle removed) or the +/// provider is dropped, so the token never lingers on disk after the tunnel ends. +struct RunningTunnel { + child: Child, + /// `Some` for a named tunnel whose token was passed via `--token-file` (L3); + /// `None` for a quick tunnel (no token). + _token_file: Option, +} + +/// A 0600 temp file holding a cloudflared named-tunnel token, removed on drop (L3). +/// +/// `cloudflared tunnel run --token-file ` reads the token from a file +/// instead of argv, so the token never appears in the process command line +/// (`ps` / `/proc//cmdline`). The file is created with `0600` (owner-only) +/// and unlinked when this value drops. NOTE (L3): the file is necessarily +/// readable by the SAME local user while it exists, and cloudflared keeps the +/// token in its own memory/argv-free state after reading; this removes the +/// `ps`-visible argv exposure, not same-user access (a same-user attacker is +/// already inside the trust boundary of the daemon). +struct TokenFile { + path: PathBuf, +} + +impl TokenFile { + /// Write `token` to a fresh owner-only (0600) temp file next to the system + /// temp dir. The filename embeds the pid + a nanosecond stamp so concurrent + /// tunnels never collide. + fn write(token: &str) -> RemoteResult { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let path = + std::env::temp_dir().join(format!("lucarne-cf-token-{}-{}", std::process::id(), nanos)); + write_owner_only(&path, token.as_bytes())?; + Ok(Self { path }) + } +} + +impl Drop for TokenFile { + fn drop(&mut self) { + // Best-effort unlink; a failure here only leaves a 0600 file behind. + let _ = std::fs::remove_file(&self.path); + } +} + +/// Write `bytes` to `path` creating it `0600` (owner read/write only) on unix so +/// the secret token file is never group/world readable (L3). On non-unix the +/// file is created with the platform default (no mode bits to set). +#[cfg(unix)] +fn write_owner_only(path: &Path, bytes: &[u8]) -> RemoteResult<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .map_err(|e| { + spawn_err(format!( + "could not create token file {}: {e}", + path.display() + )) + })?; + file.write_all(bytes).map_err(|e| { + spawn_err(format!( + "could not write token file {}: {e}", + path.display() + )) + })?; + Ok(()) +} + +#[cfg(not(unix))] +fn write_owner_only(path: &Path, bytes: &[u8]) -> RemoteResult<()> { + std::fs::write(path, bytes).map_err(|e| { + spawn_err(format!( + "could not write token file {}: {e}", + path.display() + )) + }) +} + +impl Cloudflared { + /// Create a new Cloudflared provider with an empty child registry. + pub fn new() -> Self { + Self::default() + } + + /// Allocate a fresh opaque handle id used to key the child registry. + fn new_opaque() -> String { + // Monotonic-ish unique key; uniqueness only needs to hold within one + // process so a nanosecond timestamp is sufficient and dependency-free. + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{PROVIDER_ID}-{nanos}") + } + + /// Number of children currently tracked in the registry (test-only): lets a + /// test assert that `health()` reaps a stale (exited) entry so its + /// `TokenFile` is dropped/unlinked. + #[cfg(test)] + fn tracked_children(&self) -> usize { + self.children + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .len() + } + + /// Insert a (child, token_file) pair under a fresh opaque handle (test-only). + /// Mirrors the bookkeeping `start` does after spawning, without needing the + /// `cloudflared` binary, so the reaping path in `health()` is unit-testable. + #[cfg(test)] + fn insert_for_test(&self, child: Child, token_file: Option) -> TunnelHandle { + let opaque = Self::new_opaque(); + self.children + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert( + opaque.clone(), + RunningTunnel { + child, + _token_file: token_file, + }, + ); + TunnelHandle { + provider_id: PROVIDER_ID.to_string(), + public_url: url::Url::parse("https://test.trycloudflare.com/").unwrap(), + opaque, + } + } +} + +/// Scan `stderr` for the first quick-tunnel public URL of the form +/// `https://.trycloudflare.com` and parse it into a [`url::Url`]. +/// +/// This is a hand-rolled scan (no regex dependency): it locates each +/// `https://` occurrence, reads the host up to the first delimiter, and accepts +/// it when the host ends with `.trycloudflare.com`. Robust to surrounding log +/// decoration and works on the multi-line banner cloudflared prints. +/// +/// Returns `None` when no matching URL is present. Pure and side-effect free so +/// it can be unit-tested without the `cloudflared` binary. +pub fn parse_quick_url(stderr: &str) -> Option { + const SCHEME: &str = "https://"; + const SUFFIX: &str = ".trycloudflare.com"; + + let mut offset = 0; + while let Some(rel) = stderr[offset..].find(SCHEME) { + let start = offset + rel; + let rest = &stderr[start..]; + + // The URL token runs from `https://` until the first character that + // cannot appear in a bare URL as cloudflared prints it: whitespace or + // the punctuation it decorates the banner with. Path slashes stay part + // of the token (so `https://x.trycloudflare.com/` is captured whole). + let token_len = rest + .find(|c: char| { + c.is_whitespace() + || matches!( + c, + '"' | '\'' | '|' | '<' | '>' | '(' | ')' | '[' | ']' | ',' | '`' + ) + }) + .unwrap_or(rest.len()); + let candidate = &rest[..token_len]; + + // Host is everything after the scheme, up to the first path slash. + let after_scheme = &candidate[SCHEME.len()..]; + let host = after_scheme + .split(['/', '?', '#']) + .next() + .unwrap_or(after_scheme); + if host.len() > SUFFIX.len() && host.ends_with(SUFFIX) { + if let Ok(url) = url::Url::parse(candidate) { + return Some(url); + } + } + + // Advance past this `https://` occurrence to find the next. + offset = start + SCHEME.len(); + } + None +} + +/// Resolve the `cloudflared` binary to a vetted **absolute** path (SEC-003). +/// +/// PATH-hijack defense: a daemon must not spawn whatever `cloudflared` happens +/// to be first on `$PATH`. Resolution order: +/// +/// 1. A configured `binary_path` (used verbatim) — for pinned/packaged installs. +/// 2. Otherwise an absolute lookup over `$PATH` entries (each candidate must be +/// an absolute path to an existing file). +/// +/// The resolved path is then vetted: it must exist, be a regular file, and live +/// in a directory that is **not world-writable** (so an attacker can't drop a +/// replacement binary next to it). Any failure is surfaced as +/// [`RemoteError::Spawn`] — the daemon refuses to spawn rather than running an +/// untrusted binary. +fn resolve_cloudflared_binary(cfg: &ProviderConfig) -> RemoteResult { + let resolved = match cfg.get(FIELD_BINARY_PATH).filter(|p| !p.is_empty()) { + Some(configured) => { + let path = PathBuf::from(configured); + if !path.is_absolute() { + return Err(spawn_err(format!( + "configured `{FIELD_BINARY_PATH}` must be an absolute path, got `{configured}`" + ))); + } + path + } + None => resolve_on_path(CLOUDFLARED_BIN).ok_or_else(|| { + spawn_err(format!( + "could not find `{CLOUDFLARED_BIN}` on $PATH (is it installed? \ + set `{FIELD_BINARY_PATH}` to pin an absolute path)" + )) + })?, + }; + vet_binary(&resolved)?; + Ok(resolved) +} + +/// Build a [`RemoteError::Spawn`] for this provider with `message`. +fn spawn_err(message: impl Into) -> RemoteError { + RemoteError::Spawn { + provider: PROVIDER_ID.to_string(), + message: message.into(), + } +} + +/// Find `name` as an absolute path by scanning `$PATH` entries. Only absolute +/// PATH entries are considered (a relative `.`-style entry is an injection +/// vector and is skipped), and the candidate must be an existing file. +fn resolve_on_path(name: &str) -> Option { + let path_var = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path_var) { + if !dir.is_absolute() { + continue; + } + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +/// Vet a resolved binary path (SEC-003): it must exist, be a regular file, and +/// sit in a directory that is not world-writable. +fn vet_binary(path: &Path) -> RemoteResult<()> { + let meta = std::fs::metadata(path) + .map_err(|err| spawn_err(format!("`{}` is not accessible: {err}", path.display())))?; + if !meta.is_file() { + return Err(spawn_err(format!( + "`{}` is not a regular file", + path.display() + ))); + } + // Reject a binary whose directory is world-writable — an attacker with write + // access to that directory could swap the binary for a malicious one. + if let Some(parent) = path.parent() { + if is_world_writable(parent)? { + return Err(spawn_err(format!( + "refusing to spawn `{}`: its directory `{}` is world-writable", + path.display(), + parent.display() + ))); + } + } + Ok(()) +} + +/// True if `dir` is world-writable (the `o+w` mode bit). On non-unix platforms +/// there is no such bit, so this is always `false`. +#[cfg(unix)] +fn is_world_writable(dir: &Path) -> RemoteResult { + use std::os::unix::fs::PermissionsExt; + let meta = std::fs::metadata(dir) + .map_err(|err| spawn_err(format!("`{}` is not accessible: {err}", dir.display())))?; + Ok(meta.permissions().mode() & 0o002 != 0) +} + +#[cfg(not(unix))] +fn is_world_writable(_dir: &Path) -> RemoteResult { + Ok(false) +} + +#[async_trait] +impl RemoteAccessProvider for Cloudflared { + fn id(&self) -> &'static str { + PROVIDER_ID + } + + fn name(&self) -> &'static str { + PROVIDER_NAME + } + + fn required_fields(&self) -> &[RequiredField] { + REQUIRED_FIELDS + } + + fn compat_config_sections(&self) -> &[&'static str] { + &[COMPAT_SECTION_CLOUDFLARE] + } + + /// H6a: warn when running a *quick* tunnel (no `token`). A quick tunnel + /// terminates TLS at the Cloudflare edge, so terminal content is visible in + /// plaintext there. The daemon logs this (it does NOT special-case the + /// provider id) so the caveat lives with the provider. + fn warnings(&self, cfg: &ProviderConfig) -> Vec { + let quick = cfg.get(FIELD_TOKEN).filter(|t| !t.is_empty()).is_none(); + if quick { + vec![ + "Cloudflare QUICK tunnel: terminal content is visible in plaintext at the \ + Cloudflare edge (TLS terminates there). For sensitive sessions configure a \ + named tunnel (cloudflare.token + public_url) instead." + .to_string(), + ] + } else { + Vec::new() + } + } + + /// H6b: cloudflared's edge sets `cf-connecting-ip` to the real client IP. + /// The gateway trusts it only behind the loopback tunnel source. Keeping the + /// header here (not hardcoded in the gateway) honors the provider boundary. + fn forwarded_identity_headers(&self) -> &[&'static str] { + &["cf-connecting-ip"] + } + + /// M7: validate the named-tunnel rule — a `token` selects a named tunnel, + /// which has a fixed hostname, so `public_url` must be supplied (it cannot be + /// parsed from stderr the way a quick tunnel's URL is). The descriptor's + /// `required_when` already expresses this declaratively; this override adds a + /// `public_url` well-formedness check so a malformed URL is rejected at + /// config time rather than mid-`start`. The rule stays inside the provider. + fn validate_config(&self, cfg: &ProviderConfig) -> Result<(), String> { + let named = cfg.get(FIELD_TOKEN).filter(|t| !t.is_empty()).is_some(); + if named { + match cfg.get(FIELD_PUBLIC_URL).filter(|u| !u.is_empty()) { + None => { + return Err(format!( + "named tunnel (a `{FIELD_TOKEN}` is set) requires `{FIELD_PUBLIC_URL}`" + )); + } + Some(url) => { + url::Url::parse(url).map_err(|e| { + format!("invalid `{FIELD_PUBLIC_URL}` for named tunnel: {e}") + })?; + } + } + } + Ok(()) + } + + async fn start(&self, local: SocketAddr, cfg: &ProviderConfig) -> RemoteResult { + // Mode is decided by the presence of a non-empty token field. Validate + // the per-mode config (e.g. named-tunnel `public_url`) BEFORE resolving + // or spawning the binary, so a config error is reported without touching + // the filesystem/PATH. + let token = cfg.get(FIELD_TOKEN).filter(|t| !t.is_empty()); + let named_public_url = match token { + Some(_) => { + let public_url = cfg.require(FIELD_PUBLIC_URL)?; + let public_url = url::Url::parse(public_url).map_err(|err| { + RemoteError::Parse(format!( + "invalid `{FIELD_PUBLIC_URL}` for named tunnel: {err}" + )) + })?; + Some(public_url) + } + None => None, + }; + + // SEC-003: resolve `cloudflared` to a vetted absolute path before + // spawning — never rely on a bare name found via `$PATH`. + let binary = resolve_cloudflared_binary(cfg)?; + + // L3: for a named tunnel, write the token to a 0600 temp file and pass + // `--token-file ` instead of `--token `, so the secret never + // appears in the process argv (`ps` / `/proc//cmdline`). The file is + // unlinked when the tunnel stops (its `TokenFile` is dropped with the + // child). A quick tunnel has no token → no file. + let token_file = match token { + Some(token) => Some(TokenFile::write(token)?), + None => None, + }; + + let mut command = Command::new(&binary); + match &token_file { + Some(tf) => { + // Named tunnel: fixed domain, public URL comes from config; token + // is read from the 0600 file (kept off argv — L3). + command + .arg("tunnel") + .arg("run") + .arg("--token-file") + .arg(&tf.path); + } + None => { + // Quick tunnel: random trycloudflare.com domain parsed from stderr. + command.args([ + "tunnel", + "--url", + &format!("http://127.0.0.1:{}", local.port()), + ]); + } + } + + // cloudflared logs (including the quick-tunnel URL) go to stderr. + // + // H2: stderr must not block the child. In NAMED mode there is nothing to + // parse, so discard stderr to `null` — no pipe to ever fill. In QUICK + // mode we must read stderr to extract the URL, so we pipe it and a + // background task drains it for the child's WHOLE lifetime (it returns + // the first URL via a oneshot, then keeps reading so the pipe never + // fills and back-pressures / blocks cloudflared). + let quick_mode = named_public_url.is_none(); + command + .stdout(std::process::Stdio::null()) + .kill_on_drop(true); + if quick_mode { + command.stderr(std::process::Stdio::piped()); + } else { + command.stderr(std::process::Stdio::null()); + } + + let mut child = command + .spawn() + .map_err(|err| spawn_err(format!("could not launch `{}`: {err}", binary.display())))?; + + let public_url = match named_public_url { + Some(url) => url, + None => { + // Take the piped stderr and spawn a lifetime-long drain task. It + // sends the first trycloudflare URL back over a oneshot and then + // keeps consuming stderr until EOF (child exit), so the pipe is + // never allowed to fill (H2). + let stderr = child.stderr.take().ok_or_else(|| RemoteError::Spawn { + provider: PROVIDER_ID.to_string(), + message: "cloudflared stderr was not captured".to_string(), + })?; + let (url_tx, url_rx) = tokio::sync::oneshot::channel::(); + tokio::spawn(drain_quick_stderr(stderr, url_tx)); + match tokio::time::timeout(QUICK_URL_TIMEOUT, url_rx).await { + Ok(Ok(url)) => url, + // The drain task ended (stream closed) before sending a URL. + Ok(Err(_recv)) => { + let _ = child.kill().await; + let _ = child.wait().await; + return Err(RemoteError::Parse( + "cloudflared exited before printing a trycloudflare.com URL" + .to_string(), + )); + } + Err(_elapsed) => { + let _ = child.kill().await; + let _ = child.wait().await; + return Err(RemoteError::Parse(format!( + "timed out after {}s waiting for cloudflared quick-tunnel URL", + QUICK_URL_TIMEOUT.as_secs() + ))); + } + } + } + }; + + let opaque = Self::new_opaque(); + { + let mut children = self + .children + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + children.insert( + opaque.clone(), + RunningTunnel { + child, + _token_file: token_file, + }, + ); + } + + debug!( + target: "lucarne_remote", + provider_id = PROVIDER_ID, + public_url = %public_url, + "cloudflared tunnel started" + ); + + Ok(TunnelHandle { + provider_id: PROVIDER_ID.to_string(), + public_url, + opaque, + }) + } + + async fn stop(&self, handle: TunnelHandle) -> RemoteResult<()> { + let running = { + let mut children = self + .children + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + children.remove(&handle.opaque) + }; + + match running { + // The token file (if any) is dropped with `running` after the child + // is reaped, removing the secret from disk (L3). + Some(mut running) => { + running.child.kill().await?; + // Reap the process so it does not linger as a zombie. + let _ = running.child.wait().await; + debug!( + target: "lucarne_remote", + provider_id = PROVIDER_ID, + "cloudflared tunnel stopped" + ); + Ok(()) + } + None => { + // Unknown handle: nothing to stop. Treat as not-found rather than + // silently succeeding so callers notice stale handles. + warn!( + target: "lucarne_remote", + provider_id = PROVIDER_ID, + opaque = handle.opaque.as_str(), + "stop called for unknown cloudflared handle" + ); + Err(RemoteError::NotFound(handle.opaque)) + } + } + } + + async fn health(&self, handle: &TunnelHandle) -> RemoteResult { + let mut children = self + .children + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + match children.get_mut(&handle.opaque) { + Some(running) => match running.child.try_wait() { + // Still running: process has not exited. + Ok(None) => Ok(TunnelStatus::Up), + // Exited: the tunnel is down. Reap the stale entry so the dropped + // `RunningTunnel` runs `TokenFile::drop` and unlinks the 0600 + // named-tunnel token file (L3) — otherwise a crashed named tunnel + // would leave the secret on disk until the whole provider drops. + // `health()` is the only place that detects the exit; matching the + // daemon `status()` Down→Idle reaping with the on-disk cleanup here. + Ok(Some(_status)) => { + children.remove(&handle.opaque); + debug!( + target: "lucarne_remote", + provider_id = PROVIDER_ID, + opaque = handle.opaque.as_str(), + "cloudflared child exited; reaped stale handle + token file (health)" + ); + Ok(TunnelStatus::Down) + } + // Could not determine liveness. + Err(err) => { + warn!( + target: "lucarne_remote", + provider_id = PROVIDER_ID, + error = %err, + "cloudflared try_wait failed" + ); + Ok(TunnelStatus::Unknown) + } + }, + // No child tracked for this handle. + None => Ok(TunnelStatus::Down), + } + } +} + +/// Drain a quick-tunnel's stderr for the child's whole lifetime (H2). +/// +/// Reads stderr line by line: the first `trycloudflare.com` URL is sent back +/// over `url_tx` (the caller waits on it to learn the public URL), and the task +/// then keeps reading until EOF so the stderr pipe is never allowed to fill — +/// a full pipe would back-pressure and block cloudflared. The receiver may be +/// dropped (timeout / already got the URL); `send` failing is fine, the loop +/// keeps draining regardless. +async fn drain_quick_stderr( + stderr: tokio::process::ChildStderr, + url_tx: tokio::sync::oneshot::Sender, +) { + let mut reader = BufReader::new(stderr).lines(); + let mut url_tx = Some(url_tx); + // Reads until EOF (child exited) or a read error — either ends draining. + while let Ok(Some(line)) = reader.next_line().await { + if url_tx.is_some() { + if let Some(url) = parse_quick_url(&line) { + // Send the first URL; ignore a closed receiver (timeout). + if let Some(tx) = url_tx.take() { + let _ = tx.send(url); + } + } + } + // Keep reading regardless so the pipe never fills. + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A representative multi-line cloudflared quick-tunnel banner. + const SAMPLE_STDERR: &str = "\ +2026-05-30T10:00:00Z INF Thank you for trying Cloudflare Tunnel. +2026-05-30T10:00:00Z INF Requesting new quick Tunnel on trycloudflare.com... +2026-05-30T10:00:01Z INF +--------------------------------------------------------+ +2026-05-30T10:00:01Z INF | Your quick Tunnel has been created! Visit it at: | +2026-05-30T10:00:01Z INF | https://foo-bar.trycloudflare.com | +2026-05-30T10:00:01Z INF +--------------------------------------------------------+ +2026-05-30T10:00:02Z INF Connection registered connIndex=0"; + + #[test] + fn parse_quick_url_extracts_trycloudflare_url() { + let url = parse_quick_url(SAMPLE_STDERR).expect("should find trycloudflare url"); + assert_eq!(url.as_str(), "https://foo-bar.trycloudflare.com/"); + assert_eq!(url.host_str(), Some("foo-bar.trycloudflare.com")); + assert_eq!(url.scheme(), "https"); + } + + #[test] + fn parse_quick_url_single_line() { + let url = parse_quick_url("see https://foo-bar.trycloudflare.com here") + .expect("should find url mid-line"); + assert_eq!(url.host_str(), Some("foo-bar.trycloudflare.com")); + } + + #[test] + fn parse_quick_url_returns_first_match() { + let blob = "https://aaa-111.trycloudflare.com then https://bbb-222.trycloudflare.com"; + let url = parse_quick_url(blob).expect("should find first url"); + assert_eq!(url.host_str(), Some("aaa-111.trycloudflare.com")); + } + + #[test] + fn parse_quick_url_ignores_non_trycloudflare() { + assert!(parse_quick_url("https://example.com/foo").is_none()); + assert!(parse_quick_url("connecting to https://1.1.1.1 ...").is_none()); + assert!(parse_quick_url("no url at all in this line").is_none()); + // Bare suffix with no subdomain must not match. + assert!(parse_quick_url("https://.trycloudflare.com").is_none()); + } + + #[test] + fn required_fields_advertises_optional_token() { + let provider = Cloudflared::new(); + let fields = provider.required_fields(); + // token + public_url + binary_path (M7 added the conditional public_url; + // SEC-003 added binary_path). + assert_eq!(fields.len(), 3); + let token = fields + .iter() + .find(|f| f.key == "token") + .expect("token field"); + assert!(token.secret, "token must be masked"); + assert!(!token.required, "token is optional (quick is default)"); + assert!( + token.required_when.is_none(), + "token itself is unconditional" + ); + // M7: public_url is conditionally required only when a token is set. + let public_url = fields + .iter() + .find(|f| f.key == "public_url") + .expect("public_url field"); + assert!( + !public_url.required, + "public_url is not unconditionally required" + ); + assert_eq!( + public_url.required_when, + Some(("token", crate::ANY_VALUE)), + "public_url is required-when a token is present (named tunnel)" + ); + let bin = fields + .iter() + .find(|f| f.key == "binary_path") + .expect("binary_path field"); + assert!( + !bin.required, + "binary_path is optional (PATH resolution default)" + ); + assert!(!bin.secret, "binary_path is not a secret"); + } + + // M7: the provider's `validate_config` enforces the named-tunnel rule + // (`public_url` required + well-formed when a token is set) so neither the + // daemon nor the CLI branches on the provider id. + #[test] + fn validate_config_enforces_named_tunnel_public_url() { + let provider = Cloudflared::new(); + + // Quick tunnel (no token) → always valid. + assert!(provider.validate_config(&ProviderConfig::new()).is_ok()); + + // Named tunnel (token) without public_url → error mentioning public_url. + let mut named = ProviderConfig::new(); + named.fields.insert("token".to_string(), "tok".to_string()); + let err = provider + .validate_config(&named) + .expect_err("public_url required for named tunnel"); + assert!(err.contains("public_url"), "got: {err}"); + + // Named tunnel with a malformed public_url → parse error. + let mut bad = named.clone(); + bad.fields + .insert("public_url".to_string(), "not a url".to_string()); + assert!(provider.validate_config(&bad).is_err()); + + // Named tunnel with a valid public_url → ok. + let mut good = named.clone(); + good.fields.insert( + "public_url".to_string(), + "https://term.example.com".to_string(), + ); + assert!(provider.validate_config(&good).is_ok()); + } + + #[test] + fn id_and_name_are_stable() { + let provider = Cloudflared::new(); + assert_eq!(provider.id(), "cloudflared"); + assert_eq!(provider.name(), "Cloudflare Tunnel"); + } + + // H6a: a quick tunnel (no token) yields a plaintext-at-edge warning; a named + // tunnel (token present) yields none. The daemon logs these without + // special-casing the provider id. + #[test] + fn quick_tunnel_warns_named_does_not() { + let provider = Cloudflared::new(); + // Quick mode (no token). + let quick = ProviderConfig::new(); + let warnings = provider.warnings(&quick); + assert_eq!(warnings.len(), 1, "quick tunnel must warn"); + assert!( + warnings[0].contains("plaintext") && warnings[0].contains("Cloudflare edge"), + "warning must mention plaintext at the edge: {warnings:?}" + ); + // Empty token is treated as quick → still warns. + let mut empty = ProviderConfig::new(); + empty.fields.insert("token".to_string(), String::new()); + assert_eq!(provider.warnings(&empty).len(), 1); + // Named mode (non-empty token) → no warning. + let mut named = ProviderConfig::new(); + named + .fields + .insert("token".to_string(), "eyJhbGc...".to_string()); + assert!( + provider.warnings(&named).is_empty(), + "named tunnel must not warn" + ); + } + + // H6b: cloudflared advertises `cf-connecting-ip` as its trusted forwarded + // header (the gateway trusts it only behind the loopback tunnel source). + #[test] + fn cloudflared_advertises_cf_connecting_ip_forwarded_header() { + let provider = Cloudflared::new(); + assert_eq!(provider.forwarded_identity_headers(), &["cf-connecting-ip"]); + } + + #[test] + fn cloudflared_declares_legacy_config_section_alias() { + let provider = Cloudflared::new(); + assert_eq!(provider.compat_config_sections(), &["cloudflare"]); + } + + #[test] + fn mode_selection_by_token_presence() { + // Quick mode: no token field at all. + let quick = ProviderConfig::new(); + assert!(quick.get("token").filter(|t| !t.is_empty()).is_none()); + + // Quick mode: empty token is treated as absent. + let mut empty = ProviderConfig::new(); + empty.fields.insert("token".to_string(), String::new()); + assert!(empty.get("token").filter(|t| !t.is_empty()).is_none()); + + // Named mode: non-empty token present. + let mut named = ProviderConfig::new(); + named + .fields + .insert("token".to_string(), "eyJhbGc...".to_string()); + assert!(named.get("token").filter(|t| !t.is_empty()).is_some()); + } + + #[tokio::test] + async fn named_mode_requires_public_url() { + let provider = Cloudflared::new(); + let mut cfg = ProviderConfig::new(); + cfg.fields + .insert("token".to_string(), "a-token".to_string()); + // No public_url configured -> MissingField, without ever spawning. + let local: SocketAddr = "127.0.0.1:7800".parse().unwrap(); + let err = provider.start(local, &cfg).await.expect_err("must error"); + assert!( + matches!(err, RemoteError::MissingField(ref key) if key == "public_url"), + "expected MissingField(public_url), got {err:?}" + ); + } + + #[tokio::test] + async fn health_of_unknown_handle_is_down() { + let provider = Cloudflared::new(); + let handle = TunnelHandle { + provider_id: PROVIDER_ID.to_string(), + public_url: url::Url::parse("https://foo-bar.trycloudflare.com/").unwrap(), + opaque: "not-tracked".to_string(), + }; + assert_eq!(provider.health(&handle).await.unwrap(), TunnelStatus::Down); + } + + // R3-3: when the cloudflared child has exited, `health()` reports Down AND + // reaps the stale registry entry, so the dropped `RunningTunnel` runs + // `TokenFile::drop` and unlinks the 0600 named-tunnel token file (L3) rather + // than leaving the secret on disk until the whole provider drops. + #[tokio::test] + async fn health_reaps_exited_child_and_removes_token_file() { + let provider = Cloudflared::new(); + + // A real (named-tunnel) token file on disk, tied to the child's lifetime. + let token_file = TokenFile::write("secret-named-tunnel-token").expect("token file"); + let token_path = token_file.path.clone(); + assert!( + token_path.exists(), + "token file exists while the tunnel is tracked" + ); + + // A short-lived child that exits immediately (stands in for cloudflared + // crashing). `true` exits 0 on every supported platform. + let mut child = Command::new("true") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn `true`"); + // Ensure it has actually exited before we probe health (so `try_wait` + // observes the exit deterministically). + let _ = child.wait().await; + + let handle = provider.insert_for_test(child, Some(token_file)); + assert_eq!( + provider.tracked_children(), + 1, + "child tracked before health" + ); + + // Health observes the exit → Down, AND reaps the entry. + assert_eq!(provider.health(&handle).await.unwrap(), TunnelStatus::Down); + assert_eq!( + provider.tracked_children(), + 0, + "exited child must be reaped from the registry" + ); + // Reaping dropped the `RunningTunnel` → its `TokenFile` was unlinked. + assert!( + !token_path.exists(), + "named-tunnel token file must be removed when health reaps the dead tunnel" + ); + + // A second health() on the now-untracked handle is a safe Down (no + // double-remove panic; matches the daemon stop/status NotFound handling). + assert_eq!(provider.health(&handle).await.unwrap(), TunnelStatus::Down); + assert_eq!(provider.tracked_children(), 0); + } + + // ---- SEC-003: cloudflared binary pinning / vetting ---- + + #[test] + fn configured_binary_path_must_be_absolute() { + let mut cfg = ProviderConfig::new(); + cfg.fields + .insert("binary_path".to_string(), "cloudflared".to_string()); + let err = resolve_cloudflared_binary(&cfg).expect_err("relative path must be rejected"); + assert!( + matches!(err, RemoteError::Spawn { ref message, .. } if message.contains("absolute")), + "expected absolute-path Spawn error, got {err:?}" + ); + } + + #[test] + fn configured_binary_path_rejected_when_missing() { + let mut cfg = ProviderConfig::new(); + // An absolute path that does not exist must fail closed (no spawn). + cfg.fields.insert( + "binary_path".to_string(), + "/nonexistent/definitely/not/here/cloudflared".to_string(), + ); + let err = resolve_cloudflared_binary(&cfg).expect_err("missing binary must be rejected"); + assert!( + matches!(err, RemoteError::Spawn { .. }), + "expected Spawn error for missing binary, got {err:?}" + ); + } + + #[test] + fn configured_binary_path_accepts_vetted_absolute_file() { + // A real existing file in a non-world-writable dir resolves cleanly. Use + // the test binary's own dir as a representative vetted location. + let dir = tempfile::tempdir().expect("tempdir"); + let bin = dir.path().join("cloudflared"); + std::fs::write(&bin, b"#!/bin/sh\n").expect("write fake binary"); + // tempdir is created 0700 by default — not world-writable. + let mut cfg = ProviderConfig::new(); + cfg.fields.insert( + "binary_path".to_string(), + bin.to_string_lossy().into_owned(), + ); + let resolved = resolve_cloudflared_binary(&cfg).expect("vetted absolute file accepted"); + assert_eq!(resolved, bin); + } + + #[cfg(unix)] + #[test] + fn world_writable_binary_directory_is_refused() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().expect("tempdir"); + // Make the directory world-writable (o+w). + let mut perms = std::fs::metadata(dir.path()).unwrap().permissions(); + perms.set_mode(0o777); + std::fs::set_permissions(dir.path(), perms).expect("chmod 777"); + let bin = dir.path().join("cloudflared"); + std::fs::write(&bin, b"#!/bin/sh\n").expect("write fake binary"); + + let mut cfg = ProviderConfig::new(); + cfg.fields.insert( + "binary_path".to_string(), + bin.to_string_lossy().into_owned(), + ); + let err = resolve_cloudflared_binary(&cfg) + .expect_err("world-writable binary dir must be refused"); + assert!( + matches!(err, RemoteError::Spawn { ref message, .. } if message.contains("world-writable")), + "expected world-writable Spawn error, got {err:?}" + ); + } + + // L3: the named-tunnel token is written to a 0600 file (so it stays off the + // process argv) and the file is removed when the `TokenFile` drops. + #[cfg(unix)] + #[test] + fn token_file_is_owner_only_and_removed_on_drop() { + use std::os::unix::fs::PermissionsExt; + let path = { + let tf = TokenFile::write("super-secret-tunnel-token").expect("write token file"); + // Exists while held, with 0600 permissions (owner read/write only). + let meta = std::fs::metadata(&tf.path).expect("token file exists"); + assert_eq!( + meta.permissions().mode() & 0o777, + 0o600, + "token file must be owner-only (0600)" + ); + let content = std::fs::read_to_string(&tf.path).expect("read token file"); + assert_eq!(content, "super-secret-tunnel-token"); + tf.path.clone() + }; + // Dropped → unlinked, so the secret does not linger on disk. + assert!(!path.exists(), "token file must be removed on drop"); + } +} diff --git a/crates/lucarne-remote/src/lib.rs b/crates/lucarne-remote/src/lib.rs new file mode 100644 index 0000000..7d3f977 --- /dev/null +++ b/crates/lucarne-remote/src/lib.rs @@ -0,0 +1,552 @@ +//! Pluggable remote-access tunnel adapter base for Lucarne. +//! +//! This crate defines a transport-agnostic provider abstraction that lets the +//! Lucarne daemon expose a loopback-bound gateway to the public internet via +//! pluggable tunnel backends. It mirrors the proven +//! [`AdapterPlugin`]/[`AdapterRegistry`] pattern from `lucarne-adapter` +//! (`lucarne-adapter/src/lib.rs:575-603`): a `Send + Sync` async trait plus a +//! registry that registers, looks up, and enumerates implementations. +//! +//! # Boundary (Locked decision L2) +//! +//! A [`RemoteAccessProvider`] is a **pure tunnel**: it only knows how to +//! `start` a tunnel from a local [`SocketAddr`](std::net::SocketAddr) and hand +//! back a public URL, `stop` it, and report `health`. Authentication, +//! authorization, and any token/ticket exchange live in the gateway/web layer +//! and never leak into a provider. The gateway always binds loopback; the +//! tunnel connects outbound and dials back in (Locked decision L3). +//! +//! # Reserved future backends (Locked decision L7) +//! +//! This crate is rmux-free by design. The trait is the only seam additional +//! backends need: FRP, a self-hosted lightweight relay, and other NAT-traversal +//! tunnels are **reserved** — they will implement [`RemoteAccessProvider`] in +//! their own modules later and register through [`RemoteRegistry`] with zero +//! changes to this core. **None of those reserved backends are implemented this +//! release** (see ADR `docs/decisions/2026-05-30-remote-access-tunnel-adapter.md`, +//! decision L7); only the trait seam + commented `lucarned.yaml` `provider` +//! placeholders exist for them. The first and only concrete backend this release, +//! [`Cloudflared`], lives in the [`cloudflared`] module and is wired into +//! [`builtin`]. + +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::sync::Arc; + +use async_trait::async_trait; +use tracing::info; + +pub mod cloudflared; + +pub use cloudflared::Cloudflared; + +/// Result alias for remote-access operations. +pub type RemoteResult = Result; + +/// Errors produced while managing a remote-access tunnel. +#[derive(Debug, thiserror::Error)] +pub enum RemoteError { + /// Failed to spawn or launch the underlying tunnel process/backend. + #[error("failed to spawn tunnel for provider `{provider}`: {message}")] + Spawn { + /// Provider id that failed to spawn. + provider: String, + /// Human-readable failure detail. + message: String, + }, + + /// Failed to parse backend output (e.g. the public URL from logs). + #[error("failed to parse tunnel output: {0}")] + Parse(String), + + /// A required configuration field was missing. + #[error("missing required config field `{0}`")] + MissingField(String), + + /// The referenced provider/tunnel could not be found. + #[error("remote-access provider `{0}` not found")] + NotFound(String), + + /// An underlying I/O error. + #[error("remote-access io error: {0}")] + Io(#[from] std::io::Error), +} + +/// Health of a live tunnel as reported by its provider. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TunnelStatus { + /// The tunnel is up and the public URL should be reachable. + Up, + /// The tunnel is down / no longer serving. + Down, + /// The provider could not determine the tunnel state. + Unknown, +} + +/// Describes one configuration field a provider needs, used to drive CLI +/// prompting (label/secret/required) without the CLI knowing provider details. +/// +/// M7: a field can also be *conditionally* required via [`required_when`]: it +/// becomes required only when another field already has a given value (e.g. +/// cloudflared's `public_url` is required only when a named-tunnel `token` is +/// present). This lets the CLI drive an interactive prompt for the conditional +/// field from the descriptor alone — no provider-specific branching in the CLI. +#[derive(Debug, Clone)] +pub struct RequiredField { + /// Stable machine key looked up in [`ProviderConfig`]. + pub key: &'static str, + /// Human-readable prompt label. + pub label: &'static str, + /// Whether the value is sensitive (mask input, never log). + pub secret: bool, + /// Whether the value must ALWAYS be supplied for `start` to succeed. + pub required: bool, + /// M7: conditional requirement — `Some((key, value))` makes this field + /// required only when field `key` is present with exactly `value` (e.g. + /// `Some(("token", _))`-style "required when a token is set"). The match is + /// "field `key` present and equal to `value`"; use the sentinel value + /// [`ANY_VALUE`] to mean "required when `key` is present with ANY non-empty + /// value". `None` → no conditional rule (only [`required`](Self::required) + /// applies). + pub required_when: Option<(&'static str, &'static str)>, +} + +/// Sentinel for [`RequiredField::required_when`] meaning "the gating field is +/// present with ANY non-empty value" (vs. an exact value match). +pub const ANY_VALUE: &str = "*"; + +impl RequiredField { + /// Whether this field is required given the rest of `cfg` (M7): always-required + /// fields are required unconditionally; a `required_when` field is required + /// only when its gating field is present (matching the configured value, or + /// any non-empty value for the [`ANY_VALUE`] sentinel). + pub fn is_required(&self, cfg: &ProviderConfig) -> bool { + if self.required { + return true; + } + match self.required_when { + Some((gate_key, ANY_VALUE)) => cfg.get(gate_key).is_some_and(|v| !v.is_empty()), + Some((gate_key, gate_value)) => cfg.get(gate_key) == Some(gate_value), + None => false, + } + } +} + +/// Opaque, provider-agnostic configuration: a flat key→value field map. +/// +/// Keys correspond to [`RequiredField::key`] entries advertised by a provider. +/// Backend-specific structure is intentionally kept out of this base type. +#[derive(Debug, Clone, Default)] +pub struct ProviderConfig { + /// Field values keyed by [`RequiredField::key`]. + pub fields: BTreeMap, +} + +impl ProviderConfig { + /// Create an empty config. + pub fn new() -> Self { + Self::default() + } + + /// Look up a configured field value by key. + pub fn get(&self, key: &str) -> Option<&str> { + self.fields.get(key).map(String::as_str) + } + + /// Look up a required field, returning [`RemoteError::MissingField`] when + /// absent. Convenience for providers validating their inputs in `start`. + pub fn require(&self, key: &str) -> RemoteResult<&str> { + self.get(key) + .ok_or_else(|| RemoteError::MissingField(key.to_string())) + } +} + +/// Handle to a running tunnel. +/// +/// Field set is intentionally minimal (`provider_id` + `public_url`) so the +/// base type does not grow backend-specific fields. The opaque handle the +/// provider needs to later `stop`/`health` the tunnel (e.g. a child pid or +/// async task id) is carried in [`opaque`](Self::opaque) as a provider-owned +/// string the provider alone interprets. +#[derive(Debug, Clone)] +pub struct TunnelHandle { + /// Id of the provider that owns this tunnel. + pub provider_id: String, + /// Public URL the tunnel exposes. + pub public_url: url::Url, + /// Provider-owned opaque handle (e.g. child pid / task id) the provider + /// interprets when stopping or health-checking the tunnel. + pub opaque: String, +} + +/// A transport-agnostic remote-access tunnel backend. +/// +/// Mirrors `lucarne-adapter`'s `AdapterPlugin`: `Send + Sync`, async, with a +/// stable `id`/`name`. A provider is a **pure tunnel** (Locked decision L2): +/// `start` a local addr → public URL, `stop`, and report `health`. No auth. +/// +/// # Reserved backends (Locked decision L7) +/// +/// Additional backends — FRP, a self-hosted lightweight relay, and other +/// NAT-traversal tunnels — are **reserved seams only and are not implemented +/// this release**. They will implement this trait in their own modules and +/// register via [`RemoteRegistry`] with zero core changes. The only concrete +/// implementation shipped here is [`Cloudflared`]. See ADR +/// `docs/decisions/2026-05-30-remote-access-tunnel-adapter.md` (decision L7) for +/// the reserved-backend rationale and the path to add one. +#[async_trait] +pub trait RemoteAccessProvider: Send + Sync { + /// Stable, unique provider id (e.g. `"cloudflared"`). + fn id(&self) -> &'static str; + + /// Human-readable provider name. + fn name(&self) -> &'static str; + + /// Fields this provider needs, used to drive CLI prompting. + fn required_fields(&self) -> &[RequiredField]; + + /// Deprecated provider-owned config section names accepted as compatibility + /// aliases for `remote.providers.`. + /// + /// The daemon treats these as opaque section names supplied by the provider + /// descriptor. Concrete alias names, field keys, and compatibility policy + /// stay at the provider boundary instead of becoming daemon/common schema. + fn compat_config_sections(&self) -> &[&'static str] { + &[] + } + + /// Operator-facing warnings about a given configuration (H6a). + /// + /// Lets a provider surface security/operational caveats about the config it + /// is about to run with — e.g. a Cloudflare *quick* tunnel terminates TLS at + /// the Cloudflare edge, so terminal content is visible there. The daemon + /// logs these instead of branching on a concrete provider id, keeping + /// provider-specific warnings inside the provider (AGENTS.md boundary). + /// Default: no warnings. + fn warnings(&self, _cfg: &ProviderConfig) -> Vec { + Vec::new() + } + + /// Validate a configuration before `start` (M7). + /// + /// Lets a provider enforce cross-field / conditional rules that a flat + /// per-field `required` flag cannot express — e.g. cloudflared requires + /// `public_url` only when a named-tunnel `token` is present. The daemon (and + /// the CLI's interactive prompt) call this so the rule lives with the + /// provider, never as a CLI/daemon branch on a concrete provider id + /// (AGENTS.md boundary). + /// + /// The default implementation enforces the descriptor's own + /// [`RequiredField::is_required`] rules (always-required + `required_when`), + /// so a provider that only needs declarative conditional requirements gets + /// them for free; override to add richer checks (e.g. value formats). + /// Returns `Err(message)` describing the first violation. + fn validate_config(&self, cfg: &ProviderConfig) -> Result<(), String> { + for field in self.required_fields() { + if field.is_required(cfg) && cfg.get(field.key).filter(|v| !v.is_empty()).is_none() { + return Err(format!("missing required config field `{}`", field.key)); + } + } + Ok(()) + } + + /// HTTP header name(s) this provider's edge sets to carry the real client IP + /// (H6b). The gateway trusts these to resolve client identity — but ONLY when + /// the socket peer is the loopback tunnel source (a direct remote peer could + /// otherwise spoof them). Keeping the header name in the provider (rather than + /// hardcoded in the gateway) honors the provider boundary (AGENTS.md): e.g. + /// cloudflared returns `["cf-connecting-ip"]`. Default: none → the gateway + /// uses the socket peer only. + fn forwarded_identity_headers(&self) -> &[&'static str] { + &[] + } + + /// Start a tunnel from `local` and return a handle carrying the public URL. + async fn start(&self, local: SocketAddr, cfg: &ProviderConfig) -> RemoteResult; + + /// Stop a previously started tunnel, consuming its handle. + async fn stop(&self, handle: TunnelHandle) -> RemoteResult<()>; + + /// Report the current health of a running tunnel. + async fn health(&self, handle: &TunnelHandle) -> RemoteResult; +} + +/// Registry of remote-access providers. +/// +/// Mirrors `lucarne-adapter`'s `AdapterRegistry`: register implementations, +/// then look one up by id or enumerate the registered ids. Backends are stored +/// as `Arc` so they can be shared with the daemon +/// task holding the tunnel lifecycle (Locked decision L6). +/// +/// Reserved backends (FRP / lightweight relay / other NAT-traversal tunnels — +/// Locked decision L7) are **not implemented this release**; when added they +/// join here via [`register`](Self::register) with zero core changes. See ADR +/// `docs/decisions/2026-05-30-remote-access-tunnel-adapter.md`. +#[derive(Default, Clone)] +pub struct RemoteRegistry { + providers: Vec>, +} + +impl RemoteRegistry { + /// Create an empty registry. + pub fn new() -> Self { + Self::default() + } + + /// Register a provider implementation. + pub fn register

(&mut self, provider: P) + where + P: RemoteAccessProvider + 'static, + { + info!( + target: "lucarne_remote", + provider_id = provider.id(), + provider_name = provider.name(), + "remote-access provider registered" + ); + self.providers.push(Arc::new(provider)); + } + + /// Look up a registered provider by id. + pub fn get(&self, id: &str) -> Option> { + self.providers + .iter() + .find(|provider| provider.id() == id) + .cloned() + } + + /// Alias for [`get`](Self::get) — mirrors `AdapterRegistry` lookup naming. + pub fn lookup(&self, id: &str) -> Option> { + self.get(id) + } + + /// Ids of all registered providers, in registration order. + pub fn ids(&self) -> Vec<&'static str> { + self.providers + .iter() + .map(|provider| provider.id()) + .collect() + } + + /// Alias for [`ids`](Self::ids) — mirrors `AdapterRegistry` enumerate naming. + pub fn enumerate(&self) -> Vec<&'static str> { + self.ids() + } +} + +/// Built-in provider registry. +/// +/// Ships the concrete [`Cloudflared`] backend (Free decision F3: quick tunnel by +/// default, named tunnel via token). Reserved backends (FRP / lightweight relay +/// / other tunnels — Locked decision L7) join the same way later by registering +/// through [`RemoteRegistry::register`]. +pub fn builtin() -> RemoteRegistry { + let mut registry = RemoteRegistry::new(); + registry.register(Cloudflared::new()); + registry +} + +#[cfg(test)] +mod tests { + use super::*; + + struct DummyProvider { + id: &'static str, + fields: Vec, + } + + #[async_trait] + impl RemoteAccessProvider for DummyProvider { + fn id(&self) -> &'static str { + self.id + } + + fn name(&self) -> &'static str { + "Dummy" + } + + fn required_fields(&self) -> &[RequiredField] { + &self.fields + } + + async fn start( + &self, + _local: SocketAddr, + _cfg: &ProviderConfig, + ) -> RemoteResult { + Ok(TunnelHandle { + provider_id: self.id.to_string(), + public_url: url::Url::parse("https://example.test/").unwrap(), + opaque: String::new(), + }) + } + + async fn stop(&self, _handle: TunnelHandle) -> RemoteResult<()> { + Ok(()) + } + + async fn health(&self, _handle: &TunnelHandle) -> RemoteResult { + Ok(TunnelStatus::Up) + } + } + + fn dummy(id: &'static str) -> DummyProvider { + DummyProvider { + id, + fields: vec![RequiredField { + key: "token", + label: "Access Token", + secret: true, + required: true, + required_when: None, + }], + } + } + + #[test] + fn register_get_ids_round_trip() { + let mut registry = RemoteRegistry::new(); + assert!(registry.ids().is_empty()); + + registry.register(dummy("alpha")); + registry.register(dummy("beta")); + + assert_eq!(registry.ids(), vec!["alpha", "beta"]); + assert_eq!(registry.enumerate(), vec!["alpha", "beta"]); + + let found = registry.get("beta").expect("beta registered"); + assert_eq!(found.id(), "beta"); + // lookup is an alias of get. + assert_eq!(registry.lookup("beta").unwrap().id(), "beta"); + + assert!(registry.get("missing").is_none()); + } + + #[test] + fn required_field_construction() { + let provider = dummy("alpha"); + let fields = provider.required_fields(); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].key, "token"); + assert_eq!(fields[0].label, "Access Token"); + assert!(fields[0].secret); + assert!(fields[0].required); + } + + #[test] + fn provider_config_get_and_require() { + let mut cfg = ProviderConfig::new(); + cfg.fields.insert("token".to_string(), "secret".to_string()); + + assert_eq!(cfg.get("token"), Some("secret")); + assert_eq!(cfg.require("token").unwrap(), "secret"); + assert!(cfg.get("missing").is_none()); + assert!(matches!( + cfg.require("missing"), + Err(RemoteError::MissingField(key)) if key == "missing" + )); + } + + #[test] + fn builtin_registry_contains_cloudflared() { + let registry = builtin(); + assert!(registry.ids().contains(&"cloudflared")); + assert!(registry.get("cloudflared").is_some()); + } + + // M7: a `required_when` field is required only when its gating field is + // present, and the default `validate_config` enforces both always-required + // and conditional fields. + #[test] + fn required_when_drives_conditional_requirement() { + struct CondProvider { + fields: Vec, + } + #[async_trait] + impl RemoteAccessProvider for CondProvider { + fn id(&self) -> &'static str { + "cond" + } + fn name(&self) -> &'static str { + "Cond" + } + fn required_fields(&self) -> &[RequiredField] { + &self.fields + } + async fn start( + &self, + _local: SocketAddr, + _cfg: &ProviderConfig, + ) -> RemoteResult { + unreachable!("not started in this test") + } + async fn stop(&self, _handle: TunnelHandle) -> RemoteResult<()> { + Ok(()) + } + async fn health(&self, _handle: &TunnelHandle) -> RemoteResult { + Ok(TunnelStatus::Up) + } + } + + let provider = CondProvider { + fields: vec![ + RequiredField { + key: "token", + label: "Token", + secret: true, + required: false, + required_when: None, + }, + RequiredField { + key: "public_url", + label: "Public URL", + secret: false, + required: false, + // Required only when a token is present (any non-empty value). + required_when: Some(("token", ANY_VALUE)), + }, + ], + }; + + // No token → public_url is not required → config validates. + let empty = ProviderConfig::new(); + assert!(!provider.required_fields()[1].is_required(&empty)); + assert!(provider.validate_config(&empty).is_ok()); + + // Token present but no public_url → public_url becomes required → error. + let mut with_token = ProviderConfig::new(); + with_token + .fields + .insert("token".to_string(), "abc".to_string()); + assert!(provider.required_fields()[1].is_required(&with_token)); + let err = provider + .validate_config(&with_token) + .expect_err("public_url required when token set"); + assert!(err.contains("public_url"), "got: {err}"); + + // Token + public_url → validates. + let mut both = with_token.clone(); + both.fields + .insert("public_url".to_string(), "https://x".to_string()); + assert!(provider.validate_config(&both).is_ok()); + + // Exact-value match form. + let exact = RequiredField { + key: "extra", + label: "Extra", + secret: false, + required: false, + required_when: Some(("mode", "named")), + }; + let mut mode_named = ProviderConfig::new(); + mode_named + .fields + .insert("mode".to_string(), "named".to_string()); + assert!(exact.is_required(&mode_named)); + let mut mode_quick = ProviderConfig::new(); + mode_quick + .fields + .insert("mode".to_string(), "quick".to_string()); + assert!(!exact.is_required(&mode_quick)); + } +} diff --git a/crates/lucarne-remote/tests/cloudflare_api_e2e.rs b/crates/lucarne-remote/tests/cloudflare_api_e2e.rs new file mode 100644 index 0000000..24c6f9d --- /dev/null +++ b/crates/lucarne-remote/tests/cloudflare_api_e2e.rs @@ -0,0 +1,273 @@ +//! Env-gated Cloudflare API contract E2E for named tunnel provisioning. +//! +//! This is intentionally not a default network test. Run it only when you have +//! a disposable Cloudflare account/API token available: +//! +//! ```text +//! LUCARNE_CF_API_E2E=1 \ +//! CLOUDFLARE_ACCOUNT_ID=... \ +//! CLOUDFLARE_API_TOKEN=... \ +//! cargo +nightly test -Zbuild-dir-new-layout -p lucarne-remote --test cloudflare_api_e2e +//! ``` +//! +//! Required token permission, per Cloudflare's tunnel API docs: Cloudflare +//! Tunnel write/edit on the target account. The test creates a named tunnel, +//! fetches its connector token, and best-effort deletes it. It never starts +//! `cloudflared`, creates DNS records, or prints the token, so it verifies the +//! Cloudflare API contract rather than `Cloudflared::start`'s binary path. +//! Because no connector is started, the tunnel should have no active connections +//! when the cleanup DELETE runs. + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use reqwest::StatusCode; +use serde::Deserialize; +use serde_json::json; + +const CF_API_BASE: &str = "https://api.cloudflare.com/client/v4"; + +#[derive(Debug)] +struct CfE2eEnv { + account_id: String, + api_token: String, +} + +impl CfE2eEnv { + fn load() -> Option { + if std::env::var("LUCARNE_CF_API_E2E").ok().as_deref() != Some("1") { + eprintln!("skipping Cloudflare API E2E: set LUCARNE_CF_API_E2E=1 to run it"); + return None; + } + + let account_id = match std::env::var("CLOUDFLARE_ACCOUNT_ID") { + Ok(value) if !value.trim().is_empty() => value, + _ => { + panic!( + "LUCARNE_CF_API_E2E=1 requires CLOUDFLARE_ACCOUNT_ID; \ + unset LUCARNE_CF_API_E2E to skip this network test" + ); + } + }; + let api_token = match std::env::var("CLOUDFLARE_API_TOKEN") { + Ok(value) if !value.trim().is_empty() => value, + _ => { + panic!( + "LUCARNE_CF_API_E2E=1 requires CLOUDFLARE_API_TOKEN with \ + Cloudflare Tunnel write/edit permissions; \ + unset LUCARNE_CF_API_E2E to skip this network test" + ); + } + }; + + Some(Self { + account_id, + api_token, + }) + } +} + +#[derive(Debug, Deserialize)] +struct CfEnvelope { + success: bool, + result: Option, + errors: Vec, +} + +#[derive(Debug, Deserialize)] +struct CfApiMessage { + code: Option, + message: String, +} + +#[derive(Debug, Deserialize)] +struct CreatedTunnel { + id: String, + name: String, +} + +async fn expect_cf_success Deserialize<'de>>( + response: reqwest::Response, + operation: &str, +) -> T { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|err| format!("")); + assert!( + status.is_success(), + "{operation} returned HTTP {status}: {}", + redact_cf_body(&body) + ); + + let envelope: CfEnvelope = serde_json::from_str(&body).unwrap_or_else(|err| { + panic!( + "{operation} returned non-conforming Cloudflare envelope: {err}; body: {}", + redact_cf_body(&body) + ) + }); + assert!( + envelope.success, + "{operation} returned success=false: {}", + format_cf_errors(&envelope.errors) + ); + envelope + .result + .unwrap_or_else(|| panic!("{operation} returned success=true without result")) +} + +fn format_cf_errors(errors: &[CfApiMessage]) -> String { + if errors.is_empty() { + return "".to_string(); + } + errors + .iter() + .map(|err| match err.code { + Some(code) => format!("{code}: {}", err.message), + None => err.message.clone(), + }) + .collect::>() + .join("; ") +} + +fn redact_cf_body(body: &str) -> String { + let Ok(mut value) = serde_json::from_str::(body) else { + return body.to_string(); + }; + redact_json_tokens(&mut value); + serde_json::to_string(&value).unwrap_or_else(|_| "".to_string()) +} + +fn redact_json_tokens(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(map) => { + for (key, value) in map.iter_mut() { + let sensitive = key.eq_ignore_ascii_case("token") + || key.eq_ignore_ascii_case("tunnel_token") + || key.eq_ignore_ascii_case("client_secret") + || key.eq_ignore_ascii_case("secret"); + if sensitive { + *value = serde_json::Value::String("".to_string()); + } else { + redact_json_tokens(value); + } + } + } + serde_json::Value::Array(items) => { + for item in items { + redact_json_tokens(item); + } + } + _ => {} + } +} + +fn unique_tunnel_name() -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + format!("lucarne-e2e-{}-{millis}", std::process::id()) +} + +#[tokio::test] +async fn cloudflare_api_can_create_token_and_delete_named_tunnel() { + let Some(env) = CfE2eEnv::load() else { + return; + }; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("build Cloudflare API client"); + let base = format!("{CF_API_BASE}/accounts/{}/cfd_tunnel", env.account_id); + let name = unique_tunnel_name(); + + let create_body = json!({ + "name": name, + "config_src": "cloudflare", + }); + let created: CreatedTunnel = expect_cf_success( + client + .post(&base) + .bearer_auth(&env.api_token) + .json(&create_body) + .send() + .await + .expect("send Cloudflare create tunnel request"), + "create Cloudflare tunnel", + ) + .await; + + let cleanup = TunnelCleanup { + client: client.clone(), + api_token: env.api_token.clone(), + url: format!("{base}/{}", created.id), + }; + + let mut failures = Vec::new(); + if created.name != name { + failures.push(format!( + "Cloudflare should echo the tunnel name; expected `{name}`, got `{}`", + created.name + )); + } + if created.id.trim().is_empty() { + failures.push("Cloudflare create tunnel result must include id".to_string()); + } + + match client + .get(format!("{base}/{}/token", created.id)) + .bearer_auth(&env.api_token) + .send() + .await + { + Ok(response) => { + let token: String = expect_cf_success(response, "get Cloudflare tunnel token").await; + if token.len() < 40 { + failures.push( + "Cloudflare connector token should be present and non-trivial".to_string(), + ); + } + } + Err(err) => failures.push(format!( + "could not send Cloudflare get tunnel token request: {err}" + )), + } + + cleanup.delete().await; + + assert!( + failures.is_empty(), + "Cloudflare API E2E failures after create: {}", + failures.join("; ") + ); +} + +struct TunnelCleanup { + client: reqwest::Client, + api_token: String, + url: String, +} + +impl TunnelCleanup { + async fn delete(self) { + let response = self + .client + .delete(&self.url) + .bearer_auth(&self.api_token) + .send() + .await + .expect("send Cloudflare delete tunnel request"); + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|err| format!("")); + assert!( + status.is_success() || status == StatusCode::NOT_FOUND, + "delete Cloudflare tunnel returned HTTP {status}: {}", + redact_cf_body(&body) + ); + } +} diff --git a/crates/lucarne-rmux/Cargo.toml b/crates/lucarne-rmux/Cargo.toml new file mode 100644 index 0000000..a73792f --- /dev/null +++ b/crates/lucarne-rmux/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "lucarne-rmux" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Rmux terminal capability: domain types, archive, adapter, and live monitor." +rust-version.workspace = true +repository.workspace = true + +# The rmux terminal capability package: stable terminal domain/wire types, +# archive helpers, adapter, and live monitor. This is the only crate that names +# `rmux_sdk`; preview-API churn stops here. + +[dependencies] +# PINNED rmux preview (=0.3.1) — preview APIs break between patches; bump only +# deliberately. Validated against the running daemon in spike4-attach-handoff. +rmux-sdk = "=0.3.1" +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +bitflags = "2" +dirs = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/lucarne-rmux/src/adapter.rs b/crates/lucarne-rmux/src/adapter.rs new file mode 100644 index 0000000..f7e920a --- /dev/null +++ b/crates/lucarne-rmux/src/adapter.rs @@ -0,0 +1,182 @@ +//! adapter — the sole boundary that touches `rmux_sdk` value types. +//! +//! Converts a captured `rmux_sdk::PaneSnapshot` into the stable terminal +//! [`crate::term::PaneGrid`] vocabulary. Field shapes are verbatim from the +//! rmux-sdk 0.3.1 probe (verified against the installed crate source): +//! - `PaneSnapshot { cols, rows, cells, cursor, revision }` +//! - `PaneCell { glyph, attributes, foreground, background, underline }` +//! - `PaneGlyph { text: String, width: u8, padding: bool }` +//! - `PaneColor` — 8 variants (`#[non_exhaustive]`) +//! - `PaneAttributes { bits: u16 }` +//! - `PaneCursor { row, col, visible, style: u32 }` +//! +//! Keeping this the only place that names `rmux_*` value types means preview-API +//! churn never leaks into the gateway or web client. + +use rmux_sdk::{PaneAttributes, PaneColor, PaneCursor, PaneSnapshot}; + +use crate::term::{Cell, Color, Cursor, PaneGrid, Style}; + +/// Maps one `rmux_sdk::PaneColor` to a terminal [`Color`] (1:1). +/// +/// `PaneColor` is `#[non_exhaustive]`, so the trailing arm keeps this compiling +/// against future SDK variants. Rather than dropping an unknown color +/// (fix-don't-hide), it is preserved via the raw `encoded()` round-trip. +pub fn map_color(color: PaneColor) -> Color { + match color { + PaneColor::Default => Color::Default, + PaneColor::None => Color::None, + PaneColor::Terminal => Color::Terminal, + PaneColor::Ansi { index } => Color::Ansi(index), + PaneColor::BrightAnsi { index } => Color::BrightAnsi(index), + PaneColor::Indexed { index } => Color::Indexed(index), + PaneColor::Rgb { red, green, blue } => Color::Rgb(red, green, blue), + PaneColor::Encoded { value } => Color::Encoded(value), + // Forward-compat: a future #[non_exhaustive] variant is preserved as its + // raw encoding instead of being silently lost. + other => Color::Encoded(other.encoded()), + } +} + +/// Maps `rmux_sdk::PaneAttributes` (raw `u16` bitset) to terminal [`Style`]. +/// Uses `from_bits_retain` so unknown/future bits survive (fix-don't-hide) — the +/// bit layout is identical between the two types. +pub fn map_style(attrs: PaneAttributes) -> Style { + Style::from_bits_retain(attrs.bits()) +} + +/// Maps `rmux_sdk::PaneCursor` to terminal [`Cursor`]. rmux uses `row`/`col` +/// (not `x`/`y`) and carries an undecoded DECSCUSR `style: u32`. +pub fn map_cursor(cursor: PaneCursor) -> Cursor { + Cursor { + row: cursor.row, + col: cursor.col, + visible: cursor.visible, + style_raw: cursor.style, + } +} + +/// Maps one `rmux_sdk::PaneCell` to terminal [`Cell`]. +fn map_cell(cell: &rmux_sdk::PaneCell) -> Cell { + Cell { + text: cell.glyph.text.clone(), + width: cell.glyph.width, + padding: cell.glyph.padding, + fg: map_color(cell.foreground), + bg: map_color(cell.background), + underline_color: map_color(cell.underline), + style: map_style(cell.attributes), + } +} + +/// Converts a captured `PaneSnapshot` into the rmux-free `PaneGrid` wire type. +/// Cells stay row-major (`index = row * cols + col`), so `cells.len() == cols * +/// rows` is preserved verbatim; `revision` becomes the `rev` delta baseline. +pub fn snapshot_to_grid(snap: &PaneSnapshot) -> PaneGrid { + PaneGrid { + cols: snap.cols, + rows: snap.rows, + cells: snap.cells.iter().map(map_cell).collect(), + rev: snap.revision, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rmux_sdk::{PaneCell, PaneGlyph}; + + /// Builds a tiny 2x2 snapshot: a wide CJK glyph + its padding cell on row 0, + /// an Rgb-fg / BOLD|UNDERLINE styled cell and a blank on row 1. + fn fixture() -> PaneSnapshot { + let wide = PaneCell { + glyph: PaneGlyph::new("你", 2), + ..PaneCell::default() + }; + let pad = PaneCell::padding(); + let styled = PaneCell { + glyph: PaneGlyph::new("p", 1), + attributes: PaneAttributes::BOLD | PaneAttributes::UNDERLINE, + foreground: PaneColor::rgb(10, 20, 30), + background: PaneColor::indexed(200), + underline: PaneColor::ansi(3), + }; + let blank = PaneCell::blank(); + + PaneSnapshot::new( + 2, + 2, + vec![wide, pad, styled, blank], + PaneCursor::new(1, 0, true, 7), + ) + .expect("2x2 snapshot shape is valid") + .with_revision(42) + } + + #[test] + fn grid_shape_matches_dims() { + let grid = snapshot_to_grid(&fixture()); + assert_eq!(grid.cols, 2); + assert_eq!(grid.rows, 2); + assert_eq!(grid.rev, 42); + assert_eq!( + grid.cells.len(), + usize::from(grid.cols) * usize::from(grid.rows) + ); + } + + #[test] + fn wide_glyph_and_padding_carry_width() { + let grid = snapshot_to_grid(&fixture()); + let wide = &grid.cells[0]; + assert_eq!(wide.text, "你"); + assert_eq!(wide.width, 2); + assert!(!wide.padding); + let pad = &grid.cells[1]; + assert_eq!(pad.width, 0); + assert!(pad.padding); + } + + #[test] + fn colors_and_style_map_per_variant() { + let grid = snapshot_to_grid(&fixture()); + let styled = &grid.cells[2]; + assert_eq!(styled.fg, Color::Rgb(10, 20, 30)); + assert_eq!(styled.bg, Color::Indexed(200)); + assert_eq!(styled.underline_color, Color::Ansi(3)); + assert!(styled.style.contains(Style::BOLD)); + assert!(styled.style.contains(Style::UNDERLINE)); + assert!(!styled.style.contains(Style::ITALIC)); + } + + #[test] + fn cursor_maps_row_col_and_raw_style() { + let cursor = map_cursor(PaneCursor::new(5, 9, false, 6)); + assert_eq!(cursor.row, 5); + assert_eq!(cursor.col, 9); + assert!(!cursor.visible); + assert_eq!(cursor.style_raw, 6); + } + + #[test] + fn all_eight_color_variants_round_trip() { + assert_eq!(map_color(PaneColor::Default), Color::Default); + assert_eq!(map_color(PaneColor::None), Color::None); + assert_eq!(map_color(PaneColor::Terminal), Color::Terminal); + assert_eq!(map_color(PaneColor::ansi(7)), Color::Ansi(7)); + assert_eq!(map_color(PaneColor::bright_ansi(4)), Color::BrightAnsi(4)); + assert_eq!(map_color(PaneColor::indexed(123)), Color::Indexed(123)); + assert_eq!(map_color(PaneColor::rgb(1, 2, 3)), Color::Rgb(1, 2, 3)); + assert_eq!( + map_color(PaneColor::Encoded { value: -5 }), + Color::Encoded(-5) + ); + } + + #[test] + fn unknown_bits_are_retained() { + let attrs = PaneAttributes::from_bits(0x8000 | PaneAttributes::BOLD.bits()); + let style = map_style(attrs); + assert_eq!(style.bits(), 0x8000 | Style::BOLD.bits()); + } +} diff --git a/crates/lucarne-rmux/src/archive.rs b/crates/lucarne-rmux/src/archive.rs new file mode 100644 index 0000000..4479780 --- /dev/null +++ b/crates/lucarne-rmux/src/archive.rs @@ -0,0 +1,255 @@ +//! Shared terminal-session archive store. +//! +//! Terminal archives are part of the rmux terminal capability. They are stored as +//! JSON records under `~/.lucarne/term-archive/.json`. + +use std::fs; +use std::io::Write; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +#[cfg(test)] +static TEST_ARCHIVE_DIR: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// A full archived terminal record (with preserved content). +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ArchiveRecord { + pub archive_id: String, + pub session_id: String, + pub title: String, + pub cwd: Option, + pub archived_at: u64, + pub content: String, +} + +/// Archive metadata for listings (no content). +#[derive(Serialize, Clone, Debug)] +pub struct ArchiveMeta { + pub archive_id: String, + pub session_id: String, + pub title: String, + pub cwd: Option, + pub archived_at: u64, +} + +fn dir() -> PathBuf { + #[cfg(test)] + if let Some(path) = TEST_ARCHIVE_DIR.lock().expect("archive test dir").clone() { + return path; + } + + dirs::home_dir() + .unwrap_or_default() + .join(".lucarne") + .join("term-archive") +} + +/// Persist an archive record; returns its `archive_id`. +pub fn save( + session_id: &str, + title: &str, + cwd: Option<&str>, + content: &str, + archived_at: u64, +) -> std::io::Result { + let d = dir(); + create_owner_only_dir(&d)?; + let archive_id = format!("{}-{}", session_id.replace([':', '/'], "_"), archived_at); + let record = ArchiveRecord { + archive_id: archive_id.clone(), + session_id: session_id.to_string(), + title: title.to_string(), + cwd: cwd.map(str::to_string), + archived_at, + content: content.to_string(), + }; + write_owner_only_file( + &d.join(format!("{archive_id}.json")), + &serde_json::to_vec(&record)?, + )?; + Ok(archive_id) +} + +fn create_owner_only_dir(path: &std::path::Path) -> std::io::Result<()> { + fs::create_dir_all(path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} + +fn write_owner_only_file(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + let mut options = fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + file.write_all(bytes)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +/// List archived sessions (newest first, without content). +pub fn list() -> Vec { + let mut out = Vec::new(); + if let Ok(rd) = fs::read_dir(dir()) { + for entry in rd.flatten() { + if entry.path().extension().and_then(|x| x.to_str()) != Some("json") { + continue; + } + let Ok(bytes) = fs::read(entry.path()) else { + continue; + }; + let Ok(rec) = serde_json::from_slice::(&bytes) else { + continue; + }; + out.push(ArchiveMeta { + archive_id: rec.archive_id, + session_id: rec.session_id, + title: rec.title, + cwd: rec.cwd, + archived_at: rec.archived_at, + }); + } + } + out.sort_by_key(|b| std::cmp::Reverse(b.archived_at)); + out +} + +/// Read one archive record by id (rejects path-traversal ids). +pub fn get(archive_id: &str) -> Option { + if archive_id.contains('/') || archive_id.contains("..") { + return None; + } + let bytes = fs::read(dir().join(format!("{archive_id}.json"))).ok()?; + serde_json::from_slice(&bytes).ok() +} + +/// Current unix epoch seconds (archive timestamp helper). +pub fn now_epoch() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::sync::{Mutex, MutexGuard}; + + use tempfile::TempDir; + + use super::{ArchiveRecord, TEST_ARCHIVE_DIR}; + + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + struct ArchiveSandbox { + _lock: MutexGuard<'static, ()>, + _dir: TempDir, + } + + impl ArchiveSandbox { + fn new() -> Self { + let lock = TEST_LOCK.lock().expect("archive test lock"); + let dir = tempfile::tempdir().expect("archive tempdir"); + *TEST_ARCHIVE_DIR.lock().expect("archive dir override") = + Some(dir.path().to_path_buf()); + Self { + _lock: lock, + _dir: dir, + } + } + + fn path(&self) -> &std::path::Path { + self._dir.path() + } + } + + impl Drop for ArchiveSandbox { + fn drop(&mut self) { + *TEST_ARCHIVE_DIR.lock().expect("archive dir override") = None; + } + } + + #[test] + fn save_get_and_list_roundtrip_with_newest_first_metadata() { + let sandbox = ArchiveSandbox::new(); + + let older = super::save("work:0/0", "work", Some("/tmp/work"), "first", 10).unwrap(); + let newer = super::save("work:0/0", "work", None, "second", 20).unwrap(); + + assert_eq!(older, "work_0_0-10"); + assert_eq!(newer, "work_0_0-20"); + assert!(sandbox.path().join("work_0_0-10.json").exists()); + + let record = super::get(&older).expect("older archive"); + assert_eq!(record.session_id, "work:0/0"); + assert_eq!(record.title, "work"); + assert_eq!(record.cwd.as_deref(), Some("/tmp/work")); + assert_eq!(record.content, "first"); + + let list = super::list(); + assert_eq!(list.len(), 2); + assert_eq!(list[0].archive_id, newer); + assert_eq!(list[0].archived_at, 20); + assert_eq!(list[1].archive_id, older); + assert_eq!(list[1].archived_at, 10); + } + + #[test] + fn list_skips_bad_json_and_get_rejects_path_traversal() { + let sandbox = ArchiveSandbox::new(); + + let archive_id = super::save("safe", "safe", None, "content", 42).unwrap(); + fs::write(sandbox.path().join("bad.json"), b"not json").unwrap(); + fs::write(sandbox.path().join("ignore.txt"), b"ignored").unwrap(); + + assert!(super::get("../safe").is_none()); + assert!(super::get("nested/safe").is_none()); + assert!(super::get(&archive_id).is_some()); + + let list = super::list(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].archive_id, archive_id); + } + + #[test] + fn archive_record_schema_preserves_content_on_disk() { + let sandbox = ArchiveSandbox::new(); + + let archive_id = super::save("pane", "pane", None, "full scrollback", 7).unwrap(); + let bytes = fs::read(sandbox.path().join(format!("{archive_id}.json"))).unwrap(); + let record: ArchiveRecord = serde_json::from_slice(&bytes).unwrap(); + + assert_eq!(record.archive_id, archive_id); + assert_eq!(record.content, "full scrollback"); + assert!(super::now_epoch() > 0); + } + + #[cfg(unix)] + #[test] + fn archive_dir_and_files_are_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let sandbox = ArchiveSandbox::new(); + let archive_id = super::save("pane", "pane", None, "secret scrollback", 9).unwrap(); + let file = sandbox.path().join(format!("{archive_id}.json")); + + let dir_mode = fs::metadata(sandbox.path()).unwrap().permissions().mode() & 0o777; + let file_mode = fs::metadata(file).unwrap().permissions().mode() & 0o777; + + assert_eq!(dir_mode, 0o700); + assert_eq!(file_mode, 0o600); + } +} diff --git a/crates/lucarne-rmux/src/cli.rs b/crates/lucarne-rmux/src/cli.rs new file mode 100644 index 0000000..f4dd70f --- /dev/null +++ b/crates/lucarne-rmux/src/cli.rs @@ -0,0 +1,256 @@ +//! rmux CLI helpers used by the monitor and the `lucarned tui` sessions panel. +//! +//! The live monitor uses `rmux_sdk` for hot-path observation, but a few +//! operational actions still need the tmux-compatible rmux CLI: attach/detach, +//! archive capture, and `#{pane_current_path}` lookup. Keep binary resolution and +//! bounded process waits in one place so the daemon and TUI do not drift. + +use std::path::{Path, PathBuf}; +use std::process::{ExitStatus, Output}; +use std::time::{Duration, Instant}; + +/// Default timeout for blocking rmux CLI actions. +/// +/// Archive/path/list actions should be quick. Attach is intentionally +/// interactive, so callers use [`run_status_interactive`] with a longer timeout. +pub const RMUX_CLI_TIMEOUT: Duration = Duration::from_secs(10); + +/// Timeout for interactive attach handoff. This bounds a hung child while still +/// allowing a normal operator attach/detach workflow. +pub const RMUX_ATTACH_TIMEOUT: Duration = Duration::from_secs(12 * 60 * 60); + +/// Errors from binary resolution or CLI execution. +#[derive(Debug, thiserror::Error)] +pub enum RmuxCliError { + #[error("{0}")] + Resolve(String), + #[error("rmux command `{command}` failed: {source}")] + Io { + command: String, + #[source] + source: std::io::Error, + }, + #[error("rmux command `{command}` timed out after {timeout_secs}s")] + Timeout { command: String, timeout_secs: u64 }, +} + +/// Resolve the rmux binary to a vetted path. +/// +/// Resolution order: +/// 1. `~/.cargo/bin/rmux`, when it exists. +/// 2. Absolute `$PATH` entries containing `rmux`. +/// +/// Relative `$PATH` entries and binaries in world-writable directories are +/// refused. If nothing is found, return bare `rmux` as a compatibility fallback: +/// manual/dev environments may still rely on platform launcher semantics. The +/// unsafe cases we can positively identify are rejected rather than spawned. +pub fn resolve_rmux_binary() -> Result { + if let Some(home) = std::env::var_os("HOME") { + let p = PathBuf::from(home).join(".cargo/bin/rmux"); + if p.is_file() { + vet_binary(&p)?; + return Ok(p); + } + } + + if let Some(path_var) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&path_var) { + if !dir.is_absolute() { + continue; + } + let candidate = dir.join("rmux"); + if candidate.is_file() { + vet_binary(&candidate)?; + return Ok(candidate); + } + } + } + + Ok(PathBuf::from("rmux")) +} + +/// Resolve the rmux binary for display / argv construction. +pub fn rmux_binary_display() -> String { + resolve_rmux_binary() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "rmux".to_string()) +} + +/// Run `rmux ` inheriting stdio with the default non-interactive timeout. +pub fn run_status(args: &[&str]) -> Result { + run_status_with_timeout(args, RMUX_CLI_TIMEOUT) +} + +/// Run `rmux ` inheriting stdio with the long interactive attach timeout. +pub fn run_status_interactive(args: &[&str]) -> Result { + run_status_with_timeout(args, RMUX_ATTACH_TIMEOUT) +} + +/// Run `rmux ` and capture output with the default timeout. +pub fn output(args: &[&str]) -> Result { + output_with_timeout(args, RMUX_CLI_TIMEOUT) +} + +fn run_status_with_timeout(args: &[&str], timeout: Duration) -> Result { + let bin = resolve_rmux_binary()?; + let command = command_label(args); + let mut child = std::process::Command::new(&bin) + .args(args) + .spawn() + .map_err(|source| RmuxCliError::Io { + command: command.clone(), + source, + })?; + wait_status_timeout(&mut child, &command, timeout) +} + +fn output_with_timeout(args: &[&str], timeout: Duration) -> Result { + let bin = resolve_rmux_binary()?; + let command = command_label(args); + let mut child = std::process::Command::new(&bin) + .args(args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|source| RmuxCliError::Io { + command: command.clone(), + source, + })?; + wait_status_timeout(&mut child, &command, timeout)?; + child.wait_with_output().map_err(|source| RmuxCliError::Io { + command: command.clone(), + source, + }) +} + +/// Async output helper for monitor paths that must not block the Tokio runtime. +pub async fn output_async(args: &[&str]) -> Result { + let bin = resolve_rmux_binary()?; + let command = command_label(args); + let child = tokio::process::Command::new(&bin) + .args(args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|source| RmuxCliError::Io { + command: command.clone(), + source, + })?; + match tokio::time::timeout(RMUX_CLI_TIMEOUT, child.wait_with_output()).await { + Ok(Ok(out)) => Ok(out), + Ok(Err(source)) => Err(RmuxCliError::Io { command, source }), + Err(_) => Err(RmuxCliError::Timeout { + command, + timeout_secs: RMUX_CLI_TIMEOUT.as_secs(), + }), + } +} + +fn wait_status_timeout( + child: &mut std::process::Child, + command: &str, + timeout: Duration, +) -> Result { + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status), + Ok(None) if start.elapsed() >= timeout => { + let _ = child.kill(); + let _ = child.wait(); + return Err(RmuxCliError::Timeout { + command: command.to_string(), + timeout_secs: timeout.as_secs(), + }); + } + Ok(None) => std::thread::sleep(Duration::from_millis(25)), + Err(source) => { + return Err(RmuxCliError::Io { + command: command.to_string(), + source, + }) + } + } + } +} + +fn command_label(args: &[&str]) -> String { + if args.is_empty() { + "rmux".to_string() + } else { + format!("rmux {}", args.join(" ")) + } +} + +fn vet_binary(path: &Path) -> Result<(), RmuxCliError> { + let meta = std::fs::metadata(path).map_err(|err| { + RmuxCliError::Resolve(format!("`{}` is not accessible: {err}", path.display())) + })?; + if !meta.is_file() { + return Err(RmuxCliError::Resolve(format!( + "`{}` is not a regular file", + path.display() + ))); + } + if let Some(parent) = path.parent() { + if is_world_writable(parent)? { + return Err(RmuxCliError::Resolve(format!( + "refusing to spawn `{}`: its directory `{}` is world-writable", + path.display(), + parent.display() + ))); + } + } + Ok(()) +} + +#[cfg(unix)] +fn is_world_writable(dir: &Path) -> Result { + use std::os::unix::fs::PermissionsExt; + let meta = std::fs::metadata(dir).map_err(|err| { + RmuxCliError::Resolve(format!("`{}` is not accessible: {err}", dir.display())) + })?; + Ok(meta.permissions().mode() & 0o002 != 0) +} + +#[cfg(not(unix))] +fn is_world_writable(_dir: &Path) -> Result { + Ok(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn world_writable_binary_dir_is_refused() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let bin = dir.path().join("rmux"); + std::fs::write(&bin, b"#!/bin/sh\n").expect("write fake rmux"); + let mut perms = std::fs::metadata(dir.path()).unwrap().permissions(); + perms.set_mode(0o777); + std::fs::set_permissions(dir.path(), perms).expect("chmod"); + + let err = vet_binary(&bin).expect_err("world-writable dir rejected"); + assert!(err.to_string().contains("world-writable"), "got: {err}"); + } + + #[test] + fn missing_binary_is_a_resolve_error() { + let path = std::env::temp_dir().join("lucarne-no-such-rmux-binary"); + let err = vet_binary(&path).expect_err("missing binary rejected"); + assert!(matches!(err, RmuxCliError::Resolve(_))); + } + + #[test] + fn command_label_includes_args() { + assert_eq!( + command_label(&["attach-session", "-t", "work"]), + "rmux attach-session -t work" + ); + } +} diff --git a/crates/lucarne-rmux/src/lib.rs b/crates/lucarne-rmux/src/lib.rs new file mode 100644 index 0000000..d769fff --- /dev/null +++ b/crates/lucarne-rmux/src/lib.rs @@ -0,0 +1,21 @@ +//! lucarne-rmux — rmux terminal capability package. +//! +//! Connects to the SYSTEM rmux daemon (the daemon the user's own `rmux` uses), +//! mirrors its panes into a stable terminal vocabulary, archives terminal +//! sessions, and injects input. The package owns the terminal domain types plus +//! the only `rmux_sdk` binding used by Lucarne. + +pub mod adapter; +pub mod archive; +pub mod cli; +pub mod monitor; +pub mod term; + +pub use cli::{resolve_rmux_binary, rmux_binary_display, RmuxCliError}; +pub use monitor::{GridUpdate, MonitorError, RmuxMonitor}; +pub use term::{ + control_key_token, key_token, primary_pane_session_id, split_primary_pane_session_id, + validate_primary_pane_session_id, ClientFrame, Color, ControlKey, Cursor, DiffResult, Differ, + Dims, GridDelta, KeyMods, Origin, PaneGrid, PaneSelectionError, ServerFrame, SessionDescriptor, + SessionId, SessionRegistry, Style, TermInput, +}; diff --git a/crates/lucarne-rmux/src/monitor.rs b/crates/lucarne-rmux/src/monitor.rs new file mode 100644 index 0000000..5f36c0f --- /dev/null +++ b/crates/lucarne-rmux/src/monitor.rs @@ -0,0 +1,502 @@ +//! monitor — the live rmux-sdk binding: connect to the SYSTEM rmux daemon, adopt +//! its sessions, mirror each pane (render_stream → adapter → fan-out), and inject +//! input (send_text / send_key). +//! +//! ## Monitor model +//! We connect to the DEFAULT system socket — the same daemon the user's own +//! `rmux` CLI uses — and treat its sessions as `Origin::Adopted` (we observe; we +//! don't own them). Sessions created via [`RmuxMonitor::create`] register as +//! `Origin::Managed`. The SDK is a control-mode observer; it coexists with a CLI +//! `attach-session` client on the same session (proven in spike4-attach-handoff), +//! which is exactly what "pop out into a local terminal / retract" relies on. +//! +//! ## Boundary +//! This module names `rmux_sdk` runtime handles (`Rmux`/`Pane`/`Session`); every +//! value it emits is the stable terminal vocabulary re-exported by this crate. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use rmux_sdk::{ + EnsureSession, EnsureSessionPolicy, Pane, ProcessSpec, Rmux, SessionName, TerminalSizeSpec, +}; +use tokio::sync::{broadcast, Mutex}; +use tokio::task::JoinHandle; + +use crate::cli; +use crate::term::{ + control_key_token, key_token, primary_pane_session_id, validate_primary_pane_session_id, + Cursor, Dims, Origin, PaneGrid, SessionDescriptor, SessionId, SessionRegistry, TermInput, +}; + +use crate::adapter; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const DEFAULT_COLS: u16 = 120; +const DEFAULT_ROWS: u16 = 32; +/// Bounded scrollback window for [`RmuxMonitor::capture_scrollback`]: the most +/// recent `SCROLLBACK_CAPTURE_LINES` lines, never the whole pane. ADR +/// `2026-05-30-rmux-terminal-monitor-subsystem.md` boundary rule 5 requires +/// terminal scrollback/history reads to be bounded windows, not whole-pane scans. +pub const SCROLLBACK_CAPTURE_LINES: u32 = 1000; +const _: () = assert!( + SCROLLBACK_CAPTURE_LINES > 0 && SCROLLBACK_CAPTURE_LINES <= 1000, + "scrollback capture must be a bounded, sane window" +); +/// Fan-out buffer for mirror grid updates. A slow subscriber lags (and gets a +/// `RecvError::Lagged`) rather than back-pressuring the source loops. +const GRID_BROADCAST_CAP: usize = 256; + +/// Errors from the rmux binding. rmux-sdk errors are flattened to their `Display` +/// string so the preview SDK's concrete error type never leaks across the +/// boundary (matches Lucarne's `thiserror` convention; no `anyhow` in the lib). +#[derive(Debug, thiserror::Error)] +pub enum MonitorError { + #[error("rmux: {0}")] + Rmux(String), + #[error("session not tracked: {0}")] + NotFound(SessionId), + #[error("invalid session name: {0}")] + InvalidName(String), +} + +type Result = std::result::Result; + +/// `rmux capture-pane -S` start argument for bounded archive/scrollback reads. +/// +/// Keep this shared between the live gateway monitor and the TUI archive path so +/// every terminal-history capture uses the same capped window. +pub fn scrollback_capture_start_arg() -> String { + format!("-{SCROLLBACK_CAPTURE_LINES}") +} + +/// A fresh full grid for one monitored pane, fanned out to mirror subscribers. +/// (The differ that turns these into deltas lives downstream, per-client, in the +/// gateway — the monitor publishes full frames.) +#[derive(Clone, Debug)] +pub struct GridUpdate { + pub session: SessionId, + pub grid: PaneGrid, + pub cursor: Cursor, +} + +/// A pane we mirror, plus the rmux session name needed for lifecycle ops (kill). +struct Tracked { + pane: Pane, + name: SessionName, +} + +/// Live handle to the monitored SYSTEM rmux daemon. +pub struct RmuxMonitor { + rmux: Rmux, + registry: Arc>, + tracked: Arc>>, + updates: broadcast::Sender, + /// JoinHandles for every detached per-pane source loop spawned by + /// [`RmuxMonitor::spawn_source_loop`]. Held so they can be aborted when the + /// monitor is dropped (Drop-based abort, mirroring + /// `lucarne_adapter::AdapterSupervisorHandle::drop`): without this the loops + /// would leak — they only `break` when rmux closes their render stream, so a + /// dropped monitor whose streams stay open would otherwise spin tasks forever. + source_loops: Arc>>>, +} + +/// Abort every spawned source loop when the monitor is dropped (mirrors +/// `AdapterSupervisorHandle::drop`'s task abort). Each loop's only self-exit is +/// "rmux closed the render stream"; aborting here guarantees the detached tasks +/// are reclaimed promptly on monitor teardown rather than leaking until the +/// runtime stops. `try_lock` (not blocking) keeps `Drop` non-blocking; the +/// `tracked`/`source_loops` mutex is uncontended at drop time (no other holder +/// remains once the monitor is being dropped), so the abort lands in practice. +impl Drop for RmuxMonitor { + fn drop(&mut self) { + if let Ok(loops) = self.source_loops.try_lock() { + for handle in loops.iter() { + handle.abort(); + } + } + } +} + +impl RmuxMonitor { + /// Connect to the system rmux daemon (default socket — the daemon the user's + /// own `rmux` uses). Starts the hidden daemon if none is running. + pub async fn connect() -> Result { + let rmux = Rmux::builder() + .default_timeout(CONNECT_TIMEOUT) + .connect_or_start() + .await + .map_err(|e| MonitorError::Rmux(format!("connect_or_start: {e}")))?; + tracing::info!( + target: "lucarne_rmux", + endpoint = ?rmux.endpoint(), + "connected to system rmux daemon" + ); + let (updates, _) = broadcast::channel(GRID_BROADCAST_CAP); + Ok(Self { + rmux, + registry: Arc::new(Mutex::new(SessionRegistry::new())), + tracked: Arc::new(Mutex::new(HashMap::new())), + updates, + source_loops: Arc::new(Mutex::new(Vec::new())), + }) + } + + /// Subscribe to the fan-out of fresh pane grids (one stream, all sessions). + pub fn subscribe(&self) -> broadcast::Receiver { + self.updates.subscribe() + } + + /// Snapshot of the registry (what the CLI `ls` / gateway `SessionList` read). + pub async fn sessions(&self) -> Vec { + self.registry.lock().await.list() + } + + /// Adopt every pre-existing session on the system daemon (monitor model): + /// register each as `Origin::Adopted`, open its pane, spawn its source loop. + pub async fn adopt_all(&self) -> Result> { + let names = self + .rmux + .list_sessions() + .await + .map_err(|e| MonitorError::Rmux(format!("list_sessions: {e}")))?; + let mut out = Vec::new(); + for name in names { + let title = name.as_str().to_string(); + match self.track(name, Origin::Adopted, title).await { + Ok(desc) => out.push(desc), + Err(e) => tracing::warn!( + target: "lucarne_rmux", + error = %e, + "adopt failed; skipping session" + ), + } + } + Ok(out) + } + + /// Create a new shell session on the system daemon (registers as Managed). + pub async fn create(&self, title: impl Into) -> Result { + let raw = unique_session_name(); + let name = + SessionName::new(&raw).map_err(|e| MonitorError::InvalidName(format!("{raw}: {e}")))?; + self.rmux + .ensure_session( + EnsureSession::named(name.clone()) + .policy(EnsureSessionPolicy::CreateOrReuse) + .detached(true) + .size(TerminalSizeSpec::new(DEFAULT_COLS, DEFAULT_ROWS)) + .process(ProcessSpec::argv([default_shell()])), + ) + .await + .map_err(|e| MonitorError::Rmux(format!("ensure_session: {e}")))?; + self.track(name, Origin::Managed, title.into()).await + } + + /// One-shot full grid for a tracked session (gateway resync / CLI peek). + pub async fn snapshot_grid(&self, id: &SessionId) -> Result<(PaneGrid, Cursor)> { + // Concurrency: clone out only the `Pane` handle under the `tracked` lock, + // then DROP the guard before the rmux `.await`. Holding the global + // `tracked` lock across the await would serialize snapshot/inject across + // ALL sessions (one slow pane blocks every other session). `Pane` is a + // cheap clonable handle, so this is behavior-preserving. + let pane = { + let guard = self.tracked.lock().await; + let tracked = guard + .get(id) + .ok_or_else(|| MonitorError::NotFound(id.clone()))?; + tracked.pane.clone() + }; + let snap = pane + .snapshot() + .await + .map_err(|e| MonitorError::Rmux(format!("snapshot {id}: {e}")))?; + Ok(( + adapter::snapshot_to_grid(&snap), + adapter::map_cursor(snap.cursor), + )) + } + + /// Inject input into a tracked pane. Resize is a hint only (#5). + pub async fn inject(&self, id: &SessionId, input: TermInput) -> Result<()> { + // Concurrency: clone the `Pane` under the lock and drop the guard before + // the rmux `.await` (same rationale as `snapshot_grid`): never hold the + // global `tracked` lock across a per-pane rmux await, so input to one + // pane cannot block snapshots/input on every other session. + let pane = { + let guard = self.tracked.lock().await; + let tracked = guard + .get(id) + .ok_or_else(|| MonitorError::NotFound(id.clone()))?; + tracked.pane.clone() + }; + match input { + TermInput::Text { text } => pane + .send_text(&text) + .await + .map_err(|e| MonitorError::Rmux(format!("send_text {id}: {e}")))?, + TermInput::Key { code, mods } => pane + .send_key(key_token(&code, mods)) + .await + .map_err(|e| MonitorError::Rmux(format!("send_key(key) {id}: {e}")))?, + TermInput::Control { key } => pane + .send_key(control_key_token(&key)) + .await + .map_err(|e| MonitorError::Rmux(format!("send_key {id}: {e}")))?, + TermInput::ResizeHint { cols, rows } => { + tracing::debug!( + target: "lucarne_rmux", + %id, + cols, + rows, + "resize hint (no PTY resize)" + ); + } + } + Ok(()) + } + + /// Kill a tracked session on the daemon and drop it from the registry. + pub async fn kill(&self, id: &SessionId) -> Result<()> { + let name = { + let guard = self.tracked.lock().await; + guard + .get(id) + .map(|t| t.name.clone()) + .ok_or_else(|| MonitorError::NotFound(id.clone()))? + }; + let session = self + .rmux + .session(name) + .await + .map_err(|e| MonitorError::Rmux(format!("open {id} for kill: {e}")))?; + session + .kill() + .await + .map_err(|e| MonitorError::Rmux(format!("kill {id}: {e}")))?; + self.tracked.lock().await.remove(id); + self.registry.lock().await.remove(id); + Ok(()) + } + + /// Capture the recent scrollback of a session as text (for archiving), via + /// the rmux CLI. Best-effort: a capture failure returns an error the caller + /// can downgrade to empty content. + /// + /// ADR `2026-05-30-rmux-terminal-monitor-subsystem.md` (boundary rule 5): + /// terminal scrollback reads must be BOUNDED windows, never whole-pane scans. + /// So this captures the most recent [`SCROLLBACK_CAPTURE_LINES`] lines + /// (`-S -`) rather than the entire history (`-S -`). The CLI call goes + /// through [`crate::cli::output_async`], which resolves the binary once and + /// bounds the process wait with a timeout. + pub async fn capture_scrollback(&self, id: &SessionId) -> Result { + let name = { + let guard = self.tracked.lock().await; + guard + .get(id) + .map(|t| t.name.as_str().to_string()) + .ok_or_else(|| MonitorError::NotFound(id.clone()))? + }; + let start = scrollback_capture_start_arg(); + let out = cli::output_async(&["capture-pane", "-p", "-S", &start, "-t", &name]) + .await + .map_err(|e| MonitorError::Rmux(format!("capture-pane: {e}")))?; + if !out.status.success() { + return Err(MonitorError::Rmux("capture-pane failed".to_string())); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + } + + /// Open a session by rmux name, register it, and spawn its mirror loop. + async fn track( + &self, + name: SessionName, + origin: Origin, + title: String, + ) -> Result { + let session = self + .rmux + .session(name.clone()) + .await + .map_err(|e| MonitorError::Rmux(format!("open session {}: {e}", name.as_str())))?; + let pane = session.pane(0, 0); + let id = session_id(name.as_str()); + validate_primary_pane_session_id(&id) + .map_err(|e| MonitorError::Rmux(format!("primary pane id: {e}")))?; + + // Seed dims from the first snapshot (best-effort; fall back to defaults). + let dims = match pane.snapshot().await { + Ok(snap) => Dims { + cols: snap.cols, + rows: snap.rows, + }, + Err(e) => { + tracing::debug!( + target: "lucarne_rmux", + %id, + error = %e, + "dims probe failed; using defaults" + ); + Dims { + cols: DEFAULT_COLS, + rows: DEFAULT_ROWS, + } + } + }; + + let cwd = pane_cwd(name.as_str()); + let desc = + self.registry + .lock() + .await + .register_with_cwd(id.clone(), title, origin, dims, cwd); + self.tracked.lock().await.insert( + id.clone(), + Tracked { + pane: pane.clone(), + name, + }, + ); + // Detached source loop, but its JoinHandle is RETAINED (not dropped) so + // `impl Drop for RmuxMonitor` can abort it on teardown — no leaked tasks. + let handle = self.spawn_source_loop(id, pane); + self.source_loops.lock().await.push(handle); + Ok(desc) + } + + /// Per-pane source loop: seed one full snapshot, then stream render updates + /// (each a full `PaneSnapshot` — rmux has no native delta) into the fan-out. + /// + /// Returns the spawned task's [`JoinHandle`] so the caller can retain it for + /// Drop-based abort (mirroring `AdapterSupervisorHandle`, which keeps its + /// task handles and aborts them in `Drop`). The loop's only self-exit is + /// "rmux closed the render stream"; without retaining + aborting the handle a + /// dropped monitor with still-open streams would leak the task. + fn spawn_source_loop(&self, id: SessionId, pane: Pane) -> JoinHandle<()> { + let tx = self.updates.clone(); + tokio::spawn(async move { + match pane.snapshot().await { + Ok(snap) => { + let _ = tx.send(GridUpdate { + session: id.clone(), + grid: adapter::snapshot_to_grid(&snap), + cursor: adapter::map_cursor(snap.cursor), + }); + } + Err(e) => tracing::warn!( + target: "lucarne_rmux", + session = %id, + error = %e, + "initial snapshot failed" + ), + } + + let mut stream = match pane.render_stream().await { + Ok(s) => s, + Err(e) => { + tracing::warn!( + target: "lucarne_rmux", + session = %id, + error = %e, + "render_stream unavailable; single snapshot only" + ); + return; + } + }; + loop { + match stream.next().await { + Ok(Some(update)) => { + let snap = update.snapshot(); + let _ = tx.send(GridUpdate { + session: id.clone(), + grid: adapter::snapshot_to_grid(snap), + cursor: adapter::map_cursor(snap.cursor), + }); + } + Ok(None) => { + tracing::info!( + target: "lucarne_rmux", + session = %id, + "render stream closed" + ); + break; + } + Err(e) => { + tracing::warn!( + target: "lucarne_rmux", + session = %id, + error = %e, + "render stream error; ending loop" + ); + break; + } + } + } + }) + } +} + +/// `session:window:pane` stable handle (window 0 / pane 0). +fn session_id(name: &str) -> SessionId { + primary_pane_session_id(name) +} + +/// The user's interactive shell (`$SHELL`, falling back to `/bin/sh`). A freshly +/// created rmux session must spawn this explicitly — an empty `ProcessSpec` gives +/// a dead pane (blank mirror, input goes nowhere). +fn default_shell() -> String { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) +} + +/// A process-unique session name for `create` (pid + monotonic counter — no +/// wall-clock / RNG so it stays deterministic within a run). +fn unique_session_name() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + format!("lucarne-{}-{}", std::process::id(), n) +} + +/// Best-effort pane cwd via the rmux CLI (`#{pane_current_path}`). The SDK has no +/// cwd accessor, so we ask the same daemon over its tmux-compatible CLI. +fn pane_cwd(name: &str) -> Option { + let out = cli::output(&["display-message", "-p", "-t", name, "#{pane_current_path}"]).ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (!s.is_empty()).then_some(s) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_id_shape() { + assert_eq!(session_id("work"), "work:0:0"); + } + + #[test] + fn unique_names_are_distinct() { + let a = unique_session_name(); + let b = unique_session_name(); + assert_ne!(a, b); + assert!(a.starts_with("lucarne-")); + } + + // R3-4 / ADR boundary rule 5: scrollback capture must use a BOUNDED window + // (`-S -`), never the whole-pane `-S -`. Assert the capped constant and the + // exact `-S` argument the CLI is invoked with so a regression to a full-pane + // scan is caught without needing a live rmux daemon. + #[test] + fn scrollback_capture_window_is_bounded() { + let start = format!("-{SCROLLBACK_CAPTURE_LINES}"); + assert_eq!( + start, "-1000", + "capture-pane -S start must bound the window" + ); + assert_ne!(start, "-", "must NOT capture the whole pane (-S -)"); + } +} diff --git a/crates/lucarne-rmux/src/term/diff.rs b/crates/lucarne-rmux/src/term/diff.rs new file mode 100644 index 0000000..c41cc24 --- /dev/null +++ b/crates/lucarne-rmux/src/term/diff.rs @@ -0,0 +1,236 @@ +//! diff — self-authored snapshot differ. +//! +//! rmux-sdk 0.3.1 exposes NO native cell/row delta (spike1, three-way proven): +//! `render_stream()` re-emits a full `PaneSnapshot` on every change and +//! `revision` is only a "did anything change" counter. To keep the mirror hot +//! path cheap (`<50ms`, minimal bytes), the monitor keeps the previous +//! [`PaneGrid`] and diffs each full snapshot cell-by-cell into a [`GridDelta`] of +//! dirty-row runs. + +use crate::term::grid::{Cell, CellSpan, GridDelta, PaneGrid, RowDelta}; + +/// Outcome of feeding a fresh full snapshot to the [`Differ`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DiffResult { + /// Send a full snapshot: first frame after subscribe, or a resize. + Full(PaneGrid), + /// Send an incremental update over the hot path. `base_rev` is the rev the + /// delta is computed *against*; `rev` is the new rev. + Delta { + base_rev: u64, + rev: u64, + delta: GridDelta, + }, + /// rev gap detected — the client must ask for a fresh full snapshot. + /// `have_rev` is the rev the differ last held (the client's stale baseline). + Resync { have_rev: u64 }, +} + +/// Self-authored snapshot differ. Holds the last grid for one pane/session; +/// instantiate one `Differ` per subscribed session. +#[derive(Clone, Debug, Default)] +pub struct Differ { + last_grid: Option, +} + +impl Differ { + /// New differ with no baseline — the next frame is always a `Full`. + pub fn new() -> Self { + Self { last_grid: None } + } + + /// The rev of the currently held baseline grid, if any. + pub fn current_rev(&self) -> Option { + self.last_grid.as_ref().map(|g| g.rev) + } + + /// Feed a fresh full snapshot, trusting rev continuity (no gap check). + pub fn feed(&mut self, new: PaneGrid) -> DiffResult { + self.feed_checked(new, None) + } + + /// Feed a fresh full snapshot. + pub fn feed_checked(&mut self, new: PaneGrid, expected_base_rev: Option) -> DiffResult { + match self.last_grid.take() { + None => { + self.last_grid = Some(new.clone()); + DiffResult::Full(new) + } + Some(last) => { + if last.cols != new.cols || last.rows != new.rows { + self.last_grid = Some(new.clone()); + return DiffResult::Full(new); + } + + if let Some(expected) = expected_base_rev { + if expected != last.rev { + let have_rev = last.rev; + self.last_grid = Some(new); + return DiffResult::Resync { have_rev }; + } + } + + let base_rev = last.rev; + let rev = new.rev; + let delta = diff(&last, &new); + self.last_grid = Some(new); + DiffResult::Delta { + base_rev, + rev, + delta, + } + } + } + } +} + +/// Pure cell-by-cell diff of two equally-dimensioned grids into dirty-row runs. +pub fn diff(last: &PaneGrid, new: &PaneGrid) -> GridDelta { + let cols = new.cols.min(last.cols) as usize; + let rows = new.rows.min(last.rows) as usize; + let new_stride = new.cols as usize; + let last_stride = last.cols as usize; + + let mut row_deltas: Vec = Vec::new(); + + for y in 0..rows { + let new_row = &new.cells[y * new_stride..y * new_stride + cols]; + let last_row = &last.cells[y * last_stride..y * last_stride + cols]; + + let spans = row_spans(last_row, new_row); + if !spans.is_empty() { + row_deltas.push(RowDelta { y: y as u16, spans }); + } + } + + GridDelta { rows: row_deltas } +} + +/// Coalesce contiguous changed cells of one row into [`CellSpan`]s. +fn row_spans(last_row: &[Cell], new_row: &[Cell]) -> Vec { + let mut spans: Vec = Vec::new(); + let mut run_start: Option = None; + let mut run_cells: Vec = Vec::new(); + + for (x, (old, cur)) in last_row.iter().zip(new_row.iter()).enumerate() { + if old != cur { + if run_start.is_none() { + run_start = Some(x); + } + run_cells.push(cur.clone()); + } else if let Some(start) = run_start.take() { + spans.push(CellSpan { + x: start as u16, + cells: std::mem::take(&mut run_cells), + }); + } + } + + if let Some(start) = run_start { + spans.push(CellSpan { + x: start as u16, + cells: run_cells, + }); + } + + spans +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::term::grid::{Color, Style}; + + fn cell(text: &str) -> Cell { + Cell { + text: text.to_string(), + width: 1, + padding: false, + fg: Color::Default, + bg: Color::Default, + underline_color: Color::Default, + style: Style::empty(), + } + } + + fn grid(cols: u16, rows: u16, rev: u64, fill: &str, edits: &[(usize, &str)]) -> PaneGrid { + let mut cells = vec![cell(fill); (cols as usize) * (rows as usize)]; + for (idx, text) in edits { + cells[*idx] = cell(text); + } + PaneGrid { + cols, + rows, + cells, + rev, + } + } + + #[test] + fn identical_grids_produce_empty_delta() { + let a = grid(4, 2, 1, ".", &[]); + let b = grid(4, 2, 2, ".", &[]); + assert!(diff(&a, &b).rows.is_empty()); + } + + #[test] + fn single_cell_change_emits_one_row_one_span() { + let a = grid(4, 3, 1, ".", &[]); + let b = grid(4, 3, 2, ".", &[(6, "X")]); + let d = diff(&a, &b); + assert_eq!(d.rows.len(), 1); + assert_eq!(d.rows[0].y, 1); + assert_eq!(d.rows[0].spans.len(), 1); + assert_eq!(d.rows[0].spans[0].x, 2); + assert_eq!(d.rows[0].spans[0].cells.len(), 1); + assert_eq!(d.rows[0].spans[0].cells[0].text, "X"); + } + + #[test] + fn adjacent_changes_coalesce_into_one_span() { + let a = grid(5, 1, 1, ".", &[]); + let b = grid(5, 1, 2, ".", &[(1, "A"), (2, "B")]); + let d = diff(&a, &b); + assert_eq!(d.rows[0].spans.len(), 1); + assert_eq!(d.rows[0].spans[0].x, 1); + assert_eq!(d.rows[0].spans[0].cells.len(), 2); + } + + #[test] + fn non_adjacent_changes_produce_two_spans() { + let a = grid(5, 1, 1, ".", &[]); + let b = grid(5, 1, 2, ".", &[(0, "A"), (3, "B")]); + let d = diff(&a, &b); + assert_eq!(d.rows[0].spans.len(), 2); + assert_eq!(d.rows[0].spans[0].x, 0); + assert_eq!(d.rows[0].spans[1].x, 3); + } + + #[test] + fn first_frame_returns_full() { + let mut differ = Differ::new(); + let g = grid(4, 2, 1, ".", &[]); + assert!(matches!(differ.feed(g.clone()), DiffResult::Full(full) if full == g)); + assert_eq!(differ.current_rev(), Some(1)); + } + + #[test] + fn dimension_change_returns_full() { + let mut differ = Differ::new(); + differ.feed(grid(4, 2, 1, ".", &[])); + let resized = grid(6, 3, 2, ".", &[]); + assert!(matches!(differ.feed(resized.clone()), DiffResult::Full(full) if full == resized)); + } + + #[test] + fn rev_gap_returns_resync() { + let mut differ = Differ::new(); + differ.feed(grid(4, 2, 5, ".", &[])); + let next = grid(4, 2, 10, ".", &[(0, "X")]); + assert!(matches!( + differ.feed_checked(next, Some(4)), + DiffResult::Resync { have_rev: 5 } + )); + assert_eq!(differ.current_rev(), Some(10)); + } +} diff --git a/crates/lucarne-rmux/src/term/grid.rs b/crates/lucarne-rmux/src/term/grid.rs new file mode 100644 index 0000000..00aadcb --- /dev/null +++ b/crates/lucarne-rmux/src/term/grid.rs @@ -0,0 +1,193 @@ +//! Terminal grid value types — the single vocabulary for the mirror wire. +//! +//! Field shapes are verbatim from the rmux-sdk 0.3.1 probe: +//! - `Cell.text: String` (a grapheme cluster), not `ch: char` — preserves CJK / +//! emoji / non-BMP. `width: u8` + `padding: bool` carried from rmux so the +//! client NEVER recomputes Unicode width. +//! - `Cell.underline_color: Color` — rmux has an independent underline color. +//! - `Color` has 8 variants (1:1 with `rmux_sdk::PaneColor`). +//! - `Style` is a `u16` bitflags set with the 15 named rmux attribute bits. +//! - `Cursor` uses `row`/`col` (not `x`/`y`) plus `style_raw: u32`. +//! - `PaneGrid` carries `rev: u64` (mapped from `PaneSnapshot.revision`). + +use serde::{Deserialize, Serialize}; + +/// Visible grid dimensions. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Dims { + pub cols: u16, + pub rows: u16, +} + +/// A full visible pane grid. `cells.len() == cols * rows`, row-major, +/// `index = row * cols + col`. `rev` mirrors `PaneSnapshot.revision` and is the +/// delta-resync baseline. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneGrid { + pub cols: u16, + pub rows: u16, + /// len == cols*rows, row-major; index = row*cols + col. + pub cells: Vec, + /// Maps from `PaneSnapshot.revision` — the delta baseline counter. + pub rev: u64, +} + +/// One terminal cell. Mirrors `rmux_sdk::PaneCell` (glyph flattened) plus its +/// independent underline color. The display `width` and `padding` are carried +/// from the snapshot and MUST be trusted by the renderer (no JS-side wcwidth). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Cell { + /// Glyph text — a grapheme cluster (may be multiple chars), not a bare char. + pub text: String, + /// Display width: CJK/emoji = 2, normal = 1, trailing padding = 0. + pub width: u8, + /// True when this is the trailing placeholder of a preceding wide glyph. + pub padding: bool, + pub fg: Color, + pub bg: Color, + /// rmux carries an independent underline color (distinct from fg/bg). + pub underline_color: Color, + pub style: Style, +} + +/// Terminal color — a 1:1 mapping of `rmux_sdk::PaneColor`. Serialized as an +/// externally-tagged enum (`snake_case`) so unit and data variants round-trip +/// cleanly in JSON (an `untagged` enum cannot distinguish the unit variants). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum Color { + /// Terminal default color (raw encoding 8). + #[default] + Default, + /// Explicit no-color (raw -1). + None, + /// Terminal color sentinel (raw 9). + Terminal, + /// Standard ANSI 0..=7. + Ansi(u8), + /// Bright ANSI (90..=97 index). + BrightAnsi(u8), + /// 256-color palette index. + Indexed(u8), + /// 24-bit truecolor. + Rgb(u8, u8, u8), + /// Forward-compat fallback for unknown/future raw encodings. + Encoded(i32), +} + +bitflags::bitflags! { + /// Cell style attributes. Bit values are aligned to `rmux_sdk::PaneAttributes` + /// (u16). Note the tmux aliases collapse onto the same bit: + /// BOLD==BRIGHT, UNDERLINE==UNDERSCORE, ITALIC==ITALICS. + /// + /// Serde: serialized as the raw `u16` bits (the `bitflags` dep does not enable + /// its own `serde` feature, so we implement it explicitly below — this keeps + /// the JSON compact and stable: `"style": 5` == BOLD|UNDERLINE). + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct Style: u16 { + const BOLD = 0x0001; // == BRIGHT + const DIM = 0x0002; + const UNDERLINE = 0x0004; // == UNDERSCORE + const BLINK = 0x0008; + const REVERSE = 0x0010; + const HIDDEN = 0x0020; + const ITALIC = 0x0040; // == ITALICS + const CHARSET = 0x0080; // ACS line drawing + const STRIKETHROUGH = 0x0100; + const DOUBLE_UNDERLINE = 0x0200; + const CURLY_UNDERLINE = 0x0400; + const DOTTED_UNDERLINE = 0x0800; + const DASHED_UNDERLINE = 0x1000; + const OVERLINE = 0x2000; + const NO_ATTRIBUTES = 0x4000; + } +} + +impl Serialize for Style { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u16(self.bits()) + } +} + +impl<'de> Deserialize<'de> for Style { + fn deserialize>(deserializer: D) -> Result { + let bits = u16::deserialize(deserializer)?; + // Preserve unknown bits for forward-compat (fix-don't-hide: surface, not drop). + Ok(Style::from_bits_retain(bits)) + } +} + +/// Cursor position and state. rmux uses `row`/`col` (not `x`/`y`) and carries a +/// raw DECSCUSR `style_raw` value (shape/blink, undecoded). MVP renders block +/// only; `style_raw` is preserved for backlog use. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Cursor { + pub row: u16, + pub col: u16, + pub visible: bool, + /// Raw DECSCUSR value from `PaneCursor.style` (undecoded). + pub style_raw: u32, +} + +// ---- Delta frame shapes (the differ in `diff.rs` produces these) ---- + +/// Only the changed rows of a grid. An identical grid yields `rows: []`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GridDelta { + pub rows: Vec, +} + +/// Contiguous changed runs within one row. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RowDelta { + pub y: u16, + pub spans: Vec, +} + +/// A contiguous run of changed cells starting at column `x`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CellSpan { + pub x: u16, + pub cells: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn style_serializes_as_raw_bits_and_round_trips() { + let s = Style::BOLD | Style::UNDERLINE; + let json = serde_json::to_string(&s).unwrap(); + assert_eq!(json, "5"); // 0x1 | 0x4 + let back: Style = serde_json::from_str(&json).unwrap(); + assert_eq!(back, s); + } + + #[test] + fn style_retains_unknown_bits() { + // A bit not named in Style must survive a round-trip (fix-don't-hide). + let raw = 0x8000u16 | Style::BOLD.bits(); + let back: Style = serde_json::from_str(&raw.to_string()).unwrap(); + assert_eq!(back.bits(), raw); + } + + #[test] + fn color_variants_round_trip_as_tagged_json() { + for c in [ + Color::Default, + Color::None, + Color::Terminal, + Color::Ansi(7), + Color::BrightAnsi(4), + Color::Indexed(200), + Color::Rgb(10, 20, 30), + Color::Encoded(-5), + ] { + let json = serde_json::to_string(&c).unwrap(); + let back: Color = serde_json::from_str(&json).unwrap(); + assert_eq!(back, c, "round-trip failed for {c:?} ({json})"); + } + } +} diff --git a/crates/lucarne-rmux/src/term/input.rs b/crates/lucarne-rmux/src/term/input.rs new file mode 100644 index 0000000..80c4631 --- /dev/null +++ b/crates/lucarne-rmux/src/term/input.rs @@ -0,0 +1,189 @@ +//! input — unified terminal input. + +use serde::{Deserialize, Serialize}; + +/// One unit of input destined for a monitored pane. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TermInput { + /// Bulk text / paste / IME commit → raw bytes to the pane. + Text { text: String }, + /// A printable key with modifiers → bytes to the pane. + Key { code: String, mods: KeyMods }, + /// A named control key (Enter / Ctrl-C / arrows…) → a tmux key token. + Control { key: ControlKey }, + /// Viewport changed — a hint only; never force-resizes the PTY. + ResizeHint { cols: u16, rows: u16 }, +} + +/// Keyboard modifier flags for [`TermInput::Key`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeyMods { + pub ctrl: bool, + pub alt: bool, + pub shift: bool, + pub meta: bool, +} + +/// Named control keys for mirror input. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ControlKey { + Enter, + Tab, + Backspace, + Escape, + Up, + Down, + Left, + Right, + Home, + End, + PageUp, + PageDown, + Delete, + /// Ctrl-, e.g. `CtrlChar('c')` for Ctrl-C. + CtrlChar(char), +} + +/// Maps a [`ControlKey`] to the rmux/tmux `send-keys` key token. +pub fn control_key_token(key: &ControlKey) -> String { + match key { + ControlKey::Enter => "Enter".to_string(), + ControlKey::Tab => "Tab".to_string(), + ControlKey::Backspace => "BSpace".to_string(), + ControlKey::Escape => "Escape".to_string(), + ControlKey::Up => "Up".to_string(), + ControlKey::Down => "Down".to_string(), + ControlKey::Left => "Left".to_string(), + ControlKey::Right => "Right".to_string(), + ControlKey::Home => "Home".to_string(), + ControlKey::End => "End".to_string(), + ControlKey::PageUp => "PageUp".to_string(), + ControlKey::PageDown => "PageDown".to_string(), + ControlKey::Delete => "DC".to_string(), + ControlKey::CtrlChar(c) => format!("C-{}", c.to_ascii_lowercase()), + } +} + +/// Map a printable/named key plus modifier flags to an rmux/tmux `send-keys` +/// token. +/// +/// Plain printable keys are still returned as their text value, but any modifier +/// uses tmux's `C-` / `M-` / `S-` prefixes. The browser sends ordinary typing as +/// [`TermInput::Text`]; this is for protocol-complete modified keys. +pub fn key_token(code: &str, mods: KeyMods) -> String { + let base = key_base_token(code); + let mut parts = Vec::new(); + if mods.ctrl { + parts.push("C"); + } + if mods.alt || mods.meta { + parts.push("M"); + } + // Shift only needs a prefix for non-printable/named keys. For printable + // characters the shifted glyph is already in `code` (e.g. "A" or "!"). + if mods.shift && (base.len() > 1 || code.chars().count() != 1) { + parts.push("S"); + } + if parts.is_empty() { + return base; + } + parts.push(&base); + parts.join("-") +} + +fn key_base_token(code: &str) -> String { + match code { + "Enter" | "enter" => "Enter".to_string(), + "Tab" | "tab" => "Tab".to_string(), + "Backspace" | "backspace" => "BSpace".to_string(), + "Escape" | "escape" | "Esc" => "Escape".to_string(), + "ArrowUp" | "up" => "Up".to_string(), + "ArrowDown" | "down" => "Down".to_string(), + "ArrowLeft" | "left" => "Left".to_string(), + "ArrowRight" | "right" => "Right".to_string(), + "Home" | "home" => "Home".to_string(), + "End" | "end" => "End".to_string(), + "PageUp" | "page_up" => "PageUp".to_string(), + "PageDown" | "page_down" => "PageDown".to_string(), + "Delete" | "delete" => "DC".to_string(), + other if other.chars().count() == 1 => other.to_ascii_lowercase(), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn control_tokens_are_tmux_names() { + assert_eq!(control_key_token(&ControlKey::Enter), "Enter"); + assert_eq!(control_key_token(&ControlKey::Backspace), "BSpace"); + assert_eq!(control_key_token(&ControlKey::Delete), "DC"); + assert_eq!(control_key_token(&ControlKey::CtrlChar('C')), "C-c"); + } + + #[test] + fn key_tokens_preserve_modifiers() { + assert_eq!(key_token("a", KeyMods::default()), "a"); + assert_eq!( + key_token( + "a", + KeyMods { + ctrl: true, + ..KeyMods::default() + } + ), + "C-a" + ); + assert_eq!( + key_token( + "ArrowLeft", + KeyMods { + alt: true, + ..KeyMods::default() + } + ), + "M-Left" + ); + assert_eq!( + key_token( + "Tab", + KeyMods { + shift: true, + ..KeyMods::default() + } + ), + "S-Tab" + ); + assert_eq!( + key_token( + "X", + KeyMods { + ctrl: true, + alt: true, + ..KeyMods::default() + } + ), + "C-M-x" + ); + } + + #[test] + fn term_input_round_trips_tagged() { + let ev = TermInput::Control { + key: ControlKey::CtrlChar('c'), + }; + let json = serde_json::to_string(&ev).unwrap(); + let back: TermInput = serde_json::from_str(&json).unwrap(); + assert_eq!(back, ev); + + let txt = TermInput::Text { + text: "你好".to_string(), + }; + let back: TermInput = serde_json::from_str(&serde_json::to_string(&txt).unwrap()).unwrap(); + assert_eq!(back, txt); + } +} diff --git a/crates/lucarne-rmux/src/term/mod.rs b/crates/lucarne-rmux/src/term/mod.rs new file mode 100644 index 0000000..c50d56d --- /dev/null +++ b/crates/lucarne-rmux/src/term/mod.rs @@ -0,0 +1,20 @@ +//! Terminal domain types used by the rmux-backed terminal subsystem. +//! +//! These types used to live in the small `lucarne-term` crate. They now live +//! with the rmux terminal capability so the fork has one terminal package: +//! domain vocabulary, archive helpers, SDK adapter, and monitor. + +pub mod diff; +pub mod grid; +pub mod input; +pub mod registry; +pub mod wire; + +pub use diff::{diff, DiffResult, Differ}; +pub use grid::{Cell, CellSpan, Color, Cursor, Dims, GridDelta, PaneGrid, RowDelta, Style}; +pub use input::{control_key_token, key_token, ControlKey, KeyMods, TermInput}; +pub use registry::{ + primary_pane_session_id, split_primary_pane_session_id, validate_primary_pane_session_id, + Origin, PaneSelectionError, SessionDescriptor, SessionId, SessionRegistry, +}; +pub use wire::{ClientFrame, ServerFrame}; diff --git a/crates/lucarne-rmux/src/term/registry.rs b/crates/lucarne-rmux/src/term/registry.rs new file mode 100644 index 0000000..839f946 --- /dev/null +++ b/crates/lucarne-rmux/src/term/registry.rs @@ -0,0 +1,236 @@ +//! registry — the in-process terminal-session registry. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::term::grid::Dims; + +/// Stable session handle, e.g. "{session}:{window}:{pane}". +pub type SessionId = String; + +/// Current rmux monitor scope: one primary pane per session. +pub const PRIMARY_WINDOW: u32 = 0; +/// Current rmux monitor scope: one primary pane per session. +pub const PRIMARY_PANE: u32 = 0; + +/// Error returned when a caller asks for a pane outside the supported monitor +/// scope. This is explicit because the public id shape includes +/// `{session}:{window}:{pane}`, while the current backend only tracks `(0,0)`. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum PaneSelectionError { + #[error("session id `{0}` is not a primary pane id of the form :0:0")] + Invalid(String), + #[error("only primary pane :0:0 is supported; got window={window}, pane={pane}")] + Unsupported { window: u32, pane: u32 }, +} + +/// Build the current stable id for the primary pane of an rmux session. +pub fn primary_pane_session_id(name: &str) -> SessionId { + format!("{name}:{PRIMARY_WINDOW}:{PRIMARY_PANE}") +} + +/// Split a primary-pane session id back into its rmux session name. +pub fn split_primary_pane_session_id(id: &str) -> Result<&str, PaneSelectionError> { + let Some((head, pane_raw)) = id.rsplit_once(':') else { + return Err(PaneSelectionError::Invalid(id.to_string())); + }; + let Some((session, window_raw)) = head.rsplit_once(':') else { + return Err(PaneSelectionError::Invalid(id.to_string())); + }; + if session.is_empty() { + return Err(PaneSelectionError::Invalid(id.to_string())); + } + let window = window_raw + .parse::() + .map_err(|_| PaneSelectionError::Invalid(id.to_string()))?; + let pane = pane_raw + .parse::() + .map_err(|_| PaneSelectionError::Invalid(id.to_string()))?; + if window != PRIMARY_WINDOW || pane != PRIMARY_PANE { + return Err(PaneSelectionError::Unsupported { window, pane }); + } + Ok(session) +} + +/// Validate that an incoming id targets the primary pane currently mirrored by +/// the rmux monitor. +pub fn validate_primary_pane_session_id(id: &str) -> Result<(), PaneSelectionError> { + split_primary_pane_session_id(id).map(|_| ()) +} + +/// Session provenance on the monitored system rmux daemon. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Origin { + /// Discovered on the system daemon (monitor model — not created by us). + Adopted, + /// Created by this process via the CLI / `new` command. + Managed, +} + +/// A monitored session and the metadata the mirror / CLI needs. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionDescriptor { + pub id: SessionId, + pub title: String, + /// Provenance: discovered (Adopted) vs created-by-us (Managed). + pub origin: Origin, + /// PTY grid size last seen at registration. + pub dims: Dims, + /// The pane's current working directory, if known. + #[serde(default)] + pub cwd: Option, +} + +/// In-process map of `SessionId -> SessionDescriptor`. +#[derive(Debug, Default)] +pub struct SessionRegistry { + sessions: HashMap, +} + +impl SessionRegistry { + /// Creates an empty registry. + #[must_use] + pub fn new() -> Self { + Self { + sessions: HashMap::new(), + } + } + + /// Registers (or replaces) a session (cwd unknown). + pub fn register( + &mut self, + id: SessionId, + title: impl Into, + origin: Origin, + dims: Dims, + ) -> SessionDescriptor { + self.register_with_cwd(id, title, origin, dims, None) + } + + /// Registers (or replaces) a session, recording its pane cwd. + pub fn register_with_cwd( + &mut self, + id: SessionId, + title: impl Into, + origin: Origin, + dims: Dims, + cwd: Option, + ) -> SessionDescriptor { + let descriptor = SessionDescriptor { + id: id.clone(), + title: title.into(), + origin, + dims, + cwd, + }; + self.sessions.insert(id, descriptor.clone()); + descriptor + } + + /// Looks up a session by id. + #[must_use] + pub fn get(&self, id: &str) -> Option<&SessionDescriptor> { + self.sessions.get(id) + } + + /// Returns whether a session is registered. + #[must_use] + pub fn contains(&self, id: &str) -> bool { + self.sessions.contains_key(id) + } + + /// Number of registered sessions. + #[must_use] + pub fn len(&self) -> usize { + self.sessions.len() + } + + /// Whether the registry holds no sessions. + #[must_use] + pub fn is_empty(&self) -> bool { + self.sessions.is_empty() + } + + /// Removes a session, returning the previously-stored descriptor. + pub fn remove(&mut self, id: &str) -> Option { + self.sessions.remove(id) + } + + /// Snapshot of all registered sessions (unordered). + #[must_use] + pub fn list(&self) -> Vec { + self.sessions.values().cloned().collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dims() -> Dims { + Dims { + cols: 120, + rows: 32, + } + } + + #[test] + fn register_get_list_roundtrip() { + let mut reg = SessionRegistry::new(); + assert!(reg.is_empty()); + + reg.register("s:0:0".to_string(), "shell", Origin::Adopted, dims()); + reg.register("s:0:1".to_string(), "ours", Origin::Managed, dims()); + + assert_eq!(reg.len(), 2); + assert!(reg.contains("s:0:0")); + assert_eq!(reg.get("s:0:0").map(|d| d.origin), Some(Origin::Adopted)); + assert_eq!(reg.get("s:0:1").map(|d| d.origin), Some(Origin::Managed)); + assert_eq!(reg.get("missing"), None); + + let mut titles: Vec<_> = reg.list().into_iter().map(|d| d.title).collect(); + titles.sort(); + assert_eq!(titles, vec!["ours", "shell"]); + } + + #[test] + fn register_replaces_existing_and_remove_works() { + let mut reg = SessionRegistry::new(); + reg.register("s:0:0".to_string(), "old", Origin::Adopted, dims()); + let d = reg.register("s:0:0".to_string(), "new", Origin::Managed, dims()); + assert_eq!(reg.len(), 1); + assert_eq!(d.origin, Origin::Managed); + + let removed = reg.remove("s:0:0"); + assert_eq!(removed.map(|d| d.title), Some("new".to_string())); + assert!(reg.is_empty()); + } + + #[test] + fn primary_pane_id_round_trips_session_names_with_colons() { + let id = primary_pane_session_id("10:30: standup"); + assert_eq!(id, "10:30: standup:0:0"); + assert_eq!( + split_primary_pane_session_id(&id).expect("primary pane id"), + "10:30: standup" + ); + } + + #[test] + fn non_primary_panes_are_rejected_explicitly() { + assert!(matches!( + split_primary_pane_session_id("work:1:0"), + Err(PaneSelectionError::Unsupported { window: 1, pane: 0 }) + )); + assert!(matches!( + split_primary_pane_session_id("work:0:3"), + Err(PaneSelectionError::Unsupported { window: 0, pane: 3 }) + )); + assert!(matches!( + split_primary_pane_session_id("bad"), + Err(PaneSelectionError::Invalid(_)) + )); + } +} diff --git a/crates/lucarne-rmux/src/term/wire.rs b/crates/lucarne-rmux/src/term/wire.rs new file mode 100644 index 0000000..58abcc2 --- /dev/null +++ b/crates/lucarne-rmux/src/term/wire.rs @@ -0,0 +1,116 @@ +//! wire — the tagged Server/Client frames exchanged with web mirror clients. + +use serde::{Deserialize, Serialize}; + +use crate::term::grid::{Cursor, GridDelta, PaneGrid}; +use crate::term::input::TermInput; +use crate::term::registry::{SessionDescriptor, SessionId}; + +/// Server → Client frames. Tagged by `type` in `snake_case`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServerFrame { + /// The monitored session list (sent on connect & after create/close). + SessionList { + sessions: Vec, + }, + /// Full grid — on subscribe, on resize, or as a delta-gap resync fallback. + Snapshot { + session: SessionId, + grid: PaneGrid, + cursor: Cursor, + }, + /// Incremental update — the hot path (dirty-row runs only). + SnapshotDelta { + session: SessionId, + base_rev: u64, + rev: u64, + delta: GridDelta, + cursor: Cursor, + }, + /// A session was created in response to `ClientFrame::CreateSession`. + SessionCreated { + session: SessionId, + }, + /// A session was closed (via `CloseSession` or it exited). + SessionClosed { + session: SessionId, + }, + Error { + code: u16, + msg: String, + }, + Pong { + t: u64, + }, +} + +/// Client → Server frames. Tagged by `type` in `snake_case`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ClientFrame { + Subscribe { + session: SessionId, + }, + Detach { + session: SessionId, + }, + Input { + session: SessionId, + event: TermInput, + }, + Resync { + session: SessionId, + have_rev: u64, + }, + CreateSession { + title: Option, + }, + CloseSession { + session: SessionId, + }, + Ping { + t: u64, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::term::grid::{Cursor, PaneGrid}; + + #[test] + fn server_snapshot_round_trips_tagged() { + let f = ServerFrame::Snapshot { + session: "s:0:0".into(), + grid: PaneGrid { + cols: 1, + rows: 1, + cells: vec![], + rev: 3, + }, + cursor: Cursor { + row: 0, + col: 0, + visible: true, + style_raw: 0, + }, + }; + let json = serde_json::to_string(&f).unwrap(); + assert!(json.contains("\"type\":\"snapshot\"")); + let back: ServerFrame = serde_json::from_str(&json).unwrap(); + assert_eq!(back, f); + } + + #[test] + fn client_input_round_trips_tagged() { + let f = ClientFrame::Input { + session: "s:0:0".into(), + event: TermInput::Text { + text: "ls\n".into(), + }, + }; + let back: ClientFrame = serde_json::from_str(&serde_json::to_string(&f).unwrap()).unwrap(); + assert_eq!(back, f); + } +} diff --git a/crates/lucarne-rmux/tests/contract_boundary.rs b/crates/lucarne-rmux/tests/contract_boundary.rs new file mode 100644 index 0000000..41162ae --- /dev/null +++ b/crates/lucarne-rmux/tests/contract_boundary.rs @@ -0,0 +1,73 @@ +//! Boundary contract: `lucarne-rmux` is the terminal capability package. +//! +//! Terminal domain/wire types, archive helpers, and the live rmux-sdk binding +//! intentionally live together here. Preview-API churn stops at this crate, and +//! downstream gateway/daemon crates consume the stable `lucarne_rmux` surface +//! instead of naming `rmux_sdk` directly. + +use std::process::Command; + +#[test] +fn rmux_contract_owns_rmux_sdk_binding() { + let stdout = cargo_tree(&["tree", "-p", "lucarne-rmux", "--no-dev"]); + let stdout = if stdout.trim().is_empty() { + cargo_tree(&["tree", "-p", "lucarne-rmux"]) + } else { + stdout + }; + assert!( + stdout.contains("rmux-sdk"), + "lucarne-rmux is the single layer that binds rmux_sdk — its dependency \ + graph must contain rmux-sdk:\n{stdout}" + ); +} + +#[test] +fn gateway_and_daemon_do_not_name_rmux_sdk_directly() { + let workspace = workspace_root(); + for manifest in [ + "crates/lucarne-termgw/Cargo.toml", + "crates/lucarned/Cargo.toml", + ] { + let content = std::fs::read_to_string(workspace.join(manifest)).expect("read manifest"); + assert!( + !content.contains("rmux-sdk"), + "{manifest} must consume rmux-sdk through lucarne-rmux, not directly" + ); + } +} + +#[test] +fn rmux_contract_exports_terminal_and_archive_modules() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + for relative in [ + "src/term/mod.rs", + "src/term/grid.rs", + "src/term/diff.rs", + "src/term/input.rs", + "src/term/registry.rs", + "src/term/wire.rs", + "src/archive.rs", + ] { + assert!( + root.join(relative).exists(), + "lucarne-rmux must own merged terminal/archive module {relative}" + ); + } +} + +fn cargo_tree(args: &[&str]) -> String { + let output = Command::new("cargo") + .args(args) + .output() + .expect("run cargo tree"); + String::from_utf8(output.stdout).unwrap() +} + +fn workspace_root() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("workspace root") + .to_path_buf() +} diff --git a/crates/lucarne-telegram/src/lib.rs b/crates/lucarne-telegram/src/lib.rs index 7145f48..38ed2d1 100644 --- a/crates/lucarne-telegram/src/lib.rs +++ b/crates/lucarne-telegram/src/lib.rs @@ -1,3 +1,14 @@ +#![allow( + clippy::await_holding_lock, + clippy::explicit_auto_deref, + clippy::large_enum_variant, + clippy::manual_contains, + clippy::manual_inspect, + clippy::needless_borrow, + clippy::too_many_arguments, + clippy::unnecessary_map_or +)] + //! Telegram channel + bot built on top of `lucarne` and `agent-sessions`. //! //! Layering: diff --git a/crates/lucarne-telegram/tests/topic_routing_integration.rs b/crates/lucarne-telegram/tests/topic_routing_integration.rs index 8a3f44c..1882089 100644 --- a/crates/lucarne-telegram/tests/topic_routing_integration.rs +++ b/crates/lucarne-telegram/tests/topic_routing_integration.rs @@ -1,3 +1,10 @@ +#![allow( + clippy::await_holding_lock, + clippy::manual_contains, + clippy::ptr_arg, + clippy::too_many_arguments +)] + use std::{ collections::HashMap, ffi::OsString, diff --git a/crates/lucarne-termgw/Cargo.toml b/crates/lucarne-termgw/Cargo.toml new file mode 100644 index 0000000..3f27f5b --- /dev/null +++ b/crates/lucarne-termgw/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "lucarne-termgw" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Axum WebSocket/HTTP gateway over the rmux terminal monitor." +rust-version.workspace = true +repository.workspace = true + +# Terminal gateway: an axum WebSocket + HTTP surface over the rmux terminal +# monitor. Consumes the monitor's grid fan-out, applies a per-client differ, and +# serves an interactive web terminal view + a small control API for the CLI. + +[dependencies] +lucarne = { path = "../lucarne", features = ["terminal-agent-bind"] } +lucarne-rmux = { path = "../lucarne-rmux" } +async-trait = { workspace = true } +axum = { workspace = true, features = ["ws"] } +tower-http = { version = "0.6", features = ["fs"] } +dirs = { workspace = true } +tokio = { workspace = true } +futures = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +# Public-exposure auth layer (TASK-003): CSPRNG token + single-use ws ticket + +# constant-time compare. Pinned directly here (not in root workspace deps) — the +# root Cargo.toml is owned by another task this wave. +rand = "0.8" +subtle = "2" + +[dev-dependencies] +# HTTP router integration tests (SEC-001 ws ticket gate, SEC-002 control-plane +# separation): drive routers via `tower::ServiceExt::oneshot` without binding a +# socket or standing up a live monitor / agent runtime. +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +tempfile = { workspace = true } +tokio-tungstenite = "0.29" diff --git a/crates/lucarne-termgw/src/auth.rs b/crates/lucarne-termgw/src/auth.rs new file mode 100644 index 0000000..2b02669 --- /dev/null +++ b/crates/lucarne-termgw/src/auth.rs @@ -0,0 +1,1420 @@ +//! Public-exposure auth layer for the terminal gateway. +//! +//! The gateway mirrors a live terminal with input re-injection: an unauthenticated +//! reader is effectively remote code execution, so this is the highest-risk surface +//! in the project. Everything here exists to make public exposure safe-by-default: +//! +//! - [`AccessToken`] — a ≥256-bit CSPRNG secret (url-safe base64). The long-lived +//! credential. `/api/*` requires it via `Authorization: Bearer `, compared +//! in **constant time** ([`ct_eq`], `subtle::ConstantTimeEq`) to deny timing side +//! channels. +//! - [`TicketStore`] — browsers can't set a `Authorization` header on a WebSocket, +//! and putting the long-lived token in the ws URL leaks it into logs. So the ws +//! path uses a two-step exchange: the client posts its Bearer token to +//! `/auth/ticket`, gets a **single-use, ~30s TTL** ticket, and passes it as +//! `?ticket=`. The ws handler [`consume`](TicketStore::consume)s it *before* +//! `.on_upgrade()` — a used or expired ticket is rejected, defeating replay. +//! - [`RateLimiter`] — consecutive auth failures per key lock the key out for a +//! cooldown (seed: 5 failures → 60s), defeating brute force. +//! - [`AuthMode`] / [`require_auth_or_refuse`] — **default-deny**: public mode with +//! no token configured refuses to start unless an explicit insecure override is set. +//! - [`parse_gateway_addr`] — loopback hardening: in remote mode the gateway bind +//! address must be `127.0.0.1`/`::1`. Even if auth had a bug, there is no public +//! socket to reach (defense in depth; mirrors `lucarned::health::parse_health_addr`). + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use rand::rngs::OsRng; +use rand::RngCore; +use subtle::ConstantTimeEq; +use tokio::sync::Mutex; + +/// A single ws ticket is valid for ~30s and may be consumed exactly once. +pub const TICKET_TTL: Duration = Duration::from_secs(30); +/// Consecutive auth failures from one key before it is locked out. +pub const MAX_FAILURES: u32 = 5; +/// How long a key stays locked out after hitting [`MAX_FAILURES`]. +pub const LOCKOUT: Duration = Duration::from_secs(60); + +/// Hard cap on outstanding (issued, not-yet-consumed/expired) ws tickets the +/// [`TicketStore`] holds at once (M3). A bearer is already authenticated before +/// it can mint a ticket, so this only bounds churn from a misbehaving/compromised +/// authenticated client; over the cap new ticket issuance is refused so existing +/// fresh tickets are never invalidated by a mint flood. +pub const MAX_OUTSTANDING_TICKETS: usize = 1024; + +/// Max ws tickets minted per [`TICKET_RATE_WINDOW`] across the store (M3). A +/// run of `issue` calls beyond this within the window is throttled by refusing +/// new tickets. Existing tickets remain valid until consumed or expired. +pub const MAX_TICKETS_PER_WINDOW: u32 = 256; +/// Rolling window for the [`MAX_TICKETS_PER_WINDOW`] issue-rate accounting. +pub const TICKET_RATE_WINDOW: Duration = Duration::from_secs(1); + +/// Hard cap on distinct keys the [`RateLimiter`] tracks at once (SEC-009). +/// +/// Behind a same-host tunnel the limiter is dominated by the single shared +/// loopback key, so this only bites if the gateway is ever bound to a real +/// interface (where each attacker IP is a fresh key). The cap + opportunistic +/// eviction keep the failure map from growing without bound under that load. +pub const MAX_RATE_LIMIT_ENTRIES: usize = 4096; + +/// Number of random bytes behind a token / ticket (256-bit = 32 bytes). +const SECRET_BYTES: usize = 32; + +/// Minimum length for an explicitly-configured access token (SEC-008). A +/// generated token is 256-bit (≥43 url-safe base64 chars); we require any +/// operator-supplied token to carry comparable entropy so a weak/typo'd token +/// can't silently become the live credential while auth stays "enforced". +pub const MIN_EXPLICIT_TOKEN_LEN: usize = 32; + +/// Fill `out` with CSPRNG bytes from the OS entropy source. +fn fill_random(out: &mut [u8]) { + OsRng.fill_bytes(out); +} + +/// Generate a url-safe base64 secret backed by `SECRET_BYTES` of OS entropy. +fn random_secret() -> String { + let mut bytes = [0_u8; SECRET_BYTES]; + fill_random(&mut bytes); + base64url(&bytes) +} + +/// Minimal url-safe base64 (no padding) — avoids pulling a base64 crate for a +/// single encode and keeps the secret safe to place in headers and ws query +/// strings. +fn base64url(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = *chunk.get(1).unwrap_or(&0) as u32; + let b2 = *chunk.get(2).unwrap_or(&0) as u32; + let n = (b0 << 16) | (b1 << 8) | b2; + out.push(ALPHABET[(n >> 18 & 0x3f) as usize] as char); + out.push(ALPHABET[(n >> 12 & 0x3f) as usize] as char); + if chunk.len() > 1 { + out.push(ALPHABET[(n >> 6 & 0x3f) as usize] as char); + } + if chunk.len() > 2 { + out.push(ALPHABET[(n & 0x3f) as usize] as char); + } + } + out +} + +/// Constant-time byte comparison (timing-attack resistant) via `subtle`. +pub fn ct_eq(a: &[u8], b: &[u8]) -> bool { + a.ct_eq(b).into() +} + +/// The long-lived gateway access token (≥256-bit CSPRNG secret). +/// +/// Presented over `Authorization: Bearer ` on `/api/*`. Comparison is +/// constant-time; never compare with `==`. +#[derive(Clone)] +pub struct AccessToken { + secret: String, +} + +/// Rejection reason for a configured access token (SEC-008). +/// +/// An explicit `auth_token` that is whitespace-only or too short to carry +/// meaningful entropy must fail closed rather than silently become the live +/// credential while [`AuthMode::is_enforced`] stays true. +#[derive(Debug, PartialEq, Eq)] +pub enum TokenError { + /// The configured token is empty or contained only whitespace. + Empty, + /// The configured token is shorter than [`MIN_EXPLICIT_TOKEN_LEN`]. + TooShort { len: usize, min: usize }, +} + +impl std::fmt::Display for TokenError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TokenError::Empty => write!( + f, + "configured access token is empty or whitespace-only (fail-closed)" + ), + TokenError::TooShort { len, min } => write!( + f, + "configured access token is too weak: {len} chars, need at least {min} (fail-closed)" + ), + } + } +} + +impl std::error::Error for TokenError {} + +impl AccessToken { + /// Mint a fresh token from OS entropy. + pub fn generate() -> Self { + Self { + secret: random_secret(), + } + } + + /// Reconstruct a token from a persisted/configured secret string. + /// + /// Note: this does **not** validate strength — callers handling + /// operator-supplied tokens must use [`from_secret_validated`](Self::from_secret_validated) + /// (SEC-008) so a weak/whitespace token fails closed instead of silently + /// becoming the live credential. + pub fn from_secret(secret: impl Into) -> Self { + Self { + secret: secret.into(), + } + } + + /// Reconstruct a token from an operator-supplied secret, rejecting weak + /// values (SEC-008): a whitespace-only token is [`TokenError::Empty`] and a + /// token shorter than [`MIN_EXPLICIT_TOKEN_LEN`] is [`TokenError::TooShort`]. + /// The secret itself is taken verbatim (no trimming) so it round-trips with + /// what the client must present. + pub fn from_secret_validated(secret: impl Into) -> Result { + let secret = secret.into(); + if secret.trim().is_empty() { + return Err(TokenError::Empty); + } + if secret.len() < MIN_EXPLICIT_TOKEN_LEN { + return Err(TokenError::TooShort { + len: secret.len(), + min: MIN_EXPLICIT_TOKEN_LEN, + }); + } + Ok(Self { secret }) + } + + /// The url-safe secret string (for QR / config persistence / `Bearer`). + pub fn as_str(&self) -> &str { + &self.secret + } + + /// Constant-time check that `candidate` matches this token's secret. + pub fn verify(&self, candidate: &str) -> bool { + ct_eq(self.secret.as_bytes(), candidate.as_bytes()) + } +} + +/// Single-use, short-TTL WebSocket ticket store. +/// +/// `issue()` mints a random ticket recorded with its issue instant; `consume()` +/// validates the ticket exists, is not expired, and removes it (so a second +/// `consume()` of the same ticket fails — replay protection). +/// +/// SEC-013: each ticket also carries the access scope it was issued under +/// ([`AccessScope`]). A ticket minted from the read-only credential is recorded +/// `ReadOnly`; the ws handler reads the consumed scope and refuses write frames +/// for a read-only session. Full-access tickets are unchanged. +/// +/// M3 hardening: the outstanding set is bounded by [`MAX_OUTSTANDING_TICKETS`] +/// and the mint rate by [`MAX_TICKETS_PER_WINDOW`] per [`TICKET_RATE_WINDOW`]. +/// `issue_scoped` is a fast path — it does NOT scan the whole map on every call; +/// expiry is lazy (`consume_scoped` rejects an expired entry) plus an +/// opportunistic front-prune that runs ONLY when the map is near the cap. Over +/// the cap or rate window, issuance returns [`TicketIssueError`] instead of +/// evicting unrelated fresh tickets. +#[derive(Clone)] +pub struct TicketStore { + ttl: Duration, + max_outstanding: usize, + max_per_window: u32, + rate_window: Duration, + inner: Arc>, +} + +/// The locked interior of a [`TicketStore`]. +struct TicketStoreInner { + /// Live tickets keyed by their secret string. + entries: HashMap, + /// FIFO of `(seq, ticket)` in issue order, used to evict the oldest in O(1) + /// amortized without scanning the map. Stale front entries (already consumed + /// or expired) are skipped lazily. + order: std::collections::VecDeque<(u64, String)>, + /// Monotonic issue sequence (also stored per entry to detect a re-used key). + next_seq: u64, + /// Issue-rate accounting (M3): count within the current rolling window. + window_start: Instant, + window_count: u32, +} + +/// Ticket issuance refusal reason. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TicketIssueError { + /// Too many not-yet-consumed/unexpired tickets are already outstanding. + OutstandingLimit, + /// Too many tickets were issued in the current rate window. + RateLimited, +} + +impl std::fmt::Display for TicketIssueError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TicketIssueError::OutstandingLimit => write!(f, "too many outstanding tickets"), + TicketIssueError::RateLimited => write!(f, "ticket issuance rate limit exceeded"), + } + } +} + +impl std::error::Error for TicketIssueError {} + +/// The access scope a credential (and the ws ticket minted from it) grants. +/// +/// SEC-013: read-only sessions mirror terminals (snapshots/deltas) but may not +/// inject input or create/close/drive sessions. `Full` is the existing +/// all-or-nothing behaviour and stays the default for backward compatibility. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AccessScope { + /// Full read+write access (input, session lifecycle, agent prompts). + Full, + /// Mirror-only: snapshots/deltas allowed, write frames rejected. + ReadOnly, +} + +impl AccessScope { + /// True for a read-only scope (write frames must be refused). + pub fn is_readonly(self) -> bool { + matches!(self, AccessScope::ReadOnly) + } +} + +/// One recorded ticket: its issue instant (for TTL), its access scope, and the +/// issue sequence (so the FIFO can verify a popped entry is still the live one). +#[derive(Clone, Copy)] +struct TicketEntry { + issued: Instant, + scope: AccessScope, + seq: u64, +} + +impl Default for TicketStore { + fn default() -> Self { + Self::new(TICKET_TTL) + } +} + +impl TicketStore { + pub fn new(ttl: Duration) -> Self { + Self::with_limits( + ttl, + MAX_OUTSTANDING_TICKETS, + MAX_TICKETS_PER_WINDOW, + TICKET_RATE_WINDOW, + ) + } + + /// Construct a store with explicit M3 limits (used by tests; production goes + /// through [`new`](Self::new) / [`default`](Self::default)). + pub fn with_limits( + ttl: Duration, + max_outstanding: usize, + max_per_window: u32, + rate_window: Duration, + ) -> Self { + Self { + ttl, + max_outstanding: max_outstanding.max(1), + max_per_window: max_per_window.max(1), + rate_window, + inner: Arc::new(Mutex::new(TicketStoreInner { + entries: HashMap::new(), + order: std::collections::VecDeque::new(), + next_seq: 0, + window_start: Instant::now(), + window_count: 0, + })), + } + } + + /// Mint a new single-use full-access ticket valid for the store TTL. + pub async fn issue(&self) -> Result { + self.issue_scoped(AccessScope::Full).await + } + + /// Mint a new single-use ticket carrying `scope` (SEC-013). A `ReadOnly` + /// ticket later consumes to a read-only ws session. + /// + /// M3: a fast path — no full-map scan. It updates the issue-rate window and + /// refuses issuance when at the outstanding cap or over the rate cap. It only + /// prunes stale/expired FIFO heads, never an unrelated fresh live ticket. + pub async fn issue_scoped(&self, scope: AccessScope) -> Result { + let now = Instant::now(); + let mut inner = self.inner.lock().await; + + if inner.entries.len() >= self.max_outstanding { + inner.prune_expired_and_stale_front(self.ttl, now); + } + if inner.entries.len() >= self.max_outstanding { + return Err(TicketIssueError::OutstandingLimit); + } + + // M3 issue-rate window accounting. + if now.duration_since(inner.window_start) >= self.rate_window { + inner.window_start = now; + inner.window_count = 0; + } + if inner.window_count >= self.max_per_window { + return Err(TicketIssueError::RateLimited); + } + inner.window_count = inner.window_count.saturating_add(1); + + let ticket = random_secret(); + let seq = inner.next_seq; + inner.next_seq = inner.next_seq.wrapping_add(1); + inner.entries.insert( + ticket.clone(), + TicketEntry { + issued: now, + scope, + seq, + }, + ); + inner.order.push_back((seq, ticket.clone())); + Ok(ticket) + } + + /// Validate and consume `ticket`: `true` iff it existed, was not expired, and + /// was removed by this call. A second consume of the same ticket is false. + /// (Backward-compatible boolean view of [`consume_scoped`](Self::consume_scoped).) + pub async fn consume(&self, ticket: &str) -> bool { + self.consume_scoped(ticket).await.is_some() + } + + /// Validate and consume `ticket`, returning its [`AccessScope`] on success + /// (SEC-013) or `None` when the ticket is empty/expired/already-used. + /// + /// Expiry is enforced lazily here (the M3 fast-path `issue` avoids a full + /// sweep), so an expired entry is rejected and removed on consume. + pub async fn consume_scoped(&self, ticket: &str) -> Option { + if ticket.is_empty() { + return None; + } + let now = Instant::now(); + let mut inner = self.inner.lock().await; + match inner.entries.remove(ticket) { + Some(entry) if now.duration_since(entry.issued) < self.ttl => Some(entry.scope), + // Present but expired (or absent) → not consumable. The FIFO entry is + // left to be skipped lazily when evicted. + _ => None, + } + } + + /// Number of outstanding (issued, not-yet-consumed) tickets (test-only). + #[cfg(test)] + async fn outstanding(&self) -> usize { + self.inner.lock().await.entries.len() + } +} + +impl TicketStoreInner { + /// Drop consumed/stale FIFO heads and expired oldest entries. This never + /// removes a fresh live ticket; once the oldest live ticket is still within + /// TTL, all later live tickets are fresh too. + fn prune_expired_and_stale_front(&mut self, ttl: Duration, now: Instant) { + while let Some((seq, ticket)) = self.order.pop_front() { + match self.entries.get(&ticket) { + Some(entry) if entry.seq == seq => { + let expired = now.duration_since(entry.issued) >= ttl; + if expired { + self.entries.remove(&ticket); + } else { + self.order.push_front((seq, ticket)); + return; + } + } + // Stale FIFO head (already consumed, or a re-issued key with a + // newer seq): skip it. + _ => {} + } + } + } +} + +/// Per-key failed-attempt lockout (brute-force defense). +/// +/// `record_failure` bumps a per-key counter; once it reaches `max_failures` the +/// key is locked out for `lockout`. `is_locked` reports current lockout state; +/// `record_success` clears the counter. Keyed by client identity (IP) or global. +/// +/// SEC-005: behind a same-host tunnel every request's socket peer is the +/// constant loopback `127.0.0.1`, so a blanket per-key hard lock would let 5 bad +/// bearers lock out *all* legitimate users for the whole cooldown (trivial +/// self-DoS). To avoid that, callers pass a `lockable` flag: a **real** client +/// identity (a forwarded `Cf-Connecting-Ip`, or a genuine non-loopback peer) is +/// lockable (per-attacker brute-force protection preserved), while the **shared** +/// loopback key is *not* lockable — failures are still recorded (for an +/// incremental soft delay + audit) but never produce a blanket 429 that denies +/// everyone. +/// +/// SEC-009: the failure map is swept opportunistically on every `record_failure` +/// / `is_locked` — entries whose lockout window has fully elapsed are dropped — +/// and a hard `max_entries` cap evicts the oldest unlocked entries so the map +/// cannot grow without bound if the gateway is ever bound to a real interface. +#[derive(Clone)] +pub struct RateLimiter { + max_failures: u32, + lockout: Duration, + max_entries: usize, + inner: Arc>>, +} + +#[derive(Clone, Copy)] +struct Attempts { + failures: u32, + locked_until: Option, + /// When this entry was last touched — drives expired-entry sweeping and + /// oldest-first eviction once the entry cap is hit (SEC-009). + last_seen: Instant, +} + +impl Attempts { + /// True once an entry carries no useful state: it is not (or no longer) + /// hard-locked and its idle window has elapsed, so it is safe to drop. + /// Such an entry is indistinguishable from an absent key, so sweeping it + /// never changes a future decision. + fn is_expired(&self, now: Instant, ttl: Duration) -> bool { + let locked = matches!(self.locked_until, Some(until) if now < until); + !locked && now.duration_since(self.last_seen) >= ttl + } +} + +impl Default for RateLimiter { + fn default() -> Self { + Self::new(MAX_FAILURES, LOCKOUT) + } +} + +impl RateLimiter { + pub fn new(max_failures: u32, lockout: Duration) -> Self { + Self::with_capacity(max_failures, lockout, MAX_RATE_LIMIT_ENTRIES) + } + + /// Construct a limiter with an explicit `max_entries` cap (SEC-009). Used by + /// tests; production goes through [`new`](Self::new) / [`default`](Self::default). + pub fn with_capacity(max_failures: u32, lockout: Duration, max_entries: usize) -> Self { + Self { + max_failures, + lockout, + max_entries: max_entries.max(1), + inner: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// True if `key` is currently hard-locked-out. A non-lockable (shared) key is + /// never hard-locked (SEC-005) — see [`record_failure`](Self::record_failure). + pub async fn is_locked(&self, key: &str) -> bool { + let now = Instant::now(); + let mut map = self.inner.lock().await; + // SEC-009: opportunistic sweep of fully-elapsed entries. + Self::sweep_expired(&mut map, now, self.lockout); + match map.get_mut(key) { + Some(a) => match a.locked_until { + Some(until) if now < until => true, + Some(_) => { + // Lockout elapsed — reset the window. + a.failures = 0; + a.locked_until = None; + a.last_seen = now; + false + } + None => false, + }, + None => false, + } + } + + /// Record a failed auth attempt for `key`. When `lockable` is true (a real + /// client identity) a run of `max_failures` arms a hard lockout — returns + /// true on the transition. When `lockable` is false (the shared loopback key + /// behind a tunnel) the failure is counted but **never** arms a hard lock, so + /// one attacker cannot deny everyone (SEC-005); the count still drives the + /// incremental [`soft_delay`](Self::soft_delay). + pub async fn record_failure(&self, key: &str, lockable: bool) -> bool { + let now = Instant::now(); + let mut map = self.inner.lock().await; + // SEC-009: sweep elapsed entries, then enforce the entry cap before + // inserting a brand-new key so the map cannot grow without bound. + Self::sweep_expired(&mut map, now, self.lockout); + if !map.contains_key(key) { + let has_room = Self::evict_to_capacity(&mut map, self.max_entries, now); + // M4: absolute hard cap. If the map is full and every entry is a live + // hard-lock (nothing evictable), refuse to track a brand-new key + // rather than inserting past `max_entries`. The active hard-locks are + // load-bearing and preserved; the new failing key simply isn't + // recorded this round (it still gets a 401 from the caller, and the + // attacker cannot inflate the map without bound — SEC-009/M4). + if !has_room { + tracing::warn!( + cap = self.max_entries, + "termgw: rate-limiter at hard cap with all entries locked; \ + refusing to track a new key (M4)" + ); + return false; + } + } + let a = map.entry(key.to_string()).or_insert(Attempts { + failures: 0, + locked_until: None, + last_seen: now, + }); + a.last_seen = now; + // If a prior lockout has elapsed, start a fresh window. + if matches!(a.locked_until, Some(until) if now >= until) { + a.failures = 0; + a.locked_until = None; + } + a.failures = a.failures.saturating_add(1); + if lockable && a.failures >= self.max_failures { + a.locked_until = Some(now + self.lockout); + true + } else { + false + } + } + + /// Incremental soft delay for `key` based on its current failure count + /// (SEC-005). Used for the shared loopback key — where a hard lock is unsafe — + /// to still slow a sustained bearer-guessing flood without denying anyone. + /// Capped so a legitimate user retrying after a few failures is not stalled + /// for long. + pub async fn soft_delay(&self, key: &str) -> Duration { + const PER_FAILURE_MS: u64 = 200; + const MAX_MS: u64 = 2_000; + let map = self.inner.lock().await; + let failures = map.get(key).map(|a| a.failures).unwrap_or(0); + let ms = (failures as u64).saturating_mul(PER_FAILURE_MS).min(MAX_MS); + Duration::from_millis(ms) + } + + /// Clear the failure window for `key` after a successful auth. + pub async fn record_success(&self, key: &str) { + let mut map = self.inner.lock().await; + map.remove(key); + } + + /// Number of distinct keys currently tracked (test-only introspection for + /// the SEC-009 sweep/eviction behaviour). + #[cfg(test)] + async fn entry_count(&self) -> usize { + self.inner.lock().await.len() + } + + /// Whether a `key` is currently present in the failure map (test-only). + #[cfg(test)] + async fn has_entry(&self, key: &str) -> bool { + self.inner.lock().await.contains_key(key) + } + + /// Drop every entry whose lockout window has fully elapsed (SEC-009). An + /// expired entry is indistinguishable from an absent key, so removing it + /// never changes a future decision. + fn sweep_expired(map: &mut HashMap, now: Instant, ttl: Duration) { + map.retain(|_, a| !a.is_expired(now, ttl)); + } + + /// Enforce the `max_entries` cap (SEC-009 / M4): while at/over capacity, + /// evict the oldest entry that is not currently hard-locked (a live lockout + /// is load-bearing and must be preserved). Sweeping runs first, so this only + /// fires under genuine pressure from many distinct live keys. + /// + /// Returns `true` when there is room for a new key afterwards (`len < + /// max_entries`), `false` when the map is full and every remaining entry is a + /// live hard-lock so nothing can be evicted — the M4 absolute cap: the caller + /// must then refuse to insert a new key rather than exceed `max_entries`. + fn evict_to_capacity( + map: &mut HashMap, + max_entries: usize, + now: Instant, + ) -> bool { + while map.len() >= max_entries { + let victim = map + .iter() + .filter(|(_, a)| !matches!(a.locked_until, Some(until) if now < until)) + .min_by_key(|(_, a)| a.last_seen) + .map(|(k, _)| k.clone()); + match victim { + Some(key) => { + map.remove(&key); + } + // Everything left is hard-locked — never evict a live lockout. + // M4: no room; the caller must not insert past the cap. + None => return false, + } + } + true + } +} + +/// Whether the gateway enforces auth. +/// +/// `Disabled` is local-dev only (loopback); `Token` carries the configured +/// long-lived credential for public exposure. +#[derive(Clone)] +pub enum AuthMode { + /// No auth — local development on loopback only. + Disabled, + /// Auth enforced with this access token. + Token(AccessToken), +} + +impl AuthMode { + /// True when auth is enforced (a token is configured). + pub fn is_enforced(&self) -> bool { + matches!(self, AuthMode::Token(_)) + } + + /// The configured token, if any. + pub fn token(&self) -> Option<&AccessToken> { + match self { + AuthMode::Token(t) => Some(t), + AuthMode::Disabled => None, + } + } +} + +/// Trusted forwarded-identity policy (SEC-005 / H6b). +/// +/// Behind a same-host tunnel every socket peer is the constant loopback address, +/// so the gateway resolves a client's real identity from a forwarded header. But +/// a forwarded header is attacker-controllable unless the request actually +/// arrived from the trusted local tunnel source, so it is trusted **only** when +/// the socket peer is loopback. +/// +/// The set of header names that carry the forwarded client IP is provider- +/// specific (cloudflared uses `cf-connecting-ip`); the gateway must not hardcode +/// it (AGENTS.md provider boundary). Instead the daemon injects the trusted +/// header list for whichever provider it started (e.g. `["cf-connecting-ip"]` +/// for cloudflared). An empty list (the default) means **never** trust a +/// forwarded header — identity is the socket peer only. +#[derive(Clone, Debug, Default)] +pub struct ForwardedIdentityPolicy { + /// Header names (lowercase) trusted to carry the client IP, consulted in + /// order. Empty → only the socket peer is used. + trusted_headers: Vec, +} + +impl ForwardedIdentityPolicy { + /// A policy that never trusts a forwarded header (socket peer only). This is + /// the safe default for a directly-bound gateway with no tunnel in front. + pub fn socket_peer_only() -> Self { + Self { + trusted_headers: Vec::new(), + } + } + + /// A policy trusting `headers` (case-insensitive) to carry the forwarded + /// client IP — but only when the socket peer is loopback (the trusted tunnel + /// source). The daemon builds this from the started provider's contract + /// (e.g. `["cf-connecting-ip"]` for cloudflared). + pub fn trusting(headers: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + Self { + trusted_headers: headers + .into_iter() + .map(|h| h.as_ref().to_ascii_lowercase()) + .filter(|h| !h.is_empty()) + .collect(), + } + } + + /// The trusted forwarded header names (lowercase), in lookup order. + pub fn trusted_headers(&self) -> &[String] { + &self.trusted_headers + } + + /// Resolve the forwarded client IP from `lookup` (a `header name → value` + /// resolver) using the first trusted header that yields a value. Returns + /// `None` when no trusted header is configured or none is present. + pub fn forwarded_ip<'a>(&self, lookup: impl Fn(&str) -> Option<&'a str>) -> Option<&'a str> { + self.trusted_headers + .iter() + .find_map(|name| lookup(name.as_str())) + } +} + +/// Shared, cheaply-clonable auth state threaded into the gateway router. +#[derive(Clone)] +pub struct AuthState { + pub mode: AuthMode, + pub tickets: TicketStore, + pub limiter: RateLimiter, + /// Optional read-only credential (SEC-013). When set, a bearer matching this + /// token authenticates with [`AccessScope::ReadOnly`]: it may mirror sessions + /// but its ws tickets are minted read-only so write frames are refused. + /// `None` → no read-only tier (unchanged all-or-nothing behaviour). + pub readonly_token: Option, + /// Trusted forwarded-identity policy (H6b): which header(s) the gateway may + /// believe for the real client IP, and only behind a loopback tunnel source. + /// Default → socket-peer-only (never trust a forwarded header). + pub forwarded_identity: ForwardedIdentityPolicy, +} + +impl AuthState { + /// Auth-disabled state for local dev (loopback). + pub fn disabled() -> Self { + Self { + mode: AuthMode::Disabled, + tickets: TicketStore::default(), + limiter: RateLimiter::default(), + readonly_token: None, + forwarded_identity: ForwardedIdentityPolicy::socket_peer_only(), + } + } + + /// Auth-enforced state with a configured token. + pub fn with_token(token: AccessToken) -> Self { + Self { + mode: AuthMode::Token(token), + tickets: TicketStore::default(), + limiter: RateLimiter::default(), + readonly_token: None, + forwarded_identity: ForwardedIdentityPolicy::socket_peer_only(), + } + } + + /// Auth-enforced state with a full-access token plus a read-only token + /// (SEC-013). A bearer matching `readonly` authenticates with + /// [`AccessScope::ReadOnly`]; a bearer matching `token` keeps full access. + pub fn with_tokens(token: AccessToken, readonly: AccessToken) -> Self { + Self { + mode: AuthMode::Token(token), + tickets: TicketStore::default(), + limiter: RateLimiter::default(), + readonly_token: Some(readonly), + forwarded_identity: ForwardedIdentityPolicy::socket_peer_only(), + } + } + + /// Set the trusted forwarded-identity policy (H6b). The daemon calls this + /// with the started provider's trusted header list (e.g. `cf-connecting-ip` + /// for cloudflared); the gateway crate never hardcodes a provider header. + pub fn with_forwarded_identity(mut self, policy: ForwardedIdentityPolicy) -> Self { + self.forwarded_identity = policy; + self + } + + /// Extract the `Bearer` credential from an `Authorization` header value. + pub fn bearer(authorization: Option<&str>) -> Option<&str> { + authorization?.strip_prefix("Bearer ").map(str::trim) + } + + /// Constant-time check that `candidate` matches the configured token. When + /// auth is disabled this is always true (local dev). + pub fn verify_token(&self, candidate: &str) -> bool { + self.scope_for(candidate).is_some() + } + + /// Resolve the [`AccessScope`] a presented bearer authenticates with, or + /// `None` when it matches no configured credential (SEC-013). + /// + /// - auth disabled → always full (local dev). + /// - matches the full token → [`AccessScope::Full`]. + /// - matches the read-only token (if configured) → [`AccessScope::ReadOnly`]. + /// + /// The full token is checked first so it always wins if both happened to be + /// equal; both comparisons are constant-time. + pub fn scope_for(&self, candidate: &str) -> Option { + match &self.mode { + AuthMode::Disabled => Some(AccessScope::Full), + AuthMode::Token(t) => { + if t.verify(candidate) { + Some(AccessScope::Full) + } else if self + .readonly_token + .as_ref() + .is_some_and(|ro| ro.verify(candidate)) + { + Some(AccessScope::ReadOnly) + } else { + None + } + } + } + } + + /// Resolve the rate-limiter key + lockability for a request (SEC-005). + /// + /// Behind a same-host tunnel (cloudflared) the socket `peer` is always the + /// constant loopback address, so keying the limiter on it would let a handful + /// of bad bearers hard-lock *every* user. We therefore prefer a forwarded + /// client identity, but **only trust it when the socket peer is loopback** + /// (i.e. the request actually arrived via the trusted local tunnel source — + /// a direct remote peer could otherwise spoof the header): + /// + /// - loopback peer + a parseable, non-loopback `forwarded_ip` + /// → key = that real IP, `lockable = true` (per-attacker brute-force lock). + /// - loopback peer, no usable forwarded IP + /// → the **shared** loopback key, `lockable = false` (never hard-lock; only + /// an incremental soft delay so one attacker can't deny everyone). + /// - non-loopback peer (a genuine direct client) + /// → key = peer IP, `lockable = true`. + pub fn limiter_key(peer: SocketAddr, forwarded_ip: Option<&str>) -> (String, bool) { + if peer.ip().is_loopback() { + if let Some(real) = forwarded_ip + .map(str::trim) + .filter(|s| !s.is_empty()) + .and_then(|s| s.parse::().ok()) + .filter(|ip| !ip.is_loopback()) + { + return (real.to_string(), true); + } + return (SHARED_LOOPBACK_KEY.to_string(), false); + } + (peer.ip().to_string(), true) + } +} + +/// Limiter key for requests that reach the gateway over the loopback tunnel with +/// no trustworthy forwarded client IP. Deliberately *not* an IP so it never +/// hard-locks (SEC-005) — see [`AuthState::limiter_key`]. +pub const SHARED_LOOPBACK_KEY: &str = "shared-loopback"; + +/// Refusal reasons surfaced by [`require_auth_or_refuse`]. +#[derive(Debug)] +pub enum AuthRefusal { + /// Remote/public mode requested but no token configured and no insecure override. + PublicWithoutToken, +} + +impl std::fmt::Display for AuthRefusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AuthRefusal::PublicWithoutToken => write!( + f, + "refusing to expose the terminal gateway publicly without an access token \ + (default-deny); configure a token or pass an explicit insecure override" + ), + } + } +} + +impl std::error::Error for AuthRefusal {} + +/// Default-deny gate for public exposure. +/// +/// When `remote` (public) mode is on, the gateway must have a token configured. +/// With no token and no explicit `insecure` override this returns +/// [`AuthRefusal::PublicWithoutToken`] so the daemon refuses to start publicly. +/// `insecure` is the explicit opt-out (caller is expected to log a loud warning). +pub fn require_auth_or_refuse( + remote: bool, + mode: &AuthMode, + insecure: bool, +) -> Result<(), AuthRefusal> { + if remote && !mode.is_enforced() && !insecure { + return Err(AuthRefusal::PublicWithoutToken); + } + Ok(()) +} + +/// Address parsing errors for the gateway bind address. +#[derive(Debug)] +pub enum GatewayAddrError { + /// Not a valid `SocketAddr`. + Invalid(String), + /// Valid address but not a loopback IP (rejected in remote mode). + NonLoopback(SocketAddr), +} + +impl std::fmt::Display for GatewayAddrError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GatewayAddrError::Invalid(v) => write!(f, "invalid gateway address: {v}"), + GatewayAddrError::NonLoopback(addr) => write!( + f, + "gateway address must be loopback-only when exposed via tunnel: {addr}" + ), + } + } +} + +impl std::error::Error for GatewayAddrError {} + +/// Parse + harden the gateway bind address. +/// +/// Mirrors `lucarned::health::parse_health_addr`: the gateway must bind a +/// loopback address so it never listens on a public interface (`0.0.0.0`). The +/// tunnel connects *outbound* to the edge and back to this loopback socket, so +/// the only public entry point is the authenticated tunnel ingress. +pub fn parse_gateway_addr(raw: &str) -> Result { + let addr = raw + .parse::() + .map_err(|_| GatewayAddrError::Invalid(raw.to_string()))?; + if !addr.ip().is_loopback() { + return Err(GatewayAddrError::NonLoopback(addr)); + } + Ok(addr) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn access_token_is_at_least_256_bit_and_constant_time() { + let a = AccessToken::generate(); + let b = AccessToken::generate(); + // url-safe base64 of 32 bytes is well over 32 chars; two tokens differ. + assert!(a.as_str().len() >= 32, "token too short: {}", a.as_str()); + assert_ne!(a.as_str(), b.as_str(), "tokens must be unique"); + // verify() round-trips and rejects others via constant-time compare. + assert!(a.verify(a.as_str())); + assert!(!a.verify(b.as_str())); + assert!(!a.verify("wrong")); + } + + #[test] + fn ct_eq_matches_only_equal_bytes() { + assert!(ct_eq(b"hunter2", b"hunter2")); + assert!(!ct_eq(b"hunter2", b"hunter3")); + assert!(!ct_eq(b"short", b"longerstring")); + } + + #[tokio::test] + async fn ticket_is_single_use() { + let store = TicketStore::default(); + let ticket = store.issue().await.expect("issue ticket"); + assert!(store.consume(&ticket).await, "first consume must succeed"); + assert!( + !store.consume(&ticket).await, + "replay: second consume must fail" + ); + assert!(!store.consume("never-issued").await); + assert!(!store.consume("").await); + } + + #[tokio::test] + async fn ticket_expires_after_ttl() { + // Tiny TTL so the test is fast and deterministic. + let store = TicketStore::new(Duration::from_millis(20)); + let ticket = store.issue().await.expect("issue ticket"); + tokio::time::sleep(Duration::from_millis(40)).await; + assert!( + !store.consume(&ticket).await, + "expired ticket must not consume" + ); + } + + // SEC-013: a ticket carries the scope it was minted under, surfaced on + // consume; a default `issue()` ticket is Full (backward-compatible). + #[tokio::test] + async fn ticket_carries_scope_through_consume() { + let store = TicketStore::default(); + let full = store.issue().await.expect("issue ticket"); // == issue_scoped(Full) + assert_eq!(store.consume_scoped(&full).await, Some(AccessScope::Full)); + + let ro = store + .issue_scoped(AccessScope::ReadOnly) + .await + .expect("issue read-only ticket"); + assert_eq!(store.consume_scoped(&ro).await, Some(AccessScope::ReadOnly)); + // Single-use still holds for scoped tickets. + assert_eq!(store.consume_scoped(&ro).await, None); + // Empty / never-issued → None. + assert_eq!(store.consume_scoped("").await, None); + assert_eq!(store.consume_scoped("never-issued").await, None); + } + + // M3: the outstanding ticket set is hard-capped. Minting beyond the cap is + // refused and never evicts another client's still-fresh ticket. + #[tokio::test] + async fn ticket_store_caps_outstanding_without_evicting_fresh_tickets() { + // Long TTL (nothing self-expires), small cap, generous rate so the rate + // path doesn't interfere with the pure-cap assertion. + let store = + TicketStore::with_limits(Duration::from_secs(3600), 3, 1000, Duration::from_secs(1)); + let t1 = store.issue().await.expect("issue ticket"); + let t2 = store.issue().await.expect("issue ticket"); + let t3 = store.issue().await.expect("issue ticket"); + assert_eq!(store.outstanding().await, 3, "at cap"); + // A 4th mint is refused instead of evicting the oldest fresh ticket. + assert_eq!( + store.issue().await.expect_err("cap must reject new ticket"), + TicketIssueError::OutstandingLimit + ); + assert_eq!(store.outstanding().await, 3, "still at cap after refusal"); + // All previously issued fresh tickets survive the mint flood. + assert!(store.consume(&t1).await); + assert!(store.consume(&t2).await); + assert!(store.consume(&t3).await); + } + + // M3: the issue-rate cap rejects a mint flood without evicting previously + // issued fresh tickets. + #[tokio::test] + async fn ticket_store_rate_cap_refuses_flood_without_evicting_fresh_tickets() { + let cap = 8; + let store = TicketStore::with_limits( + Duration::from_secs(3600), + cap, + 4, // 4 mints per window + Duration::from_secs(3600), // window won't roll during the test + ); + let mut issued = Vec::new(); + for _ in 0..4 { + issued.push(store.issue().await.expect("issue ticket")); + } + assert_eq!( + store + .issue() + .await + .expect_err("rate cap must reject new ticket"), + TicketIssueError::RateLimited + ); + assert_eq!(store.outstanding().await, 4); + for ticket in issued { + assert!( + store.consume(&ticket).await, + "fresh ticket must survive rate refusal" + ); + } + } + + // M3: expiry is enforced lazily on consume even though issue does not sweep. + #[tokio::test] + async fn ticket_expiry_is_lazy_on_consume() { + let store = TicketStore::with_limits( + Duration::from_millis(20), + 1024, + 1000, + Duration::from_secs(1), + ); + let ticket = store.issue().await.expect("issue ticket"); + tokio::time::sleep(Duration::from_millis(40)).await; + assert!( + !store.consume(&ticket).await, + "expired ticket must not consume" + ); + } + + // SEC-013: the readonly token authenticates as ReadOnly; the full token as + // Full; an unrelated token as nothing. Both comparisons are constant-time. + #[test] + fn readonly_token_resolves_distinct_scope() { + let full = AccessToken::generate(); + let readonly = AccessToken::generate(); + let auth = AuthState::with_tokens(full.clone(), readonly.clone()); + + assert_eq!(auth.scope_for(full.as_str()), Some(AccessScope::Full)); + assert_eq!( + auth.scope_for(readonly.as_str()), + Some(AccessScope::ReadOnly) + ); + assert_eq!(auth.scope_for("unrelated"), None); + + // Backward compatibility: no readonly token configured → only the full + // token authenticates, always as Full. + let only_full = AuthState::with_token(full.clone()); + assert_eq!(only_full.scope_for(full.as_str()), Some(AccessScope::Full)); + assert_eq!(only_full.scope_for(readonly.as_str()), None); + assert!(only_full.readonly_token.is_none()); + + // Auth disabled (local dev) → always Full. + let disabled = AuthState::disabled(); + assert_eq!(disabled.scope_for("anything"), Some(AccessScope::Full)); + } + + #[tokio::test] + async fn rate_limiter_locks_out_after_max_failures() { + let limiter = RateLimiter::new(MAX_FAILURES, LOCKOUT); + let key = "203.0.113.7"; + assert!(!limiter.is_locked(key).await); + let mut locked = false; + for _ in 0..MAX_FAILURES { + locked = limiter.record_failure(key, true).await; + } + assert!(locked, "reaching MAX_FAILURES must lock out"); + assert!(limiter.is_locked(key).await, "key must be locked"); + // A success clears the window. + limiter.record_success(key).await; + assert!(!limiter.is_locked(key).await); + } + + #[tokio::test] + async fn rate_limiter_lockout_expires() { + let limiter = RateLimiter::new(2, Duration::from_millis(20)); + let key = "lock"; + assert!(limiter.record_failure(key, true).await || true); + assert!( + limiter.record_failure(key, true).await, + "second failure locks" + ); + assert!(limiter.is_locked(key).await); + tokio::time::sleep(Duration::from_millis(40)).await; + assert!(!limiter.is_locked(key).await, "lockout must elapse"); + } + + // SEC-009: an entry whose lockout window has fully elapsed is swept on the + // next limiter touch, so the failure map cannot accumulate dead entries. + #[tokio::test] + async fn rate_limiter_sweeps_expired_entries() { + let limiter = RateLimiter::with_capacity(2, Duration::from_millis(20), 64); + // Arm a lockout for an attacker key, then let the window elapse. + limiter.record_failure("203.0.113.1", true).await; + assert!(limiter.record_failure("203.0.113.1", true).await); + assert_eq!(limiter.entry_count().await, 1); + tokio::time::sleep(Duration::from_millis(40)).await; + // Touching the limiter with an unrelated failure sweeps the now-expired + // entry along with itself if it too is past its window. + limiter.record_failure("203.0.113.2", true).await; + // The expired attacker entry is gone; only the fresh one remains. + assert!(!limiter.has_entry("203.0.113.1").await); + assert!(limiter.has_entry("203.0.113.2").await); + } + + // SEC-009: over the entry cap, the oldest unlocked entry is evicted so the + // map size stays bounded, but a live hard-lock is never evicted. + #[tokio::test] + async fn rate_limiter_evicts_oldest_when_over_capacity() { + // Cap of 3 distinct keys; a long lockout so nothing self-expires mid-test. + let limiter = RateLimiter::with_capacity(100, Duration::from_secs(3600), 3); + // Three unlocked keys (one failure each, below max_failures so no lock). + limiter.record_failure("a", true).await; + tokio::time::sleep(Duration::from_millis(2)).await; + limiter.record_failure("b", true).await; + tokio::time::sleep(Duration::from_millis(2)).await; + limiter.record_failure("c", true).await; + assert_eq!(limiter.entry_count().await, 3); + // A fourth distinct key must evict the oldest ("a") to stay at the cap. + limiter.record_failure("d", true).await; + assert_eq!(limiter.entry_count().await, 3); + assert!( + !limiter.has_entry("a").await, + "oldest entry must be evicted" + ); + assert!(limiter.has_entry("d").await, "newest entry must be present"); + } + + // SEC-009: a live hard-lock is load-bearing and must survive eviction + // pressure even when it is the oldest entry. + #[tokio::test] + async fn rate_limiter_never_evicts_a_live_hard_lock() { + let limiter = RateLimiter::with_capacity(2, Duration::from_secs(3600), 2); + // "locked" hits max_failures → hard-locked (and is the oldest entry). + limiter.record_failure("locked", true).await; + assert!(limiter.record_failure("locked", true).await); + assert!(limiter.is_locked("locked").await); + // Insert more distinct keys; the locked entry must be preserved. + limiter.record_failure("x", true).await; + limiter.record_failure("y", true).await; + assert!( + limiter.is_locked("locked").await, + "a live lockout must never be evicted" + ); + } + + // M4: absolute hard cap. When the map is full and EVERY entry is a live + // hard-lock (nothing evictable), a brand-new failing key is NOT inserted — + // the cap is never exceeded, and the existing hard-locks are preserved. + #[tokio::test] + async fn rate_limiter_never_exceeds_cap_when_all_locked() { + // Cap of 2, lock after 1 failure, long lockout so both stay locked. + let limiter = RateLimiter::with_capacity(1, Duration::from_secs(3600), 2); + // Two distinct keys each hard-lock immediately → map full of live locks. + assert!(limiter.record_failure("a", true).await, "a locks"); + assert!(limiter.record_failure("b", true).await, "b locks"); + assert!(limiter.is_locked("a").await); + assert!(limiter.is_locked("b").await); + assert_eq!(limiter.entry_count().await, 2, "at cap, all locked"); + + // A third distinct key cannot be tracked (no victim) → not locked, and + // the map does NOT grow past the cap. + let locked_new = limiter.record_failure("c", true).await; + assert!( + !locked_new, + "a new key under a full all-locked cap is not locked" + ); + assert_eq!( + limiter.entry_count().await, + 2, + "hard cap: the map must not exceed max_entries even under flood" + ); + assert!( + !limiter.has_entry("c").await, + "new key was refused tracking (M4)" + ); + // The existing live hard-locks are untouched. + assert!(limiter.is_locked("a").await); + assert!(limiter.is_locked("b").await); + } + + // SEC-005: the shared loopback key (no real client identity behind the + // tunnel) must NEVER hard-lock, so one attacker can't deny everyone. It only + // accrues an incremental soft delay. + #[tokio::test] + async fn rate_limiter_shared_key_never_hard_locks_and_soft_delays() { + let limiter = RateLimiter::new(MAX_FAILURES, LOCKOUT); + let key = SHARED_LOOPBACK_KEY; + let mut ever_locked = false; + for _ in 0..(MAX_FAILURES * 4) { + ever_locked |= limiter.record_failure(key, false).await; + } + assert!(!ever_locked, "shared key must never arm a hard lock"); + assert!( + !limiter.is_locked(key).await, + "shared key must never report locked" + ); + // But it does accrue a (capped) incremental soft delay. + assert!( + limiter.soft_delay(key).await > Duration::ZERO, + "repeated failures must produce a soft delay" + ); + assert!( + limiter.soft_delay(key).await <= Duration::from_millis(2_000), + "soft delay must stay capped" + ); + } + + // SEC-005: a real forwarded client IP (trusted only behind a loopback peer) + // is lockable; the shared key is not; a direct non-loopback peer is keyed by + // its own IP. + #[test] + fn limiter_key_prefers_trusted_forwarded_ip() { + let loopback: SocketAddr = "127.0.0.1:55000".parse().unwrap(); + let direct: SocketAddr = "203.0.113.9:55000".parse().unwrap(); + + // Loopback peer + real forwarded IP → key = that IP, lockable. + let (key, lockable) = AuthState::limiter_key(loopback, Some("198.51.100.7")); + assert_eq!(key, "198.51.100.7"); + assert!(lockable); + + // Loopback peer + no forwarded IP → shared key, NOT lockable. + let (key, lockable) = AuthState::limiter_key(loopback, None); + assert_eq!(key, SHARED_LOOPBACK_KEY); + assert!(!lockable); + + // Loopback peer + a forwarded *loopback* IP is not trusted as a real + // client → shared key, NOT lockable. + let (key, lockable) = AuthState::limiter_key(loopback, Some("127.0.0.1")); + assert_eq!(key, SHARED_LOOPBACK_KEY); + assert!(!lockable); + + // Garbage forwarded value → shared key, NOT lockable. + let (key, lockable) = AuthState::limiter_key(loopback, Some("not-an-ip")); + assert_eq!(key, SHARED_LOOPBACK_KEY); + assert!(!lockable); + + // Direct non-loopback peer → keyed by its own IP, lockable; a spoofed + // forwarded header is ignored (peer is not the trusted tunnel source). + let (key, lockable) = AuthState::limiter_key(direct, Some("10.0.0.1")); + assert_eq!(key, "203.0.113.9"); + assert!(lockable); + } + + // SEC-008: explicit tokens that are empty/whitespace or too short fail closed. + #[test] + fn explicit_token_validation_rejects_weak_tokens() { + assert_eq!( + AccessToken::from_secret_validated("").err(), + Some(TokenError::Empty) + ); + assert_eq!( + AccessToken::from_secret_validated(" \t ").err(), + Some(TokenError::Empty) + ); + // Shorter than MIN_EXPLICIT_TOKEN_LEN → TooShort. + let short = "abc123"; + assert!(matches!( + AccessToken::from_secret_validated(short), + Err(TokenError::TooShort { .. }) + )); + // A long-enough token is accepted verbatim (no trimming of the secret). + let strong = "0123456789abcdef0123456789abcdef"; // 32 chars + let token = AccessToken::from_secret_validated(strong).expect("strong token accepted"); + assert_eq!(token.as_str(), strong); + assert!(token.verify(strong)); + // A generated token always passes validation. + let generated = AccessToken::generate(); + assert!(AccessToken::from_secret_validated(generated.as_str().to_string()).is_ok()); + } + + #[test] + fn gateway_addr_must_be_loopback() { + assert!(parse_gateway_addr("127.0.0.1:7800").is_ok()); + assert!(parse_gateway_addr("[::1]:7800").is_ok()); + assert!(matches!( + parse_gateway_addr("0.0.0.0:7800"), + Err(GatewayAddrError::NonLoopback(_)) + )); + assert!(matches!( + parse_gateway_addr("203.0.113.5:7800"), + Err(GatewayAddrError::NonLoopback(_)) + )); + assert!(matches!( + parse_gateway_addr("not-an-addr"), + Err(GatewayAddrError::Invalid(_)) + )); + } + + #[test] + fn default_deny_refuses_public_without_token() { + // Public mode, no token, no override → refused. + assert!(matches!( + require_auth_or_refuse(true, &AuthMode::Disabled, false), + Err(AuthRefusal::PublicWithoutToken) + )); + // Public mode, token configured → allowed. + assert!( + require_auth_or_refuse(true, &AuthMode::Token(AccessToken::generate()), false).is_ok() + ); + // Public mode, no token but explicit insecure override → allowed (loud warn elsewhere). + assert!(require_auth_or_refuse(true, &AuthMode::Disabled, true).is_ok()); + // Local (non-remote) mode never refuses. + assert!(require_auth_or_refuse(false, &AuthMode::Disabled, false).is_ok()); + } + + #[test] + fn bearer_extraction() { + assert_eq!(AuthState::bearer(Some("Bearer abc123")), Some("abc123")); + assert_eq!(AuthState::bearer(Some("bearer abc123")), None); + assert_eq!(AuthState::bearer(Some("abc123")), None); + assert_eq!(AuthState::bearer(None), None); + } + + // H6b: the gateway never hardcodes a provider's forwarded header; the policy + // is injected by the daemon. The default trusts nothing (socket-peer only), + // a `trusting` policy consults its headers case-insensitively in order, and + // an `AuthState` carries the policy so `bearer_guard` can use it. + #[test] + fn forwarded_identity_policy_resolution() { + // Default: never trust a forwarded header (socket peer only). + let none = ForwardedIdentityPolicy::socket_peer_only(); + assert!(none.trusted_headers().is_empty()); + assert_eq!(none.forwarded_ip(|_| Some("198.51.100.7")), None); + + // A trusting policy normalizes header names to lowercase and consults + // them in order, returning the first present value. + let policy = ForwardedIdentityPolicy::trusting(["CF-Connecting-IP"]); + assert_eq!(policy.trusted_headers(), &["cf-connecting-ip".to_string()]); + // Lookup keyed by the normalized name yields the value. + let headers = std::collections::HashMap::from([("cf-connecting-ip", "198.51.100.7")]); + assert_eq!( + policy.forwarded_ip(|name| headers.get(name).copied()), + Some("198.51.100.7") + ); + // A header that isn't trusted is ignored. + let other = std::collections::HashMap::from([("x-real-ip", "10.0.0.1")]); + assert_eq!(policy.forwarded_ip(|name| other.get(name).copied()), None); + + // Empty header names are filtered out at construction. + let filtered = ForwardedIdentityPolicy::trusting(["", "X-Forwarded-For"]); + assert_eq!(filtered.trusted_headers(), &["x-forwarded-for".to_string()]); + + // The default AuthState carries a socket-peer-only policy; `with_*` + // builders can attach a trusting one. + let auth = AuthState::with_token(AccessToken::generate()); + assert!(auth.forwarded_identity.trusted_headers().is_empty()); + let trusting = AuthState::with_token(AccessToken::generate()) + .with_forwarded_identity(ForwardedIdentityPolicy::trusting(["cf-connecting-ip"])); + assert_eq!( + trusting.forwarded_identity.trusted_headers(), + &["cf-connecting-ip".to_string()] + ); + } +} diff --git a/crates/lucarne-termgw/src/bin/termgw-dev.rs b/crates/lucarne-termgw/src/bin/termgw-dev.rs new file mode 100644 index 0000000..e7aba78 --- /dev/null +++ b/crates/lucarne-termgw/src/bin/termgw-dev.rs @@ -0,0 +1,35 @@ +//! termgw-dev — standalone runner for the terminal gateway (manual/e2e test). +//! +//! Connects to the SYSTEM rmux daemon, adopts its sessions, and serves the ws + +//! HTTP gateway. NOT shipped: `lucarned` builds the production router directly +//! into the default product binary; this runner exists only for local +//! end-to-end testing against a real daemon. +//! +//! ```text +//! TERMGW_ADDR=127.0.0.1:7800 TERMGW_WEB=web cargo run -p lucarne-termgw --bin termgw-dev +//! ``` + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use lucarne_rmux::RmuxMonitor; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let monitor = Arc::new(RmuxMonitor::connect().await?); + let adopted = monitor.adopt_all().await?; + eprintln!("termgw-dev: adopted {} system session(s)", adopted.len()); + + let addr: SocketAddr = std::env::var("TERMGW_ADDR") + .unwrap_or_else(|_| "127.0.0.1:7800".to_string()) + .parse()?; + let web_dir = PathBuf::from(std::env::var("TERMGW_WEB").unwrap_or_else(|_| "web".to_string())); + + eprintln!( + "termgw-dev: serving http://{addr} (web dir: {})", + web_dir.display() + ); + lucarne_termgw::serve(monitor, addr, web_dir).await?; + Ok(()) +} diff --git a/crates/lucarne-termgw/src/lib.rs b/crates/lucarne-termgw/src/lib.rs new file mode 100644 index 0000000..8653dec --- /dev/null +++ b/crates/lucarne-termgw/src/lib.rs @@ -0,0 +1,2802 @@ +//! lucarne-termgw — the terminal gateway. +//! +//! An axum WebSocket + HTTP surface over the [`RmuxMonitor`]: +//! - **WS `/ws`** — per-client mirror. On connect it sends a `SessionList`, then +//! for each `Subscribe`d session it sends a full `Snapshot` and streams +//! `SnapshotDelta`s (via a per-client [`Differ`]). Inbound `Input` frames are +//! injected into the pane (`send_text`/`send_key`). +//! - **HTTP `/api/sessions`** — `GET` lists monitored sessions, `POST` creates a +//! shell session, `DELETE /api/sessions/{id}` kills one. This is what the thin +//! CLI hits (pop-out/retract itself is rmux-native and does not go through here). +//! - **Static** — anything else is served from the web asset dir. Production web +//! apps are external consumers of this gateway API. +//! +//! ## Concurrency +//! Output fan-out is the monitor's `broadcast`; each ws client owns its own set +//! of [`Differ`]s so a slow client only lags itself (`RecvError::Lagged` → it +//! re-snapshots its subscribed sessions). Input is injected directly through the +//! monitor, which serializes writes per pane. + +use std::collections::{HashMap, HashSet}; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{ConnectInfo, FromRequestParts, Path, Query, State}; +use axum::http::request::Parts; +use axum::http::{Request, StatusCode}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use futures::stream::{SplitSink, StreamExt}; +use futures::SinkExt; +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::sync::broadcast::error::RecvError; +use tower_http::services::ServeDir; + +use lucarne::control_plane::ControlPlaneSqliteStore; +use lucarne::terminal_agent_bind; +use lucarne_rmux::{ + archive, ClientFrame, Cursor, DiffResult, Differ, GridUpdate, MonitorError, PaneGrid, + RmuxMonitor, ServerFrame, SessionDescriptor, SessionId, TermInput, +}; + +pub mod auth; + +pub use auth::{ + parse_gateway_addr, require_auth_or_refuse, AccessScope, AccessToken, AuthMode, AuthRefusal, + AuthState, ForwardedIdentityPolicy, GatewayAddrError, TokenError, +}; + +/// Monotonic per-process ws connection sequence (SEC-011 audit logging). +/// +/// A connect/disconnect pair is logged with this id so operators can correlate a +/// session's lifetime in the logs WITHOUT ever logging the ticket/token. It is +/// not a security token — just a log correlation handle. +static WS_CONN_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// Next ws connection sequence number for audit logging (SEC-011). +fn next_conn_seq() -> u64 { + WS_CONN_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +/// Snapshot of the daemon-owned tunnel, returned by the loopback control routes. +/// +/// Intentionally minimal and transport-agnostic: the gateway never learns +/// backend-specific details (Locked decision L2). `public_url` is the tunnel's +/// ingress URL when up; `access_token` is the credential a remote client appends +/// as `#token=` to reach the authenticated surface (the CLI renders it as a QR). +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct RemoteControlStatus { + /// Whether a tunnel is currently running. + pub running: bool, + /// Provider id backing the tunnel (e.g. `"cloudflared"`), when up. + pub provider: Option, + /// Public URL the tunnel exposes, when up. + pub public_url: Option, + /// The gateway access token to hand the remote client, when auth is enforced. + pub access_token: Option, +} + +/// Optional control-plane overrides for `POST /api/remote/start` (G3). +/// +/// The CLI (`lucarned remote start`) collects the chosen provider id + that provider's +/// fields and posts them here. When present they override / merge with the +/// daemon's pre-configured tunnel; when absent the daemon falls back to its +/// `lucarned.yaml` pre-configured provider + config (backward compatible). +/// +/// Transport-agnostic by design (Locked decision L2): just a provider id and a +/// flat key→value field map — no backend-specific structure, no daemon types. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct RemoteStartParams { + /// Provider id to start (e.g. `"cloudflared"`). `None` → daemon's configured + /// provider. + #[serde(default)] + pub provider: Option, + /// Provider field overrides keyed by `RequiredField::key`. Merged over the + /// daemon's configured provider fields; empty → use configured fields as-is. + #[serde(default)] + pub fields: std::collections::BTreeMap, +} + +impl RemoteStartParams { + /// True when the caller supplied nothing actionable (no provider override and + /// no field overrides) — the daemon should use its pre-configured tunnel. + pub fn is_empty(&self) -> bool { + self.provider.is_none() && self.fields.is_empty() + } +} + +/// Daemon-owned tunnel control plane, driven by the loopback `/api/remote/*` +/// routes. +/// +/// lucarned (which owns the tunnel lifecycle — Locked decision L6) implements +/// this; the gateway only forwards `start`/`stop`/`status` to it. Defining the +/// seam here (rather than depending on `lucarne-remote` from `lucarne-termgw`) +/// keeps the provider abstraction out of the gateway crate. +#[async_trait] +pub trait RemoteControl: Send + Sync { + /// Start the tunnel (idempotent: if already up, return the current status). + /// + /// G3: `params` carries optional CLI-supplied provider + field overrides; an + /// empty `params` means "use the daemon's pre-configured tunnel". + /// + /// M2: errors are typed ([`RemoteControlError`]) so the loopback control + /// plane can map them to a meaningful status (400 bad config, 404 unknown + /// provider, 502 tunnel/backend failure) instead of a blanket 500. + async fn start( + &self, + params: RemoteStartParams, + ) -> Result; + /// Stop the tunnel (idempotent: if already down, succeeds). + async fn stop(&self) -> Result; + /// Report the current tunnel status. + async fn status(&self) -> RemoteControlStatus; +} + +/// Typed control-plane error surfaced by [`RemoteControl`] (M2). +/// +/// The loopback `/api/remote/*` handlers map each variant to a status code and a +/// human-readable message. Because the control plane is loopback-only (SEC-002), +/// returning a descriptive message here is safe — it never reaches the tunnel. +#[derive(Debug, Clone)] +pub enum RemoteControlError { + /// The requested provider id is not registered (→ 404). + UnknownProvider(String), + /// Provider configuration was invalid / a required field was missing (→ 400). + BadConfig(String), + /// The tunnel backend itself failed to start / stop (→ 502). + Backend(String), +} + +impl RemoteControlError { + /// HTTP status this error maps to on the loopback control plane (M2). + pub fn status_code(&self) -> StatusCode { + match self { + RemoteControlError::UnknownProvider(_) => StatusCode::NOT_FOUND, + RemoteControlError::BadConfig(_) => StatusCode::BAD_REQUEST, + RemoteControlError::Backend(_) => StatusCode::BAD_GATEWAY, + } + } + + /// The human-readable detail (safe to return on the loopback control plane). + pub fn detail(&self) -> &str { + match self { + RemoteControlError::UnknownProvider(m) + | RemoteControlError::BadConfig(m) + | RemoteControlError::Backend(m) => m, + } + } +} + +impl std::fmt::Display for RemoteControlError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RemoteControlError::UnknownProvider(m) => write!(f, "unknown provider: {m}"), + RemoteControlError::BadConfig(m) => write!(f, "bad config: {m}"), + RemoteControlError::Backend(m) => write!(f, "backend error: {m}"), + } + } +} + +impl std::error::Error for RemoteControlError {} + +/// Shared gateway state (cheap to clone — `Arc` to the monitor plus the auth layer). +#[derive(Clone)] +struct AppState { + monitor: Arc, + control_store: ControlPlaneSqliteStore, + auth: AuthState, + /// Connection/session caps + ws session lifetime knobs (SEC-004/SEC-006). + limits: GatewayLimits, + /// Global concurrent-ws-connection permit pool (SEC-004 / H1). Shared across + /// all gateway ws routes (`/ws` and `/agent/{id}`). + ws_pool: WsConnectionPool, +} + +/// Narrow monitor seam used by the gateway. Production wires this to +/// [`RmuxMonitor`]; tests can provide a fake without launching a system rmux +/// daemon. +#[async_trait] +trait TerminalMonitor: Send + Sync { + fn subscribe(&self) -> tokio::sync::broadcast::Receiver; + async fn sessions(&self) -> Vec; + async fn create(&self, title: String) -> Result; + async fn snapshot_grid(&self, id: &SessionId) -> Result<(PaneGrid, Cursor), MonitorError>; + async fn inject(&self, id: &SessionId, input: TermInput) -> Result<(), MonitorError>; + async fn kill(&self, id: &SessionId) -> Result<(), MonitorError>; + async fn capture_scrollback(&self, id: &SessionId) -> Result; +} + +#[async_trait] +impl TerminalMonitor for RmuxMonitor { + fn subscribe(&self) -> tokio::sync::broadcast::Receiver { + self.subscribe() + } + + async fn sessions(&self) -> Vec { + self.sessions().await + } + + async fn create(&self, title: String) -> Result { + self.create(title).await + } + + async fn snapshot_grid(&self, id: &SessionId) -> Result<(PaneGrid, Cursor), MonitorError> { + self.snapshot_grid(id).await + } + + async fn inject(&self, id: &SessionId, input: TermInput) -> Result<(), MonitorError> { + self.inject(id, input).await + } + + async fn kill(&self, id: &SessionId) -> Result<(), MonitorError> { + self.kill(id).await + } + + async fn capture_scrollback(&self, id: &SessionId) -> Result { + self.capture_scrollback(id).await + } +} + +/// Tunable connection/session limits + ws session lifetime (SEC-004 / SEC-006). +/// +/// Defaults are sane for a single-user remote terminal: a small connection cap, +/// a per-connection session-creation cap (anti fork-bomb), an inbound-frame rate +/// limit (anti flood), and an idle + max-lifetime close on every ws client loop +/// (a connect-time ticket alone is not enough — a live socket must not outlive +/// its credential indefinitely). +#[derive(Clone, Copy, Debug)] +pub struct GatewayLimits { + /// Max concurrent ws connections across all ws routes. Excess upgrades 503. + pub max_ws_connections: usize, + /// Max sessions one ws connection may create (anti fork-bomb). + pub max_sessions_per_conn: usize, + /// Max inbound ws frames per second per connection (anti flood); 0 = off. + pub max_inbound_frames_per_sec: u32, + /// Close a ws client after this much inactivity (no inbound frame). + pub idle_timeout: std::time::Duration, + /// Hard cap on a single ws session's wall-clock lifetime. + pub max_session_lifetime: std::time::Duration, +} + +impl Default for GatewayLimits { + fn default() -> Self { + Self { + max_ws_connections: 32, + max_sessions_per_conn: 16, + max_inbound_frames_per_sec: 200, + idle_timeout: std::time::Duration::from_secs(7200), + max_session_lifetime: std::time::Duration::from_secs(43_200), // 12h + } + } +} + +/// Shared ws-connection governor (SEC-004 / H1): the [`GatewayLimits`] plus the +/// SINGLE global connection-permit pool that every ws route draws from. +/// +/// Cloning shares the same underlying semaphore (`Arc`). The daemon builds one +/// pool for the gateway so `/ws` and `/agent` obey one `max_ws_connections` cap +/// and the same idle / lifetime / inbound-frame-rate knobs. In-process extension +/// routes can use [`authorize_ws`] with the same pool, but production Web apps +/// should consume the gateway API directly rather than merge a Lucarne-owned web +/// runtime route. +#[derive(Clone)] +pub struct WsConnectionPool { + limits: GatewayLimits, + permits: Arc, +} + +impl WsConnectionPool { + /// Build a pool sized to `limits.max_ws_connections`. + pub fn new(limits: GatewayLimits) -> Self { + Self { + permits: Arc::new(tokio::sync::Semaphore::new(limits.max_ws_connections)), + limits, + } + } + + /// The limits this pool enforces (idle / lifetime / inbound frame rate / caps). + pub fn limits(&self) -> GatewayLimits { + self.limits + } + + /// Try to take a connection permit, held for the socket lifetime. `None` when + /// the global cap is saturated — the caller must reject the upgrade with 503 + /// (and, for ws routes, do so BEFORE consuming a single-use ticket — M5). + pub fn try_acquire(&self) -> Option { + self.permits.clone().try_acquire_owned().ok() + } +} + +/// Loopback-only control-plane state: the daemon tunnel control plane (SEC-002). +/// +/// Served on a SEPARATE loopback listener bound to a distinct port the tunnel +/// never targets — `LoopbackOnly` is not a trust boundary behind a same-host +/// tunnel (cloudflared proxies from 127.0.0.1, so a tunneled peer always looks +/// loopback). Keeping these routes off the public gateway router is the actual +/// fix; `LoopbackOnly` stays as defense-in-depth on this listener. +#[derive(Clone)] +struct ControlState { + /// `None` when no remote subsystem is wired (local dev / default daemon). + remote_control: Option>, +} + +/// Loopback-only extractor: rejects any request whose peer is not a loopback +/// address. Defense-in-depth for the `/api/remote/*` control plane (Locked +/// decision L3, SEC-002) — these routes now live on a SEPARATE loopback listener +/// bound to a distinct port the tunnel never targets, so they are unreachable +/// from the public tunnel by construction; this extractor re-asserts loopback on +/// the control listener as belt-and-suspenders. +struct LoopbackOnly; + +impl FromRequestParts for LoopbackOnly +where + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + match parts.extensions.get::>() { + Some(ConnectInfo(peer)) if peer.ip().is_loopback() => Ok(LoopbackOnly), + Some(ConnectInfo(peer)) => { + tracing::warn!(target: "lucarne_termgw", %peer, "rejected non-loopback access to /api/remote control plane"); + Err(( + StatusCode::FORBIDDEN, + "remote control plane is loopback-only", + ) + .into_response()) + } + // No connect info recorded — fail closed. + None => Err(( + StatusCode::FORBIDDEN, + "remote control plane is loopback-only", + ) + .into_response()), + } + } +} + +/// Require [`AccessScope::Full`] on a `/api/*` write handler (SEC-013 / C1). +/// +/// [`bearer_guard`] resolves the authenticated scope and stores it in the +/// request extensions; this extractor reads it and rejects a read-only session +/// with 403 BEFORE the handler runs (so a read-only credential can never reach a +/// state-mutating route — create / close / archive). A missing scope extension +/// (auth misconfigured / a route not behind `bearer_guard`) fails closed. +/// +/// Read handlers (GET list / files / agents / history) deliberately do NOT use +/// this extractor: a read-only bearer is allowed to mirror. +struct RequireFull; + +impl FromRequestParts for RequireFull +where + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + match parts.extensions.get::() { + Some(AccessScope::Full) => Ok(RequireFull), + Some(AccessScope::ReadOnly) => { + tracing::info!(target: "lucarne_termgw", "refused write request on read-only HTTP session"); + Err(( + StatusCode::FORBIDDEN, + "read-only session: write operations are not permitted", + ) + .into_response()) + } + // No scope recorded — fail closed (the route must be bearer-gated). + None => Err(( + StatusCode::FORBIDDEN, + "read-only session: write operations are not permitted", + ) + .into_response()), + } + } +} + +/// Build the gateway router with auth **disabled** (local dev on loopback). +pub fn router(monitor: Arc, web_dir: PathBuf) -> Router { + let control_store = ControlPlaneSqliteStore::open_in_memory() + .expect("in-memory control-plane store for local gateway router"); + router_with_auth_and_store(monitor, web_dir, AuthState::disabled(), control_store) +} + +/// Build the gateway router: ws mirror + control API + static web assets, gated +/// by `auth`. +/// +/// - `/api/*` is wrapped in a `Bearer` middleware (constant-time compare; failures +/// feed the rate-limiter and return 401). +/// - `POST /auth/ticket` (also `Bearer`-gated) mints a single-use ws ticket. +/// - `/ws` and `/agent/{id}` consume a `?ticket=` *before* upgrading. +/// +/// When `auth` is [`AuthState::disabled`] the gate is a no-op (local dev). +/// +/// SEC-002: this **tunneled** router carries NO `/api/remote/*` control plane — +/// that lives on a separate loopback listener ([`control_router`] / +/// [`serve_control_plane`]) the tunnel never targets. +pub fn router_with_auth(monitor: Arc, web_dir: PathBuf, auth: AuthState) -> Router { + let control_store = ControlPlaneSqliteStore::open_in_memory() + .expect("in-memory control-plane store for local gateway router"); + router_with_auth_and_store(monitor, web_dir, auth, control_store) +} + +/// Build the gateway router with auth disabled and an explicit Lucarne +/// control-plane store. Daemon entrypoints use this so terminal-agent bindings +/// persist as core cold state instead of a sidecar DB. +pub fn router_with_store( + monitor: Arc, + web_dir: PathBuf, + control_store: ControlPlaneSqliteStore, +) -> Router { + router_with_auth_and_store(monitor, web_dir, AuthState::disabled(), control_store) +} + +/// Build the gateway router with explicit auth and Lucarne control-plane store. +pub fn router_with_auth_and_store( + monitor: Arc, + web_dir: PathBuf, + auth: AuthState, + control_store: ControlPlaneSqliteStore, +) -> Router { + router_with_limits_and_store( + monitor, + web_dir, + auth, + GatewayLimits::default(), + control_store, + ) +} + +/// Build the tunneled gateway router with explicit connection/session limits +/// (SEC-004/SEC-006). Same surface as [`router_with_auth`]; the limits cap +/// concurrent ws connections, per-connection session creation, inbound frame +/// rate, and enforce idle + max-lifetime close on the ws client loops. +pub fn router_with_limits( + monitor: Arc, + web_dir: PathBuf, + auth: AuthState, + limits: GatewayLimits, +) -> Router { + let control_store = ControlPlaneSqliteStore::open_in_memory() + .expect("in-memory control-plane store for local gateway router"); + router_with_limits_and_store(monitor, web_dir, auth, limits, control_store) +} + +/// Build the gateway router with explicit limits and Lucarne control-plane store. +pub fn router_with_limits_and_store( + monitor: Arc, + web_dir: PathBuf, + auth: AuthState, + limits: GatewayLimits, + control_store: ControlPlaneSqliteStore, +) -> Router { + router_with_pool_and_store( + monitor, + web_dir, + auth, + WsConnectionPool::new(limits), + control_store, + ) +} + +/// Build the tunneled gateway router sharing an EXISTING [`WsConnectionPool`] +/// (H1). Daemon remote uses this so `/ws` and `/agent` draw from the same global +/// connection cap and session lifetime policy. +pub fn router_with_pool( + monitor: Arc, + web_dir: PathBuf, + auth: AuthState, + ws_pool: WsConnectionPool, +) -> Router { + let control_store = ControlPlaneSqliteStore::open_in_memory() + .expect("in-memory control-plane store for local gateway router"); + router_with_pool_and_store(monitor, web_dir, auth, ws_pool, control_store) +} + +/// Build the gateway router sharing an existing ws pool and using the provided +/// Lucarne control-plane store for terminal-agent binding history. +pub fn router_with_pool_and_store( + monitor: Arc, + web_dir: PathBuf, + auth: AuthState, + ws_pool: WsConnectionPool, + control_store: ControlPlaneSqliteStore, +) -> Router { + router_with_terminal_monitor_and_store(monitor, web_dir, auth, ws_pool, control_store) +} + +fn router_with_terminal_monitor_and_store( + monitor: Arc, + web_dir: PathBuf, + auth: AuthState, + ws_pool: WsConnectionPool, + control_store: ControlPlaneSqliteStore, +) -> Router { + let state = AppState { + monitor, + control_store, + auth, + limits: ws_pool.limits(), + ws_pool, + }; + gateway_router(state, web_dir) +} + +/// Assemble the public (tunnel-reachable) gateway router from a built [`AppState`]. +/// +/// SEC-002: deliberately excludes `/api/remote/*`. The control plane is served +/// separately on loopback ([`control_router`]). +fn gateway_router(state: AppState, web_dir: PathBuf) -> Router { + // /api/* + /auth/ticket: long-lived Bearer credential, constant-time compare. + let bearer_gated = Router::new() + .route("/api/sessions", get(http_list).post(http_create)) + .route("/api/sessions/{id}", delete(http_close)) + .route("/api/sessions/{id}/agent", get(http_agent)) + .route("/api/sessions/{id}/files", get(http_files)) + .route("/api/sessions/{id}/archive", post(http_archive)) + .route("/api/archives", get(http_archives)) + .route("/api/archives/{archive_id}", get(http_archive_get)) + .route("/api/agents", get(http_agents)) + .route("/api/agent-history", get(http_agent_history)) + .route("/api/agent-history/{session}", get(http_agent_history_get)) + .route("/auth/ticket", post(issue_ticket)) + .route_layer(middleware::from_fn_with_state(state.clone(), bearer_guard)); + + // ws routes consume a single-use ticket inside the handler, before upgrade. + Router::new() + .route("/ws", get(ws_handler)) + .route("/agent/{id}", get(agent_ws)) + .merge(bearer_gated) + .with_state(state) + .fallback_service(ServeDir::new(web_dir)) +} + +/// Build the loopback-only control-plane router (SEC-002). +/// +/// Hosts ONLY `/api/remote/{start,stop,status}`. Intended to be served on a +/// SEPARATE loopback listener bound to a distinct port the public tunnel never +/// targets (see [`serve_control_plane`]) — that separation, not `LoopbackOnly`, +/// is the trust boundary. `LoopbackOnly` re-asserts loopback as defense in depth. +/// The `access_token` is only ever returned over this listener, never over any +/// tunnel-reachable route. +pub fn control_router(remote_control: Option>) -> Router { + let state = ControlState { remote_control }; + Router::new() + .route("/api/remote/start", post(http_remote_start)) + .route("/api/remote/stop", post(http_remote_stop)) + .route("/api/remote/status", get(http_remote_status)) + .with_state(state) +} + +/// Gate a ws router with the gateway's single-use-ticket auth (SEC-001). +/// +/// Wraps every route in `inner` with a middleware that consumes a `?ticket=` +/// query param via [`AuthState::tickets`] BEFORE the wrapped handler runs (so it +/// rejects with 401 *before* any `.on_upgrade()`). No-op when auth is disabled +/// (local dev). Apply it to any ws router that must share the gateway's auth — +/// for example a local test or an optional in-process extension route. +/// +/// NOTE: this gate enforces auth only — it does NOT apply [`GatewayLimits`] / +/// the connection-permit pool. A ws route that must also obey the global +/// connection cap + idle/lifetime/frame-rate (H1) should instead call +/// [`authorize_ws`] from inside its own handler (so it can hold the permit for +/// the socket lifetime and drive the limits in its client loop). +pub fn gate_ws_router(inner: Router, auth: AuthState) -> Router { + inner.layer(middleware::from_fn_with_state(auth, ws_ticket_gate_fn)) +} + +/// Authorize a ws upgrade for a route that shares the gateway's auth + limits +/// (SEC-001 / SEC-004 / H1 / M5). +/// +/// This is the seam an optional in-process ws handler can call at the top of its +/// upgrade path when it must be governed by the same rules as termgw's `/ws` and +/// `/agent`: +/// +/// 1. **Acquire a connection permit FIRST** from the shared [`WsConnectionPool`] +/// (M5): a saturated global cap returns `Err(503)` WITHOUT consuming the +/// single-use ticket. +/// 2. **Consume the single-use ticket** and resolve its [`AccessScope`] +/// (SEC-001/SEC-013); an invalid/expired/used ticket returns `Err(401)` and +/// releases the permit. Auth disabled (local dev) → `AccessScope::Full`, no +/// ticket required. +/// +/// On success returns `(scope, permit)` — the caller upgrades the socket, holds +/// the permit for its lifetime, refuses write actions when `scope.is_readonly()`, +/// and enforces `pool.limits()` (idle / max-lifetime / inbound frame rate) in its +/// client loop. +#[allow(clippy::result_large_err)] +pub async fn authorize_ws( + auth: &AuthState, + ticket: Option<&str>, + pool: &WsConnectionPool, +) -> Result<(AccessScope, tokio::sync::OwnedSemaphorePermit), Response> { + // M5: permit first so a full cap never burns a ticket. + let Some(permit) = pool.try_acquire() else { + tracing::warn!(target: "lucarne_termgw", + cap = pool.limits().max_ws_connections, + "ws connection cap reached; rejecting upgrade with 503" + ); + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + "too many concurrent connections", + ) + .into_response()); + }; + match check_ws_ticket(auth, ticket).await { + Ok(scope) => Ok((scope, permit)), + Err(refusal) => { + drop(permit); // release the slot on auth failure + Err(refusal) + } + } +} + +/// The middleware body for [`gate_ws_router`]: reject before the wrapped ws +/// handler runs when the ticket is missing/invalid (auth enforced). +async fn ws_ticket_gate_fn( + State(auth): State, + Query(q): Query, + req: Request, + next: Next, +) -> Response { + match check_ws_ticket(&auth, q.ticket.as_deref()).await { + Ok(_scope) => next.run(req).await, + Err(refusal) => refusal, + } +} + +/// Bearer-token middleware for `/api/*` and `/auth/ticket`. +/// +/// Disabled auth → pass through (local dev). Otherwise require a valid +/// `Authorization: Bearer ` compared in constant time; a missing/invalid +/// token feeds the per-client rate-limiter and returns 401. A client that is +/// already locked out gets 429 without a compare. +/// +/// SEC-005: behind a same-host tunnel every socket peer is the constant loopback +/// address, so we key the limiter on a trusted forwarded client IP when present. +/// The header(s) that carry that IP are provider-specific and NOT hardcoded here +/// (H6b): the daemon injects the trusted-header list via +/// [`AuthState::forwarded_identity`]; a forwarded header is trusted only when the +/// socket peer is the loopback tunnel source. We never hard-lock the shared +/// loopback key — a flood there only earns an incremental soft delay so one +/// attacker can't deny everyone. +/// +/// SEC-013/C1: on success the authenticated [`AccessScope`] is written into the +/// request extensions so downstream handlers (HTTP write routes) can require +/// `Full`. A read-only bearer reaches GET/list handlers but is rejected by write +/// handlers via [`RequireFull`]. +async fn bearer_guard( + State(s): State, + ConnectInfo(peer): ConnectInfo, + mut req: Request, + next: Next, +) -> Response { + if !s.auth.mode.is_enforced() { + // Auth disabled (local dev): everything is full access. + req.extensions_mut().insert(AccessScope::Full); + return next.run(req).await; + } + // H6b: resolve the forwarded client IP only from the daemon-configured + // trusted header(s) for the started provider — never a hardcoded header. + let forwarded_ip = { + let headers = req.headers(); + s.auth + .forwarded_identity + .forwarded_ip(|name| headers.get(name).and_then(|v| v.to_str().ok())) + }; + let (key, lockable) = AuthState::limiter_key(peer, forwarded_ip); + if lockable && s.auth.limiter.is_locked(&key).await { + return (StatusCode::TOO_MANY_REQUESTS, "locked out").into_response(); + } + let presented = req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()); + match AuthState::bearer(presented).and_then(|tok| s.auth.scope_for(tok)) { + Some(scope) => { + s.auth.limiter.record_success(&key).await; + // C1: surface the authenticated scope to downstream handlers. + req.extensions_mut().insert(scope); + next.run(req).await + } + None => { + let locked = s.auth.limiter.record_failure(&key, lockable).await; + // For the shared loopback key (no real client identity) we can't + // hard-lock without denying everyone (SEC-005); apply a bounded + // incremental delay instead so a sustained guessing flood is slowed. + if !lockable { + let delay = s.auth.limiter.soft_delay(&key).await; + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + } + if locked { + tracing::warn!(target: "lucarne_termgw", %key, "rate-limiter locked out client after repeated auth failures"); + } else { + tracing::warn!(target: "lucarne_termgw", %key, lockable, "rejected bearer auth failure"); + } + (StatusCode::UNAUTHORIZED, "invalid or missing bearer token").into_response() + } + } +} + +/// `POST /auth/ticket` — mint a fresh single-use, short-TTL ws ticket. Bearer is +/// already enforced by [`bearer_guard`]; the ticket is what the browser puts in +/// the ws URL (so the long-lived token never lands in ws-connect logs). +/// +/// SEC-013: the ticket is minted with the scope the presented bearer +/// authenticates as — a read-only bearer yields a read-only ticket, so the ws +/// session it opens refuses write frames. A full bearer (or auth disabled) +/// yields a full-access ticket (unchanged). +/// +/// R3-5: the scope is read from the [`AccessScope`] that [`bearer_guard`] already +/// resolved and wrote into the request extensions — NOT by re-parsing the +/// `Authorization` header here. `bearer_guard` is the single place that maps a +/// bearer → scope (constant-time, rate-limited, with the readonly tier), and it +/// always runs before this handler ([`bearer_guard`] gates `/auth/ticket`). A +/// missing extension (which would only happen if the route were ever detached +/// from `bearer_guard`) is rejected by the extractor before this handler mints +/// any ticket. +async fn issue_ticket( + State(s): State, + axum::Extension(scope): axum::Extension, +) -> Response { + match s.auth.tickets.issue_scoped(scope).await { + Ok(ticket) => Json(json!({ "ticket": ticket })).into_response(), + Err(e) => { + tracing::warn!(target: "lucarne_termgw", error = %e, "ws ticket issuance refused"); + (StatusCode::TOO_MANY_REQUESTS, e.to_string()).into_response() + } + } +} + +/// Bind `addr` and serve the gateway with auth **disabled** (local dev, loopback). +pub async fn serve( + monitor: Arc, + addr: SocketAddr, + web_dir: PathBuf, +) -> std::io::Result<()> { + serve_with_auth(monitor, addr, web_dir, AuthState::disabled(), false, false).await +} + +/// Bind `addr` and serve the gateway with the given auth state. +/// +/// Hardening enforced before binding: +/// - **default-deny**: if `remote` is on and no token is configured and `insecure` +/// is not set, refuses to start ([`require_auth_or_refuse`]). +/// - **loopback**: in `remote` mode the bind address must be loopback +/// ([`parse_gateway_addr`]) — the gateway never listens on a public interface; +/// the tunnel connects outbound and back to this loopback socket. +pub async fn serve_with_auth( + monitor: Arc, + addr: SocketAddr, + web_dir: PathBuf, + auth: AuthState, + remote: bool, + insecure: bool, +) -> std::io::Result<()> { + // default-deny: refuse public exposure without a token (unless explicit override). + if let Err(refusal) = require_auth_or_refuse(remote, &auth.mode, insecure) { + tracing::error!(target: "lucarne_termgw", %refusal, "refusing to start"); + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + refusal.to_string(), + )); + } + if remote && insecure && !auth.mode.is_enforced() { + tracing::warn!(target: "lucarne_termgw", + "INSECURE public exposure with NO access token — anyone reaching \ + the tunnel can drive your terminals. This is RCE-equivalent." + ); + } + // loopback hardening: in remote mode the gateway must bind a loopback addr. + if remote && !addr.ip().is_loopback() { + let err = GatewayAddrError::NonLoopback(addr); + tracing::error!(target: "lucarne_termgw", %err, "refusing to bind a non-loopback address in remote mode"); + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + err.to_string(), + )); + } + + // SEC-002: this serves only the tunneled gateway surface; the `/api/remote/*` + // control plane is served separately on loopback (see [`serve_control_plane`]). + let app = router_with_limits(monitor, web_dir, auth, GatewayLimits::default()); + let listener = tokio::net::TcpListener::bind(addr).await?; + tracing::info!(target: "lucarne_termgw", %addr, remote, "listening"); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await +} + +/// Serve the loopback-only `/api/remote/*` control plane on its OWN listener +/// (SEC-002). +/// +/// `addr` must be a loopback address bound to a port the public tunnel never +/// targets (the tunnel only points at the public gateway port). This is the real +/// trust boundary that keeps the tunnel control plane — and the `access_token` +/// it returns — unreachable from anyone on the tunnel. `LoopbackOnly` on the +/// routes is belt-and-suspenders. Refuses to bind a non-loopback `addr`. +pub async fn serve_control_plane( + addr: SocketAddr, + remote_control: Option>, +) -> std::io::Result<()> { + if !addr.ip().is_loopback() { + let err = GatewayAddrError::NonLoopback(addr); + tracing::error!(target: "lucarne_termgw", %err, "refusing to bind a non-loopback control-plane address"); + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + err.to_string(), + )); + } + let app = control_router(remote_control); + let listener = tokio::net::TcpListener::bind(addr).await?; + tracing::info!(target: "lucarne_termgw", %addr, "control plane listening (loopback-only)"); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await +} + +// ---- Remote tunnel control plane (separate loopback listener — SEC-002) ---- + +/// `POST /api/remote/start` — start the daemon-owned tunnel, return its status +/// (public URL + access token). Loopback-only ([`LoopbackOnly`]) on a dedicated +/// control listener (SEC-002); the daemon owns the tunnel lifecycle so it +/// survives the CLI exiting. +/// +/// G3: an optional JSON body `{provider?, fields?}` ([`RemoteStartParams`]) lets +/// the CLI override / merge the provider + its fields. +/// +/// M2: body handling is explicit — an EMPTY body falls back to the daemon's +/// pre-configured tunnel (older clients / bodyless `curl -XPOST`), but a +/// NON-empty body that fails to parse is a `400 Bad Request` (no silent +/// `unwrap_or_default` downgrade). Typed [`RemoteControlError`]s from the daemon +/// map to 400/404/502 with a descriptive message (safe: loopback-only). +async fn http_remote_start( + _loopback: LoopbackOnly, + State(s): State, + body: axum::body::Bytes, +) -> Response { + let params = match parse_start_params(&body) { + Ok(params) => params, + Err(detail) => { + tracing::warn!(target: "lucarne_termgw", %detail, "remote tunnel start rejected — malformed body"); + return ( + StatusCode::BAD_REQUEST, + format!("invalid request body: {detail}"), + ) + .into_response(); + } + }; + match &s.remote_control { + Some(control) => match control.start(params).await { + Ok(status) => Json(status).into_response(), + Err(e) => { + tracing::warn!(target: "lucarne_termgw", error = %e, "remote tunnel start failed"); + // M2: the control plane is loopback-only (SEC-002), so a precise + // status + safe message is fine here — it never reaches a tunnel. + (e.status_code(), e.detail().to_string()).into_response() + } + }, + None => ( + StatusCode::SERVICE_UNAVAILABLE, + "remote subsystem not configured", + ) + .into_response(), + } +} + +/// Parse the optional `/api/remote/start` body into [`RemoteStartParams`] (M2). +/// +/// - An EMPTY body → `Ok(default)` (use the daemon's pre-configured tunnel), so +/// the control plane stays forgiving of bodyless POSTs (G3). +/// - A NON-empty body that is not the expected JSON shape → `Err(detail)` so the +/// handler returns 400 instead of silently downgrading to the default +/// (no `unwrap_or_default`). +fn parse_start_params(body: &[u8]) -> Result { + if body.is_empty() { + return Ok(RemoteStartParams::default()); + } + serde_json::from_slice::(body).map_err(|e| e.to_string()) +} + +/// `POST /api/remote/stop` — stop the daemon-owned tunnel. Loopback-only. +async fn http_remote_stop(_loopback: LoopbackOnly, State(s): State) -> Response { + match &s.remote_control { + Some(control) => match control.stop().await { + Ok(status) => Json(status).into_response(), + Err(e) => { + tracing::warn!(target: "lucarne_termgw", error = %e, "remote tunnel stop failed"); + // M2: loopback-only control plane → safe to surface the detail. + (e.status_code(), e.detail().to_string()).into_response() + } + }, + None => ( + StatusCode::SERVICE_UNAVAILABLE, + "remote subsystem not configured", + ) + .into_response(), + } +} + +/// `GET /api/remote/status` — report the daemon-owned tunnel status. Loopback-only. +async fn http_remote_status(_loopback: LoopbackOnly, State(s): State) -> Response { + match &s.remote_control { + Some(control) => Json(control.status().await).into_response(), + None => Json(RemoteControlStatus::default()).into_response(), + } +} + +// ---- HTTP control surface (the CLI hits these) ---- + +async fn http_list(State(s): State) -> Json> { + Json(s.monitor.sessions().await) +} + +#[derive(Deserialize)] +struct CreateReq { + title: Option, +} + +async fn http_create( + _full: RequireFull, + State(s): State, + Json(req): Json, +) -> Response { + match s + .monitor + .create(req.title.unwrap_or_else(|| "shell".to_string())) + .await + { + Ok(desc) => (StatusCode::CREATED, Json(desc)).into_response(), + // SEC-007: generic client message; detail logged server-side only. + Err(e) => { + tracing::warn!(target: "lucarne_termgw", error = %e, "session create failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() + } + } +} + +async fn http_close( + _full: RequireFull, + State(s): State, + Path(id): Path, +) -> Response { + match s.monitor.kill(&id).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(lucarne_rmux::MonitorError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(), + // SEC-007: generic client message; detail logged server-side only. + Err(e) => { + tracing::warn!(target: "lucarne_termgw", %id, error = %e, "session close failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() + } + } +} + +// ---- agent-session binding (P7): pane cwd → agent transcript ---- + +/// Find a monitored session's cwd by id. +async fn session_cwd(s: &AppState, id: &str) -> Option { + s.monitor + .sessions() + .await + .into_iter() + .find(|d| d.id == id) + .and_then(|d| d.cwd) +} + +/// GET the agent (if any) bound to a session's cwd, plus its transcript so far. +async fn http_agent(State(s): State, Path(id): Path) -> Response { + let Some(cwd) = session_cwd(&s, &id).await else { + return (StatusCode::NOT_FOUND, "session has no known cwd").into_response(); + }; + match terminal_agent_bind::bind(&cwd) { + Some(agent) => { + let (msgs, _) = terminal_agent_bind::read_messages(&agent.transcript, 0); + Json(json!({ + "bound": true, + "kind": agent.kind, + "session_id": agent.session_id, + "cwd": cwd, + "messages": msgs.iter().map(|m| json!({"role": m.role, "text": m.text})).collect::>(), + })) + .into_response() + } + None => Json(json!({"bound": false, "cwd": cwd})).into_response(), + } +} + +/// A ws ticket carried in the upgrade request query string (`?ticket=…`). +/// +/// Browsers can't set an `Authorization` header on a WebSocket, so the client +/// first posts its Bearer token to `/auth/ticket`, then passes the returned +/// single-use ticket here. Validated + consumed *before* `.on_upgrade()`. +#[derive(Deserialize)] +struct TicketQuery { + ticket: Option, +} + +/// Validate + consume the ws ticket before upgrading. Returns `Err(refusal)` to +/// reject (without upgrading) when auth is enforced and the ticket is +/// missing/expired/already-used; `Ok(scope)` to proceed with the consumed +/// ticket's [`AccessScope`] (SEC-013). When auth is disabled this is always +/// `Ok(AccessScope::Full)` (local dev — no readonly tier without a token). +async fn check_ws_ticket(auth: &AuthState, ticket: Option<&str>) -> Result { + if !auth.mode.is_enforced() { + return Ok(AccessScope::Full); + } + match ticket { + Some(t) => match auth.tickets.consume_scoped(t).await { + Some(scope) => Ok(scope), + None => { + // SEC-011: audit a rejected ws ticket (used/expired/forged). The + // ticket value itself is never logged. + tracing::info!(target: "lucarne_termgw", "rejected ws upgrade — invalid or used ticket"); + Err((StatusCode::UNAUTHORIZED, "invalid or missing ws ticket").into_response()) + } + }, + None => { + tracing::info!(target: "lucarne_termgw", "rejected ws upgrade — missing ticket"); + Err((StatusCode::UNAUTHORIZED, "invalid or missing ws ticket").into_response()) + } + } +} + +/// Try to acquire a global ws-connection permit (SEC-004). Returns the owned +/// permit (held for the socket's lifetime) or `Err` with a 503 response when the +/// concurrency cap is already saturated, so excess upgrades are rejected before +/// `.on_upgrade()`. +// The error is an axum `Response` (the project's pervasive handler return type); +// boxing it would only obscure the call sites that `return` it directly. +#[allow(clippy::result_large_err)] +fn acquire_ws_permit(s: &AppState) -> Result { + match s.ws_pool.try_acquire() { + Some(permit) => Ok(permit), + None => { + tracing::warn!(target: "lucarne_termgw", + cap = s.limits.max_ws_connections, + "ws connection cap reached; rejecting upgrade with 503" + ); + Err(( + StatusCode::SERVICE_UNAVAILABLE, + "too many concurrent connections", + ) + .into_response()) + } + } +} + +async fn agent_ws( + State(s): State, + Path(id): Path, + Query(q): Query, + ws: WebSocketUpgrade, +) -> Response { + // SEC-004 / M5: acquire the connection permit FIRST. When the cap is + // saturated this returns 503 WITHOUT consuming the single-use ticket, so a + // full gateway never burns a client's ticket. The permit is held for the + // socket lifetime; if auth fails below it is dropped (released) immediately. + let permit = match acquire_ws_permit(&s) { + Ok(p) => p, + Err(resp) => return resp, + }; + // Auth gate: consume a single-use ticket BEFORE upgrading. A used or expired + // ticket never reaches `.on_upgrade(`. SEC-013: a read-only ticket opens a + // mirror-only agent session (prompts — a write — are refused). + let scope = match check_ws_ticket(&s.auth, q.ticket.as_deref()).await { + Ok(scope) => scope, + // Drop the permit on auth failure so a rejected upgrade frees a slot. + Err(refusal) => { + drop(permit); + return refusal; + } + }; + let limits = s.limits; + ws.on_upgrade(move |socket| agent_client(s, id, socket, limits, scope, permit)) +} + +/// Stream a pane's bound agent transcript as chat bubbles; route inbound prompts +/// into the pane (typing into the interactive agent). +/// +/// SEC-006: enforces an idle timeout and a max session lifetime — the socket is +/// closed on either, so a live session can't outlive its connect-time ticket +/// indefinitely. The `_permit` is held until this returns (SEC-004 conn cap). +/// +/// SEC-013: a read-only `scope` mirrors the transcript but refuses inbound +/// prompts (a write into the live agent). +/// SEC-011: logs connect/disconnect at info with a per-connection sequence (no +/// credential is ever logged). +async fn agent_client( + s: AppState, + id: SessionId, + socket: WebSocket, + limits: GatewayLimits, + scope: AccessScope, + _permit: tokio::sync::OwnedSemaphorePermit, +) { + let conn = next_conn_seq(); + let readonly = scope.is_readonly(); + tracing::info!(target: "lucarne_termgw", conn, %id, readonly, "agent ws connected"); + agent_client_inner(&s, &id, socket, limits, readonly).await; + tracing::info!(target: "lucarne_termgw", conn, %id, "agent ws disconnected"); +} + +async fn agent_client_inner( + s: &AppState, + id: &SessionId, + socket: WebSocket, + limits: GatewayLimits, + readonly: bool, +) { + let (mut tx, mut rx) = socket.split(); + + let Some(cwd) = session_cwd(s, id).await else { + let _ = send_value( + &mut tx, + &json!({"type":"error","msg":"session has no known cwd"}), + ) + .await; + return; + }; + let Some(agent) = terminal_agent_bind::bind(&cwd) else { + let _ = send_value( + &mut tx, + &json!({"type":"error","msg":"no agent transcript bound to this cwd","cwd":cwd}), + ) + .await; + return; + }; + if !send_value(&mut tx, &json!({"type":"ready","kind":agent.kind,"session_id":agent.session_id,"cwd":cwd,"readonly":readonly})).await { + return; + } + + let (initial, mut offset) = terminal_agent_bind::read_messages(&agent.transcript, 0); + for m in &initial { + if !send_value( + &mut tx, + &json!({"type":"message","role":m.role,"text":m.text}), + ) + .await + { + return; + } + } + + // SEC-006: idle + max-lifetime close. + let deadline = tokio::time::Instant::now() + limits.max_session_lifetime; + let mut idle = tokio::time::interval(limits.idle_timeout); + idle.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + idle.tick().await; // consume the immediate first tick + let mut tick = tokio::time::interval(std::time::Duration::from_millis(700)); + loop { + tokio::select! { + _ = tokio::time::sleep_until(deadline) => { + tracing::info!(target: "lucarne_termgw", %id, "agent ws closed on max session lifetime"); + break; + } + _ = idle.tick() => { + tracing::info!(target: "lucarne_termgw", %id, "agent ws closed on idle timeout"); + break; + } + inbound = rx.next() => match inbound { + Some(Ok(Message::Text(t))) => { + idle.reset(); + if let Ok(v) = serde_json::from_str::(t.as_str()) { + if v.get("type").and_then(Value::as_str) == Some("prompt") { + // SEC-013: a prompt types into the live agent — a write. + // Read-only sessions may only mirror; refuse it. + if readonly { + tracing::info!(target: "lucarne_termgw", %id, "refused agent prompt on read-only session"); + let _ = send_value(&mut tx, &json!({"type":"error","msg":"read-only session: prompts are not permitted"})).await; + } else { + let text = v.get("text").and_then(Value::as_str).unwrap_or(""); + // Type the message into the interactive agent + Enter. + let _ = s.monitor.inject(id, TermInput::Text { text: format!("{text}\n") }).await; + } + } + } + } + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(_)) => { idle.reset(); } + Some(Err(_)) => break, + }, + _ = tick.tick() => { + let (new_msgs, new_off) = terminal_agent_bind::read_messages(&agent.transcript, offset); + offset = new_off; + for m in &new_msgs { + if !send_value(&mut tx, &json!({"type":"message","role":m.role,"text":m.text})).await { + return; + } + } + } + } + } +} + +async fn send_value(tx: &mut Sender, v: &Value) -> bool { + tx.send(Message::Text(v.to_string().into())).await.is_ok() +} + +// ---- archive (P9): close the local rmux terminal, keep its content ---- + +/// Archive a session: capture its scrollback, persist it, then kill the rmux +/// session (freeing the process). Agent transcripts persist on their own; this +/// preserves a plain terminal's content too. +async fn http_archive( + _full: RequireFull, + State(s): State, + Path(id): Path, +) -> Response { + let Some(desc) = s.monitor.sessions().await.into_iter().find(|d| d.id == id) else { + return (StatusCode::NOT_FOUND, "unknown session").into_response(); + }; + let content = s.monitor.capture_scrollback(&id).await.unwrap_or_default(); + let archive_id = match archive::save( + &id, + &desc.title, + desc.cwd.as_deref(), + &content, + archive::now_epoch(), + ) { + Ok(a) => a, + // SEC-007: generic client message; detail logged server-side only. + Err(e) => { + tracing::warn!(target: "lucarne_termgw", %id, error = %e, "archive save failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response(); + } + }; + let _ = s.monitor.kill(&id).await; // content is preserved on disk + Json(json!({ "archive_id": archive_id })).into_response() +} + +async fn http_archives() -> Response { + Json(json!({ "archived": archive::list() })).into_response() +} + +async fn http_archive_get(Path(archive_id): Path) -> Response { + if archive_id.contains('/') || archive_id.contains("..") { + return StatusCode::BAD_REQUEST.into_response(); + } + match archive::get(&archive_id) { + Some(v) => Json(v).into_response(), + None => StatusCode::NOT_FOUND.into_response(), + } +} + +// ---- agent session pickers (live-bound + history) ---- + +/// Monitored panes that currently have a bound agent transcript (for the chat +/// picker — these are drivable: prompts type into the live pane). +async fn http_agents(State(s): State) -> Response { + let mut agents = Vec::new(); + for d in s.monitor.sessions().await { + if let Some(cwd) = &d.cwd { + if let Some(a) = terminal_agent_bind::bind(cwd) { + // Record the rmux↔agent binding so chat history shows ONLY + // rmux-related sessions (never a blind ~/.claude scan). The + // observation is cold core control-plane state, not a side DB. + if let Err(e) = terminal_agent_bind::record( + &s.control_store, + &a.kind, + &a.session_id, + cwd, + &d.id, + &d.title, + &a.transcript, + ) { + tracing::warn!(target: "lucarne_termgw", error = %e, "terminal-agent binding record failed"); + } + agents.push(json!({ + "term_session": d.id, + "title": d.title, + "cwd": cwd, + "kind": a.kind, + "agent_session_id": a.session_id, + "summary": terminal_agent_bind::first_user_message(&a.transcript), + })); + } + } + } + Json(json!({ "agents": agents })).into_response() +} + +/// Recent rmux-related agent sessions (from the SQLite registry, NOT a global +/// transcript scan — only sessions ever bound to an rmux pane appear). +async fn http_agent_history(State(s): State) -> Response { + let rows = match terminal_agent_bind::history(&s.control_store, 40) { + Ok(rows) => rows, + Err(e) => { + tracing::warn!(target: "lucarne_termgw", error = %e, "terminal-agent history read failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response(); + } + }; + Json(json!({ + "sessions": rows.iter().map(|r| json!({ + "kind": r.kind, + "session_id": r.session_id, + "cwd": r.cwd, + "rmux_session": r.rmux_session, + "title": r.title, + "summary": r.summary, + "last_seen": r.last_seen, + })).collect::>() + })) + .into_response() +} + +/// Messages of one recorded history transcript (read-only — no live pane). +async fn http_agent_history_get( + State(s): State, + Path(session): Path, +) -> Response { + let path = match terminal_agent_bind::transcript_path(&s.control_store, &session) { + Ok(Some(path)) => path, + Ok(None) => return StatusCode::NOT_FOUND.into_response(), + Err(e) => { + tracing::warn!(target: "lucarne_termgw", error = %e, "terminal-agent transcript lookup failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response(); + } + }; + let (msgs, _) = terminal_agent_bind::read_messages(&path, 0); + Json(json!({ + "session_id": session, + "messages": msgs.iter().map(|m| json!({"role": m.role, "text": m.text})).collect::>(), + })) + .into_response() +} + +// ---- file tree (P8): browse the pane's cwd ---- + +#[derive(Deserialize)] +struct FilesQuery { + /// Sub-path relative to the session cwd (empty = the cwd itself). + path: Option, +} + +/// List one directory under a session's cwd. Bounded (≤2000 entries, no +/// recursion — the web tree lazy-loads each level) and sandboxed to the cwd. +async fn http_files( + State(s): State, + Path(id): Path, + Query(q): Query, +) -> Response { + let Some(cwd) = session_cwd(&s, &id).await else { + return (StatusCode::NOT_FOUND, "session has no known cwd").into_response(); + }; + let base = std::path::Path::new(&cwd); + let target = base.join(q.path.unwrap_or_default()); + // Canonicalize both and ensure the target stays inside the cwd (no `..` escape). + let (Ok(target), Ok(base_c)) = (target.canonicalize(), base.canonicalize()) else { + return (StatusCode::NOT_FOUND, "path not found").into_response(); + }; + if !target.starts_with(&base_c) { + return (StatusCode::FORBIDDEN, "path is outside the session cwd").into_response(); + } + if !target.is_dir() { + return (StatusCode::BAD_REQUEST, "not a directory").into_response(); + } + + let mut entries: Vec<(String, bool)> = Vec::new(); + if let Ok(rd) = std::fs::read_dir(&target) { + for entry in rd.flatten().take(2000) { + let name = entry.file_name().to_string_lossy().into_owned(); + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + entries.push((name, is_dir)); + } + } + // Directories first, then case-insensitive by name. + entries.sort_by(|a, b| { + b.1.cmp(&a.1) + .then(a.0.to_lowercase().cmp(&b.0.to_lowercase())) + }); + + let rel = target + .strip_prefix(&base_c) + .ok() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + Json(json!({ + "cwd": cwd, + "path": rel, + "entries": entries.iter().map(|(n, d)| json!({"name": n, "dir": d})).collect::>(), + })) + .into_response() +} + +// ---- WebSocket mirror ---- + +async fn ws_handler( + State(s): State, + Query(q): Query, + ws: WebSocketUpgrade, +) -> Response { + // SEC-004 / M5: acquire the connection permit FIRST so a saturated cap + // returns 503 WITHOUT consuming the single-use ticket. The permit is held + // for the socket lifetime; on auth failure below it is dropped immediately. + let permit = match acquire_ws_permit(&s) { + Ok(p) => p, + Err(resp) => return resp, + }; + // Auth gate: consume a single-use ticket BEFORE upgrading. A used or expired + // ticket never reaches `.on_upgrade(`. The consumed ticket's scope (SEC-013) + // decides whether this session may inject/create/close (Full) or only mirror + // (ReadOnly). + let scope = match check_ws_ticket(&s.auth, q.ticket.as_deref()).await { + Ok(scope) => scope, + Err(refusal) => { + drop(permit); + return refusal; + } + }; + let limits = s.limits; + let monitor = s.monitor.clone(); + ws.on_upgrade(move |socket| client_task(monitor, socket, limits, scope, permit)) +} + +type Sender = SplitSink; + +/// Serialize and send one server frame. Returns `false` if the socket is closed. +async fn send_frame(sender: &mut Sender, frame: &ServerFrame) -> bool { + match serde_json::to_string(frame) { + Ok(json) => sender.send(Message::Text(json.into())).await.is_ok(), + Err(e) => { + tracing::warn!(target: "lucarne_termgw", error = %e, "serialize frame failed"); + true // a serialization bug must not silently drop the connection + } + } +} + +/// Push a fresh full snapshot for `session` and (re)seed its differ baseline. +async fn snapshot_into( + monitor: &dyn TerminalMonitor, + sender: &mut Sender, + differs: &mut HashMap, + session: SessionId, +) -> bool { + snapshot_into_with_client_rev(monitor, sender, differs, session, None).await +} + +/// Push a fresh full snapshot for `session`, validating the client-supplied +/// `have_rev` when present before replacing the differ baseline. +async fn snapshot_into_with_client_rev( + monitor: &dyn TerminalMonitor, + sender: &mut Sender, + differs: &mut HashMap, + session: SessionId, + have_rev: Option, +) -> bool { + match monitor.snapshot_grid(&session).await { + Ok((grid, cursor)) => { + if let Some(have_rev) = have_rev { + match differs.get(&session).and_then(Differ::current_rev) { + Some(current) if current != have_rev => tracing::debug!( + target: "lucarne_termgw", + %session, + have_rev, + current_rev = current, + "client resync requested with stale revision; sending full snapshot" + ), + Some(current) => tracing::trace!( + target: "lucarne_termgw", + %session, + have_rev, + current_rev = current, + "client resync requested with matching revision; refreshing full snapshot" + ), + None => tracing::debug!( + target: "lucarne_termgw", + %session, + have_rev, + "client resync requested before a server baseline existed; sending full snapshot" + ), + } + } + let mut differ = Differ::new(); + let seeded = differ.feed(grid); + differs.insert(session.clone(), differ); + if let DiffResult::Full(grid) = seeded { + return send_frame( + sender, + &ServerFrame::Snapshot { + session, + grid, + cursor, + }, + ) + .await; + } + true + } + Err(e) => { + // SEC-007: detail logged server-side; client gets a generic message. + tracing::warn!(target: "lucarne_termgw", %session, error = %e, "snapshot failed"); + send_frame( + sender, + &ServerFrame::Error { + code: 404, + msg: "session unavailable".to_string(), + }, + ) + .await + } + } +} + +/// Simple per-connection inbound-frame rate limiter (SEC-004): a leaky bucket of +/// `max_per_sec` frames refilled each second. `allow()` returns false when the +/// connection has exceeded its budget within the current 1-second window. +struct FrameRate { + max_per_sec: u32, + count: u32, + window_start: tokio::time::Instant, +} + +impl FrameRate { + fn new(max_per_sec: u32) -> Self { + Self { + max_per_sec, + count: 0, + window_start: tokio::time::Instant::now(), + } + } + + /// Record an inbound frame; returns false when over budget (0 = unlimited). + fn allow(&mut self) -> bool { + if self.max_per_sec == 0 { + return true; + } + let now = tokio::time::Instant::now(); + if now.duration_since(self.window_start) >= std::time::Duration::from_secs(1) { + self.window_start = now; + self.count = 0; + } + self.count = self.count.saturating_add(1); + self.count <= self.max_per_sec + } +} + +async fn client_task( + monitor: Arc, + socket: WebSocket, + limits: GatewayLimits, + scope: AccessScope, + _permit: tokio::sync::OwnedSemaphorePermit, +) { + // SEC-011: log connect/disconnect with a per-connection sequence id (never a + // credential). SEC-013: a read-only session refuses write frames. + let conn = next_conn_seq(); + let readonly = scope.is_readonly(); + tracing::info!(target: "lucarne_termgw", conn, readonly, "ws mirror connected"); + + let (mut sender, mut receiver) = socket.split(); + let mut bcast = monitor.subscribe(); + let mut subscribed: HashSet = HashSet::new(); + let mut differs: HashMap = HashMap::new(); + // SEC-004: anti fork-bomb / flood. + let mut sessions_created: usize = 0; + let mut frame_rate = FrameRate::new(limits.max_inbound_frames_per_sec); + + let list = ServerFrame::SessionList { + sessions: monitor.sessions().await, + }; + if !send_frame(&mut sender, &list).await { + tracing::info!(target: "lucarne_termgw", conn, "ws mirror disconnected"); + return; + } + + // SEC-006: idle + max-lifetime close. A connect-time ticket is not enough — + // a live socket must not outlive its credential indefinitely. + let deadline = tokio::time::Instant::now() + limits.max_session_lifetime; + let mut idle = tokio::time::interval(limits.idle_timeout); + idle.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + idle.tick().await; // consume the immediate first tick + + 'conn: loop { + tokio::select! { + _ = tokio::time::sleep_until(deadline) => { + tracing::info!(target: "lucarne_termgw", conn, "ws closed on max session lifetime"); + break 'conn; + } + _ = idle.tick() => { + tracing::info!(target: "lucarne_termgw", conn, "ws closed on idle timeout"); + break 'conn; + } + inbound = receiver.next() => { + match inbound { + Some(Ok(Message::Text(text))) => { + idle.reset(); + // SEC-004: throttle inbound frames per connection. + if !frame_rate.allow() { + tracing::warn!(target: "lucarne_termgw", conn, "ws inbound frame rate exceeded; closing connection"); + break 'conn; + } + match serde_json::from_str::(text.as_str()) { + Ok(frame) => { + // SEC-013: a read-only session refuses write frames + // (input / create / close) before any side effect; + // mirror/control frames pass through unchanged. + if readonly && is_write_frame(&frame) { + tracing::info!(target: "lucarne_termgw", conn, "refused write frame on read-only ws session"); + if !send_frame( + &mut sender, + &ServerFrame::Error { + code: 403, + msg: "read-only session: write operations are not permitted".to_string(), + }, + ) + .await + { + break 'conn; + } + } else if !handle_client_frame( + &monitor, &mut sender, &mut subscribed, &mut differs, + frame, &limits, &mut sessions_created, + ) + .await + { + break 'conn; + } + } + Err(e) => { + // SEC-007: detail logged server-side; client gets generic. + tracing::warn!(target: "lucarne_termgw", error = %e, "bad ws client frame"); + if !send_frame( + &mut sender, + &ServerFrame::Error { code: 400, msg: "bad request".to_string() }, + ) + .await + { + break 'conn; + } + } + } + } + Some(Ok(Message::Close(_))) | None => break 'conn, + Some(Ok(_)) => { idle.reset(); } // ping/pong/binary — ignore + Some(Err(e)) => { + tracing::debug!(target: "lucarne_termgw", error = %e, "ws recv error"); + break 'conn; + } + } + } + update = bcast.recv() => { + match update { + Ok(GridUpdate { session, grid, cursor }) => { + if subscribed.contains(&session) { + let differ = differs.entry(session.clone()).or_default(); + let expected_base = differ.current_rev(); + let frame = match differ.feed_checked(grid, expected_base) { + DiffResult::Full(grid) => ServerFrame::Snapshot { session, grid, cursor }, + DiffResult::Delta { base_rev, rev, delta } => { + ServerFrame::SnapshotDelta { session, base_rev, rev, delta, cursor } + } + DiffResult::Resync { have_rev } => { + tracing::debug!(target: "lucarne_termgw", %session, have_rev, "server differ gap; waiting for client resync"); + continue; + } + }; + if !send_frame(&mut sender, &frame).await { + break 'conn; + } + } + } + Err(RecvError::Lagged(n)) => { + tracing::debug!(target: "lucarne_termgw", skipped = n, "client lagged; re-snapshotting subscriptions"); + for session in subscribed.iter().cloned().collect::>() { + if !snapshot_into(monitor.as_ref(), &mut sender, &mut differs, session).await { + break 'conn; + } + } + } + Err(RecvError::Closed) => break 'conn, + } + } + } + } + tracing::info!(target: "lucarne_termgw", conn, "ws mirror disconnected"); +} + +/// True for client frames that mutate server state (SEC-013): keystroke/text +/// injection and session lifecycle. Mirror + control frames (`Subscribe`, +/// `Detach`, `Resync`, `Ping`) return false. A read-only session refuses these. +fn is_write_frame(frame: &ClientFrame) -> bool { + matches!( + frame, + ClientFrame::Input { .. } + | ClientFrame::CreateSession { .. } + | ClientFrame::CloseSession { .. } + ) +} + +/// Handle one inbound client frame. Returns `false` if the connection should close. +/// +/// SEC-013: write frames (`Input`, `CreateSession`, `CloseSession`) on a +/// read-only session are refused by the caller ([`client_task`]) BEFORE this +/// runs (see [`is_write_frame`]), so this handler only ever sees frames the +/// session is permitted to perform. +async fn handle_client_frame( + monitor: &Arc, + sender: &mut Sender, + subscribed: &mut HashSet, + differs: &mut HashMap, + frame: ClientFrame, + limits: &GatewayLimits, + sessions_created: &mut usize, +) -> bool { + match frame { + ClientFrame::Subscribe { session } => { + subscribed.insert(session.clone()); + snapshot_into(monitor.as_ref(), sender, differs, session).await + } + ClientFrame::Detach { session } => { + subscribed.remove(&session); + differs.remove(&session); + true + } + ClientFrame::Input { session, event } => { + if let Err(e) = monitor.inject(&session, event).await { + tracing::debug!(target: "lucarne_termgw", %session, error = %e, "inject failed"); + } + true + } + ClientFrame::Resync { session, have_rev } => { + snapshot_into_with_client_rev( + monitor.as_ref(), + sender, + differs, + session, + Some(have_rev), + ) + .await + } + ClientFrame::CreateSession { title } => { + // SEC-004: cap sessions created per connection (anti fork-bomb). + if *sessions_created >= limits.max_sessions_per_conn { + tracing::warn!(target: "lucarne_termgw", + cap = limits.max_sessions_per_conn, + "per-connection session-creation cap reached" + ); + return send_frame( + sender, + &ServerFrame::Error { + code: 429, + msg: "session limit reached".to_string(), + }, + ) + .await; + } + match monitor + .create(title.unwrap_or_else(|| "shell".to_string())) + .await + { + Ok(desc) => { + *sessions_created += 1; + if !send_frame(sender, &ServerFrame::SessionCreated { session: desc.id }).await + { + return false; + } + send_frame( + sender, + &ServerFrame::SessionList { + sessions: monitor.sessions().await, + }, + ) + .await + } + Err(e) => { + // SEC-007: detail logged server-side; client gets generic. + tracing::warn!(target: "lucarne_termgw", error = %e, "ws session create failed"); + send_frame( + sender, + &ServerFrame::Error { + code: 500, + msg: "internal error".to_string(), + }, + ) + .await + } + } + } + ClientFrame::CloseSession { session } => match monitor.kill(&session).await { + Ok(()) => { + subscribed.remove(&session); + differs.remove(&session); + if !send_frame(sender, &ServerFrame::SessionClosed { session }).await { + return false; + } + send_frame( + sender, + &ServerFrame::SessionList { + sessions: monitor.sessions().await, + }, + ) + .await + } + Err(e) => { + // SEC-007: detail logged server-side; client gets generic. + tracing::warn!(target: "lucarne_termgw", %session, error = %e, "ws session close failed"); + send_frame( + sender, + &ServerFrame::Error { + code: 501, + msg: "internal error".to_string(), + }, + ) + .await + } + }, + ClientFrame::Ping { t } => send_frame(sender, &ServerFrame::Pong { t }).await, + } +} + +#[cfg(test)] +mod gate_tests { + use super::*; + + // The ws auth gate runs BEFORE `.on_upgrade(`. These cover the decision the + // upgrade handlers make from a ticket query, without needing a live monitor. + + #[tokio::test] + async fn ws_gate_noop_when_auth_disabled() { + let auth = AuthState::disabled(); + // Disabled (local dev): no ticket required, never refuses; always full. + assert_eq!( + check_ws_ticket(&auth, None).await.ok(), + Some(AccessScope::Full) + ); + assert_eq!( + check_ws_ticket(&auth, Some("anything")).await.ok(), + Some(AccessScope::Full) + ); + } + + // M5: `authorize_ws` acquires the permit FIRST, so a saturated cap returns + // 503 WITHOUT consuming a ticket; on auth failure the permit is released so a + // later connection can proceed. + #[tokio::test] + async fn authorize_ws_acquires_permit_before_consuming_ticket() { + let auth = AuthState::with_token(AccessToken::generate()); + // Cap of 1 so we can saturate it. + let limits = GatewayLimits { + max_ws_connections: 1, + ..GatewayLimits::default() + }; + let pool = WsConnectionPool::new(limits); + + // Hold the only permit. + let held = pool.try_acquire().expect("first permit"); + // A fresh, valid ticket exists — but the cap is full, so authorize must + // 503 and must NOT consume the ticket. + let ticket = auth.tickets.issue().await.expect("issue ticket"); + let resp = authorize_ws(&auth, Some(&ticket), &pool).await; + assert!(resp.is_err(), "cap full → reject"); + assert_eq!( + resp.err().unwrap().status(), + StatusCode::SERVICE_UNAVAILABLE + ); + // The ticket was NOT burned: after releasing the permit it still works. + drop(held); + let (scope, permit) = authorize_ws(&auth, Some(&ticket), &pool) + .await + .expect("ticket survived the 503"); + assert_eq!(scope, AccessScope::Full); + // Release this permit so the next case exercises the auth-failure path + // (not another cap-full 503). + drop(permit); + + // Auth failure releases the permit (a forged ticket): the slot is free + // again for a subsequent valid connection. + let resp = authorize_ws(&auth, Some("forged"), &pool).await; + assert!(resp.is_err()); + assert_eq!(resp.err().unwrap().status(), StatusCode::UNAUTHORIZED); + let ticket2 = auth.tickets.issue().await.expect("issue ticket"); + let (_scope, _permit) = authorize_ws(&auth, Some(&ticket2), &pool) + .await + .expect("permit was released after auth failure"); + } + + // C1: a read-only HTTP session is rejected with 403 on a write route (the + // `RequireFull` extractor), while a full session and a missing-scope request + // are handled as expected. Driven via a small router that injects the scope + // exactly like `bearer_guard` does. + #[tokio::test] + async fn require_full_rejects_readonly_http_write() { + async fn write_handler(_full: RequireFull) -> Response { + (StatusCode::OK, "wrote").into_response() + } + let app = Router::new().route("/write", post(write_handler)); + + // Full scope in extensions → handler runs (200). + let resp = app + .clone() + .oneshot( + HttpRequest::builder() + .method("POST") + .uri("/write") + .extension(AccessScope::Full) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // ReadOnly scope → 403 before the handler runs. + let resp = app + .clone() + .oneshot( + HttpRequest::builder() + .method("POST") + .uri("/write") + .extension(AccessScope::ReadOnly) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + // No scope recorded → fail closed (403). + let resp = app + .oneshot( + HttpRequest::builder() + .method("POST") + .uri("/write") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + } + + // M2: a non-empty malformed `/api/remote/start` body is a 400 (not a silent + // fallback); an empty body still falls back to the configured tunnel (200). + #[tokio::test] + async fn remote_start_rejects_malformed_body_with_400() { + let recorder = RecordingControl::default(); + let control = control_router(Some(Arc::new(recorder.clone()) as Arc)); + let resp = control + .oneshot( + HttpRequest::builder() + .method("POST") + .uri("/api/remote/start") + .header("content-type", "application/json") + .extension(ConnectInfo("127.0.0.1:9999".parse::().unwrap())) + .body(Body::from("{ this is not json")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + // The control was never started (malformed body short-circuits). + assert!(recorder.last_start.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn ws_gate_rejects_missing_and_invalid_ticket_when_enforced() { + let auth = AuthState::with_token(AccessToken::generate()); + // No ticket → refused (a Response is returned instead of proceeding). + assert!(check_ws_ticket(&auth, None).await.is_err()); + // Never-issued ticket → refused. + assert!(check_ws_ticket(&auth, Some("forged-ticket")).await.is_err()); + } + + #[tokio::test] + async fn ws_gate_accepts_valid_ticket_once_then_replay_fails() { + let auth = AuthState::with_token(AccessToken::generate()); + let ticket = auth.tickets.issue().await.expect("issue ticket"); + // First use: valid ticket consumed → proceed (Ok = no refusal). + assert_eq!( + check_ws_ticket(&auth, Some(&ticket)).await.ok(), + Some(AccessScope::Full) + ); + // Replay: same ticket already consumed → refused. + assert!(check_ws_ticket(&auth, Some(&ticket)).await.is_err()); + } + + #[tokio::test] + async fn bearer_decision_constant_time_against_configured_token() { + let token = AccessToken::generate(); + let auth = AuthState::with_token(token.clone()); + // Correct token verifies; wrong/empty does not (constant-time compare). + assert!(auth.verify_token(token.as_str())); + assert!(!auth.verify_token("not-the-token")); + // Bearer header parsing feeds the guard. + assert_eq!( + AuthState::bearer(Some(&format!("Bearer {}", token.as_str()))), + Some(token.as_str()) + ); + } + + // ---- SEC-013: read-only token tier ---- + + #[tokio::test] + async fn readonly_token_scopes_bearer_and_ticket() { + let full = AccessToken::generate(); + let readonly = AccessToken::generate(); + let auth = AuthState::with_tokens(full.clone(), readonly.clone()); + + // The full token authenticates Full; the readonly token authenticates + // ReadOnly; an unrelated token authenticates as nothing. + assert_eq!(auth.scope_for(full.as_str()), Some(AccessScope::Full)); + assert_eq!( + auth.scope_for(readonly.as_str()), + Some(AccessScope::ReadOnly) + ); + assert_eq!(auth.scope_for("nope"), None); + // Both are accepted by the boolean view used by the bearer guard. + assert!(auth.verify_token(full.as_str())); + assert!(auth.verify_token(readonly.as_str())); + + // A ticket minted under the readonly scope consumes back to ReadOnly, + // and the ws gate surfaces that scope to the handler. + let ro_ticket = auth + .tickets + .issue_scoped(AccessScope::ReadOnly) + .await + .expect("issue read-only ticket"); + assert_eq!( + check_ws_ticket(&auth, Some(&ro_ticket)).await.ok(), + Some(AccessScope::ReadOnly) + ); + // A full-scope ticket consumes back to Full. + let full_ticket = auth + .tickets + .issue_scoped(AccessScope::Full) + .await + .expect("issue full ticket"); + assert_eq!( + check_ws_ticket(&auth, Some(&full_ticket)).await.ok(), + Some(AccessScope::Full) + ); + } + + // R3-5: ticket issuance reads the AccessScope from the request EXTENSION that + // `bearer_guard` wrote — it does NOT re-parse the Authorization header. This + // drives a faithful stand-in for `issue_ticket` (same `Extension` + // → `issue_scoped` body, no AppState/live monitor needed): an extension- + // injecting layer feeds the handler exactly as `bearer_guard` does, and the + // minted ticket round-trips to the injected scope. A request with NO scope + // extension (which would only happen off `bearer_guard`) is rejected before a + // ticket can be minted. + #[tokio::test] + async fn issue_ticket_reads_scope_from_extension_and_rejects_missing_scope() { + // Stand-in for `issue_ticket`'s body: scope is required from the + // extension, matching the real handler's `Extension` + // extractor. No extension means no ticket. + async fn ticket_handler( + State(auth): State, + axum::Extension(scope): axum::Extension, + ) -> Response { + match auth.tickets.issue_scoped(scope).await { + Ok(ticket) => Json(json!({ "ticket": ticket })).into_response(), + Err(e) => (StatusCode::TOO_MANY_REQUESTS, e.to_string()).into_response(), + } + } + + let auth = AuthState::with_tokens(AccessToken::generate(), AccessToken::generate()); + let app = Router::new() + .route("/auth/ticket", post(ticket_handler)) + .with_state(auth.clone()); + + // Extract the minted ticket string from the JSON response body. + async fn minted_ticket(resp: Response) -> String { + let bytes = http_body_util::BodyExt::collect(resp.into_body()) + .await + .unwrap() + .to_bytes(); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + v["ticket"].as_str().unwrap().to_string() + } + + // ReadOnly scope in the extension → a ReadOnly ticket (consumes ReadOnly). + let resp = app + .clone() + .oneshot( + HttpRequest::builder() + .method("POST") + .uri("/auth/ticket") + .extension(AccessScope::ReadOnly) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let ro = minted_ticket(resp).await; + assert_eq!( + auth.tickets.consume_scoped(&ro).await, + Some(AccessScope::ReadOnly), + "readonly scope extension must mint a readonly ticket" + ); + + // Full scope in the extension → a Full ticket. + let resp = app + .clone() + .oneshot( + HttpRequest::builder() + .method("POST") + .uri("/auth/ticket") + .extension(AccessScope::Full) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let full = minted_ticket(resp).await; + assert_eq!( + auth.tickets.consume_scoped(&full).await, + Some(AccessScope::Full), + "full scope extension must mint a full ticket" + ); + + // No scope extension at all → extractor rejects the request before + // `issue_scoped` can mint any ticket, even if a Full bearer header were + // present and ignored. + let resp = app + .oneshot( + HttpRequest::builder() + .method("POST") + .uri("/auth/ticket") + .header( + "authorization", + format!("Bearer {}", auth.mode.token().unwrap().as_str()), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn write_frames_are_classified_for_readonly_refusal() { + use lucarne_rmux::TermInput; + // Write frames: input + session lifecycle. + assert!(is_write_frame(&ClientFrame::Input { + session: "s:0:0".into(), + event: TermInput::Text { text: "x".into() }, + })); + assert!(is_write_frame(&ClientFrame::CreateSession { title: None })); + assert!(is_write_frame(&ClientFrame::CloseSession { + session: "s:0:0".into() + })); + // Mirror / control frames: not writes (allowed under readonly). + assert!(!is_write_frame(&ClientFrame::Subscribe { + session: "s:0:0".into() + })); + assert!(!is_write_frame(&ClientFrame::Detach { + session: "s:0:0".into() + })); + assert!(!is_write_frame(&ClientFrame::Resync { + session: "s:0:0".into(), + have_rev: 0 + })); + assert!(!is_write_frame(&ClientFrame::Ping { t: 1 })); + } + + #[test] + fn resync_have_rev_is_compared_before_reseeding_differ() { + use lucarne_rmux::term::Cell; + use lucarne_rmux::{Color, Style}; + + fn cell(text: &str) -> Cell { + Cell { + text: text.to_string(), + width: 1, + padding: false, + fg: Color::Default, + bg: Color::Default, + underline_color: Color::Default, + style: Style::empty(), + } + } + + let session = "s:0:0".to_string(); + let mut differs = HashMap::new(); + let mut differ = Differ::new(); + differ.feed(PaneGrid { + cols: 1, + rows: 1, + cells: vec![cell("a")], + rev: 7, + }); + differs.insert(session.clone(), differ); + + assert_eq!( + differs.get(&session).and_then(Differ::current_rev), + Some(7), + "server baseline is the value Resync.have_rev is compared against" + ); + assert_ne!( + differs.get(&session).and_then(Differ::current_rev), + Some(3), + "a stale client have_rev must be observable before full snapshot reseed" + ); + } + + // A read-only session refuses a write frame (returns the connection-keep + // `true`) without ever touching the monitor; a mirror frame is unaffected by + // the readonly gate. Driven against the pure classification + the gate's + // refusal branch (no live monitor needed for the refusal path). + #[tokio::test] + async fn readonly_session_refuses_write_frames() { + // The refusal short-circuits before the monitor is consulted: assert the + // classification the gate uses to decide. + use lucarne_rmux::TermInput; + let write = ClientFrame::Input { + session: "s:0:0".into(), + event: TermInput::Text { + text: "rm -rf /\n".into(), + }, + }; + // readonly + write → must be classified as a write (and thus refused). + assert!(is_write_frame(&write)); + // A subscribe is never a write, so readonly never refuses it. + let mirror = ClientFrame::Subscribe { + session: "s:0:0".into(), + }; + assert!(!is_write_frame(&mirror)); + } + + // ---- SEC-004: per-connection inbound frame rate limiter ---- + + #[test] + fn frame_rate_limits_burst_and_allows_unlimited_when_zero() { + // 0 = unlimited. + let mut unlimited = FrameRate::new(0); + for _ in 0..1000 { + assert!(unlimited.allow()); + } + // A small budget: the first N pass, the next is rejected within the window. + let mut limited = FrameRate::new(3); + assert!(limited.allow()); + assert!(limited.allow()); + assert!(limited.allow()); + assert!(!limited.allow(), "4th frame in the window must be rejected"); + } + + // ---- SEC-001 / SEC-002: HTTP router-level integration tests ---- + // + // These drive built routers via `tower::ServiceExt::oneshot`, so they assert + // the actual route/middleware wiring without binding a socket or standing up + // a live monitor / agent runtime. + + use axum::body::Body; + use axum::http::Request as HttpRequest; + use tower::ServiceExt; + + #[derive(Clone)] + struct FakeTerminalMonitor { + sessions: Arc>>, + injections: Arc>>, + updates: tokio::sync::broadcast::Sender, + } + + impl FakeTerminalMonitor { + fn new(sessions: Vec) -> Self { + let (updates, _) = tokio::sync::broadcast::channel(16); + Self { + sessions: Arc::new(tokio::sync::Mutex::new(sessions)), + injections: Arc::new(tokio::sync::Mutex::new(Vec::new())), + updates, + } + } + + async fn injections(&self) -> Vec<(SessionId, TermInput)> { + self.injections.lock().await.clone() + } + } + + fn claude_session_line(cwd: &str, session_id: &str) -> String { + format!( + r#"{{"type":"user","sessionId":"{session_id}","cwd":"{cwd}","timestamp":"2026-05-30T00:00:00Z","message":{{"role":"user","content":[{{"type":"text","text":"hello there"}}]}}}}"# + ) + } + + fn claude_assistant_line() -> String { + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi back"}],"stop_reason":"end_turn"}}"#.to_string() + } + + #[async_trait] + impl TerminalMonitor for FakeTerminalMonitor { + fn subscribe(&self) -> tokio::sync::broadcast::Receiver { + self.updates.subscribe() + } + + async fn sessions(&self) -> Vec { + self.sessions.lock().await.clone() + } + + async fn create(&self, title: String) -> Result { + let id = format!("fake-{}:0:0", self.sessions.lock().await.len()); + let desc = SessionDescriptor { + id: id.clone(), + title, + origin: lucarne_rmux::Origin::Managed, + dims: lucarne_rmux::Dims { cols: 80, rows: 24 }, + cwd: None, + }; + self.sessions.lock().await.push(desc.clone()); + Ok(desc) + } + + async fn snapshot_grid(&self, id: &SessionId) -> Result<(PaneGrid, Cursor), MonitorError> { + Err(MonitorError::NotFound(id.clone())) + } + + async fn inject(&self, id: &SessionId, input: TermInput) -> Result<(), MonitorError> { + self.injections.lock().await.push((id.clone(), input)); + Ok(()) + } + + async fn kill(&self, id: &SessionId) -> Result<(), MonitorError> { + let mut sessions = self.sessions.lock().await; + let before = sessions.len(); + sessions.retain(|session| session.id != *id); + if sessions.len() == before { + return Err(MonitorError::NotFound(id.clone())); + } + Ok(()) + } + + async fn capture_scrollback(&self, id: &SessionId) -> Result { + if self + .sessions + .lock() + .await + .iter() + .any(|session| session.id == *id) + { + Ok("fake scrollback".to_string()) + } else { + Err(MonitorError::NotFound(id.clone())) + } + } + } + + /// A stand-in for an external extension ws route: it upgrades immediately, + /// so if the gate lets the request through, the response is a 101/426-style + /// upgrade attempt rather than our 401. + async fn dummy_extension_upgrade(ws: WebSocketUpgrade) -> Response { + ws.on_upgrade(|_socket| async {}) + } + + fn dummy_extension_router() -> Router { + Router::new().route("/extension-ws", get(dummy_extension_upgrade)) + } + + #[tokio::test] + async fn sec001_extension_gate_refuses_without_ticket_when_enforced() { + // Auth enforced + no ticket → the gate rejects with 401 BEFORE upgrade. + let auth = AuthState::with_token(AccessToken::generate()); + let app = gate_ws_router(dummy_extension_router(), auth); + let resp = app + .oneshot( + HttpRequest::builder() + .uri("/extension-ws") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn sec001_extension_gate_refuses_invalid_ticket_but_allows_valid_once() { + let auth = AuthState::with_token(AccessToken::generate()); + + // Forged ticket → 401. + let app = gate_ws_router(dummy_extension_router(), auth.clone()); + let resp = app + .oneshot( + HttpRequest::builder() + .uri("/extension-ws?ticket=forged") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + // A valid single-use ticket passes the gate (so it reaches the upgrade + // handler — which, lacking ws upgrade headers, is NOT our 401). + let ticket = auth.tickets.issue().await.expect("issue ticket"); + let app = gate_ws_router(dummy_extension_router(), auth.clone()); + let resp = app + .oneshot( + HttpRequest::builder() + .uri(format!("/extension-ws?ticket={ticket}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!( + resp.status(), + StatusCode::UNAUTHORIZED, + "valid ticket must pass the gate" + ); + } + + #[tokio::test] + async fn sec001_extension_gate_is_noop_when_auth_disabled() { + // Local dev: gate is a pass-through, the upgrade handler runs. + let app = gate_ws_router(dummy_extension_router(), AuthState::disabled()); + let resp = app + .oneshot( + HttpRequest::builder() + .uri("/extension-ws") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn sec002_control_plane_routes_live_only_on_control_router() { + // The control router serves /api/remote/status (loopback peer in test). + let control = control_router(None); + let resp = control + .oneshot( + HttpRequest::builder() + .uri("/api/remote/status") + .extension(ConnectInfo("127.0.0.1:9999".parse::().unwrap())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn sec002_public_gateway_does_not_expose_remote_control_routes() { + let temp = tempfile::tempdir().expect("web dir"); + let monitor = FakeTerminalMonitor::new(Vec::new()); + let app = router_with_terminal_monitor_and_store( + Arc::new(monitor), + temp.path().to_path_buf(), + AuthState::disabled(), + WsConnectionPool::new(GatewayLimits::default()), + ControlPlaneSqliteStore::open_in_memory().expect("store"), + ); + + for (method, path) in [ + ("GET", "/api/remote/status"), + ("POST", "/api/remote/start"), + ("POST", "/api/remote/stop"), + ] { + let resp = app + .clone() + .oneshot( + HttpRequest::builder() + .method(method) + .uri(path) + .extension(ConnectInfo("127.0.0.1:9999".parse::().unwrap())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!( + resp.status(), + StatusCode::OK, + "public gateway route {method} {path} must not reach remote control plane" + ); + let body = http_body_util::BodyExt::collect(resp.into_body()) + .await + .unwrap() + .to_bytes(); + let body = String::from_utf8_lossy(&body); + assert!( + !body.contains("access_token") && !body.contains("remote subsystem"), + "public gateway response must not leak remote control-plane data: {body}" + ); + } + } + + #[tokio::test] + async fn agent_ws_readonly_prompt_refuses_before_terminal_inject() { + let temp = tempfile::tempdir().expect("temp claude config"); + let cwd = temp.path().join("workspace"); + std::fs::create_dir_all(&cwd).expect("workspace"); + let projects = temp.path().join("projects").join("term-agent"); + std::fs::create_dir_all(&projects).expect("claude projects"); + let transcript = projects.join("sess-agent.jsonl"); + std::fs::write( + &transcript, + format!( + "{}\n{}\n", + claude_session_line(cwd.to_str().unwrap(), "sess-agent"), + claude_assistant_line() + ), + ) + .expect("write transcript"); + + let session_id = "agent-session:0:0".to_string(); + let monitor = FakeTerminalMonitor::new(vec![SessionDescriptor { + id: session_id.clone(), + title: "agent".to_string(), + origin: lucarne_rmux::Origin::Adopted, + dims: lucarne_rmux::Dims { cols: 80, rows: 24 }, + cwd: Some(cwd.to_string_lossy().into_owned()), + }]); + + let full = AccessToken::generate(); + let readonly = AccessToken::generate(); + let auth = AuthState::with_tokens(full, readonly.clone()); + let app = router_with_terminal_monitor_and_store( + Arc::new(monitor.clone()), + temp.path().join("web"), + auth.clone(), + WsConnectionPool::new(GatewayLimits { + idle_timeout: std::time::Duration::from_secs(30), + max_session_lifetime: std::time::Duration::from_secs(30), + ..GatewayLimits::default() + }), + ControlPlaneSqliteStore::open_in_memory().expect("store"), + ); + + let prev = std::env::var_os("CLAUDE_CONFIG_DIR"); + unsafe { + std::env::set_var("CLAUDE_CONFIG_DIR", temp.path()); + } + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + }); + + let ticket = auth + .tickets + .issue_scoped(AccessScope::ReadOnly) + .await + .expect("issue readonly ticket"); + let url = format!("ws://{addr}/agent/{session_id}?ticket={ticket}"); + let (mut socket, _response) = tokio_tungstenite::connect_async(url) + .await + .expect("connect agent ws"); + + let ready = socket + .next() + .await + .expect("ready frame") + .expect("ready ok") + .into_text() + .expect("ready text"); + let ready: Value = serde_json::from_str(&ready).expect("ready json"); + assert_eq!(ready["type"], "ready"); + assert_eq!(ready["readonly"], true); + + socket + .send(tokio_tungstenite::tungstenite::Message::Text( + r#"{"type":"prompt","text":"should not inject"}"#.into(), + )) + .await + .expect("send readonly prompt"); + let mut refusal = None; + for _ in 0..5 { + let frame = socket + .next() + .await + .expect("agent frame") + .expect("agent frame ok") + .into_text() + .expect("agent text"); + let value: Value = serde_json::from_str(&frame).expect("agent json"); + if value["type"] == "error" { + refusal = Some(value); + break; + } + } + let refusal = refusal.expect("read-only prompt refusal frame"); + assert_eq!(refusal["type"], "error"); + assert_eq!( + refusal["msg"], + "read-only session: prompts are not permitted" + ); + assert!( + monitor.injections().await.is_empty(), + "read-only prompt must be refused before terminal injection" + ); + + let _ = socket.close(None).await; + server.abort(); + match prev { + Some(value) => unsafe { std::env::set_var("CLAUDE_CONFIG_DIR", value) }, + None => unsafe { std::env::remove_var("CLAUDE_CONFIG_DIR") }, + } + } + + // ---- G3: CLI-supplied provider + fields flow through /api/remote/start ---- + + #[test] + fn parse_start_params_distinguishes_empty_from_malformed() { + // M2: an EMPTY body → Ok(empty params) (configured fallback / bodyless POST). + let empty = parse_start_params(b"").expect("empty body is ok"); + assert!(empty.is_empty()); + // A NON-empty malformed body → Err (the handler returns 400, no silent + // downgrade to default). + assert!(parse_start_params(b"not json").is_err()); + // A well-formed body → provider + fields parsed. + let body = br#"{"provider":"cloudflared","fields":{"token":"t","public_url":"https://x"}}"#; + let params = parse_start_params(body).expect("valid body parses"); + assert_eq!(params.provider.as_deref(), Some("cloudflared")); + assert_eq!(params.fields.get("token").map(String::as_str), Some("t")); + assert_eq!( + params.fields.get("public_url").map(String::as_str), + Some("https://x") + ); + assert!(!params.is_empty()); + } + + /// A `RemoteControl` test double that records the [`RemoteStartParams`] it was + /// last started with, so the handler→trait body plumbing (G3) is assertable. + #[derive(Clone, Default)] + struct RecordingControl { + last_start: Arc>>, + } + + #[async_trait] + impl RemoteControl for RecordingControl { + async fn start( + &self, + params: RemoteStartParams, + ) -> Result { + *self.last_start.lock().unwrap() = Some(params); + Ok(RemoteControlStatus { + running: true, + provider: Some("cloudflared".to_string()), + public_url: Some("https://demo.example.test".to_string()), + access_token: Some("token".to_string()), + }) + } + async fn stop(&self) -> Result { + Ok(RemoteControlStatus::default()) + } + async fn status(&self) -> RemoteControlStatus { + RemoteControlStatus::default() + } + } + + #[tokio::test] + async fn g3_start_forwards_cli_provider_and_fields_to_control() { + let recorder = RecordingControl::default(); + let control = control_router(Some(Arc::new(recorder.clone()) as Arc)); + let body = + r#"{"provider":"cloudflared","fields":{"public_url":"https://demo.example.test"}}"#; + let resp = control + .oneshot( + HttpRequest::builder() + .method("POST") + .uri("/api/remote/start") + .header("content-type", "application/json") + .extension(ConnectInfo("127.0.0.1:9999".parse::().unwrap())) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + // The handler parsed the body and forwarded it verbatim to the control. + let recorded = recorder + .last_start + .lock() + .unwrap() + .clone() + .expect("started"); + assert_eq!(recorded.provider.as_deref(), Some("cloudflared")); + assert_eq!( + recorded.fields.get("public_url").map(String::as_str), + Some("https://demo.example.test") + ); + } + + #[tokio::test] + async fn g3_start_with_empty_body_falls_back_to_configured_tunnel() { + let recorder = RecordingControl::default(); + let control = control_router(Some(Arc::new(recorder.clone()) as Arc)); + // Bodyless POST (older client / curl): the handler must still start the + // tunnel and pass empty params (daemon uses its pre-configured tunnel). + let resp = control + .oneshot( + HttpRequest::builder() + .method("POST") + .uri("/api/remote/start") + .extension(ConnectInfo("127.0.0.1:9999".parse::().unwrap())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let recorded = recorder + .last_start + .lock() + .unwrap() + .clone() + .expect("started"); + assert!( + recorded.is_empty(), + "empty body must yield empty params (configured fallback)" + ); + } + + #[tokio::test] + async fn sec002_control_plane_loopback_only_rejects_non_loopback_peer() { + let control = control_router(None); + let resp = control + .oneshot( + HttpRequest::builder() + .uri("/api/remote/status") + .extension(ConnectInfo( + "203.0.113.7:443".parse::().unwrap(), + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // Defense-in-depth: a non-loopback peer is refused even on this listener. + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + } + + // SEC-002: the public (tunneled) gateway router must NOT register the + // `/api/remote/*` control plane — those routes belong only to the separate + // loopback control listener. `gateway_router` needs a live monitor to build, + // so assert the wiring at the source level: only `control_router` mounts the + // control routes. + #[test] + fn sec002_remote_routes_only_on_control_router_not_gateway_router() { + let src = include_str!("lib.rs"); + let production = src.split("#[cfg(test)]").next().unwrap_or(src); + // `gateway_router`'s body ends at its closing — take everything up to the + // doc comment that precedes `control_router` (the `// Build the loopback` + // doc line), so the control router's docs/routes are not captured here. + let gateway = production + .split("fn gateway_router") + .nth(1) + .and_then(|rest| { + rest.split("/// Build the loopback-only control-plane router") + .next() + }) + .expect("gateway_router body"); + let control = production + .split("pub fn control_router") + .nth(1) + .expect("control_router body"); + assert!( + !gateway.contains(".route(\"/api/remote/"), + "gateway (tunneled) router must not mount /api/remote/* (SEC-002)" + ); + assert!( + control.contains("/api/remote/start") + && control.contains("/api/remote/stop") + && control.contains("/api/remote/status"), + "control_router must mount the full /api/remote/* control plane" + ); + } +} diff --git a/crates/lucarne-wechat/src/lib.rs b/crates/lucarne-wechat/src/lib.rs index 5b976a8..f1a11eb 100644 --- a/crates/lucarne-wechat/src/lib.rs +++ b/crates/lucarne-wechat/src/lib.rs @@ -1,3 +1,14 @@ +#![allow( + clippy::chars_last_cmp, + clippy::chars_next_cmp, + clippy::clone_on_copy, + clippy::explicit_auto_deref, + clippy::large_enum_variant, + clippy::manual_div_ceil, + clippy::obfuscated_if_else, + clippy::unnecessary_map_or +)] + //! WeChat notification bridge for lucarne. //! //! This crate intentionally implements only the WeChat user journey: diff --git a/crates/lucarne/Cargo.toml b/crates/lucarne/Cargo.toml index 0c87511..ebad9c4 100644 --- a/crates/lucarne/Cargo.toml +++ b/crates/lucarne/Cargo.toml @@ -13,6 +13,10 @@ copilot = [] gemini = [] pi = [] memory-profiling = ["agent-sessions/memory-profiling"] +# Terminal-adjacent agent transcript binding for rmux/web entrypoints. Kept as +# an opt-in core feature so entry layers can reuse Lucarne's provider and +# control-plane contracts without carrying a separate sidecar crate. +terminal-agent-bind = [] [dependencies] serde = { workspace = true } diff --git a/crates/lucarne/src/control_plane/store.rs b/crates/lucarne/src/control_plane/store.rs index e81da02..1211cc5 100644 --- a/crates/lucarne/src/control_plane/store.rs +++ b/crates/lucarne/src/control_plane/store.rs @@ -124,7 +124,7 @@ impl ControlPlaneSqliteStore { Ok(count > 0) } - fn entity_by_id( + pub fn entity_state( &self, kind: &str, entity_id: &str, @@ -144,7 +144,7 @@ impl ControlPlaneSqliteStore { .transpose() } - fn entities_by_kind( + pub fn entities_by_kind( &self, kind: &str, ) -> Result, ControlPlaneStoreError> { @@ -229,14 +229,14 @@ impl ControlPlaneSqliteStore { &self, provider_session_id: &ProviderSessionId, ) -> Result, ControlPlaneStoreError> { - self.entity_by_id("provider_session", provider_session_id.as_str()) + self.entity_state("provider_session", provider_session_id.as_str()) } pub fn workspace( &self, workspace_id: &WorkspaceId, ) -> Result, ControlPlaneStoreError> { - self.entity_by_id("workspace", workspace_id.as_str()) + self.entity_state("workspace", workspace_id.as_str()) } pub fn workspace_bindings(&self) -> Result, ControlPlaneStoreError> { @@ -265,7 +265,7 @@ impl ControlPlaneSqliteStore { &self, live_instance_id: &LiveInstanceId, ) -> Result, ControlPlaneStoreError> { - self.entity_by_id("live_instance", live_instance_id.as_str()) + self.entity_state("live_instance", live_instance_id.as_str()) } pub fn live_instances_for_restart_cleanup( @@ -290,7 +290,7 @@ impl ControlPlaneSqliteStore { } pub fn turn(&self, turn_id: &TurnId) -> Result, ControlPlaneStoreError> { - self.entity_by_id("turn", turn_id.as_str()) + self.entity_state("turn", turn_id.as_str()) } pub fn turns_for_workspace( @@ -304,7 +304,7 @@ impl ControlPlaneSqliteStore { &self, command_id: &CommandId, ) -> Result, ControlPlaneStoreError> { - self.entity_by_id("command", command_id.as_str()) + self.entity_state("command", command_id.as_str()) } pub fn command_workflows_for_workspace( @@ -318,14 +318,14 @@ impl ControlPlaneSqliteStore { &self, token: &CommandCallbackToken, ) -> Result, ControlPlaneStoreError> { - self.entity_by_id("command_callback", token.as_str()) + self.entity_state("command_callback", token.as_str()) } pub fn intervention_callback( &self, token: &InterventionCallbackToken, ) -> Result, ControlPlaneStoreError> { - self.entity_by_id("intervention_callback", token.as_str()) + self.entity_state("intervention_callback", token.as_str()) } pub fn delete_intervention_callbacks_for_live_instances( @@ -401,21 +401,21 @@ impl ControlPlaneSqliteStore { &self, workspace_id: &WorkspaceId, ) -> Result, ControlPlaneStoreError> { - self.entity_by_id("history_replay", workspace_id.as_str()) + self.entity_state("history_replay", workspace_id.as_str()) } pub fn history_older_callback( &self, token: &HistoryOlderCallbackToken, ) -> Result, ControlPlaneStoreError> { - self.entity_by_id("history_older_callback", token.as_str()) + self.entity_state("history_older_callback", token.as_str()) } pub fn channel_binding( &self, binding_id: &ChannelBindingId, ) -> Result, ControlPlaneStoreError> { - self.entity_by_id("channel_binding", binding_id.as_str()) + self.entity_state("channel_binding", binding_id.as_str()) } pub fn channel_bindings_for_workspace( @@ -448,7 +448,7 @@ impl ControlPlaneSqliteStore { &self, panel_id: &PanelRenderId, ) -> Result, ControlPlaneStoreError> { - self.entity_by_id("panel_render", panel_id.as_str()) + self.entity_state("panel_render", panel_id.as_str()) } pub fn panel_renders(&self) -> Result, ControlPlaneStoreError> { @@ -467,7 +467,7 @@ impl ControlPlaneSqliteStore { &self, task_id: &ScheduledTaskId, ) -> Result, ControlPlaneStoreError> { - self.entity_by_id("scheduled_task", task_id.as_str()) + self.entity_state("scheduled_task", task_id.as_str()) } pub fn due_scheduled_tasks( @@ -515,14 +515,14 @@ impl ControlPlaneSqliteStore { &self, link_id: &SubAgentLinkId, ) -> Result, ControlPlaneStoreError> { - self.entity_by_id("subagent_link", link_id.as_str()) + self.entity_state("subagent_link", link_id.as_str()) } pub fn subagent_callback( &self, token: &SubAgentCallbackToken, ) -> Result, ControlPlaneStoreError> { - self.entity_by_id("subagent_callback", token.as_str()) + self.entity_state("subagent_callback", token.as_str()) } pub fn workspace_for_provider_session( diff --git a/crates/lucarne/src/lib.rs b/crates/lucarne/src/lib.rs index 3ba503a..d261d86 100644 --- a/crates/lucarne/src/lib.rs +++ b/crates/lucarne/src/lib.rs @@ -1,3 +1,35 @@ +#![allow( + clippy::await_holding_lock, + clippy::bind_instead_of_map, + clippy::clone_on_copy, + clippy::collapsible_if, + clippy::derivable_impls, + clippy::explicit_auto_deref, + clippy::items_after_test_module, + clippy::large_enum_variant, + clippy::manual_inspect, + clippy::manual_div_ceil, + clippy::manual_repeat_n, + clippy::needless_borrows_for_generic_args, + clippy::needless_return, + clippy::needless_update, + clippy::new_ret_no_self, + clippy::new_without_default, + clippy::obfuscated_if_else, + clippy::redundant_closure, + clippy::too_many_arguments, + clippy::type_complexity, + clippy::unneeded_struct_pattern, + clippy::unnecessary_lazy_evaluations, + clippy::unnecessary_filter_map, + clippy::unnecessary_map_or, + clippy::unnecessary_sort_by, + clippy::unnecessary_to_owned, + clippy::unwrap_or_default, + clippy::useless_conversion, + clippy::wrong_self_convention +)] + //! lucarne — agent multiplexer. //! //! A protocol-normalizing layer for AI agent CLIs (Claude / Codex / @@ -42,6 +74,8 @@ pub mod launcher; pub mod observability; mod provider_id; pub mod runtime; +#[cfg(feature = "terminal-agent-bind")] +pub mod terminal_agent_bind; pub(crate) mod time_display; pub mod testing; diff --git a/crates/lucarne/src/terminal_agent_bind.rs b/crates/lucarne/src/terminal_agent_bind.rs new file mode 100644 index 0000000..d5758e0 --- /dev/null +++ b/crates/lucarne/src/terminal_agent_bind.rs @@ -0,0 +1,601 @@ +//! Terminal-adjacent agent binding. +//! +//! This module binds a terminal pane cwd to the agent session running inside it +//! and reads that provider-owned transcript as chat messages. It deliberately +//! lives in `lucarne` core, not a sidecar crate: the binding is product context +//! between terminal entrypoints and Lucarne provider/session state. +//! +//! Provider-specific discovery, metadata and parsing rules still stay inside +//! `agent-sessions` descriptors. This module only orchestrates through those +//! typed provider contracts and persists rmux-related binding observations in +//! [`crate::control_plane::ControlPlaneSqliteStore`] as cold control-plane rows. + +use std::path::{Path, PathBuf}; + +use agent_sessions::agent_session::{Actor, Body, ContentBlock, Session, SessionMeta}; +use agent_sessions::reader::SessionReader; +use agent_sessions::{agent_providers, AgentProviderDescriptor, ParseSelection}; +use serde::{Deserialize, Serialize}; +use tracing::{debug, warn}; + +use crate::control_plane::{ControlPlaneSqliteStore, ControlPlaneStoreError, ProviderSessionId}; + +const TERMINAL_AGENT_BINDING_KIND: &str = "terminal_agent_binding"; +const INITIAL_TRANSCRIPT_WINDOW_BYTES: u64 = 256 * 1024; +const INCREMENTAL_TRANSCRIPT_READ_BYTES: u64 = 256 * 1024; + +/// The agent session a pane is bound to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BoundAgent { + /// Provider id, owned by the provider descriptor. + pub kind: String, + /// The provider's own session id. + pub session_id: String, + /// Path to the provider-owned transcript file. + pub transcript: PathBuf, +} + +/// One parsed conversation turn. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Msg { + pub role: String, + pub text: String, +} + +/// Cold record that a provider session was observed from a terminal pane. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TerminalAgentBindingRecord { + pub provider_session_id: ProviderSessionId, + pub kind: String, + pub session_id: String, + pub cwd: Option, + pub rmux_session: Option, + pub title: Option, + pub summary: Option, + pub transcript: PathBuf, + pub first_seen: i64, + pub last_seen: i64, +} + +/// Most-recently-seen rmux-related agent session row. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HistRow { + pub kind: String, + pub session_id: String, + pub cwd: Option, + pub rmux_session: Option, + pub title: Option, + pub summary: Option, + pub last_seen: i64, +} + +/// Resolve the agent session bound to a pane with the given cwd, if any. +pub fn bind(cwd: &str) -> Option { + let mut best: Option<(i64, BoundAgent)> = None; + for provider in agent_providers() { + let _ = provider.discover_sources_into(&mut |source| { + let Ok(meta) = provider.parse_source_meta(&source) else { + return; + }; + if !meta_cwd_matches(&meta, cwd) { + return; + } + let modified = source.last_modified_unix(); + if best.as_ref().is_some_and(|(best_m, _)| modified <= *best_m) { + return; + } + let session_id = meta + .session_id + .as_deref() + .map(str::to_owned) + .or_else(|| { + source + .path() + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + }) + .unwrap_or_default(); + best = Some(( + modified, + BoundAgent { + kind: provider.id().to_string(), + session_id, + transcript: source.path().to_path_buf(), + }, + )); + }); + } + let bound = best.map(|(_, agent)| agent); + match &bound { + Some(agent) => debug!( + target: "lucarne::terminal_agent_bind", + cwd, + provider = %agent.kind, + provider_session_id = %agent.session_id, + transcript = %agent.transcript.display(), + "bound terminal cwd to provider transcript" + ), + None => debug!( + target: "lucarne::terminal_agent_bind", + cwd, + "no provider transcript matched terminal cwd" + ), + } + bound +} + +fn meta_cwd_matches(meta: &SessionMeta, cwd: &str) -> bool { + meta.cwd.as_deref() == Some(cwd) +} + +/// Read transcript messages appended since byte `from`. +/// +/// Only complete newline-terminated lines are consumed; a partial trailing line +/// is left for the next read. Provider schema parsing is delegated to the +/// descriptor that owns the transcript. +/// +/// `from == 0` is treated as an initial/history read and starts from a bounded +/// tail window, not from byte zero. Terminal transcript files are provider-owned +/// append-only logs and can grow indefinitely; terminal-adjacent views should +/// bootstrap from a recent cursor window instead of scanning the full file. +pub fn read_messages(path: &Path, from: u64) -> (Vec, u64) { + let Some((bytes, consumed)) = read_complete_lines_from(path, from) else { + warn!( + target: "lucarne::terminal_agent_bind", + transcript = %path.display(), + offset = from, + "failed to read terminal-bound transcript" + ); + return (Vec::new(), from); + }; + if bytes.is_empty() { + return (Vec::new(), consumed); + } + let Some(provider) = provider_for(path) else { + return (Vec::new(), consumed); + }; + match provider.parse_agent_session_bytes(bytes, ParseSelection::empty().with_messages()) { + Ok(session) => { + let messages = messages_from_session(provider, &session); + debug!( + target: "lucarne::terminal_agent_bind", + transcript = %path.display(), + from, + consumed, + messages = messages.len(), + "read terminal-bound transcript messages" + ); + (messages, consumed) + } + Err(err) => { + warn!( + target: "lucarne::terminal_agent_bind", + transcript = %path.display(), + from, + consumed, + error = %err, + "failed to parse terminal-bound transcript messages" + ); + (Vec::new(), consumed) + } + } +} + +fn read_complete_lines_from(path: &Path, from: u64) -> Option<(Vec, u64)> { + use std::io::{Read, Seek, SeekFrom}; + + let mut file = std::fs::File::open(path).ok()?; + let len = file.metadata().map(|m| m.len()).unwrap_or(0); + if len <= from && from != 0 { + return Some((Vec::new(), from)); + } + let start = read_start_for_cursor(&mut file, len, from)?; + if len <= start { + return Some((Vec::new(), start)); + } + file.seek(SeekFrom::Start(start)).ok()?; + let mut buf = Vec::new(); + let limit = if from == 0 { + INITIAL_TRANSCRIPT_WINDOW_BYTES + } else { + INCREMENTAL_TRANSCRIPT_READ_BYTES + }; + file.take(limit).read_to_end(&mut buf).ok()?; + + let mut consumed_start = start; + if from == 0 && start > 0 { + let Some(first_newline) = buf.iter().position(|byte| *byte == b'\n') else { + return Some((Vec::new(), start)); + }; + buf.drain(..=first_newline); + consumed_start += first_newline as u64 + 1; + } + + let consumed_len = match buf.iter().rposition(|byte| *byte == b'\n') { + Some(last_newline) => last_newline + 1, + None => return Some((Vec::new(), consumed_start)), + }; + buf.truncate(consumed_len); + Some((buf, consumed_start + consumed_len as u64)) +} + +fn read_start_for_cursor(file: &mut std::fs::File, len: u64, from: u64) -> Option { + if from > 0 { + return Some(from.min(len)); + } + if len <= INITIAL_TRANSCRIPT_WINDOW_BYTES { + return Some(0); + } + let start = len.saturating_sub(INITIAL_TRANSCRIPT_WINDOW_BYTES); + std::io::Seek::seek(file, std::io::SeekFrom::Start(start)).ok()?; + Some(start) +} + +fn messages_from_session(provider: AgentProviderDescriptor, session: &Session) -> Vec { + let mut msgs = Vec::new(); + for event in session.events.iter() { + let msg = match (&event.actor, &event.body) { + (Actor::User, Body::Prompt(prompt)) => { + let text = text_of(prompt.text.as_deref(), &prompt.blocks); + if text.is_empty() || !provider.is_transcript_user_text_visible(&text) { + continue; + } + Msg { + role: "user".to_string(), + text, + } + } + (Actor::Assistant, Body::Response(response)) => { + let text = text_of(response.text.as_deref(), &response.blocks); + if text.is_empty() { + continue; + } + Msg { + role: "assistant".to_string(), + text, + } + } + _ => continue, + }; + msgs.push(msg); + } + msgs +} + +fn text_of(inline: Option<&str>, blocks: &[ContentBlock]) -> String { + if let Some(text) = inline.map(str::trim).filter(|text| !text.is_empty()) { + return text.to_string(); + } + agent_sessions::agent_session::text_from_blocks(blocks) + .map(|text| text.trim().to_string()) + .unwrap_or_default() +} + +fn provider_for(path: &Path) -> Option { + agent_providers() + .into_iter() + .find(|provider| provider.parse_file_meta(path.to_path_buf()).is_ok()) +} + +/// Short title for a transcript: provider title or first visible user message. +pub fn first_user_message(path: &Path) -> Option { + let provider = provider_for(path)?; + if let Ok(meta) = provider.parse_file_meta(path.to_path_buf()) { + if let Some(title) = meta + .title + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + { + return Some(title.chars().take(60).collect()); + } + } + let bytes = read_tail_window(path)?; + let session = provider + .parse_agent_session_bytes(bytes, ParseSelection::empty().with_messages()) + .ok()?; + session + .events + .iter() + .find_map(|event| match (&event.actor, &event.body) { + (Actor::User, Body::Prompt(prompt)) => { + let text = text_of(prompt.text.as_deref(), &prompt.blocks); + if text.is_empty() || !provider.is_transcript_user_text_visible(&text) { + return None; + } + let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); + let s: String = first.chars().take(60).collect(); + (!s.trim().is_empty()).then_some(s) + } + _ => None, + }) +} + +fn read_tail_window(path: &Path) -> Option> { + const MAX_HEAD_BYTES: u64 = 256 * 1024; + let reader = SessionReader::open(path).ok()?; + let mut lines = reader.reverse_lines_limited(MAX_HEAD_BYTES).ok()?; + let mut collected = Vec::new(); + while let Some(line) = lines.next_line().ok()? { + collected.push(line); + } + if collected.is_empty() { + return None; + } + let mut bytes = Vec::new(); + for line in collected.iter().rev() { + bytes.extend_from_slice(line); + bytes.push(b'\n'); + } + Some(bytes) +} + +fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn provider_session_id(kind: &str, session_id: &str) -> ProviderSessionId { + ProviderSessionId::new(format!("{kind}:{session_id}")) +} + +/// Record that `session_id` was seen bound to an rmux pane. +pub fn record( + store: &ControlPlaneSqliteStore, + kind: &str, + session_id: &str, + cwd: &str, + rmux_session: &str, + title: &str, + transcript: &Path, +) -> Result<(), ControlPlaneStoreError> { + let provider_session_id = provider_session_id(kind, session_id); + let existing = terminal_agent_binding(store, &provider_session_id)?; + let ts = now(); + let record = TerminalAgentBindingRecord { + provider_session_id: provider_session_id.clone(), + kind: kind.to_string(), + session_id: session_id.to_string(), + cwd: Some(cwd.to_string()), + rmux_session: Some(rmux_session.to_string()), + title: Some(title.to_string()), + summary: first_user_message(transcript), + transcript: transcript.to_path_buf(), + first_seen: existing.as_ref().map(|r| r.first_seen).unwrap_or(ts), + last_seen: ts, + }; + let result = store.upsert_entity_state( + TERMINAL_AGENT_BINDING_KIND, + provider_session_id.as_str(), + None, + &record, + ); + match &result { + Ok(()) => debug!( + target: "lucarne::terminal_agent_bind", + provider = kind, + provider_session_id = provider_session_id.as_str(), + rmux_session, + cwd, + transcript = %transcript.display(), + "recorded terminal-agent binding" + ), + Err(err) => warn!( + target: "lucarne::terminal_agent_bind", + provider = kind, + provider_session_id = provider_session_id.as_str(), + rmux_session, + cwd, + transcript = %transcript.display(), + error = %err, + "failed to record terminal-agent binding" + ), + } + result +} + +/// Most-recently-seen terminal-related agent sessions. +pub fn history( + store: &ControlPlaneSqliteStore, + limit: usize, +) -> Result, ControlPlaneStoreError> { + let mut rows = terminal_agent_bindings(store)? + .into_iter() + .map(|r| HistRow { + kind: r.kind, + session_id: r.session_id, + cwd: r.cwd, + rmux_session: r.rmux_session, + title: r.title, + summary: r.summary, + last_seen: r.last_seen, + }) + .collect::>(); + rows.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); + rows.truncate(limit); + Ok(rows) +} + +pub fn transcript_path( + store: &ControlPlaneSqliteStore, + session_id: &str, +) -> Result, ControlPlaneStoreError> { + let records = terminal_agent_bindings(store)?; + Ok(records + .into_iter() + .find(|record| record.session_id == session_id) + .map(|record| record.transcript)) +} + +pub fn terminal_agent_binding( + store: &ControlPlaneSqliteStore, + provider_session_id: &ProviderSessionId, +) -> Result, ControlPlaneStoreError> { + store.entity_state(TERMINAL_AGENT_BINDING_KIND, provider_session_id.as_str()) +} + +pub fn terminal_agent_bindings( + store: &ControlPlaneSqliteStore, +) -> Result, ControlPlaneStoreError> { + store.entities_by_kind(TERMINAL_AGENT_BINDING_KIND) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn claude_session_line(cwd: &str, session_id: &str) -> String { + format!( + r#"{{"type":"user","sessionId":"{session_id}","cwd":"{cwd}","timestamp":"2026-05-30T00:00:00Z","message":{{"role":"user","content":[{{"type":"text","text":"hello there"}}]}}}}"# + ) + } + + fn claude_assistant_line() -> String { + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi back"}],"stop_reason":"end_turn"}}"#.to_string() + } + + #[test] + fn read_messages_projects_user_and_assistant_via_provider() { + let mut f = tempfile::Builder::new() + .suffix(".jsonl") + .tempfile() + .unwrap(); + writeln!(f, "{}", claude_session_line("/tmp/x", "sess-1")).unwrap(); + writeln!(f, "{}", claude_assistant_line()).unwrap(); + f.flush().unwrap(); + + let (msgs, off) = read_messages(f.path(), 0); + assert!(off > 0, "consumed offset advances past complete lines"); + assert_eq!(msgs.len(), 2, "one user + one assistant bubble"); + assert_eq!(msgs[0].role, "user"); + assert_eq!(msgs[0].text, "hello there"); + assert_eq!(msgs[1].role, "assistant"); + assert_eq!(msgs[1].text, "hi back"); + } + + #[test] + fn read_messages_leaves_partial_trailing_line_unconsumed() { + let mut f = tempfile::Builder::new() + .suffix(".jsonl") + .tempfile() + .unwrap(); + let l1 = format!("{}\n", claude_session_line("/tmp/x", "sess-1")); + f.write_all(l1.as_bytes()).unwrap(); + f.flush().unwrap(); + let (msgs, off) = read_messages(f.path(), 0); + assert_eq!(msgs.len(), 1); + assert_eq!(off, l1.len() as u64); + + let l2 = format!("{}\n", claude_assistant_line()); + let partial = r#"{"type":"user","message":{"rol"#; + f.write_all(l2.as_bytes()).unwrap(); + f.write_all(partial.as_bytes()).unwrap(); + f.flush().unwrap(); + let (msgs2, off2) = read_messages(f.path(), off); + assert_eq!(msgs2.len(), 1); + assert_eq!(msgs2[0].text, "hi back"); + assert_eq!(off2, off + l2.len() as u64); + } + + #[test] + fn initial_read_uses_bounded_tail_window() { + let mut f = tempfile::Builder::new() + .suffix(".jsonl") + .tempfile() + .unwrap(); + writeln!(f, "{}", claude_session_line("/tmp/x", "sess-old")).unwrap(); + let filler = "x".repeat((INITIAL_TRANSCRIPT_WINDOW_BYTES as usize) + 1024); + writeln!(f, "{filler}").unwrap(); + writeln!(f, "{}", claude_session_line("/tmp/x", "sess-new")).unwrap(); + f.flush().unwrap(); + + let (msgs, off) = read_messages(f.path(), 0); + assert!(off > INITIAL_TRANSCRIPT_WINDOW_BYTES); + assert_eq!(msgs.len(), 1); + assert_eq!( + msgs[0].text, "hello there", + "initial load should parse the recent complete transcript line" + ); + } + + #[test] + fn first_user_message_uses_provider_parsed_title_or_first_user_text() { + let mut f = tempfile::Builder::new() + .suffix(".jsonl") + .tempfile() + .unwrap(); + writeln!(f, "{}", claude_session_line("/tmp/x", "sess-1")).unwrap(); + writeln!(f, "{}", claude_assistant_line()).unwrap(); + f.flush().unwrap(); + let summary = first_user_message(f.path()).expect("a summary line"); + assert!(summary.contains("hello there"), "summary was: {summary}"); + } + + #[test] + fn bind_matches_pane_cwd_to_provider_parsed_session() { + let temp = tempfile::tempdir().unwrap(); + let projects = temp.path().join("projects").join("-tmp-proj"); + std::fs::create_dir_all(&projects).unwrap(); + let cwd = "/tmp/proj-bind-test"; + std::fs::write( + projects.join("sess-bind.jsonl"), + format!( + "{}\n{}\n", + claude_session_line(cwd, "sess-bind"), + claude_assistant_line() + ), + ) + .unwrap(); + + let prev = std::env::var_os("CLAUDE_CONFIG_DIR"); + unsafe { + std::env::set_var("CLAUDE_CONFIG_DIR", temp.path()); + } + let bound = bind(cwd); + match prev { + Some(v) => unsafe { std::env::set_var("CLAUDE_CONFIG_DIR", v) }, + None => unsafe { std::env::remove_var("CLAUDE_CONFIG_DIR") }, + } + + let bound = bound.expect("bind resolves the session for the matching cwd"); + assert_eq!(bound.kind, "claude"); + assert_eq!(bound.session_id, "sess-bind"); + assert!(bound.transcript.ends_with("sess-bind.jsonl")); + } + + #[test] + fn terminal_agent_binding_uses_control_plane_store() { + let store = ControlPlaneSqliteStore::open_in_memory().expect("store"); + let mut f = tempfile::Builder::new() + .suffix(".jsonl") + .tempfile() + .unwrap(); + writeln!(f, "{}", claude_session_line("/tmp/x", "sess-store")).unwrap(); + f.flush().unwrap(); + + record( + &store, + "claude", + "sess-store", + "/tmp/x", + "rmux-a", + "terminal", + f.path(), + ) + .expect("record binding"); + + let history = history(&store, 10).expect("history"); + assert_eq!(history.len(), 1); + assert_eq!(history[0].session_id, "sess-store"); + assert_eq!(history[0].rmux_session.as_deref(), Some("rmux-a")); + assert_eq!( + transcript_path(&store, "sess-store") + .expect("transcript lookup") + .as_deref(), + Some(f.path()) + ); + } +} diff --git a/crates/lucarne/tests/agent_runtime_bus.rs b/crates/lucarne/tests/agent_runtime_bus.rs index c72cf00..3ec3b18 100644 --- a/crates/lucarne/tests/agent_runtime_bus.rs +++ b/crates/lucarne/tests/agent_runtime_bus.rs @@ -1,3 +1,5 @@ +#![allow(clippy::useless_conversion)] + use async_trait::async_trait; use lucarne::agent_runtime::{ AgentError, AgentErrorKind, AgentInput, AgentRuntime, AgentSession, AgentSessionFacade, diff --git a/crates/lucarne/tests/agent_runtime_integration.rs b/crates/lucarne/tests/agent_runtime_integration.rs index 98ebef7..e24228f 100644 --- a/crates/lucarne/tests/agent_runtime_integration.rs +++ b/crates/lucarne/tests/agent_runtime_integration.rs @@ -1,3 +1,5 @@ +#![allow(clippy::single_match)] + pub mod common; use common::agent_runtime::{ diff --git a/crates/lucarne/tests/agent_runtime_live_e2e.rs b/crates/lucarne/tests/agent_runtime_live_e2e.rs index c5b4123..ecd1258 100644 --- a/crates/lucarne/tests/agent_runtime_live_e2e.rs +++ b/crates/lucarne/tests/agent_runtime_live_e2e.rs @@ -1,3 +1,5 @@ +#![allow(clippy::while_let_loop)] + mod live; use base64::Engine; diff --git a/crates/lucarne/tests/agent_runtime_provider.rs b/crates/lucarne/tests/agent_runtime_provider.rs index 00d39d7..35aefe3 100644 --- a/crates/lucarne/tests/agent_runtime_provider.rs +++ b/crates/lucarne/tests/agent_runtime_provider.rs @@ -1,3 +1,5 @@ +#![allow(clippy::needless_update, clippy::redundant_closure)] + use lucarne::adapter::{ ArgProfile, Capabilities, Protocol, ProtocolAdapter, ProtocolOptions, ProtocolSessionParts, Spec, diff --git a/crates/lucarne/tests/common/mod.rs b/crates/lucarne/tests/common/mod.rs index 84ff8b4..edffca9 100644 --- a/crates/lucarne/tests/common/mod.rs +++ b/crates/lucarne/tests/common/mod.rs @@ -68,7 +68,6 @@ pub fn fixture_path(subdir: &str, name: &str) -> PathBuf { /// `cat`s `fixture` to stdout. Used as a stand-in binary for adapters /// whose fixtures are raw JSONL (copilot, pi) rather than fakeagent /// directive scripts. - pub fn write_cat_script(fixture: &std::path::Path) -> PathBuf { let dir = tempfile::tempdir().expect("tempdir"); #[cfg(unix)] @@ -94,7 +93,6 @@ pub fn write_cat_script(fixture: &std::path::Path) -> PathBuf { } /// Write a script that handles `pi --list-models` and otherwise cats the fixture. - pub fn write_pi_cat_script(fixture: &std::path::Path) -> PathBuf { let dir = tempfile::tempdir().expect("tempdir"); #[cfg(unix)] diff --git a/crates/lucarne/tests/observability_coverage.rs b/crates/lucarne/tests/observability_coverage.rs index 1757a41..e184d07 100644 --- a/crates/lucarne/tests/observability_coverage.rs +++ b/crates/lucarne/tests/observability_coverage.rs @@ -190,6 +190,18 @@ fn runtime_modules_have_structured_tracing_or_are_declared_pure() { "crates/lucarned/src/onboarding/mod.rs", "crates/lucarned/src/onboarding/session.rs", "crates/lucarned/src/onboarding/terminal.rs", + // `lucarned tui` dashboard: an interactive, + // synchronous front-end that surfaces state through on-screen panels and + // status lines, not a daemon-runtime path. Like the onboarding modules + // above, it is observability-pure by design (no structured tracing). + "crates/lucarned/src/tui/mod.rs", + "crates/lucarned/src/tui/app.rs", + "crates/lucarned/src/tui/event.rs", + "crates/lucarned/src/tui/ui.rs", + "crates/lucarned/src/tui/sessions.rs", + "crates/lucarned/src/tui/remote.rs", + "crates/lucarned/src/tui/config.rs", + "crates/lucarned/src/tui/nav.rs", ] .into_iter() .collect::>(); diff --git a/crates/lucarne/tests/terminal_live_journey_contract.rs b/crates/lucarne/tests/terminal_live_journey_contract.rs new file mode 100644 index 0000000..d7ab84e --- /dev/null +++ b/crates/lucarne/tests/terminal_live_journey_contract.rs @@ -0,0 +1,131 @@ +//! Terminal-adjacent journey coverage contract. +//! +//! This is not a substitute for provider/device live E2E. It prevents this +//! branch from claiming merge readiness with a checklist-only manifest: every +//! local/no-external-device part of the terminal journey must point at an +//! executable test or harness that is present in the repository. + +use std::path::PathBuf; + +#[derive(Clone, Copy, Debug)] +struct LocalJourneyEvidence { + id: &'static str, + source_file: &'static str, + required_test_or_harness: &'static str, + required_terms: &'static [&'static str], +} + +const LOCAL_EVIDENCE: &[LocalJourneyEvidence] = &[ + LocalJourneyEvidence { + id: "remote_quick_tunnel_harness", + source_file: "../../scripts/remote-quick-tunnel-e2e.sh", + required_test_or_harness: "LUCARNE_QUICK_TUNNEL_E2E", + required_terms: &[ + "remote start", + "/api/remote/status", + "/api/sessions", + "readonly", + "cleanup", + ], + }, + LocalJourneyEvidence { + id: "remote_readonly_agent_prompt_refusal", + source_file: "../lucarne-termgw/src/lib.rs", + required_test_or_harness: "agent_ws_readonly_prompt_refuses_before_terminal_inject", + required_terms: &[ + "read-only session: prompts are not permitted", + "monitor.injections().await.is_empty()", + ], + }, + LocalJourneyEvidence { + id: "remote_readonly_terminal_write_refusal", + source_file: "../lucarne-termgw/src/lib.rs", + required_test_or_harness: "readonly_session_refuses_write_frames", + required_terms: &["ClientFrame::Input", "readonly", "write"], + }, + LocalJourneyEvidence { + id: "terminal_transcript_bounded_initial_read", + source_file: "src/terminal_agent_bind.rs", + required_test_or_harness: "initial_read_uses_bounded_tail_window", + required_terms: &[ + "INITIAL_TRANSCRIPT_WINDOW_BYTES", + "INCREMENTAL_TRANSCRIPT_READ_BYTES", + ], + }, + LocalJourneyEvidence { + id: "terminal_archive_owner_only_storage", + source_file: "../lucarne-rmux/src/archive.rs", + required_test_or_harness: "archive_dir_and_files_are_owner_only", + required_terms: &["0o700", "0o600"], + }, +]; + +fn repo_path(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative) +} + +#[test] +fn local_terminal_journey_evidence_points_to_executable_checks() { + for evidence in LOCAL_EVIDENCE { + let path = repo_path(evidence.source_file); + let source = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("read {}: {err}", path.display())); + assert!( + source.contains(evidence.required_test_or_harness), + "{} must point at an executable test/harness named `{}` in {}", + evidence.id, + evidence.required_test_or_harness, + path.display() + ); + for term in evidence.required_terms { + assert!( + source.contains(term), + "{} evidence must include `{}` in {}", + evidence.id, + term, + path.display() + ); + } + } +} + +#[test] +fn terminal_history_and_agent_bootstrap_use_bounded_transcript_reads() { + let source = std::fs::read_to_string(repo_path("src/terminal_agent_bind.rs")) + .expect("read terminal_agent_bind source"); + assert!( + source.contains("from == 0"), + "initial/history reads must have explicit cursor bootstrap semantics" + ); + assert!( + source.contains("file.take(limit).read_to_end"), + "transcript reads must use a bounded reader, not unbounded read_to_end" + ); + assert!( + !source.contains("file.read_to_end(&mut buf)"), + "terminal transcript hot paths must not scan whole files" + ); +} + +#[test] +fn terminal_archive_close_does_not_reach_core_live_sessions() { + let termgw = std::fs::read_to_string(repo_path("../lucarne-termgw/src/lib.rs")) + .expect("read termgw source"); + let archive_fn = termgw + .split("async fn http_archive") + .nth(1) + .and_then(|tail| tail.split("async fn http_archives").next()) + .expect("http_archive source block"); + assert!( + archive_fn.contains("archive::save"), + "archive-and-close must persist terminal content" + ); + assert!( + archive_fn.contains("s.monitor.kill"), + "archive-and-close must close the terminal/rmux session" + ); + assert!( + !archive_fn.contains("LucarneCore") && !archive_fn.contains("live_sessions"), + "archive-and-close must not mutate LucarneCore live session state" + ); +} diff --git a/crates/lucarned-ctl/src/args.rs b/crates/lucarned-ctl/src/args.rs index 13b7bbb..6e9689e 100644 --- a/crates/lucarned-ctl/src/args.rs +++ b/crates/lucarned-ctl/src/args.rs @@ -1,4 +1,4 @@ -use std::{ffi::OsString, path::PathBuf}; +use std::{collections::BTreeMap, ffi::OsString, path::PathBuf}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { @@ -9,6 +9,9 @@ pub enum Command { Paths, Doctor, Update, + /// Launch the interactive full-screen TUI. + Tui, + Remote(RemoteCommand), Autostart(AutostartCommand), } @@ -21,6 +24,21 @@ pub enum AutostartCommand { Status, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteCommand { + Start(RemoteCommandOptions), + Stop(RemoteCommandOptions), + Status(RemoteCommandOptions), +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RemoteCommandOptions { + pub control_port: Option, + pub json: bool, + pub provider: Option, + pub fields: BTreeMap, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ParseError { pub message: String, @@ -55,6 +73,8 @@ fn parse_first(first: OsString, rest: Vec) -> Result require_no_args(Command::Paths, rest), "doctor" => require_no_args(Command::Doctor, rest), "update" => require_no_args(Command::Update, rest), + "tui" => require_no_args(Command::Tui, rest), + "remote" => parse_remote(rest), "autostart" => parse_autostart(rest), other => Err(ParseError::new(format!("unknown command: {other}"))), } @@ -71,6 +91,93 @@ fn require_no_args(command: Command, rest: Vec) -> Result) -> Result { + let mut args = args.into_iter(); + let Some(subcommand) = args.next() else { + return Err(ParseError::new("missing remote subcommand")); + }; + let options = parse_remote_options(args.collect())?; + match subcommand.to_string_lossy().as_ref() { + "start" => Ok(Command::Remote(RemoteCommand::Start(options))), + "stop" => { + reject_start_only_remote_options(&options, "stop")?; + Ok(Command::Remote(RemoteCommand::Stop(options))) + } + "status" => { + reject_start_only_remote_options(&options, "status")?; + Ok(Command::Remote(RemoteCommand::Status(options))) + } + other => Err(ParseError::new(format!( + "unknown remote subcommand: {other}" + ))), + } +} + +fn parse_remote_options(args: Vec) -> Result { + let mut options = RemoteCommandOptions::default(); + let mut iter = args.into_iter(); + while let Some(arg) = iter.next() { + match arg.to_string_lossy().as_ref() { + "--json" => options.json = true, + "--control-port" => { + let Some(port) = iter.next() else { + return Err(ParseError::new("--control-port requires a port")); + }; + options.control_port = Some(parse_control_port(&port)?); + } + "--provider" => { + let Some(provider) = iter.next() else { + return Err(ParseError::new("--provider requires an id")); + }; + let provider = provider.to_string_lossy().trim().to_string(); + if provider.is_empty() { + return Err(ParseError::new("--provider requires a non-empty id")); + } + options.provider = Some(provider); + } + "--field" => { + let Some(pair) = iter.next() else { + return Err(ParseError::new("--field requires KEY=VALUE")); + }; + let pair = pair.to_string_lossy(); + let Some((key, value)) = pair.split_once('=') else { + return Err(ParseError::new("--field requires KEY=VALUE")); + }; + let key = key.trim(); + if key.is_empty() { + return Err(ParseError::new("--field requires a non-empty key")); + } + options.fields.insert(key.to_string(), value.to_string()); + } + other => return Err(ParseError::new(format!("unexpected argument: {other}"))), + } + } + Ok(options) +} + +fn parse_control_port(raw: &OsString) -> Result { + raw.to_string_lossy() + .parse::() + .map_err(|_| ParseError::new(format!("invalid control port: {}", raw.to_string_lossy()))) +} + +fn reject_start_only_remote_options( + options: &RemoteCommandOptions, + subcommand: &str, +) -> Result<(), ParseError> { + if options.provider.is_some() { + return Err(ParseError::new(format!( + "--provider is only valid for remote start, not remote {subcommand}" + ))); + } + if !options.fields.is_empty() { + return Err(ParseError::new(format!( + "--field is only valid for remote start, not remote {subcommand}" + ))); + } + Ok(()) +} + fn parse_autostart(args: Vec) -> Result { let mut args = args.into_iter(); let Some(subcommand) = args.next() else { @@ -140,6 +247,10 @@ Usage:\n\ lucarned init Configure lucarned interactively\n\ lucarned doctor Diagnose install and runtime state\n\ lucarned update Check latest Lucarne release status\n\ + lucarned tui Launch the interactive terminal dashboard\n\ + lucarned remote start [--control-port PORT] [--provider ID] [--field KEY=VALUE] [--json]\n\ + lucarned remote stop [--control-port PORT] [--json]\n\ + lucarned remote status [--control-port PORT] [--json]\n\ lucarned paths Print resolved paths\n\ lucarned autostart install [--start] [--bin PATH]\n\ lucarned autostart uninstall [--stop]\n\ @@ -175,12 +286,50 @@ mod tests { parse_words(&["lucarned", "update"]).unwrap(), Command::Update ); + assert_eq!(parse_words(&["lucarned", "tui"]).unwrap(), Command::Tui); + assert_eq!( + parse_words(&["lucarned", "remote", "status"]).unwrap(), + Command::Remote(RemoteCommand::Status(RemoteCommandOptions::default())) + ); assert_eq!( parse_words(&["lucarned", "--version"]).unwrap(), Command::Version ); } + #[test] + fn parses_remote_start_options() { + let mut fields = BTreeMap::new(); + fields.insert("token".to_string(), "abc".to_string()); + fields.insert( + "public_url".to_string(), + "https://demo.example.test".to_string(), + ); + assert_eq!( + parse_words(&[ + "lucarned", + "remote", + "start", + "--control-port", + "7901", + "--provider", + "cloudflared", + "--field", + "token=abc", + "--field", + "public_url=https://demo.example.test", + "--json", + ]) + .unwrap(), + Command::Remote(RemoteCommand::Start(RemoteCommandOptions { + control_port: Some(7901), + json: true, + provider: Some("cloudflared".to_string()), + fields, + })) + ); + } + #[test] fn parses_autostart_install_flags() { assert_eq!( @@ -211,6 +360,7 @@ mod tests { #[test] fn usage_lists_update_command() { assert!(usage().contains("lucarned update")); + assert!(usage().contains("lucarned remote start")); } #[test] @@ -224,4 +374,20 @@ mod tests { let err = parse_words(&["lucarned", "autostart", "install", "--bin"]).unwrap_err(); assert_eq!(err.message, "--bin requires a path"); } + + #[test] + fn rejects_remote_start_only_flags_on_status() { + let err = parse_words(&["lucarned", "remote", "status", "--field", "token=x"]).unwrap_err(); + assert_eq!( + err.message, + "--field is only valid for remote start, not remote status" + ); + } + + #[test] + fn rejects_invalid_remote_control_port() { + let err = + parse_words(&["lucarned", "remote", "stop", "--control-port", "nope"]).unwrap_err(); + assert_eq!(err.message, "invalid control port: nope"); + } } diff --git a/crates/lucarned-ctl/src/lib.rs b/crates/lucarned-ctl/src/lib.rs index 664c3f1..9ce1eff 100644 --- a/crates/lucarned-ctl/src/lib.rs +++ b/crates/lucarned-ctl/src/lib.rs @@ -6,7 +6,9 @@ pub mod process; #[cfg(feature = "updates")] pub mod updates; -pub use args::{parse, usage, AutostartCommand, Command, ParseError}; +pub use args::{ + parse, usage, AutostartCommand, Command, ParseError, RemoteCommand, RemoteCommandOptions, +}; pub fn run(command: Command) -> Result<(), String> { match command { @@ -27,6 +29,8 @@ pub fn run(command: Command) -> Result<(), String> { } Command::Doctor => doctor::run_doctor(), Command::Update => update_requires_async_or_feature(), + Command::Tui => Err("tui command is handled by lucarned".to_string()), + Command::Remote(_) => Err("remote command is handled by lucarned".to_string()), Command::Autostart(command) => run_autostart(command), } } diff --git a/crates/lucarned-ctl/src/paths.rs b/crates/lucarned-ctl/src/paths.rs index 96d2493..50763b4 100644 --- a/crates/lucarned-ctl/src/paths.rs +++ b/crates/lucarned-ctl/src/paths.rs @@ -144,22 +144,22 @@ fn autostart_location(_config_dir: &Path) -> Result<(&'static str, PathBuf), Str #[cfg(target_os = "macos")] { let home = env::var_os("HOME").ok_or_else(|| "HOME is not set".to_string())?; - return Ok(( + Ok(( "launchagent", PathBuf::from(home).join("Library/LaunchAgents/com.tuchg.lucarned.plist"), - )); + )) } #[cfg(windows)] { - return Ok(("scheduled-task", PathBuf::from("LucarneLucarned"))); + Ok(("scheduled-task", PathBuf::from("LucarneLucarned"))) } #[cfg(target_os = "linux")] { let home = env::var_os("HOME").ok_or_else(|| "HOME is not set".to_string())?; - return Ok(( + Ok(( "systemd-user", PathBuf::from(home).join(".config/systemd/user/lucarned.service"), - )); + )) } #[cfg(not(any(target_os = "macos", windows, target_os = "linux")))] { diff --git a/crates/lucarned/Cargo.toml b/crates/lucarned/Cargo.toml index f4974c1..024b150 100644 --- a/crates/lucarned/Cargo.toml +++ b/crates/lucarned/Cargo.toml @@ -11,6 +11,22 @@ readme = "../../README.md" [features] default = [] memory-profiling = ["lucarne/memory-profiling"] +remote-access = ["dep:lucarne-remote"] +terminal-rmux = ["dep:lucarne-rmux"] +terminal-gateway = [ + "remote-access", + "terminal-rmux", + "dep:lucarne-termgw", + "lucarne/terminal-agent-bind", + "reqwest/blocking", +] +tui = [ + "terminal-gateway", + "dep:ratatui", + "dep:crossterm", + "dep:qrcode", +] +product-terminal = ["terminal-gateway", "tui"] [[bin]] name = "lucarned" @@ -23,6 +39,15 @@ lucarned-ctl = { path = "../lucarned-ctl", features = ["updates"] } lucarne-adapter = { path = "../lucarne-adapter" } lucarne-telegram = { path = "../lucarne-telegram" } lucarne-wechat = { path = "../lucarne-wechat" } +lucarne-remote = { path = "../lucarne-remote", optional = true } +lucarne-termgw = { path = "../lucarne-termgw", optional = true } +lucarne-rmux = { path = "../lucarne-rmux", optional = true } +axum = { workspace = true } +# ratatui 0.30 ships the crossterm 0.29 backend by default, so the two stay on +# one crossterm major. +ratatui = { version = "0.30", optional = true } +crossterm = { version = "0.29", optional = true } +qrcode = { version = "0.14.1", default-features = false, optional = true } tokio = { workspace = true } tracing = { workspace = true } tracing-appender = { workspace = true } @@ -35,3 +60,6 @@ dotenvy = "0.15" [dev-dependencies] tempfile = { workspace = true } + +[package.metadata.dist] +features = ["product-terminal"] diff --git a/crates/lucarned/src/main.rs b/crates/lucarned/src/main.rs index ec0fad5..b3e0686 100644 --- a/crates/lucarned/src/main.rs +++ b/crates/lucarned/src/main.rs @@ -1,3 +1,9 @@ +#![allow( + clippy::io_other_error, + clippy::needless_borrows_for_generic_args, + clippy::unnecessary_get_then_check +)] + use std::{ io::Write, path::{Path, PathBuf}, @@ -15,6 +21,8 @@ use lucarne_adapter::{ use lucarne_telegram::telegram_plugin; use lucarne_wechat::wechat_plugin; use lucarned_ctl::updates::{UpdateNotice, UpdateRuntime, UpdateStateStore}; +#[cfg(feature = "terminal-gateway")] +use remote_config::RemoteFileConfig; use serde::Deserialize; use tokio::sync::watch; use tracing::{info, warn}; @@ -29,11 +37,18 @@ use tracing_subscriber::{ mod health; mod onboarding; +#[cfg(feature = "terminal-gateway")] +mod remote; +#[cfg(feature = "terminal-gateway")] +mod remote_cli; +#[cfg(feature = "terminal-gateway")] +mod remote_config; +#[cfg(feature = "tui")] +mod tui; const DEFAULT_LOG_BUFFERED_LINES: usize = 1024; const DEFAULT_LOG_MAX_FILES: usize = 16; const DEFAULT_HEALTH_ADDR: &str = "127.0.0.1:7766"; - const DEFAULT_LUCARNED_CONFIG: &str = r#"agents: - claude - codex @@ -56,6 +71,27 @@ health: enabled: false addr: 127.0.0.1:7766 +remote: + enabled: false + provider: cloudflared + gateway_addr: 127.0.0.1:7800 + control_addr: 127.0.0.1:7801 + auth_token: null + readonly_token: null + insecure: false + # Generic per-provider field maps keyed by provider id (H6c). The selected + # `provider` above picks which block is used; the daemon passes the fields + # through to the provider verbatim, so a new backend (frp / relay) is just a + # new providers. block — no daemon change. + providers: + cloudflared: + token: null + public_url: null + binary_path: null + # Reserved backends (NOT implemented this release — adapter seams only): + # frp: { server_addr: "", token: "" } + # relay: { url: "" } + updates: enabled: true notify: true @@ -132,11 +168,55 @@ async fn main() -> Result<(), Box> { lucarned_ctl::Command::Doctor | lucarned_ctl::Command::Update => { run_ctl_network_command(command).await } + lucarned_ctl::Command::Remote(command) => run_remote_command(command), + // The TUI is fully synchronous and uses `reqwest::blocking` for its + // loopback control-plane calls. `reqwest::blocking` builds and drops its + // own current-thread Tokio runtime; dropping a runtime inside this + // `#[tokio::main]` async context panics ("Cannot drop a runtime in a + // context where blocking is not allowed"). Run the whole TUI on a + // dedicated OS thread with no ambient runtime so the blocking client is + // safe. The panic hook installed in `tui::run` is process-global, so a + // panic on that thread still restores the terminal. + lucarned_ctl::Command::Tui => run_tui_command(), command => lucarned_ctl::run(command) .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err).into()), } } +#[cfg(feature = "terminal-gateway")] +fn run_remote_command( + command: lucarned_ctl::RemoteCommand, +) -> Result<(), Box> { + std::thread::spawn(move || remote_cli::run_remote_command(command)) + .join() + .unwrap_or_else(|_| Err("remote command thread panicked".to_string())) + .map_err(|err| -> Box { + std::io::Error::new(std::io::ErrorKind::Other, err).into() + }) +} + +#[cfg(not(feature = "terminal-gateway"))] +fn run_remote_command( + _command: lucarned_ctl::RemoteCommand, +) -> Result<(), Box> { + Err("`lucarned remote` requires the `terminal-gateway` Cargo feature".into()) +} + +#[cfg(feature = "tui")] +fn run_tui_command() -> Result<(), Box> { + std::thread::spawn(tui::run) + .join() + .unwrap_or_else(|_| Err("tui thread panicked".to_string())) + .map_err(|err| -> Box { + std::io::Error::new(std::io::ErrorKind::Other, err).into() + }) +} + +#[cfg(not(feature = "tui"))] +fn run_tui_command() -> Result<(), Box> { + Err("`lucarned tui` requires the `tui` Cargo feature".into()) +} + async fn run_ctl_network_command( command: lucarned_ctl::Command, ) -> Result<(), Box> { @@ -173,7 +253,13 @@ async fn run_daemon() -> Result<(), Box> { )); lucarne::memory_profile_snapshot!("lucarned.main.after_register_adapters"); - if enabled_adapter_count == 0 && health_addr.is_none() { + // H5 + cold-daemon lazy start: when the terminal gateway product capability + // is built, resolve remote config before the idle-exit decision so the + // off-tunnel control plane can stay up for `lucarned remote start`. + let remote_cfg = remote_config_from_config(&file_config)?; + let remote_enabled = remote_cfg.is_some(); + + if enabled_adapter_count == 0 && health_addr.is_none() && !remote_enabled { lucarne::memory_profile_snapshot!("lucarned.main.no_enabled_adapters"); info!( config_path = ?config_path, @@ -236,6 +322,41 @@ async fn run_daemon() -> Result<(), Box> { }); info!(addr = %addr, "lucarned health server started"); } + + // Remote-access subsystem (Locked decision L6: daemon owns the tunnel + // lifecycle). Cold-daemon lazy start: the control plane (port 7801) is served + // from boot regardless of `remote.enabled` — `remote_config_from_config` always + // resolves to `Some` (resolved BEFORE the idle-exit check — H5). The gateway + + // tunnel come up lazily on the first `lucarned remote start`, OR immediately + // when `autostart` (`remote.enabled:true`) is set. Wired to the daemon + // shutdown signal so a running tunnel is torn down gracefully. + #[cfg(feature = "terminal-gateway")] + if let Some(remote_cfg) = remote_cfg { + match remote::spawn_remote_subsystem( + remote_cfg, + core.control_plane_store(), + shutdown_tx.subscribe(), + ) + .await + { + Ok(handle) => match handle.public_url { + Some(public_url) => info!( + provider = %handle.provider, + public_url = %public_url, + "lucarned remote tunnel started" + ), + None => info!( + "lucarned remote control plane ready; tunnel idle — run `lucarned remote start` to open public access" + ), + }, + Err(err) => { + warn!(error = %err, "lucarned remote subsystem failed to start"); + } + } + } + #[cfg(not(feature = "terminal-gateway"))] + let _ = remote_cfg; + info!( adapters = adapter_status_reader.snapshot().len(), config_path = ?config_path, @@ -411,6 +532,9 @@ struct LucarnedFileConfig { logging: LoggingFileConfig, #[serde(default)] health: HealthFileConfig, + #[cfg(feature = "terminal-gateway")] + #[serde(default)] + remote: RemoteFileConfig, #[serde(default)] updates: UpdateFileConfig, #[serde(default)] @@ -673,6 +797,20 @@ fn health_addr_from_config( .map_err(Into::into) } +#[cfg(feature = "terminal-gateway")] +fn remote_config_from_config( + config: &LucarnedFileConfig, +) -> Result, Box> { + remote_config::remote_config_from_config(&config.remote) +} + +#[cfg(not(feature = "terminal-gateway"))] +fn remote_config_from_config( + _config: &LucarnedFileConfig, +) -> Result, Box> { + Ok(None) +} + fn parse_bool(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Some(true), @@ -1137,6 +1275,27 @@ mod tests { assert!(compact_source.contains("#[tokio::main(flavor=\"multi_thread\",worker_threads=2)]")); } + #[test] + fn tui_dispatch_runs_on_a_dedicated_thread_off_the_tokio_runtime() { + // Regression guard for the `lucarned tui` startup panic + // ("Cannot drop a runtime in a context where blocking is not allowed"): + // the TUI uses `reqwest::blocking`, which builds+drops its own runtime, + // so it MUST NOT run inside the `#[tokio::main]` async context. The Tui + // dispatch arm must hand off to a dedicated OS thread. + let source = include_str!("main.rs"); + let compact_source = source + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(); + + assert!( + compact_source.contains("Command::Tui=>run_tui_command()") + && compact_source.contains("fnrun_tui_command()->Result<(),Box>{std::thread::spawn(tui::run)"), + "the `Command::Tui` arm must run `tui::run` on a dedicated thread, \ + not inside the tokio runtime" + ); + } + #[test] fn memory_profile_snapshots_are_feature_gated() { let manifest = include_str!("../Cargo.toml"); @@ -1694,6 +1853,534 @@ health: ); } + #[cfg(feature = "terminal-gateway")] + #[test] + fn default_config_parses_including_remote_providers() { + // Regression guard: the bootstrapped default config (which writes + // remote.providers.cloudflared.{token,public_url,binary_path}: null) + // must round-trip through the real parse path. + let cfg = LucarnedFileConfig::from_yaml_str(default_lucarned_config().as_ref()) + .expect("default lucarned config must parse, including remote.providers with nulls"); + assert_eq!(cfg.remote.enabled, Some(false)); + + // Regression guard (live-test bug): YAML `null` provider fields MUST + // resolve to ABSENT — never the literal string "null". Otherwise a + // quick-tunnel default (token/public_url: null) is misread as a named + // tunnel and `cloudflared` validation fails ("invalid public_url"). + let resolved = remote_config::resolve_provider_fields(&cfg.remote, "cloudflared"); + assert!( + !resolved.contains_key("token"), + "null token must be absent, got {:?}", + resolved.get("token") + ); + assert!( + !resolved.contains_key("public_url"), + "null public_url must be absent, got {:?}", + resolved.get("public_url") + ); + } + + #[cfg(feature = "terminal-gateway")] + #[test] + fn remote_file_config_parses_full_section() { + // H6c: the generic `providers:` map is the new shape. + let config = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: true + provider: cloudflared + gateway_addr: 127.0.0.1:7810 + control_addr: 127.0.0.1:7811 + auth_token: secret-token + readonly_token: readonly-secret-token + insecure: false + providers: + cloudflared: + token: cf-token + public_url: https://tunnel.example.com + binary_path: /usr/local/bin/cloudflared +"#, + ) + .expect("parse remote config"); + + assert_eq!(config.remote.enabled, Some(true)); + assert_eq!(config.remote.provider.as_deref(), Some("cloudflared")); + assert_eq!( + config.remote.gateway_addr.as_deref(), + Some("127.0.0.1:7810") + ); + assert_eq!( + config.remote.control_addr.as_deref(), + Some("127.0.0.1:7811") + ); + assert_eq!(config.remote.auth_token.as_deref(), Some("secret-token")); + assert_eq!( + config.remote.readonly_token.as_deref(), + Some("readonly-secret-token") + ); + assert_eq!(config.remote.insecure, Some(false)); + let cf = config + .remote + .providers + .get("cloudflared") + .expect("cloudflared provider block"); + assert_eq!(cf.get("token").and_then(|v| v.as_deref()), Some("cf-token")); + assert_eq!( + cf.get("public_url").and_then(|v| v.as_deref()), + Some("https://tunnel.example.com") + ); + assert_eq!( + cf.get("binary_path").and_then(|v| v.as_deref()), + Some("/usr/local/bin/cloudflared") + ); + // resolve_provider_fields surfaces the concrete values for the provider. + let resolved = remote_config::resolve_provider_fields(&config.remote, "cloudflared"); + assert_eq!(resolved.get("token").map(String::as_str), Some("cf-token")); + assert_eq!( + resolved.get("public_url").map(String::as_str), + Some("https://tunnel.example.com") + ); + + // Back-compat: the legacy provider-owned `cloudflare:` compat section + // still parses without daemon-owned concrete provider structs (H6c). + let legacy = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: true + cloudflare: + token: legacy-token + public_url: https://legacy.example.com +"#, + ) + .expect("parse legacy remote config"); + assert_eq!( + legacy + .remote + .extra_sections + .get("cloudflare") + .and_then(|section| section.get("token")) + .and_then(serde_json::Value::as_str), + Some("legacy-token") + ); + assert_eq!( + legacy + .remote + .extra_sections + .get("cloudflare") + .and_then(|section| section.get("public_url")) + .and_then(serde_json::Value::as_str), + Some("https://legacy.example.com") + ); + + // Missing section → all-None defaults (serde(default)), like health. + let missing = + LucarnedFileConfig::from_yaml_str("channels: {}\n").expect("parse missing remote"); + assert_eq!(missing.remote.enabled, None); + assert_eq!(missing.remote.provider, None); + assert_eq!(missing.remote.control_addr, None); + assert!(missing.remote.providers.is_empty()); + assert!(!missing.remote.extra_sections.contains_key("cloudflare")); + } + + // H6c: the generic provider_fields map is resolved with provider-declared + // compat sections merged first and `providers.` merged over them. The + // daemon never interprets a provider-specific field name. + #[cfg(feature = "terminal-gateway")] + #[test] + fn remote_config_resolves_generic_provider_fields() { + with_env( + &[ + ("LUCARNED_REMOTE_ENABLED", None), + ("LUCARNED_REMOTE_PROVIDER", None), + ("LUCARNED_REMOTE_GATEWAY_ADDR", None), + ("LUCARNED_REMOTE_CONTROL_ADDR", None), + ("LUCARNED_REMOTE_AUTH_TOKEN", None), + ], + || { + // Generic providers map alone → fields pass through verbatim. + let generic = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: true + provider: cloudflared + providers: + cloudflared: + token: gen-token + public_url: https://gen.example.com +"#, + ) + .expect("parse generic remote config"); + let resolved = remote_config_from_config(&generic) + .expect("generic remote config") + .expect("enabled → Some"); + assert_eq!( + resolved.provider_fields.get("token").map(String::as_str), + Some("gen-token") + ); + assert_eq!( + resolved + .provider_fields + .get("public_url") + .map(String::as_str), + Some("https://gen.example.com") + ); + + // Legacy cloudflare block alone → still resolved (back-compat). + let legacy = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: true + cloudflare: + token: legacy-token + binary_path: /usr/local/bin/cloudflared +"#, + ) + .expect("parse legacy remote config"); + let resolved = remote_config_from_config(&legacy) + .expect("legacy remote config") + .expect("enabled → Some"); + assert_eq!( + resolved.provider_fields.get("token").map(String::as_str), + Some("legacy-token") + ); + assert_eq!( + resolved + .provider_fields + .get("binary_path") + .map(String::as_str), + Some("/usr/local/bin/cloudflared") + ); + + // Both set → providers. wins over the legacy block per key. + let both = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: true + cloudflare: + token: legacy-token + binary_path: /legacy/cloudflared + providers: + cloudflared: + token: generic-token +"#, + ) + .expect("parse merged remote config"); + let resolved = remote_config_from_config(&both) + .expect("merged remote config") + .expect("enabled → Some"); + // providers.cloudflared.token overrides the legacy token. + assert_eq!( + resolved.provider_fields.get("token").map(String::as_str), + Some("generic-token") + ); + // A legacy-only key with no generic override is preserved. + assert_eq!( + resolved + .provider_fields + .get("binary_path") + .map(String::as_str), + Some("/legacy/cloudflared") + ); + + // A non-cloudflared provider ignores the legacy cloudflare block. + let other = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: true + provider: frp + cloudflare: + token: should-be-ignored + providers: + frp: + server_addr: relay.example.com +"#, + ) + .expect("parse other-provider config"); + let resolved = remote_config_from_config(&other) + .expect("other-provider remote config") + .expect("enabled → Some"); + assert_eq!(resolved.provider, "frp"); + assert!( + resolved.provider_fields.get("token").is_none(), + "legacy cloudflare block must not bleed into a non-cloudflared provider" + ); + assert_eq!( + resolved + .provider_fields + .get("server_addr") + .map(String::as_str), + Some("relay.example.com") + ); + }, + ); + } + #[cfg(feature = "terminal-gateway")] + #[test] + fn remote_config_requires_enabled_gate_and_honors_env() { + // Cold-daemon lazy start: lucarned ALWAYS resolves to `Some` (the + // control plane is served from boot); `remote.enabled` now drives + // `autostart`, not whether the subsystem exists. + with_env( + &[ + ("LUCARNED_REMOTE_ENABLED", None), + ("LUCARNED_REMOTE_PROVIDER", None), + ("LUCARNED_REMOTE_GATEWAY_ADDR", None), + ("LUCARNED_REMOTE_CONTROL_ADDR", None), + ("LUCARNED_REMOTE_AUTH_TOKEN", None), + ("LUCARNED_REMOTE_INSECURE", None), + ], + || { + // Default (no remote section) → still Some, with autostart=false + // (the control plane is up but the tunnel stays idle). + let default_config = LucarnedFileConfig::default(); + let resolved = remote_config_from_config(&default_config) + .expect("default remote config") + .expect("lucarned always resolves to Some (lazy start)"); + assert!( + !resolved.autostart, + "remote disabled (default) → control plane ready, tunnel idle (autostart=false)" + ); + // The control info is still resolved so `lucarned remote start` works. + assert_eq!( + resolved.control_addr, + remote_config::DEFAULT_REMOTE_CONTROL_ADDR.parse().unwrap() + ); + + // Enabled in file, defaults fill provider + loopback gateway addr + // + the distinct off-tunnel control addr (SEC-002), and autostart + // is true. + let enabled = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: true +"#, + ) + .expect("parse enabled remote config"); + let resolved = remote_config_from_config(&enabled) + .expect("enabled remote config") + .expect("enabled → Some"); + assert!(resolved.autostart, "remote.enabled:true → autostart=true"); + assert_eq!(resolved.provider, remote_config::DEFAULT_REMOTE_PROVIDER); + assert_eq!( + resolved.gateway_addr, + remote_config::DEFAULT_REMOTE_GATEWAY_ADDR.parse().unwrap() + ); + assert_eq!( + resolved.control_addr, + remote_config::DEFAULT_REMOTE_CONTROL_ADDR.parse().unwrap() + ); + // SEC-002: control plane must be on a DISTINCT port from the gateway. + assert_ne!(resolved.control_addr.port(), resolved.gateway_addr.port()); + assert!(resolved.auth_token.is_none()); + assert!(!resolved.insecure); + }, + ); + + // Env overrides win over file values (and can enable autostart even when + // the file disables it). + with_env( + &[ + ("LUCARNED_REMOTE_ENABLED", Some("true")), + ("LUCARNED_REMOTE_PROVIDER", Some("cloudflared")), + ("LUCARNED_REMOTE_GATEWAY_ADDR", Some("127.0.0.1:7900")), + ("LUCARNED_REMOTE_CONTROL_ADDR", None), + ( + "LUCARNED_REMOTE_AUTH_TOKEN", + Some("env-token-0123456789abcdef0123456789"), + ), + ("LUCARNED_REMOTE_INSECURE", Some("true")), + ], + || { + let disabled_file = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: false + gateway_addr: 127.0.0.1:7810 +"#, + ) + .expect("parse remote config"); + let resolved = remote_config_from_config(&disabled_file) + .expect("env-enabled remote config") + .expect("env enabled → Some"); + assert!( + resolved.autostart, + "LUCARNED_REMOTE_ENABLED=true overrides remote.enabled:false → autostart=true" + ); + assert_eq!(resolved.gateway_addr, "127.0.0.1:7900".parse().unwrap()); + // No control addr configured → derived as gateway port + 1. + assert_eq!(resolved.control_addr, "127.0.0.1:7901".parse().unwrap()); + assert_eq!( + resolved.auth_token.as_deref(), + Some("env-token-0123456789abcdef0123456789") + ); + assert!(resolved.insecure); + }, + ); + } + + // SEC-002: a control_addr sharing the gateway port is rejected (it would + // re-expose the control plane on the tunnel). + #[cfg(feature = "terminal-gateway")] + #[test] + fn remote_config_rejects_control_addr_sharing_gateway_port() { + with_env( + &[ + ("LUCARNED_REMOTE_ENABLED", None), + ("LUCARNED_REMOTE_GATEWAY_ADDR", None), + ("LUCARNED_REMOTE_CONTROL_ADDR", None), + ("LUCARNED_REMOTE_AUTH_TOKEN", None), + ], + || { + let same_port = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: true + gateway_addr: 127.0.0.1:7800 + control_addr: 127.0.0.1:7800 +"#, + ) + .expect("parse shared-port remote config"); + assert!( + remote_config_from_config(&same_port).is_err(), + "control plane sharing the gateway port must be rejected" + ); + }, + ); + } + + // SEC-008: a weak explicit auth_token (whitespace / too short) fails closed + // at config resolution. + #[cfg(feature = "terminal-gateway")] + #[test] + fn remote_config_rejects_weak_auth_token() { + with_env( + &[ + ("LUCARNED_REMOTE_ENABLED", None), + ("LUCARNED_REMOTE_GATEWAY_ADDR", None), + ("LUCARNED_REMOTE_CONTROL_ADDR", None), + ("LUCARNED_REMOTE_AUTH_TOKEN", None), + ], + || { + let short = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: true + auth_token: "abc123" +"#, + ) + .expect("parse short-token remote config"); + assert!( + remote_config_from_config(&short).is_err(), + "a <32-char auth_token must be rejected (SEC-008)" + ); + + // A strong token (≥32 chars) is accepted. + let strong = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: true + auth_token: "0123456789abcdef0123456789abcdef" +"#, + ) + .expect("parse strong-token remote config"); + let resolved = remote_config_from_config(&strong) + .expect("strong token accepted") + .expect("enabled → Some"); + assert_eq!( + resolved.auth_token.as_deref(), + Some("0123456789abcdef0123456789abcdef") + ); + }, + ); + } + + // SEC-013: a read-only token is resolved when set and validated like the + // full token (weak ⇒ rejected). Absent ⇒ no read-only tier. + #[cfg(feature = "terminal-gateway")] + #[test] + fn remote_config_resolves_and_validates_readonly_token() { + with_env( + &[ + ("LUCARNED_REMOTE_ENABLED", None), + ("LUCARNED_REMOTE_GATEWAY_ADDR", None), + ("LUCARNED_REMOTE_CONTROL_ADDR", None), + ("LUCARNED_REMOTE_AUTH_TOKEN", None), + ("LUCARNED_REMOTE_READONLY_TOKEN", None), + ], + || { + // No readonly token configured → None (no read-only tier). + let none = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: true +"#, + ) + .expect("parse remote config"); + let resolved = remote_config_from_config(&none) + .expect("enabled remote config") + .expect("enabled → Some"); + assert!(resolved.readonly_token.is_none()); + + // A strong readonly token (≥32 chars) is accepted and surfaced. + let strong = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: true + auth_token: "0123456789abcdef0123456789abcdef" + readonly_token: "readonly-0123456789abcdef0123456789" +"#, + ) + .expect("parse strong-readonly remote config"); + let resolved = remote_config_from_config(&strong) + .expect("strong readonly accepted") + .expect("enabled → Some"); + assert_eq!( + resolved.readonly_token.as_deref(), + Some("readonly-0123456789abcdef0123456789") + ); + + // A weak readonly token (<32 chars) is rejected (fail-closed). + let weak = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: true + auth_token: "0123456789abcdef0123456789abcdef" + readonly_token: "short" +"#, + ) + .expect("parse weak-readonly remote config"); + assert!( + remote_config_from_config(&weak).is_err(), + "a <32-char readonly_token must be rejected (SEC-008/SEC-013)" + ); + }, + ); + } + #[cfg(feature = "terminal-gateway")] + #[test] + fn remote_config_rejects_non_loopback_gateway_addr() { + with_env( + &[ + ("LUCARNED_REMOTE_ENABLED", None), + ("LUCARNED_REMOTE_GATEWAY_ADDR", None), + ("LUCARNED_REMOTE_CONTROL_ADDR", None), + ("LUCARNED_REMOTE_AUTH_TOKEN", None), + ], + || { + let public = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: true + gateway_addr: 0.0.0.0:7800 +"#, + ) + .expect("parse public-bind remote config"); + // Loopback hardening (L3): a non-loopback gateway bind is refused. + assert!(remote_config_from_config(&public).is_err()); + }, + ); + } + #[test] fn log_filter_defaults_stderr_to_warn() { with_env(&[("RUST_LOG", None), ("LUCARNE_STDERR_LOG", None)], || { @@ -1802,10 +2489,89 @@ logging: .expect("http client creation"); assert!(run_daemon_source.contains("enabled_adapter_count == 0 && health_addr.is_none()")); + // H5: the idle-exit must also account for an enabled remote subsystem so + // a "remote only" config does not early-exit before the tunnel starts. + assert!(run_daemon_source.contains("&& !remote_enabled")); assert!(idle_exit < open_core); assert!(idle_exit < http_client); } + // H5 + cold-daemon lazy start: when the terminal gateway product capability + // is built, remote config is resolved BEFORE the idle-exit decision and + // resolves to `Some` so the control plane is served from boot and the daemon + // does NOT idle-exit. + #[cfg(feature = "terminal-gateway")] + #[test] + fn remote_only_config_keeps_daemon_from_idle_exit() { + // Source-level: remote_cfg is resolved before the idle-exit guidance log. + let source = include_str!("main.rs"); + let production_source = source.split("#[cfg(test)]").next().unwrap_or(source); + let run_daemon_source = production_source + .split("async fn run_daemon()") + .nth(1) + .and_then(|rest| rest.split("fn init_tracing").next()) + .expect("run_daemon body"); + let resolve = run_daemon_source + .find("remote_config_from_config(&file_config)") + .expect("remote config resolved in run_daemon"); + let idle_exit = run_daemon_source + .find("no adapters enabled; edit lucarned config to enable a channel") + .expect("idle exit guidance log"); + assert!( + resolve < idle_exit, + "remote config must be resolved before the idle-exit decision (H5)" + ); + + // Behavioral: even a remote-disabled config (no health, no channels) + // resolves to Some in lucarned (cold-daemon lazy start: the control + // plane is served from boot) → remote_enabled true → no idle exit. + with_env( + &[ + ("LUCARNED_REMOTE_ENABLED", None), + ("LUCARNED_REMOTE_GATEWAY_ADDR", None), + ("LUCARNED_REMOTE_CONTROL_ADDR", None), + ("LUCARNED_REMOTE_AUTH_TOKEN", None), + ("LUCARNED_HEALTH_ENABLED", None), + ], + || { + let remote_only = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: true +"#, + ) + .expect("parse remote-only config"); + // No health enabled. + assert!(health_addr_from_config(&remote_only) + .expect("health addr") + .is_none()); + // Remote autostart on → Some → the idle-exit guard's + // `!remote_enabled` is false, so the daemon stays up. + assert!(remote_config_from_config(&remote_only) + .expect("remote config") + .is_some()); + + // Cold daemon (remote disabled): STILL resolves to Some so the + // control plane is up and `lucarned remote start` can reach it — the + // daemon must not idle-exit even with the tunnel idle. + let cold = LucarnedFileConfig::from_yaml_str( + r#" +remote: + enabled: false +"#, + ) + .expect("parse cold remote config"); + let resolved = remote_config_from_config(&cold) + .expect("cold remote config") + .expect("lucarned resolves to Some even when disabled"); + assert!( + !resolved.autostart, + "remote.enabled:false → control plane ready, tunnel idle" + ); + }, + ); + } + #[test] fn daemon_creates_system_notification_bus_and_drives_update_runtime() { let source = include_str!("main.rs"); diff --git a/crates/lucarned/src/onboarding/config.rs b/crates/lucarned/src/onboarding/config.rs index 8af5577..7f0165b 100644 --- a/crates/lucarned/src/onboarding/config.rs +++ b/crates/lucarned/src/onboarding/config.rs @@ -1,6 +1,6 @@ use std::{ fs::{self, File, OpenOptions}, - io, + io::{self, Write}, path::Path, time::{SystemTime, UNIX_EPOCH}, }; @@ -194,7 +194,15 @@ pub(crate) fn write_config_with_backup( std::process::id(), SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() )); - fs::write(&tmp_path, contents)?; + // SEC-001: the config can hold provider secrets (tunnel token), so create the + // tmp file owner-only (`0o600`) on unix before writing — the secret is never + // briefly group/world-readable. Preserve the atomic tmp+rename below. + let mut tmp = owner_only_options() + .write(true) + .create_new(true) + .open(&tmp_path)?; + tmp.write_all(contents.as_bytes())?; + drop(tmp); if let Err(err) = fs::rename(&tmp_path, path) { let _ = fs::remove_file(&tmp_path); return Err(err.into()); @@ -203,6 +211,20 @@ pub(crate) fn write_config_with_backup( Ok(()) } +/// An [`OpenOptions`] pre-set to create owner-only (`0o600`) files on unix so the +/// secret-bearing config (and its backup) is never group/world-readable +/// (SEC-001). On non-unix there are no mode bits to set, so the platform default +/// is used. +fn owner_only_options() -> OpenOptions { + let mut options = OpenOptions::new(); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options +} + fn create_backup(path: &Path, parent: &Path) -> Result<(), Box> { let file_name = path .file_name() @@ -212,7 +234,9 @@ fn create_backup(path: &Path, parent: &Path) -> Result<(), Box Result<(), RemoteControlError>; +} + +/// Local capability selected for remote exposure. +/// +/// This keeps RemoteAccess as a tunnel/admission layer. The terminal gateway is +/// one capability that can be published, not the definition of remote access. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExposedCapability { + TerminalGateway, +} + +/// Web asset directory served by the gateway for local/static clients. Env +/// `LUCARNED_REMOTE_WEB` overrides; defaults to `web` (relative to the daemon's +/// working dir), matching the `termgw-dev` runner. +const DEFAULT_WEB_DIR: &str = "web"; + +/// How often the H3 health watcher polls the running tunnel and reaps it if the +/// provider reports the child has exited (so `/api/remote/status` reflects +/// reality and a crashed tunnel can be restarted without a manual status call). +const REAPER_INTERVAL: std::time::Duration = std::time::Duration::from_secs(15); + +/// Resolved remote-access runtime configuration (produced by +/// `remote_config_from_config` in `main.rs`, after env overrides + the +/// loopback-hardened gateway address parse). +/// +/// H6c: the provider's own configuration is a GENERIC, transport-agnostic +/// `provider_fields` map (keyed by `RequiredField::key`) — the daemon no longer +/// owns cloudflare-specific fields. Adding FRP / relay / any future backend is a +/// new provider impl + a `providers.` block in `lucarned.yaml`, with zero +/// daemon-config changes (AGENTS.md provider boundary; ADR L1/L2/L7). +#[derive(Clone, Debug)] +pub struct RemoteRuntimeConfig { + /// Tunnel backend provider id (e.g. `"cloudflared"`). + pub provider: String, + /// Loopback gateway bind address (already validated loopback — L3). This is + /// the ONLY port the tunnel targets. + pub gateway_addr: SocketAddr, + /// Loopback control-plane bind address (SEC-002). A DISTINCT port from + /// `gateway_addr` that the tunnel never targets; serves `/api/remote/*` and + /// returns the `access_token`. Already validated loopback. + pub control_addr: SocketAddr, + /// Configured gateway access token; `None` → generate at startup (L4). + pub auth_token: Option, + /// Optional read-only access token (SEC-013); `None` → no read-only tier. + pub readonly_token: Option, + /// Explicit opt-out of auth (loud warning; never the default — L4). + pub insecure: bool, + /// H6c: opaque per-provider field map (keyed by the provider's + /// `RequiredField::key`, e.g. cloudflared's `token` / `public_url` / + /// `binary_path`). The daemon passes this straight through to + /// [`ProviderConfig`] without interpreting any field — provider-specific + /// structure stays at the provider boundary. + pub provider_fields: std::collections::BTreeMap, + /// Capability published through the remote-access tunnel. + pub capability: ExposedCapability, + /// Cold-daemon lazy start (this change): when `true` the daemon auto-starts + /// the configured tunnel at boot (the historical `remote.enabled:true` + /// behaviour — gateway + monitor + tunnel come up immediately). When `false` + /// the control plane is still served from boot but the gateway / rmux monitor + /// / tunnel stay idle until the first `lucarned remote start` (`/api/remote/start`). + pub autostart: bool, +} + +impl RemoteRuntimeConfig { + /// Build the opaque per-provider [`ProviderConfig`] for the tunnel backend + /// (Locked decision L2: providers take a flat key→value map, no daemon + /// types leak in). H6c: this is now a pure copy of the generic + /// `provider_fields` map — the daemon maps NO provider-specific field names. + /// + /// G3: `overrides` are CLI-supplied field values (from `lucarned remote start`) + /// merged **over** the daemon's configured fields — a present override wins, + /// an absent one keeps the configured value. An empty `overrides` map yields + /// exactly the daemon's pre-configured fields (backward compatible). + fn provider_config( + &self, + overrides: &std::collections::BTreeMap, + ) -> ProviderConfig { + let mut cfg = ProviderConfig::new(); + // Daemon-configured provider fields (verbatim — no per-field mapping). + for (key, value) in &self.provider_fields { + if value.is_empty() { + continue; + } + cfg.fields.insert(key.clone(), value.clone()); + } + // G3: CLI-supplied fields override / extend the configured ones. + for (key, value) in overrides { + if value.is_empty() { + continue; + } + cfg.fields.insert(key.clone(), value.clone()); + } + cfg + } +} + +/// Handle returned to `run_daemon` after the subsystem is up — carries the +/// fields the daemon logs (`provider`, `public_url`). The live tunnel + control +/// plane live in the spawned tasks / the `Arc`. +/// +/// `public_url` is `Some` only when the tunnel was actually started (autostart), +/// and `None` when the control plane is up but the tunnel is still idle (cold +/// daemon waiting for `lucarned remote start`). +pub struct RemoteSubsystem { + pub provider: String, + pub public_url: Option, +} + +/// The daemon's [`RemoteControl`] implementation: it owns the tunnel lifecycle +/// (Locked decision L6) and is driven by the gateway's loopback-only +/// `/api/remote/{start,stop,status}` routes. +/// +/// H4: a small [`TunnelState`] state machine behind a `Mutex` lets `start` / +/// `stop` / `status` run the provider's `start` / `stop` / `health` **outside** +/// the lock. The lock is only held to read/transition the state and take out the +/// data needed for the await; the long-running provider await then runs lock-free +/// (so status/stop/start never block each other and shutdown can't deadlock), +/// and the result is written back under a fresh lock. +struct RemoteExposureManager { + registry: RemoteRegistry, + config: RemoteRuntimeConfig, + /// The gateway access token handed to remote clients (`#token=…`). Present + /// unless running `insecure` with no token. + access_token: Option, + /// Tunnel lifecycle state machine (H4). Guards transitions; provider awaits + /// happen with this lock released. + state: Mutex, + /// Auth/admission state for the selected exposed capability. For the current + /// terminal capability this feeds the termgw router. + auth: AuthState, + ws_pool: WsConnectionPool, + control_store: ControlPlaneSqliteStore, + web_dir: PathBuf, + /// Once-guard for the lazy capability bring-up. For `TerminalGateway`, this + /// means rmux connect + termgw bind/serve. Future capabilities should add + /// their own branch in `ensure_capability_ready` instead of pushing tunnel + /// logic into rmux. + capability_up: Mutex, + #[cfg(test)] + gateway_starter: Option>, +} + +/// Tunnel lifecycle (H4). `Starting` / `Stopping` are transient markers so a +/// concurrent caller observes work-in-progress instead of racing a second +/// provider start/stop while the first is awaiting lock-free. +enum TunnelState { + /// No tunnel running and none being started. + Idle, + /// A `start` is in flight (provider await running lock-free). + Starting, + /// A tunnel is up; the handle is needed to `stop` / `health` it. + Running(TunnelHandle), + /// A `stop` is in flight (provider await running lock-free). + Stopping, +} + +impl RemoteExposureManager { + fn status_from(&self, handle: Option<&TunnelHandle>) -> RemoteControlStatus { + match handle { + Some(h) => RemoteControlStatus { + running: true, + provider: Some(h.provider_id.clone()), + public_url: Some(h.public_url.to_string()), + access_token: self.access_token.clone(), + }, + None => RemoteControlStatus { + running: false, + provider: None, + public_url: None, + access_token: self.access_token.clone(), + }, + } + } +} + +#[async_trait] +impl RemoteControl for RemoteExposureManager { + /// H4: the `self.state` lock is NEVER held across an `await`. The + /// already-running idempotent path clones the [`TunnelHandle`] out under the + /// lock (phase 1a), probes `provider.health(&handle)` with the lock RELEASED + /// (phase 1b), then re-acquires the lock and re-checks that the SAME handle is + /// still `Running` (compares `opaque`) before deciding the transition (phase + /// 1c) — so a concurrent stop/start that ran during the lock-free health await + /// is never clobbered. The provider `start` spawn (phase 2) and the + /// write-back of its outcome (phase 3) are likewise split across the lock. + async fn start( + &self, + params: RemoteStartParams, + ) -> Result { + // Phase 1a (locked): inspect state, handle the busy cases, and — for a + // running tunnel — clone the handle out so the health probe runs OUTSIDE + // the lock (H4: no state lock is ever held across an `await`). For Idle we + // claim `Starting` directly here so a concurrent start can't double-spawn. + let running_handle = { + let mut guard = self.state.lock().await; + match &*guard { + // Already running → clone the handle and verify health lock-free + // (Phase 1b). We do NOT decide the transition under this lock. + TunnelState::Running(handle) => Some(handle.clone()), + TunnelState::Idle => { + *guard = TunnelState::Starting; + None + } + TunnelState::Starting => { + return Err(RemoteControlError::Backend( + "a tunnel start is already in progress".to_string(), + )); + } + TunnelState::Stopping => { + return Err(RemoteControlError::Backend( + "a tunnel stop is in progress; retry shortly".to_string(), + )); + } + } + }; + + // Phase 1b (lock-free): if a tunnel was running, probe its health with the + // state lock released. H3: a dead tunnel is restarted; a healthy one is the + // idempotent success path. + if let Some(handle) = running_handle { + let healthy = match self.registry.lookup(&handle.provider_id) { + Some(p) => !matches!( + p.health(&handle).await.unwrap_or(TunnelStatus::Unknown), + TunnelStatus::Down + ), + None => false, + }; + + // Phase 1c (locked): re-acquire and re-check the state. A concurrent + // start/stop may have changed it while health awaited lock-free, so we + // only act when the SAME handle is still Running (compare opaque). + { + let mut guard = self.state.lock().await; + match &*guard { + TunnelState::Running(current) if current.opaque == handle.opaque => { + if healthy { + // Idempotent: still up and healthy. + return Ok(self.status_from(Some(current))); + } + // Dead/unknown: drop the stale handle and claim Starting so + // we restart below (Phase 2/3). + info!("lucarned remote: tunnel reported Down on start; restarting"); + *guard = TunnelState::Starting; + } + // The state moved on under us (a concurrent stop reaped it, or a + // start replaced the handle). Report the live status without + // racing a second spawn. + TunnelState::Running(current) => { + return Ok(self.status_from(Some(current))); + } + TunnelState::Idle => { + // A concurrent stop cleared it; claim Starting and restart. + *guard = TunnelState::Starting; + } + TunnelState::Starting => { + return Err(RemoteControlError::Backend( + "a tunnel start is already in progress".to_string(), + )); + } + TunnelState::Stopping => { + return Err(RemoteControlError::Backend( + "a tunnel stop is in progress; retry shortly".to_string(), + )); + } + } + } + } + + // Phase 2 (lock-free): resolve provider + config, then await the spawn. + let result = self.do_start(params).await; + + // Phase 3 (locked): write the outcome back. + let mut guard = self.state.lock().await; + match result { + Ok(handle) => { + let status = self.status_from(Some(&handle)); + *guard = TunnelState::Running(handle); + Ok(status) + } + Err(e) => { + // The start failed; return to Idle so a retry is possible. + *guard = TunnelState::Idle; + Err(e) + } + } + } + + async fn stop(&self) -> Result { + // Phase 1 (locked): take the running handle (if any) and claim Stopping. + let handle = { + let mut guard = self.state.lock().await; + match &*guard { + TunnelState::Running(_) => { + // Replace with Stopping and extract the handle. + match std::mem::replace(&mut *guard, TunnelState::Stopping) { + TunnelState::Running(handle) => handle, + _ => unreachable!("matched Running above"), + } + } + // Nothing running (Idle / a transient start/stop): succeed + // idempotently without touching the in-flight transition. + _ => return Ok(self.status_from(None)), + } + }; + + // SEC-011: audit the tunnel stop (provider + public host; no token). + info!( + provider = %handle.provider_id, + public_host = handle.public_url.host_str().unwrap_or(""), + "lucarned remote: tunnel stopping" + ); + + // Phase 2 (lock-free): run the provider stop await. + let provider = match self.registry.lookup(&handle.provider_id) { + Some(p) => p, + None => { + // Unknown provider: the handle is unusable, so drop it. Treat as + // stopped (no process we can reap through this registry). + let mut guard = self.state.lock().await; + *guard = TunnelState::Idle; + return Ok(self.status_from(None)); + } + }; + let provider_id = handle.provider_id.clone(); + let stop_result = provider.stop(handle.clone()).await; + + // Phase 3 (locked): M1 — only clear the handle on success / NotFound / + // Down (a tunnel that is genuinely gone). A recoverable error keeps the + // handle so the caller can retry `stop`. + let mut guard = self.state.lock().await; + match stop_result { + Ok(()) => { + *guard = TunnelState::Idle; + Ok(self.status_from(None)) + } + Err(lucarne_remote::RemoteError::NotFound(_)) => { + // The provider has no live child for this handle → already gone. + *guard = TunnelState::Idle; + Ok(self.status_from(None)) + } + Err(e) => { + // M1: recoverable error — keep the handle for a retry. + warn!(provider = %provider_id, error = %e, "lucarned remote: tunnel stop failed; retaining handle"); + *guard = TunnelState::Running(handle); + Err(RemoteControlError::Backend(e.to_string())) + } + } + } + + async fn status(&self) -> RemoteControlStatus { + // Phase 1 (locked): clone the handle out so the health await is lock-free. + let handle = { + let guard = self.state.lock().await; + match &*guard { + TunnelState::Running(handle) => Some(handle.clone()), + _ => None, + } + }; + let Some(handle) = handle else { + return self.status_from(None); + }; + + // Phase 2 (lock-free): H3 — ask the provider for live health. + let health = match self.registry.lookup(&handle.provider_id) { + Some(p) => p.health(&handle).await.unwrap_or(TunnelStatus::Unknown), + None => TunnelStatus::Down, + }; + + if matches!(health, TunnelStatus::Down) { + // H3: a dead tunnel is reaped so a future `start` can relaunch and + // `/api/remote/status` reflects reality (running=false). + let mut guard = self.state.lock().await; + // Only clear if still the same running handle (avoid clobbering a + // concurrent start/stop transition). + if let TunnelState::Running(current) = &*guard { + if current.opaque == handle.opaque { + info!("lucarned remote: tunnel health=Down; clearing handle (status)"); + *guard = TunnelState::Idle; + } + } + return self.status_from(None); + } + + self.status_from(Some(&handle)) + } +} + +impl RemoteExposureManager { + /// Lazily bring up the gateway exactly once (cold-daemon lazy start). + /// + /// On a cold daemon (`autostart:false`) the control plane is served from boot + /// but the rmux monitor + termgw gateway are NOT — the first `start()` (from + /// `lucarned remote start`, or autostart) runs this. It connects the system rmux + /// monitor, builds the termgw router on the shared ws pool, binds the + /// loopback gateway, and spawns its serve task. + /// + /// H4 discipline: the `capability_up` lock is NEVER held across the heavy awaits + /// (rmux connect / bind / serve). Phase 1 reads the flag under the lock and + /// returns early if already up; phase 2 does the work lock-free; phase 3 + /// re-acquires the lock, re-checks the flag (a concurrent caller may have + /// raced us), and only commits the spawn + sets the flag when it is still + /// down — otherwise it discards the duplicate listener so we never double-bind + /// or double-serve. + /// + /// An rmux connect / bind failure returns [`RemoteControlError::Backend`] so + /// the CLI gets a clear error WITHOUT crashing the daemon (the control plane + /// stays up; a later `lucarned remote start` can retry once rmux is available). + async fn ensure_capability_ready(&self) -> Result<(), RemoteControlError> { + match self.config.capability { + ExposedCapability::TerminalGateway => self.ensure_terminal_gateway().await, + } + } + + async fn ensure_terminal_gateway(&self) -> Result<(), RemoteControlError> { + // Phase 1 (locked): already up → nothing to do. + if *self.capability_up.lock().await { + return Ok(()); + } + + #[cfg(test)] + if let Some(starter) = &self.gateway_starter { + starter.start_gateway().await?; + let mut guard = self.capability_up.lock().await; + if !*guard { + *guard = true; + } + return Ok(()); + } + + // Phase 2 (lock-free): connect rmux, build + bind the gateway. None of + // these awaits hold the `capability_up` lock (H4). + let monitor = Arc::new( + RmuxMonitor::connect() + .await + .map_err(|e| RemoteControlError::Backend(format!("rmux connect failed: {e}")))?, + ); + let adopted = monitor + .adopt_all() + .await + .map_err(|e| RemoteControlError::Backend(format!("rmux adopt failed: {e}")))?; + info!( + sessions = adopted.len(), + "lucarned remote: adopted system rmux sessions" + ); + + // H1: build the gateway router on the shared ws pool so `/ws` and + // `/agent` share one global connection cap. Web apps are external + // consumers of this gateway API; the daemon no longer owns a separate + // web-chat runtime crate. + let app = lucarne_termgw::router_with_pool_and_store( + monitor, + self.web_dir.clone(), + self.auth.clone(), + self.ws_pool.clone(), + self.control_store.clone(), + ); + let listener = tokio::net::TcpListener::bind(self.config.gateway_addr) + .await + .map_err(|e| { + RemoteControlError::Backend(format!( + "gateway bind {} failed: {e}", + self.config.gateway_addr + )) + })?; + let bound = listener + .local_addr() + .map_err(|e| RemoteControlError::Backend(format!("gateway local_addr failed: {e}")))?; + + // Phase 3 (locked): re-check the flag — a concurrent caller may have won + // the race while we were connecting/binding lock-free. If so, discard this + // duplicate listener (drop it) so we never double-serve the same port. + { + let mut guard = self.capability_up.lock().await; + if *guard { + drop(listener); + return Ok(()); + } + tokio::spawn(async move { + if let Err(err) = axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + { + warn!(error = %err, "lucarned remote gateway stopped"); + } + }); + *guard = true; + } + info!(addr = %bound, "lucarned remote gateway listening (loopback)"); + Ok(()) + } + + /// Resolve provider + config and spawn the tunnel (lock-free). H6a: log the + /// provider's own `warnings(cfg)` instead of special-casing a provider id. + async fn do_start( + &self, + params: RemoteStartParams, + ) -> Result { + // Cold-daemon lazy start: bring up the selected exposed capability on a + // loopback listener before the tunnel targets it. For TerminalGateway, + // this means rmux monitor + termgw router + bind. The tunnel layer + // itself remains capability-agnostic. + self.ensure_capability_ready().await?; + + // G3: a CLI-supplied provider id overrides the daemon's configured one; + // absent → fall back to the pre-configured provider. + let provider_id = params + .provider + .as_deref() + .filter(|p| !p.is_empty()) + .unwrap_or(self.config.provider.as_str()) + .to_string(); + let provider = self + .registry + .lookup(&provider_id) + .ok_or_else(|| RemoteControlError::UnknownProvider(provider_id.clone()))?; + // G3: merge CLI fields over the daemon's configured provider fields. + let cfg = self.config.provider_config(¶ms.fields); + + // M7: let the provider validate its own config (e.g. cloudflared requires + // a named-tunnel `public_url` when a `token` is present, and checks the + // URL is well-formed). The daemon does NOT branch on the provider id; the + // rule lives in the provider. A violation → typed BadConfig (400). + if let Err(detail) = provider.validate_config(&cfg) { + return Err(RemoteControlError::BadConfig(detail)); + } + + // H6a: log any provider-declared warnings about this config (e.g. a + // cloudflared quick tunnel exposes terminal content at the CF edge). The + // daemon does NOT special-case the provider id; the provider owns the text. + for warning in provider.warnings(&cfg) { + warn!(provider = %provider_id, "lucarned remote: {warning}"); + } + + let handle = provider + .start(self.config.gateway_addr, &cfg) + .await + .map_err(map_remote_error)?; + // SEC-011: audit the tunnel start (provider + public host only; never the + // access token). + info!( + provider = %handle.provider_id, + public_host = handle.public_url.host_str().unwrap_or(""), + "lucarned remote: tunnel started" + ); + Ok(handle) + } +} + +/// Map a [`lucarne_remote::RemoteError`] to a typed [`RemoteControlError`] (M2): +/// a missing config field → bad config (400), a not-found provider/handle → +/// not-found (404), everything else (spawn/parse/io) → a backend error (502). +fn map_remote_error(err: lucarne_remote::RemoteError) -> RemoteControlError { + use lucarne_remote::RemoteError; + match err { + RemoteError::MissingField(_) | RemoteError::Parse(_) => { + RemoteControlError::BadConfig(err.to_string()) + } + RemoteError::NotFound(_) => RemoteControlError::UnknownProvider(err.to_string()), + RemoteError::Spawn { .. } | RemoteError::Io(_) => { + RemoteControlError::Backend(err.to_string()) + } + } +} + +/// Build the gateway [`AuthState`] from the full-access token plus the optional +/// read-only token (SEC-013). A configured `readonly_token` is validated +/// (SEC-008: non-whitespace, ≥32 chars) and wired as the read-only credential; +/// when absent the behaviour is the existing single-token all-or-nothing model. +fn build_auth( + token: AccessToken, + readonly_token: Option<&str>, +) -> Result> { + match readonly_token.filter(|t| !t.is_empty()) { + Some(ro) => { + let readonly = AccessToken::from_secret_validated(ro.to_string()).map_err( + |e| -> Box { format!("remote.readonly_token: {e}").into() }, + )?; + info!("lucarned remote: read-only access token enabled (SEC-013)"); + Ok(AuthState::with_tokens(token, readonly)) + } + None => Ok(AuthState::with_token(token)), + } +} + +/// Start the remote-access subsystem: build the default-deny auth state, serve +/// the loopback-only control plane (so `lucarned remote start` can reach the daemon from +/// boot), and — only when `config.autostart` — bring up the gateway + tunnel. +/// +/// Cold-daemon lazy start (this change): the control plane (SEC-002, port 7801) +/// is ALWAYS served, even when the tunnel is idle, so `/api/remote/status` +/// returns the access token + `running:false` from startup and `lucarned remote start` +/// is never refused with a connection-refused. The rmux monitor + termgw gateway +/// + tunnel are brought up lazily on the first `start()`: +/// +/// - from `lucarned remote start`, or +/// - when `autostart` is set (`remote.enabled:true`), from a single +/// `control.start()` here at boot. +/// +/// An rmux-less environment therefore no longer crashes the daemon: the control +/// plane stays up and a `start()` returns a clear [`RemoteControlError`] instead. +/// +/// Returns the [`RemoteSubsystem`] handle (for daemon logging). Tunnel + gateway +/// run in spawned tasks; the tunnel is stopped when `shutdown` fires. +pub async fn spawn_remote_subsystem( + config: RemoteRuntimeConfig, + control_store: ControlPlaneSqliteStore, + mut shutdown: watch::Receiver, +) -> Result> { + // default-deny (L4): require a token. Generate one when absent unless the + // operator explicitly opted into insecure exposure. SEC-008: an explicit + // token must be validated (non-whitespace, ≥32 chars) and fail closed. + let (auth, access_token) = match (&config.auth_token, config.insecure) { + (Some(token), _) => { + let token = AccessToken::from_secret_validated(token.clone()) + .map_err(|e| -> Box { e.to_string().into() })?; + let secret = token.as_str().to_string(); + ( + build_auth(token, config.readonly_token.as_deref())?, + Some(secret), + ) + } + (None, false) => { + let token = AccessToken::generate(); + let secret = token.as_str().to_string(); + warn!( + "lucarned remote: no auth_token configured — generated an ephemeral \ + access token for this session (set remote.auth_token to persist it)" + ); + ( + build_auth(token, config.readonly_token.as_deref())?, + Some(secret), + ) + } + (None, true) => { + warn!( + "lucarned remote: INSECURE public exposure with NO access token — anyone \ + reaching the tunnel can drive your terminals. This is RCE-equivalent." + ); + (AuthState::disabled(), None) + } + }; + + // H6b: resolve the trusted forwarded-identity policy from the CONFIGURED + // provider's own contract (cloudflared → `cf-connecting-ip`), so the gateway + // never hardcodes a provider header. The gateway trusts the header only + // behind the loopback tunnel source. An unknown provider / no headers → the + // safe socket-peer-only default. + let registry = lucarne_remote::builtin(); + let forwarded_policy = registry + .lookup(&config.provider) + .map(|p| ForwardedIdentityPolicy::trusting(p.forwarded_identity_headers().iter().copied())) + .unwrap_or_default(); + let auth = auth.with_forwarded_identity(forwarded_policy); + + // H1: ONE shared ws-connection pool drives every ws route on the gateway port + // (`/ws` + `/agent`) so a single `max_ws_connections` cap (plus the same + // idle/lifetime/inbound-frame-rate limits) governs all of them. Built here + // so it is shared by the lazy capability bring-up. + let ws_pool = WsConnectionPool::new(GatewayLimits::default()); + + // Web asset dir served by the terminal gateway (resolved here so capability + // bring-up does not re-read the env per start). + let web_dir = std::path::PathBuf::from( + std::env::var("LUCARNED_REMOTE_WEB").unwrap_or_else(|_| DEFAULT_WEB_DIR.to_string()), + ); + let control_addr = config.control_addr; + + // The daemon owns the exposure lifecycle; a SEPARATE loopback control + // listener (SEC-002) forwards `/api/remote/*` to it. H4: the control starts + // in the Idle state; its state machine runs provider awaits lock-free. The + // selected capability is NOT connected here — it is brought up lazily on the + // first `start()` (cold-daemon lazy start). + let control = Arc::new(RemoteExposureManager { + registry, + config: config.clone(), + access_token: access_token.clone(), + state: Mutex::new(TunnelState::Idle), + auth, + ws_pool, + control_store, + web_dir, + capability_up: Mutex::new(false), + #[cfg(test)] + gateway_starter: None, + }); + + // SEC-002: serve the loopback-only control plane on its OWN distinct port the + // tunnel never targets. This separation — not peer-IP — is the trust boundary + // that keeps `/api/remote/*` (and the `access_token`) off the tunnel. Always + // served (even on a cold daemon) so `lucarned remote start` can reach the daemon. + let control_for_plane = control.clone() as Arc; + let control_listener = tokio::net::TcpListener::bind(control_addr).await?; + let control_bound = control_listener.local_addr()?; + if !control_bound.ip().is_loopback() { + return Err("remote control plane must bind a loopback address (SEC-002)".into()); + } + tokio::spawn(async move { + let control_app = lucarne_termgw::control_router(Some(control_for_plane)); + if let Err(err) = axum::serve( + control_listener, + control_app.into_make_service_with_connect_info::(), + ) + .await + { + warn!(error = %err, "lucarned remote control plane stopped"); + } + }); + info!(addr = %control_bound, "lucarned remote control plane listening (loopback-only, off-tunnel)"); + + // Autostart (historical `remote.enabled:true` behaviour): bring up the + // gateway + tunnel immediately via one `start()` (the SAME lazy path + // `lucarned remote start` uses — capability readiness then `provider.start`). + // When `autostart` is false the control plane is ready and the tunnel stays idle + // until the first `lucarned remote start`. The daemon's auto-start uses its + // pre-configured provider + fields (empty params — the G3 override path is the + // CLI's `/api/remote/start` body). + let public_url = if config.autostart { + let status = control + .start(RemoteStartParams::default()) + .await + .map_err(|e| -> Box { e.into() })?; + Some( + status + .public_url + .clone() + .ok_or("remote tunnel started without a public URL")?, + ) + } else { + info!( + "lucarned remote: control plane ready, tunnel idle — run `lucarned remote start` to open public access" + ); + None + }; + let provider = config.provider.clone(); + + // Graceful shutdown: when the daemon signals shutdown, stop the tunnel so the + // provider process (e.g. cloudflared) is reaped (mirrors the health subsystem + // shutdown wiring). + // A second receiver for the H3 health watcher below (the stop task moves the + // original `shutdown`). + let shutdown_rx_for_watcher = shutdown.clone(); + let shutdown_control = control.clone(); + tokio::spawn(async move { + // Wait for the shutdown flag to flip to true. + loop { + if *shutdown.borrow() { + break; + } + if shutdown.changed().await.is_err() { + break; + } + } + if let Err(err) = shutdown_control.stop().await { + warn!(error = %err, "lucarned remote: tunnel stop on shutdown failed"); + } else { + info!("lucarned remote tunnel stopped on shutdown"); + } + }); + + // H3: periodic health watcher / reaper. The provider's `health` (via + // `status()`) detects a child that exited and reaps it (clearing the handle), + // so a crashed tunnel is noticed and `/api/remote/status` reflects reality + // even without a client status request. `status()` itself is a no-op when + // Idle (nothing running), so this is cheap; it stops with the daemon. + let watcher_control = control.clone(); + let mut watcher_shutdown = shutdown_rx_for_watcher; + tokio::spawn(async move { + let mut tick = tokio::time::interval(REAPER_INTERVAL); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + _ = tick.tick() => { + // Polling status() runs health() and reaps a Down child. + let _ = watcher_control.status().await; + } + changed = watcher_shutdown.changed() => { + if changed.is_err() || *watcher_shutdown.borrow() { + break; + } + } + } + } + }); + + Ok(RemoteSubsystem { + provider, + public_url, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use lucarne_remote::{RemoteAccessProvider, RemoteError, RemoteResult, RequiredField}; + // `TunnelHandle::public_url` is a `url::Url`; lucarned does not depend on the + // `url` crate directly, but reqwest re-exports the very same type, so build + // test URLs through `reqwest::Url` (== `url::Url`). + use reqwest::Url; + + /// A 32+ char token that satisfies SEC-008 (`from_secret_validated`: + /// non-whitespace, `>= MIN_EXPLICIT_TOKEN_LEN` = 32). Used wherever a test + /// needs a *valid* operator-supplied token. + const VALID_TOKEN: &str = "0123456789abcdef0123456789abcdef"; // exactly 32 chars + const VALID_READONLY: &str = "ro-0123456789abcdef0123456789abcdef"; // > 32 chars + + fn base_config() -> RemoteRuntimeConfig { + RemoteRuntimeConfig { + provider: "cloudflared".to_string(), + gateway_addr: "127.0.0.1:7800".parse().unwrap(), + control_addr: "127.0.0.1:7801".parse().unwrap(), + auth_token: None, + readonly_token: None, + insecure: false, + provider_fields: BTreeMap::new(), + capability: ExposedCapability::TerminalGateway, + autostart: false, + } + } + + fn fields(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + /// Build a `RemoteExposureManager` for the pure-logic tests (no tunnel/rmux): + /// it sits in `Idle` with no gateway up, so `status`/`stop`/`status_from` can + /// be exercised without ever connecting rmux or binding a port. + fn control(config: RemoteRuntimeConfig, access_token: Option) -> RemoteExposureManager { + control_with_registry(config, access_token, lucarne_remote::builtin(), None) + } + + fn control_with_registry( + config: RemoteRuntimeConfig, + access_token: Option, + registry: lucarne_remote::RemoteRegistry, + gateway_starter: Option>, + ) -> RemoteExposureManager { + RemoteExposureManager { + registry, + config, + access_token, + state: Mutex::new(TunnelState::Idle), + auth: AuthState::disabled(), + ws_pool: WsConnectionPool::new(GatewayLimits::default()), + control_store: ControlPlaneSqliteStore::open_in_memory() + .expect("open in-memory control-plane store"), + web_dir: PathBuf::from("web"), + capability_up: Mutex::new(false), + gateway_starter, + } + } + + #[derive(Default)] + struct CountingGatewayStarter { + calls: AtomicUsize, + failures: AtomicUsize, + } + + impl CountingGatewayStarter { + fn fail_next(&self) { + self.failures.fetch_add(1, Ordering::SeqCst); + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + } + + #[async_trait] + impl GatewayStarter for CountingGatewayStarter { + async fn start_gateway(&self) -> Result<(), RemoteControlError> { + self.calls.fetch_add(1, Ordering::SeqCst); + if self.failures.load(Ordering::SeqCst) > 0 { + self.failures.fetch_sub(1, Ordering::SeqCst); + return Err(RemoteControlError::Backend("gateway failed".to_string())); + } + Ok(()) + } + } + + #[derive(Clone, Default)] + struct FakeProvider { + state: Arc, + } + + #[derive(Default)] + struct FakeProviderState { + starts: AtomicUsize, + stops: AtomicUsize, + health: AtomicUsize, + fail_starts: AtomicUsize, + fail_stops: AtomicUsize, + health_down: AtomicUsize, + } + + impl FakeProvider { + fn fail_next_start(&self) { + self.state.fail_starts.fetch_add(1, Ordering::SeqCst); + } + + fn fail_next_stop(&self) { + self.state.fail_stops.fetch_add(1, Ordering::SeqCst); + } + + fn report_down_once(&self) { + self.state.health_down.fetch_add(1, Ordering::SeqCst); + } + + fn starts(&self) -> usize { + self.state.starts.load(Ordering::SeqCst) + } + + fn stops(&self) -> usize { + self.state.stops.load(Ordering::SeqCst) + } + + fn health_checks(&self) -> usize { + self.state.health.load(Ordering::SeqCst) + } + } + + #[async_trait] + impl RemoteAccessProvider for FakeProvider { + fn id(&self) -> &'static str { + "fake" + } + + fn name(&self) -> &'static str { + "Fake" + } + + fn required_fields(&self) -> &[RequiredField] { + &[] + } + + async fn start( + &self, + _local: std::net::SocketAddr, + _cfg: &ProviderConfig, + ) -> RemoteResult { + self.state.starts.fetch_add(1, Ordering::SeqCst); + if self.state.fail_starts.load(Ordering::SeqCst) > 0 { + self.state.fail_starts.fetch_sub(1, Ordering::SeqCst); + return Err(RemoteError::Spawn { + provider: "fake".to_string(), + message: "start failed".to_string(), + }); + } + Ok(TunnelHandle { + provider_id: "fake".to_string(), + public_url: Url::parse("https://fake.example.test/").unwrap(), + opaque: format!("fake-{}", self.state.starts.load(Ordering::SeqCst)), + }) + } + + async fn stop(&self, _handle: TunnelHandle) -> RemoteResult<()> { + self.state.stops.fetch_add(1, Ordering::SeqCst); + if self.state.fail_stops.load(Ordering::SeqCst) > 0 { + self.state.fail_stops.fetch_sub(1, Ordering::SeqCst); + return Err(RemoteError::Io(std::io::Error::other("stop failed"))); + } + Ok(()) + } + + async fn health(&self, _handle: &TunnelHandle) -> RemoteResult { + self.state.health.fetch_add(1, Ordering::SeqCst); + if self.state.health_down.load(Ordering::SeqCst) > 0 { + self.state.health_down.fetch_sub(1, Ordering::SeqCst); + return Ok(TunnelStatus::Down); + } + Ok(TunnelStatus::Up) + } + } + + fn fake_control( + provider: FakeProvider, + gateway: Arc, + ) -> RemoteExposureManager { + let mut config = base_config(); + config.provider = "fake".to_string(); + let mut registry = lucarne_remote::RemoteRegistry::new(); + registry.register(provider); + control_with_registry( + config, + Some(VALID_TOKEN.to_string()), + registry, + Some(gateway), + ) + } + + // ---- G3: RemoteRuntimeConfig::provider_config override / merge ---- + + #[test] + fn provider_config_uses_daemon_fields_when_no_overrides() { + let mut config = base_config(); + config.provider_fields = + fields(&[("token", "cfg-token"), ("public_url", "https://a.test")]); + let cfg = config.provider_config(&BTreeMap::new()); + assert_eq!(cfg.get("token"), Some("cfg-token")); + assert_eq!(cfg.get("public_url"), Some("https://a.test")); + } + + #[test] + fn provider_config_cli_overrides_win_over_daemon_fields() { + let mut config = base_config(); + config.provider_fields = + fields(&[("token", "cfg-token"), ("public_url", "https://a.test")]); + // G3: a present override wins; an absent one keeps the configured value. + let overrides = fields(&[("token", "cli-token")]); + let cfg = config.provider_config(&overrides); + assert_eq!(cfg.get("token"), Some("cli-token"), "CLI override must win"); + assert_eq!( + cfg.get("public_url"), + Some("https://a.test"), + "absent override keeps the configured value" + ); + } + + #[test] + fn provider_config_skips_empty_values_on_both_sides() { + let mut config = base_config(); + // An empty configured field is skipped (treated as unset). + config.provider_fields = fields(&[("token", ""), ("public_url", "https://a.test")]); + // An empty override is skipped too — it must NOT clear a configured value. + let overrides = fields(&[("public_url", "")]); + let cfg = config.provider_config(&overrides); + assert_eq!(cfg.get("token"), None, "empty configured field is skipped"); + assert_eq!( + cfg.get("public_url"), + Some("https://a.test"), + "empty override must not clear the configured value" + ); + } + + #[test] + fn provider_config_override_extends_with_new_field() { + let config = base_config(); // no configured fields + let overrides = fields(&[("token", "cli-only")]); + let cfg = config.provider_config(&overrides); + assert_eq!(cfg.get("token"), Some("cli-only")); + } + + // ---- build_auth: read-only token branches (SEC-013) ---- + + #[test] + fn build_auth_without_readonly_token_is_single_token() { + let token = AccessToken::from_secret_validated(VALID_TOKEN).unwrap(); + // None / empty readonly → single-token model, must succeed. + assert!(build_auth(token.clone(), None).is_ok()); + let token2 = AccessToken::from_secret_validated(VALID_TOKEN).unwrap(); + assert!( + build_auth(token2, Some("")).is_ok(), + "an empty readonly token is treated as absent (single-token model)" + ); + } + + #[test] + fn build_auth_with_valid_readonly_token_succeeds() { + let token = AccessToken::from_secret_validated(VALID_TOKEN).unwrap(); + assert!( + build_auth(token, Some(VALID_READONLY)).is_ok(), + "a valid (>=32 char) readonly token enables the read-only tier" + ); + } + + #[test] + fn build_auth_rejects_weak_readonly_token() { + let token = AccessToken::from_secret_validated(VALID_TOKEN).unwrap(); + // SEC-008: a non-empty but too-short readonly token must fail closed. + // (`AuthState` is not `Debug`, so match the Result rather than `expect_err`.) + match build_auth(token, Some("too-short")) { + Ok(_) => panic!("weak readonly token must be rejected"), + Err(err) => assert!( + err.to_string().contains("readonly_token"), + "error must point at the readonly_token field: {err}" + ), + } + } + + // ---- M2: map_remote_error → RemoteControlError ---- + + #[test] + fn map_remote_error_missing_field_is_bad_config() { + let mapped = map_remote_error(lucarne_remote::RemoteError::MissingField("token".into())); + assert!(matches!(mapped, RemoteControlError::BadConfig(_))); + assert_eq!(mapped.status_code(), axum::http::StatusCode::BAD_REQUEST); + } + + #[test] + fn map_remote_error_parse_is_bad_config() { + let mapped = map_remote_error(lucarne_remote::RemoteError::Parse("bad url".into())); + assert!(matches!(mapped, RemoteControlError::BadConfig(_))); + } + + #[test] + fn map_remote_error_not_found_is_unknown_provider() { + let mapped = map_remote_error(lucarne_remote::RemoteError::NotFound("frp".into())); + assert!(matches!(mapped, RemoteControlError::UnknownProvider(_))); + assert_eq!(mapped.status_code(), axum::http::StatusCode::NOT_FOUND); + } + + #[test] + fn map_remote_error_spawn_and_io_are_backend() { + let spawn = map_remote_error(lucarne_remote::RemoteError::Spawn { + provider: "cloudflared".into(), + message: "no binary".into(), + }); + assert!(matches!(spawn, RemoteControlError::Backend(_))); + assert_eq!(spawn.status_code(), axum::http::StatusCode::BAD_GATEWAY); + + let io = map_remote_error(lucarne_remote::RemoteError::Io(std::io::Error::other( + "boom", + ))); + assert!(matches!(io, RemoteControlError::Backend(_))); + } + + // ---- RemoteExposureManager::status_from: running / idle, token passthrough ---- + + #[test] + fn status_from_running_handle_reports_provider_and_url() { + let ctl = control(base_config(), Some(VALID_TOKEN.to_string())); + let handle = TunnelHandle { + provider_id: "cloudflared".to_string(), + public_url: Url::parse("https://foo-bar.trycloudflare.com/").unwrap(), + opaque: "op-1".to_string(), + }; + let status = ctl.status_from(Some(&handle)); + assert!(status.running); + assert_eq!(status.provider.as_deref(), Some("cloudflared")); + assert_eq!( + status.public_url.as_deref(), + Some("https://foo-bar.trycloudflare.com/") + ); + // L4: the access token is passed through regardless of running state. + assert_eq!(status.access_token.as_deref(), Some(VALID_TOKEN)); + } + + #[test] + fn status_from_none_reports_idle_but_keeps_token() { + let ctl = control(base_config(), Some(VALID_TOKEN.to_string())); + let status = ctl.status_from(None); + assert!(!status.running); + assert_eq!(status.provider, None); + assert_eq!(status.public_url, None); + // Token still surfaced when idle so `lucarned remote start` can render the QR. + assert_eq!(status.access_token.as_deref(), Some(VALID_TOKEN)); + } + + #[test] + fn status_from_passes_through_absent_token() { + // insecure (no token) → access_token is None in both states. + let ctl = control(base_config(), None); + assert_eq!(ctl.status_from(None).access_token, None); + let handle = TunnelHandle { + provider_id: "cloudflared".to_string(), + public_url: Url::parse("https://x.trycloudflare.com/").unwrap(), + opaque: "op".to_string(), + }; + assert_eq!(ctl.status_from(Some(&handle)).access_token, None); + } + + // ---- state machine: the rmux-free reachable branches ---- + + #[tokio::test] + async fn status_when_idle_is_not_running() { + // `status()` on an Idle control needs no rmux: it short-circuits before + // any provider health await. + let ctl = control(base_config(), Some(VALID_TOKEN.to_string())); + let status = ctl.status().await; + assert!(!status.running); + assert_eq!(status.access_token.as_deref(), Some(VALID_TOKEN)); + } + + #[tokio::test] + async fn stop_when_idle_is_idempotent_success() { + // `stop()` on an Idle control is the idempotent no-op path (no rmux / + // provider call): it returns a not-running status without erroring. + let ctl = control(base_config(), Some(VALID_TOKEN.to_string())); + let status = ctl.stop().await.expect("idempotent stop must succeed"); + assert!(!status.running); + assert_eq!(status.provider, None); + } + + #[tokio::test] + async fn second_start_is_rejected_while_starting() { + // Provider-lookup / validate sit behind capability readiness (which + // connects rmux for TerminalGateway), so the reachable rmux-free + // state-machine guard is the busy-state rejection: a control already in + // `Starting` rejects a concurrent start. + let ctl = control(base_config(), Some(VALID_TOKEN.to_string())); + *ctl.state.lock().await = TunnelState::Starting; + let err = ctl + .start(RemoteStartParams::default()) + .await + .expect_err("a start already in progress must be rejected"); + assert!(matches!(err, RemoteControlError::Backend(_))); + } + + #[tokio::test] + async fn start_success_sets_running_and_reuses_gateway_on_idempotent_start() { + let provider = FakeProvider::default(); + let gateway = Arc::new(CountingGatewayStarter::default()); + let ctl = fake_control(provider.clone(), gateway.clone()); + + let status = ctl + .start(RemoteStartParams::default()) + .await + .expect("start succeeds"); + assert!(status.running); + assert_eq!(status.provider.as_deref(), Some("fake")); + assert_eq!( + status.public_url.as_deref(), + Some("https://fake.example.test/") + ); + assert_eq!(provider.starts(), 1); + assert_eq!(gateway.calls(), 1); + + let status = ctl + .start(RemoteStartParams::default()) + .await + .expect("idempotent running start succeeds"); + assert!(status.running); + assert_eq!( + provider.starts(), + 1, + "healthy running tunnel must not start a second provider" + ); + assert_eq!( + gateway.calls(), + 1, + "lazy gateway is guarded and must start once" + ); + } + + #[tokio::test] + async fn start_provider_failure_returns_to_idle_for_retry() { + let provider = FakeProvider::default(); + provider.fail_next_start(); + let gateway = Arc::new(CountingGatewayStarter::default()); + let ctl = fake_control(provider.clone(), gateway.clone()); + + let err = ctl + .start(RemoteStartParams::default()) + .await + .expect_err("first provider start fails"); + assert!(matches!(err, RemoteControlError::Backend(_))); + assert!( + !ctl.status().await.running, + "failed start must return to Idle" + ); + + let status = ctl + .start(RemoteStartParams::default()) + .await + .expect("retry after failed start succeeds"); + assert!(status.running); + assert_eq!(provider.starts(), 2, "retry must call provider.start again"); + assert_eq!( + gateway.calls(), + 1, + "gateway already came up before provider failure and is reused" + ); + } + + #[tokio::test] + async fn start_gateway_failure_returns_to_idle_and_allows_retry() { + let provider = FakeProvider::default(); + let gateway = Arc::new(CountingGatewayStarter::default()); + gateway.fail_next(); + let ctl = fake_control(provider.clone(), gateway.clone()); + + let err = ctl + .start(RemoteStartParams::default()) + .await + .expect_err("first lazy gateway start fails"); + assert!(matches!(err, RemoteControlError::Backend(_))); + assert!( + !ctl.status().await.running, + "gateway failure must return to Idle" + ); + assert_eq!( + provider.starts(), + 0, + "provider must not start until gateway is available" + ); + + let status = ctl + .start(RemoteStartParams::default()) + .await + .expect("retry starts gateway and provider"); + assert!(status.running); + assert_eq!(gateway.calls(), 2); + assert_eq!(provider.starts(), 1); + } + + #[tokio::test] + async fn stop_running_tunnel_transitions_to_idle() { + let provider = FakeProvider::default(); + let gateway = Arc::new(CountingGatewayStarter::default()); + let ctl = fake_control(provider.clone(), gateway); + + ctl.start(RemoteStartParams::default()) + .await + .expect("start succeeds"); + let stopped = ctl.stop().await.expect("stop succeeds"); + assert!(!stopped.running); + assert_eq!(provider.stops(), 1); + assert!(!ctl.status().await.running); + } + + #[tokio::test] + async fn status_reaps_down_tunnel_and_next_start_relaunches() { + let provider = FakeProvider::default(); + let gateway = Arc::new(CountingGatewayStarter::default()); + let ctl = fake_control(provider.clone(), gateway); + + ctl.start(RemoteStartParams::default()) + .await + .expect("start succeeds"); + assert_eq!(provider.starts(), 1); + + provider.report_down_once(); + let status = ctl.status().await; + assert!(!status.running, "health=Down must clear the running handle"); + assert_eq!( + provider.health_checks(), + 1, + "status probes provider health once" + ); + + let relaunched = ctl + .start(RemoteStartParams::default()) + .await + .expect("start after reap succeeds"); + assert!(relaunched.running); + assert_eq!( + provider.starts(), + 2, + "reaped tunnel must be relaunched on the next start" + ); + } + + #[tokio::test] + async fn stop_recoverable_failure_retains_handle_for_retry() { + let provider = FakeProvider::default(); + let gateway = Arc::new(CountingGatewayStarter::default()); + let ctl = fake_control(provider.clone(), gateway); + + ctl.start(RemoteStartParams::default()) + .await + .expect("start succeeds"); + provider.fail_next_stop(); + + let err = ctl + .stop() + .await + .expect_err("recoverable stop failure is surfaced"); + assert!(matches!(err, RemoteControlError::Backend(_))); + assert!( + ctl.status().await.running, + "recoverable stop failure must retain the running handle" + ); + + let stopped = ctl.stop().await.expect("retry stop succeeds"); + assert!(!stopped.running); + assert_eq!(provider.stops(), 2, "retry must call provider.stop again"); + } +} diff --git a/crates/lucarned/src/remote_cli.rs b/crates/lucarned/src/remote_cli.rs new file mode 100644 index 0000000..f2b5256 --- /dev/null +++ b/crates/lucarned/src/remote_cli.rs @@ -0,0 +1,148 @@ +//! Headless remote-access CLI for `lucarned remote start|stop|status`. +//! +//! This is intentionally a thin loopback control-plane client. The daemon still +//! owns the tunnel lifecycle in [`crate::remote`]; the CLI only asks the running +//! daemon to start, stop, or report status. + +use lucarned_ctl::{RemoteCommand, RemoteCommandOptions}; +use serde::Deserialize; +use tracing::{debug, warn}; + +const DEFAULT_CONTROL_PORT: u16 = 7801; + +#[derive(Debug, Clone, Default, Deserialize)] +struct RemoteStatus { + running: bool, + provider: Option, + public_url: Option, + access_token: Option, +} + +pub fn run_remote_command(command: RemoteCommand) -> Result<(), String> { + let (status, json) = match command { + RemoteCommand::Start(options) => (call_start(&options)?, options.json), + RemoteCommand::Stop(options) => (call_empty_body("stop", &options)?, options.json), + RemoteCommand::Status(options) => (call_empty_body("status", &options)?, options.json), + }; + print_status(&status, json) +} + +fn call_start(options: &RemoteCommandOptions) -> Result { + let url = control_url(options.control_port, "start"); + let body = serde_json::json!({ + "provider": options.provider.clone().unwrap_or_default(), + "fields": options.fields, + }); + let client = reqwest::blocking::Client::new(); + send_control(client.post(&url).json(&body), &url) +} + +fn call_empty_body(verb: &str, options: &RemoteCommandOptions) -> Result { + let url = control_url(options.control_port, verb); + let client = reqwest::blocking::Client::new(); + let request = if verb == "status" { + client.get(&url) + } else { + client.post(&url) + }; + send_control(request, &url) +} + +fn send_control( + request: reqwest::blocking::RequestBuilder, + url: &str, +) -> Result { + debug!(target: "lucarned::remote_cli", %url, "sending loopback remote control request"); + let resp = match request.send() { + Ok(resp) => resp, + Err(e) => { + warn!( + target: "lucarned::remote_cli", + %url, + error = %e, + "failed to reach loopback remote control plane" + ); + return Err(format!("failed to reach daemon at {url}: {e}")); + } + }; + if !resp.status().is_success() { + let code = resp.status(); + let detail = resp.text().unwrap_or_default(); + warn!( + target: "lucarned::remote_cli", + %url, + status = %code, + "loopback remote control request failed" + ); + return Err(format!("daemon returned {code}: {detail}")); + } + let status = resp.json::().map_err(|e| { + warn!( + target: "lucarned::remote_cli", + %url, + error = %e, + "failed to parse loopback remote control response" + ); + format!("failed to parse daemon response: {e}") + })?; + debug!( + target: "lucarned::remote_cli", + %url, + running = status.running, + provider = status.provider.as_deref().unwrap_or(""), + has_public_url = status.public_url.is_some(), + has_access_token = status.access_token.is_some(), + "received loopback remote control status" + ); + Ok(status) +} + +fn control_url(control_port: Option, verb: &str) -> String { + format!( + "http://127.0.0.1:{}/api/remote/{verb}", + control_port.unwrap_or(DEFAULT_CONTROL_PORT) + ) +} + +fn print_status(status: &RemoteStatus, json: bool) -> Result<(), String> { + if json { + let value = serde_json::json!({ + "running": status.running, + "provider": status.provider, + "public_url": status.public_url, + "access_token": status.access_token, + }); + println!("{value}"); + return Ok(()); + } + + if !status.running { + println!("remote access: stopped"); + return Ok(()); + } + let provider = status.provider.as_deref().unwrap_or("(unknown provider)"); + let public_url = status.public_url.as_deref().unwrap_or("(no public URL)"); + let token = match status.access_token.as_deref() { + Some(token) if !token.is_empty() => format!("token: {token}"), + _ => "token: none".to_string(), + }; + println!("remote access: running via {provider} - {public_url} - {token}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn control_url_defaults_to_daemon_control_port() { + assert_eq!( + control_url(None, "status"), + "http://127.0.0.1:7801/api/remote/status" + ); + assert_eq!( + control_url(Some(7901), "start"), + "http://127.0.0.1:7901/api/remote/start" + ); + } +} diff --git a/crates/lucarned/src/remote_config.rs b/crates/lucarned/src/remote_config.rs new file mode 100644 index 0000000..439eb9a --- /dev/null +++ b/crates/lucarned/src/remote_config.rs @@ -0,0 +1,262 @@ +//! Typed remote-access config resolution shared by the daemon and tests. +//! +//! Keep the YAML shape, env precedence, token validation, provider-field merge, +//! and loopback/control-port hardening out of `main.rs` so future UI/CLI changes +//! do not grow a second interpretation of `remote:`. + +use std::collections::BTreeMap; +use std::net::SocketAddr; + +use serde::Deserialize; +use serde_json::Value; +use tracing::{debug, warn}; + +use crate::remote; + +/// Default loopback gateway port. +pub const DEFAULT_REMOTE_GATEWAY_PORT: u16 = 7800; +/// Default loopback control-plane port. +pub const DEFAULT_REMOTE_CONTROL_PORT: u16 = 7801; +/// Default loopback bind for the remote-access gateway. This is the ONLY port +/// the public tunnel targets. +pub const DEFAULT_REMOTE_GATEWAY_ADDR: &str = "127.0.0.1:7800"; +/// Default loopback bind for the remote-access control plane. It must stay on a +/// distinct port the public tunnel never targets. +pub const DEFAULT_REMOTE_CONTROL_ADDR: &str = "127.0.0.1:7801"; +/// Default remote-access tunnel provider id. +pub const DEFAULT_REMOTE_PROVIDER: &str = "cloudflared"; + +/// Remote-access (public tunnel) configuration — the `remote:` section of +/// `lucarned.yaml`. Mirrors the public documented schema while leaving provider +/// fields as opaque key/value maps. +#[derive(Clone, Debug, Default, Deserialize)] +pub(crate) struct RemoteFileConfig { + pub enabled: Option, + pub provider: Option, + pub gateway_addr: Option, + pub control_addr: Option, + pub auth_token: Option, + pub readonly_token: Option, + pub insecure: Option, + #[serde(default)] + pub providers: BTreeMap>>, + /// Opaque extra YAML sections. Provider-owned compatibility aliases are + /// discovered through [`lucarne_remote::RemoteAccessProvider`] descriptors. + /// Daemon/common config must not own concrete provider-specific structs. + #[serde(default, flatten)] + pub extra_sections: BTreeMap, +} + +/// Environment read seam so tests and future callers can resolve config without +/// mutating process-global env. +pub(crate) trait RemoteConfigEnv { + fn var(&self, key: &str) -> Option; +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct ProcessEnv; + +impl RemoteConfigEnv for ProcessEnv { + fn var(&self, key: &str) -> Option { + std::env::var(key).ok() + } +} + +/// Resolve the remote-access runtime config from file config + process env. +pub(crate) fn remote_config_from_config( + remote: &RemoteFileConfig, +) -> Result, Box> { + remote_config_from_config_with_env(remote, ProcessEnv) +} + +/// Resolve the remote-access runtime config from file config + injected env. +/// +/// Cold-daemon lazy start: this always resolves to `Some`. `remote.enabled` +/// means autostart, not whether the loopback control plane exists. +pub(crate) fn remote_config_from_config_with_env( + remote: &RemoteFileConfig, + env: E, +) -> Result, Box> { + let autostart = env + .var("LUCARNED_REMOTE_ENABLED") + .as_deref() + .and_then(parse_bool) + .or(remote.enabled) + .unwrap_or(false); + + let provider = env + .var("LUCARNED_REMOTE_PROVIDER") + .or_else(|| remote.provider.clone()) + .unwrap_or_else(|| DEFAULT_REMOTE_PROVIDER.to_string()); + + let gateway_addr_raw = env + .var("LUCARNED_REMOTE_GATEWAY_ADDR") + .or_else(|| remote.gateway_addr.clone()) + .unwrap_or_else(default_remote_gateway_addr); + let gateway_addr = lucarne_termgw::parse_gateway_addr(&gateway_addr_raw)?; + + let control_addr_raw = env + .var("LUCARNED_REMOTE_CONTROL_ADDR") + .or_else(|| remote.control_addr.clone()) + .map(Ok) + .unwrap_or_else(|| derive_control_addr(&gateway_addr_raw, gateway_addr))?; + let control_addr = lucarne_termgw::parse_gateway_addr(&control_addr_raw)?; + ensure_control_plane_off_tunnel(gateway_addr, control_addr)?; + + let auth_token = env + .var("LUCARNED_REMOTE_AUTH_TOKEN") + .filter(|t| !t.is_empty()) + .or_else(|| remote.auth_token.clone().filter(|t| !t.is_empty())); + let readonly_token = env + .var("LUCARNED_REMOTE_READONLY_TOKEN") + .filter(|t| !t.is_empty()) + .or_else(|| remote.readonly_token.clone().filter(|t| !t.is_empty())); + let insecure = env + .var("LUCARNED_REMOTE_INSECURE") + .as_deref() + .and_then(parse_bool) + .or(remote.insecure) + .unwrap_or(false); + + validate_tokens(auth_token.as_deref(), readonly_token.as_deref())?; + let provider_fields = resolve_provider_fields(remote, &provider); + + if insecure { + warn!( + target: "lucarned::remote_config", + "remote config explicitly disables gateway auth" + ); + } + debug!( + target: "lucarned::remote_config", + provider = %provider, + autostart, + gateway_addr = %gateway_addr, + control_addr = %control_addr, + auth_configured = auth_token.is_some(), + readonly_configured = readonly_token.is_some(), + insecure, + provider_field_count = provider_fields.len(), + "resolved remote config" + ); + + Ok(Some(remote::RemoteRuntimeConfig { + provider: provider.clone(), + gateway_addr, + control_addr, + auth_token, + readonly_token, + insecure, + provider_fields, + capability: remote::ExposedCapability::TerminalGateway, + autostart, + })) +} + +fn derive_control_addr( + gateway_addr_raw: &str, + gateway_addr: SocketAddr, +) -> Result> { + if gateway_addr_raw == DEFAULT_REMOTE_GATEWAY_ADDR { + return Ok(default_remote_control_addr()); + } + match gateway_addr.port().checked_add(1) { + Some(port) => Ok(SocketAddr::new(gateway_addr.ip(), port).to_string()), + None => Err(format!( + "remote.gateway_addr port {} leaves no room for a derived control port; \ + set remote.control_addr / --control-port explicitly (L1)", + gateway_addr.port() + ) + .into()), + } +} + +fn default_remote_gateway_addr() -> String { + format!("127.0.0.1:{DEFAULT_REMOTE_GATEWAY_PORT}") +} + +fn default_remote_control_addr() -> String { + debug_assert_eq!( + DEFAULT_REMOTE_CONTROL_ADDR, + format!("127.0.0.1:{DEFAULT_REMOTE_CONTROL_PORT}") + ); + DEFAULT_REMOTE_CONTROL_ADDR.to_string() +} + +fn ensure_control_plane_off_tunnel( + gateway_addr: SocketAddr, + control_addr: SocketAddr, +) -> Result<(), Box> { + if control_addr.port() == gateway_addr.port() { + return Err(format!( + "remote.control_addr ({control_addr}) must use a different port than \ + remote.gateway_addr ({gateway_addr}) so the control plane is off the tunnel (SEC-002)" + ) + .into()); + } + Ok(()) +} + +fn validate_tokens( + auth_token: Option<&str>, + readonly_token: Option<&str>, +) -> Result<(), Box> { + if let Some(token) = auth_token { + lucarne_termgw::AccessToken::from_secret_validated(token.to_string()) + .map_err(|e| -> Box { e.to_string().into() })?; + } + if let Some(token) = readonly_token { + lucarne_termgw::AccessToken::from_secret_validated(token.to_string()).map_err( + |e| -> Box { format!("remote.readonly_token: {e}").into() }, + )?; + } + Ok(()) +} + +/// Assemble the generic provider field map for `provider` (H6c). +/// +/// Precedence: provider-declared compatibility sections first, then +/// `remote.providers.` over them. Empty/null values are absent. +pub(crate) fn resolve_provider_fields( + remote: &RemoteFileConfig, + provider: &str, +) -> BTreeMap { + let mut fields = BTreeMap::new(); + if let Some(provider_descriptor) = lucarne_remote::builtin().lookup(provider) { + for section in provider_descriptor.compat_config_sections() { + if let Some(section_fields) = remote.extra_sections.get(*section) { + merge_provider_section(&mut fields, section_fields); + } + } + } + if let Some(map) = remote.providers.get(provider) { + for (key, value) in map { + if let Some(v) = value.as_deref().filter(|v| !v.is_empty()) { + fields.insert(key.clone(), v.to_string()); + } + } + } + fields +} + +fn merge_provider_section(fields: &mut BTreeMap, section: &Value) { + let Some(map) = section.as_object() else { + return; + }; + for (key, value) in map { + match value { + Value::String(value) if !value.is_empty() => { + fields.insert(key.clone(), value.clone()); + } + _ => {} + } + } +} + +pub(crate) fn parse_bool(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + } +} diff --git a/crates/lucarned/src/tui/app.rs b/crates/lucarned/src/tui/app.rs new file mode 100644 index 0000000..cf8cbdd --- /dev/null +++ b/crates/lucarned/src/tui/app.rs @@ -0,0 +1,267 @@ +//! TUI application state machine. +//! +//! The dashboard is a small enum state machine (Free decision F: opencode-style +//! left list + right detail + bottom hints). `App` tracks the active [`Panel`] +//! and a per-panel [`ListState`] selection. Panel-specific content + actions are +//! filled in by the panel tasks (Sessions → TASK-002, GoPublic → TASK-003, +//! Config → TASK-004); this skeleton only owns navigation + selection so those +//! tasks can run in parallel. + +use ratatui::widgets::ListState; + +use super::config::ConfigPanel; +use super::remote::GoPublicPanel; +use super::sessions::{SessionAction, SessionsPanel}; + +/// The three top-level panels of the dashboard. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Panel { + /// rmux session list + actions (attach / detach / kill / archive). + Sessions, + /// Public tunnel start/status + login QR + access key. + GoPublic, + /// Remote-access provider configuration editor. + Config, +} + +impl Panel { + /// Panels in tab order. + pub const ALL: [Panel; 3] = [Panel::Sessions, Panel::GoPublic, Panel::Config]; + + /// Short title shown in the left list / tab bar. + pub fn title(self) -> &'static str { + match self { + Panel::Sessions => "Sessions", + Panel::GoPublic => "Go Public", + Panel::Config => "Config", + } + } + + /// The next panel in tab order (wraps). + pub fn next(self) -> Panel { + let idx = Self::ALL.iter().position(|&p| p == self).unwrap_or(0); + Self::ALL[(idx + 1) % Self::ALL.len()] + } + + /// The previous panel in tab order (wraps). + pub fn prev(self) -> Panel { + let idx = Self::ALL.iter().position(|&p| p == self).unwrap_or(0); + Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()] + } +} + +/// Top-level dashboard state. +pub struct App { + /// Whether the event loop should keep running. + pub running: bool, + /// The currently focused panel. + pub active: Panel, + /// Selection state for the left panel selector list. Mirrors `active` so the + /// highlighted entry always matches the focused panel. + pub list: ListState, + /// The Sessions panel: rmux session list + actions (TASK-002). + pub sessions: SessionsPanel, + /// The Go-Public panel: tunnel start/stop/status + login QR (TASK-003). + pub go_public: GoPublicPanel, + /// The Config panel: descriptor-driven provider-field editor (TASK-004). + pub config: ConfigPanel, +} + +impl Default for App { + fn default() -> Self { + let mut list = ListState::default(); + // Select the first panel by default (index-bound: ALL is non-empty). + list.select(Some(0)); + Self { + running: true, + active: Panel::Sessions, + list, + sessions: SessionsPanel::new(), + go_public: GoPublicPanel::new(), + config: ConfigPanel::new(), + } + } +} + +impl App { + /// Create a fresh dashboard focused on the Sessions panel. + pub fn new() -> Self { + Self::default() + } + + /// Request the event loop to stop on the next iteration. + pub fn quit(&mut self) { + self.running = false; + } + + /// Switch focus to the next panel and keep the selector list in sync. + pub fn focus_next(&mut self) { + self.active = self.active.next(); + self.sync_list_to_active(); + } + + /// Switch focus to the previous panel and keep the selector list in sync. + pub fn focus_prev(&mut self) { + self.active = self.active.prev(); + self.sync_list_to_active(); + } + + /// Move the active panel's content selection down by one. Routed to the + /// focused panel's own list (Sessions today; GoPublic/Config add theirs in + /// TASK-003/004). Never touches the panel selector. + pub fn select_next(&mut self) { + match self.active { + Panel::Sessions => self.sessions.select_next(), + Panel::Config => self.config.select_next(), + Panel::GoPublic => {} + } + } + + /// Move the active panel's content selection up by one (see [`Self::select_next`]). + pub fn select_previous(&mut self) { + match self.active { + Panel::Sessions => self.sessions.select_previous(), + Panel::Config => self.config.select_previous(), + Panel::GoPublic => {} + } + } + + /// Dispatch a non-navigation key to the active panel. Returns the panel's + /// [`SessionAction`] so the event loop can perform deferred work (the attach + /// pop-out handoff) that needs the terminal owner. The Sessions panel is the + /// only one with deferred work; the Go-Public and Config panels run inline and + /// always resolve to [`SessionAction::None`]. + /// + /// PART 1: when the Go-Public panel is active and the key is `s` (start), the + /// App BRIDGES the two panels it owns — it reads the Config panel's live + /// [`start_params`](ConfigPanel::start_params) and drives + /// [`GoPublicPanel::start_with`] with them, so the operator's in-TUI provider + + /// fields are used without saving `lucarned.yaml` first (an empty Config → + /// empty params → the daemon's pre-configured tunnel). Every other Go-Public + /// key (`x`/`r`/`Enter`/`Esc`) is routed to the panel as before. + pub fn handle_panel_key(&mut self, code: crossterm::event::KeyCode) -> SessionAction { + use crossterm::event::KeyCode; + match self.active { + Panel::Sessions => self.sessions.handle_key(code), + Panel::GoPublic => { + // Bridge: `s` starts using the Config panel's live edits (PART 1); + // the QR modal (when open) still consumes keys via handle_key below. + if code == KeyCode::Char('s') && !self.go_public.qr_open { + let (provider, fields) = self.config.start_params(); + self.go_public.start_with(provider, fields); + } else { + self.go_public.handle_key(code); + } + SessionAction::None + } + Panel::Config => { + self.config.handle_key(code); + SessionAction::None + } + } + } + + /// Whether the active panel has a modal open that must consume `q`/`Esc` + /// (so those keys close the modal / are captured by an inline editor instead + /// of quitting the app). The Go-Public login-QR modal does; so does the Config + /// panel's inline field editor (so a literal `q` types into the field and `Esc` + /// cancels the edit rather than quitting). + pub fn modal_open(&self) -> bool { + (matches!(self.active, Panel::GoPublic) && self.go_public.qr_open) + || (matches!(self.active, Panel::Config) && self.config.editing.is_some()) + } + + /// Mirror the active panel into the selector list selection. + fn sync_list_to_active(&mut self) { + if let Some(idx) = Panel::ALL.iter().position(|&p| p == self.active) { + self.list.select(Some(idx)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn panel_tab_order_wraps() { + assert_eq!(Panel::Sessions.next(), Panel::GoPublic); + assert_eq!(Panel::GoPublic.next(), Panel::Config); + assert_eq!(Panel::Config.next(), Panel::Sessions); + assert_eq!(Panel::Sessions.prev(), Panel::Config); + } + + #[test] + fn focus_next_syncs_list_and_active() { + let mut app = App::new(); + assert_eq!(app.active, Panel::Sessions); + assert_eq!(app.list.selected(), Some(0)); + app.focus_next(); + assert_eq!(app.active, Panel::GoPublic); + assert_eq!(app.list.selected(), Some(1)); + } + + #[test] + fn focus_prev_wraps_and_syncs_list() { + let mut app = App::new(); + app.focus_prev(); + assert_eq!(app.active, Panel::Config); + assert_eq!(app.list.selected(), Some(2)); + } + + #[test] + fn navigation_on_non_sessions_panel_is_noop() { + let mut app = App::new(); + app.focus_next(); // GoPublic — no content list yet + assert_eq!(app.active, Panel::GoPublic); + // Must not panic and must leave the selector untouched. + app.select_next(); + app.select_previous(); + assert_eq!(app.active, Panel::GoPublic); + assert_eq!(app.list.selected(), Some(1)); + } + + #[test] + fn quit_stops_running() { + let mut app = App::new(); + assert!(app.running); + app.quit(); + assert!(!app.running); + } + + #[test] + fn go_public_start_uses_config_params_and_validation_blocks_bad_config() { + // PART 1: pressing `s` on the Go-Public panel must bridge through the App to + // the Config panel's live start_params. When the Config provider has a + // missing REQUIRED field, the provider's validate_config rejects it inline + // and NOTHING is sent (no daemon needed for this path). + use crossterm::event::KeyCode; + let mut app = App::new(); + app.active = Panel::GoPublic; + + // Configure cloudflared with NO fields. cloudflared requires a tunnel + // `token` for a named tunnel; with a token absent but a public_url present + // it would be invalid — but more simply, drive a config the descriptor + // rejects. We set a provider + a token-gated public_url is required when a + // token is set, so set a token and omit the conditionally-required field. + app.config.edits.provider = "cloudflared".to_string(); + app.config + .edits + .fields + .insert("token".to_string(), "abc".to_string()); + // The conditional `public_url` (required_when token present) is omitted, so + // validate_config must fail and block the start before any network call. + + let before = app.go_public.message.clone(); + app.handle_panel_key(KeyCode::Char('s')); + let msg = app.go_public.message.clone(); + assert_ne!(msg, before, "a blocked start must surface a message"); + assert!( + msg.as_deref().unwrap_or("").starts_with("start blocked — "), + "validation failure must block + surface inline, got: {msg:?}" + ); + // No tunnel status was set (the start was blocked before sending). + assert!(app.go_public.status.is_none()); + assert!(!app.go_public.qr_open); + } +} diff --git a/crates/lucarned/src/tui/config.rs b/crates/lucarned/src/tui/config.rs new file mode 100644 index 0000000..cd0750e --- /dev/null +++ b/crates/lucarned/src/tui/config.rs @@ -0,0 +1,1293 @@ +//! Config panel — a remote-access provider editor driven ENTIRELY by the +//! built-in provider descriptors (`lucarne_remote::builtin()`), with edits saved +//! back to `lucarned.yaml` via the in-crate +//! [`write_config_with_backup`](crate::onboarding::config::write_config_with_backup) +//! (backup + atomic tmp+rename — Decision 5). +//! +//! Provider boundary (AGENTS.md): the panel NEVER enumerates concrete provider +//! field names. It lists providers from the registry and, for the selected +//! provider, builds a form from `RemoteAccessProvider::required_fields()` +//! (`RequiredField { key, label, secret, required }`). Secret fields are masked +//! on display AND while editing (the secret is never echoed). Only top-level +//! remote daemon config keys and provider descriptor fields are editable here. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use crossterm::event::KeyCode; +use ratatui::widgets::ListState; + +use lucarne_remote::ProviderConfig; + +use crate::remote_config; + +/// Default gateway/control ports come from the daemon's typed remote config +/// service, not duplicated literals in the TUI. +const DEFAULT_GATEWAY_PORT: u16 = remote_config::DEFAULT_REMOTE_GATEWAY_PORT; +const DEFAULT_CONTROL_PORT: u16 = remote_config::DEFAULT_REMOTE_CONTROL_PORT; + +// ---- Config panel (TASK-004): descriptor-driven form + YAML write-back ---- + +/// One editable row in the config form. Top-level rows are panel-owned daemon +/// config; [`Field`](Row::Field) rows are built one-per-descriptor-entry from the +/// selected provider's `required_fields()` — the panel never names a concrete +/// provider field itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Row { + /// `remote.enabled`: true autostarts the tunnel at daemon boot; false keeps + /// the loopback control plane ready and starts lazily. + Enabled, + /// The selected `remote.provider` top-level key. Editing cycles through the + /// registered provider ids (a closed set — never free-typed). + Provider, + /// The `remote.gateway_addr` loopback port. + GatewayPort, + /// The `remote.control_addr` loopback port. + ControlPort, + /// `remote.auth_token`, optional full-access bearer token. + AuthToken, + /// `remote.readonly_token`, optional read-only bearer token. + ReadonlyToken, + /// `remote.insecure`, explicit no-auth opt-out. + Insecure, + /// One provider field from the descriptor: `remote.providers..`. + Field { + /// Stable machine key from [`RequiredField::key`]. + key: &'static str, + /// Human-readable label from [`RequiredField::label`]. + label: &'static str, + /// Whether the value is sensitive (masked on display + while editing). + secret: bool, + /// Whether the provider requires this field. + required: bool, + }, +} + +/// Render one config row's current display value. Secret field values are MASKED +/// (`••••` when set, `(unset)` when empty) so a token is never echoed; non-secret +/// values are shown verbatim. The `provider`/port rows are always plain. +fn display_value(row: &Row, edits: &ConfigEdits) -> String { + match row { + Row::Enabled => edits.enabled.to_string(), + Row::Provider => edits.provider.clone(), + Row::GatewayPort => edits.gateway_port.to_string(), + Row::ControlPort => edits.control_port.to_string(), + Row::AuthToken => mask_value(edits.auth_token.as_deref().unwrap_or(""), true), + Row::ReadonlyToken => mask_value(edits.readonly_token.as_deref().unwrap_or(""), true), + Row::Insecure => edits.insecure.to_string(), + Row::Field { key, secret, .. } => { + let value = edits.fields.get(*key).map(String::as_str).unwrap_or(""); + mask_value(value, *secret) + } + } +} + +/// Mask a value for display: secret + non-empty → `••••` (fixed length, never the +/// real length so width is not a hint); secret + empty → `(unset)`; non-secret → +/// the value verbatim (or `(unset)` when empty). +fn mask_value(value: &str, secret: bool) -> String { + if value.is_empty() { + return "(unset)".to_string(); + } + if secret { + "••••".to_string() + } else { + value.to_string() + } +} + +/// The panel-owned edit buffer: the values the user is editing, kept separate +/// from the parsed-YAML source so a save merges these over the file (preserving +/// unrelated keys). Top-level remote keys are generic daemon config; `fields` +/// are the selected provider's opaque field map (`key` → value), keyed by +/// descriptor [`RequiredField::key`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ConfigEdits { + /// `remote.enabled`: autostart flag. Lazy start still works when false. + pub enabled: bool, + /// Selected `remote.provider` id. + pub provider: String, + /// `remote.gateway_addr` loopback port. + pub gateway_port: u16, + /// `remote.control_addr` loopback port. + pub control_port: u16, + /// The selected provider's field values (`key` → value); empty values are + /// dropped on save so they never shadow a "leave blank" default. + pub fields: BTreeMap, + /// Optional full-access bearer token. + pub auth_token: Option, + /// Optional read-only bearer token. + pub readonly_token: Option, + /// Explicit no-auth opt-out. + pub insecure: bool, +} + +/// The Config panel: a descriptor-driven provider-field editor. +/// +/// Holds the registered provider ids, the in-progress [`ConfigEdits`], the form +/// rows for the selected provider, list/cursor state, an inline edit buffer, and +/// a status line. All provider specifics flow through `lucarne_remote` descriptors +/// — the panel only routes opaque ids + descriptor-supplied keys (AGENTS.md). +pub struct ConfigPanel { + /// Registered provider ids (registration order), from `builtin().ids()`. + pub providers: Vec<&'static str>, + /// Index into [`providers`](Self::providers) of the selected provider. + pub provider_index: usize, + /// The current edit buffer merged onto `lucarned.yaml` on save. + pub edits: ConfigEdits, + /// The form rows for the selected provider (top-level keys + descriptor fields). + pub rows: Vec, + /// Cursor over [`rows`](Self::rows). + pub list: ListState, + /// When editing a row, the in-progress input buffer (raw, even for secrets). + pub editing: Option, + /// Last status / error / save-result line. + pub status: Option, + /// Resolved `lucarned.yaml` path (the daemon's resolution); `None` if it could + /// not be resolved (no `$LUCARNE_CONFIG` and no home dir). + pub config_path: Option, +} + +impl Default for ConfigPanel { + fn default() -> Self { + let providers = lucarne_remote::builtin().ids(); + let provider_index = 0; + let provider = providers.first().copied().unwrap_or("").to_string(); + let edits = ConfigEdits { + enabled: false, + provider, + gateway_port: DEFAULT_GATEWAY_PORT, + control_port: DEFAULT_CONTROL_PORT, + fields: BTreeMap::new(), + auth_token: None, + readonly_token: None, + insecure: false, + }; + let rows = build_rows(edits.provider.as_str()); + let mut list = ListState::default(); + if !rows.is_empty() { + list.select(Some(0)); + } + Self { + providers, + provider_index, + edits, + rows, + list, + editing: None, + status: None, + config_path: None, + } + } +} + +impl ConfigPanel { + /// Build a fresh panel. Construction is I/O-free + test-friendly; call + /// [`Self::load`] once to resolve the config path and seed values from + /// `lucarned.yaml`. + pub fn new() -> Self { + Self::default() + } + + /// The live start parameters the Go-Public panel's `s` key should use (PART 1): + /// the currently-selected provider id and its NON-EMPTY field values, taken + /// straight from the in-TUI edit buffer (no `lucarned.yaml` save required). The + /// daemon merges these over its pre-config (G3), so configuring here and then + /// pressing `s` in Go Public "configures + goes public" entirely inside the TUI. + /// + /// Pure + I/O-free: empty field values are skipped so a blank optional field + /// never shadows a daemon default. An empty `provider` (the degenerate + /// no-registered-providers case) yields empty params → the daemon falls back to + /// its pre-configured tunnel. + pub fn start_params(&self) -> (String, BTreeMap) { + let fields = self + .edits + .fields + .iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + (self.edits.provider.clone(), fields) + } + + /// Resolve the daemon config path (`$LUCARNE_CONFIG`/`$LUCARNED_CONFIG` or + /// `~/.lucarned/lucarned.yaml`) and seed the edit buffer from its current + /// `remote:` section when the file exists. Never panics on a missing file — + /// the form just opens with defaults and the status offers to create it. + pub fn load(&mut self) { + let path = crate::onboarding::resolve_init_config_path(); + self.config_path = path.clone(); + match path.as_ref().and_then(|p| std::fs::read_to_string(p).ok()) { + Some(raw) => { + self.seed_from_yaml(&raw); + self.status = Some(format!( + "loaded {}", + self.config_path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default() + )); + } + None => { + self.status = Some(match &self.config_path { + Some(p) => format!("{} not found — Enter to edit, S to create", p.display()), + None => "could not resolve lucarned.yaml path (set LUCARNE_CONFIG)".to_string(), + }); + } + } + self.reload_rows(); + } + + /// Seed the edit buffer from a parsed `lucarned.yaml` string: the selected + /// provider, the gateway/control ports, and the selected provider's field + /// values. Unknown / malformed values fall back to the existing defaults. + fn seed_from_yaml(&mut self, raw: &str) { + let Ok(value) = serde_yaml::from_str::(raw) else { + return; + }; + #[derive(serde::Deserialize)] + struct RemoteSeed { + #[serde(default)] + remote: remote_config::RemoteFileConfig, + } + if let Ok(parsed) = serde_yaml::from_str::(raw).map(|seed| seed.remote) { + // Only adopt a provider we actually know about (opaque routing); an + // unknown id keeps the default selection. + if let Some(provider) = parsed.provider.as_deref() { + if let Some(idx) = self.providers.iter().position(|p| *p == provider) { + self.provider_index = idx; + self.edits.provider = provider.to_string(); + } + } + self.edits.enabled = parsed.enabled.unwrap_or(self.edits.enabled); + self.edits.auth_token = parsed.auth_token.filter(|v| !v.is_empty()); + self.edits.readonly_token = parsed.readonly_token.filter(|v| !v.is_empty()); + self.edits.insecure = parsed.insecure.unwrap_or(false); + } + let remote = mapping_get(&value, "remote"); + if let Some(port) = remote + .and_then(|r| mapping_get(r, "gateway_addr")) + .and_then(serde_yaml::Value::as_str) + .and_then(port_of_addr) + { + self.edits.gateway_port = port; + } + if let Some(port) = remote + .and_then(|r| mapping_get(r, "control_addr")) + .and_then(serde_yaml::Value::as_str) + .and_then(port_of_addr) + { + self.edits.control_port = port; + } + // Provider field values come from remote.providers..* — opaque + // key→value pairs, never interpreted here. + seed_fields_for_provider(&value, self.edits.provider.as_str(), &mut self.edits.fields); + } + + /// Re-seed the edit buffer's `fields` for the CURRENTLY-selected provider from + /// the on-disk `lucarned.yaml` (COR-001). Called on a provider change so + /// switching providers — and switching back — restores a provider's persisted + /// `remote.providers..*` values instead of leaving them blanked (which a + /// subsequent save would otherwise wipe via `merge_config_yaml`). A missing / + /// unreadable / malformed file just leaves the fields empty (no panic). + fn reseed_fields_from_file(&mut self) { + self.edits.fields.clear(); + let Some(raw) = self + .config_path + .as_ref() + .and_then(|p| std::fs::read_to_string(p).ok()) + else { + return; + }; + let Ok(value) = serde_yaml::from_str::(&raw) else { + return; + }; + seed_fields_for_provider(&value, self.edits.provider.as_str(), &mut self.edits.fields); + } + + /// Rebuild the form rows for the currently-selected provider and clamp the + /// cursor back into range via the shared [`super::nav::clamp`] + /// ([`super::nav::EmptyPolicy::SelectFirst`]: a non-empty form always keeps a + /// focused row; an empty form clears the selection). + fn reload_rows(&mut self) { + self.rows = build_rows(self.edits.provider.as_str()); + super::nav::clamp( + &mut self.list, + self.rows.len(), + super::nav::EmptyPolicy::SelectFirst, + ); + } + + /// The display string for the row under the cursor (used by the renderer). + pub fn row_display(&self, row: &Row) -> String { + display_value(row, &self.edits) + } + + /// Move the cursor down one row (clamped; ignored while editing). Delegates the + /// clamp/step to the shared [`super::nav::step`]. + pub fn select_next(&mut self) { + if self.editing.is_some() { + return; + } + super::nav::step(&mut self.list, self.rows.len(), true); + } + + /// Move the cursor up one row (clamped; ignored while editing). Delegates the + /// clamp/step to the shared [`super::nav::step`]. + pub fn select_previous(&mut self) { + if self.editing.is_some() { + return; + } + super::nav::step(&mut self.list, self.rows.len(), false); + } + + /// The row currently under the cursor, if any. + fn selected_row(&self) -> Option { + self.list.selected().and_then(|i| self.rows.get(i).cloned()) + } + + /// Handle a key for the Config panel. + /// + /// Not editing: `Enter` begins editing the selected row (the `Provider` row + /// instead CYCLES to the next registered id — a closed set), `s` saves the + /// config. While editing: printable chars append, `Backspace` deletes, `Enter` + /// commits the buffer, `Esc` cancels. Secret rows capture chars but the + /// renderer masks them — the typed value is never echoed. + pub fn handle_key(&mut self, code: KeyCode) { + if self.editing.is_some() { + self.handle_edit_key(code); + return; + } + match code { + KeyCode::Enter => self.begin_edit(), + KeyCode::Char('s') | KeyCode::Char('S') => self.save(), + _ => {} + } + } + + /// Begin editing the selected row. The `Provider` row is a closed-set cycle + /// (no free text), so `Enter` there advances to the next registered provider + /// and boolean rows toggle instead of opening a buffer. + /// The edit buffer starts EMPTY (typing replaces, not appends); committing an + /// empty buffer clears a field / cancels a port edit. + fn begin_edit(&mut self) { + match self.selected_row() { + Some(Row::Enabled) => { + self.edits.enabled = !self.edits.enabled; + self.status = Some(format!("autostart → {}", self.edits.enabled)); + } + Some(Row::Provider) => self.cycle_provider(), + Some(Row::Insecure) => { + self.edits.insecure = !self.edits.insecure; + self.status = Some(format!("insecure → {}", self.edits.insecure)); + } + Some(_) => self.editing = Some(String::new()), + None => {} + } + } + + /// Advance the selected provider to the next registered id (wraps) and rebuild + /// the form from the new provider's descriptor. + fn cycle_provider(&mut self) { + if self.providers.is_empty() { + return; + } + self.provider_index = (self.provider_index + 1) % self.providers.len(); + self.edits.provider = self.providers[self.provider_index].to_string(); + // A different provider has a different field set. Re-seed the new + // provider's persisted values from `lucarned.yaml` (COR-001) so visiting a + // provider — and a later save — never wipes its on-disk fields; then + // rebuild the rows from the new descriptor. + self.reseed_fields_from_file(); + self.reload_rows(); + self.status = Some(format!("provider → {}", self.edits.provider)); + } + + /// Apply one key while an inline edit buffer is open. + fn handle_edit_key(&mut self, code: KeyCode) { + match code { + KeyCode::Char(c) => { + if let Some(buf) = self.editing.as_mut() { + buf.push(c); + } + } + KeyCode::Backspace => { + if let Some(buf) = self.editing.as_mut() { + buf.pop(); + } + } + KeyCode::Esc => { + self.editing = None; + self.status = Some("edit cancelled".to_string()); + } + KeyCode::Enter => self.commit_edit(), + _ => {} + } + } + + /// Commit the inline edit buffer into the edit state for the selected row. + /// An empty buffer on a port row keeps the current value (a no-op cancel); + /// an empty buffer on a field row clears that field. + fn commit_edit(&mut self) { + let Some(buf) = self.editing.take() else { + return; + }; + let buf = buf.trim().to_string(); + match self.selected_row() { + Some(Row::Enabled) => {} + Some(Row::GatewayPort) if buf.is_empty() => {} + Some(Row::GatewayPort) => match buf.parse::() { + Ok(p) => self.edits.gateway_port = p, + Err(_) => self.status = Some(format!("invalid port `{buf}`")), + }, + Some(Row::ControlPort) if buf.is_empty() => {} + Some(Row::ControlPort) => match buf.parse::() { + Ok(p) => self.edits.control_port = p, + Err(_) => self.status = Some(format!("invalid port `{buf}`")), + }, + Some(Row::AuthToken) => { + self.edits.auth_token = if buf.is_empty() { None } else { Some(buf) }; + } + Some(Row::ReadonlyToken) => { + self.edits.readonly_token = if buf.is_empty() { None } else { Some(buf) }; + } + Some(Row::Insecure) => {} + Some(Row::Field { key, .. }) => { + if buf.is_empty() { + self.edits.fields.remove(key); + } else { + self.edits.fields.insert(key.to_string(), buf); + } + } + _ => {} + } + } + + /// Validate (via the provider descriptor) then save the edits back to + /// `lucarned.yaml`: merge over the existing file (preserving unrelated keys) + /// and write via the in-crate [`write_config_with_backup`] + /// (crate::onboarding::config::write_config_with_backup) — backup + atomic + /// tmp+rename. The status line reports the backup path / validation error / + /// failure; never panics. + pub fn save(&mut self) { + // Provider-side validation lives in the descriptor (AGENTS.md): build the + // opaque ProviderConfig and let the provider enforce its own rules. + if let Err(e) = self.validate() { + self.status = Some(format!("validation failed: {e}")); + return; + } + let Some(path) = self.config_path.clone() else { + self.status = Some("no config path resolved (set LUCARNE_CONFIG)".to_string()); + return; + }; + // Merge over the existing file when present; otherwise start from an empty + // mapping so a missing file is created with just the remote section. + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + let merged = match merge_config_yaml(&existing, &self.edits) { + Ok(m) => m, + Err(e) => { + self.status = Some(format!("failed to render config: {e}")); + return; + } + }; + match crate::onboarding::config::write_config_with_backup(&path, &merged) { + Ok(()) => { + self.status = Some(format!("saved {} (previous backed up)", path.display())); + } + Err(e) => { + self.status = Some(format!("save failed: {e}")); + } + } + } + + /// Validate the current edits against the selected provider's descriptor. + fn validate(&self) -> Result<(), String> { + // COR-007 / SEC-002: mirror the daemon's `remote_config_from_config` + // check — the control plane must bind a DISTINCT loopback port the tunnel + // never targets, so a shared gateway/control port is rejected before any + // write (otherwise the panel could persist a config the daemon refuses). + if self.edits.control_port == self.edits.gateway_port { + return Err(format!( + "control port ({}) must differ from the gateway port ({}) so the control plane \ + stays off the tunnel (SEC-002)", + self.edits.control_port, self.edits.gateway_port + )); + } + if let Some(token) = self.edits.auth_token.as_ref() { + lucarne_termgw::AccessToken::from_secret_validated(token.clone()) + .map_err(|e| format!("auth_token: {e}"))?; + } + if let Some(token) = self.edits.readonly_token.as_ref() { + lucarne_termgw::AccessToken::from_secret_validated(token.clone()) + .map_err(|e| format!("readonly_token: {e}"))?; + } + let registry = lucarne_remote::builtin(); + let provider = registry + .get(&self.edits.provider) + .ok_or_else(|| format!("unknown provider `{}`", self.edits.provider))?; + let mut cfg = ProviderConfig::new(); + for (k, v) in &self.edits.fields { + if !v.is_empty() { + cfg.fields.insert(k.clone(), v.clone()); + } + } + provider.validate_config(&cfg) + } +} + +/// Build the form rows for `provider`: the panel-owned top-level keys followed by +/// one row per descriptor [`RequiredField`]. The field rows come ENTIRELY from +/// `required_fields()` — the panel never names a concrete provider field. +pub fn build_rows(provider: &str) -> Vec { + let mut rows = vec![ + Row::Enabled, + Row::Provider, + Row::GatewayPort, + Row::ControlPort, + Row::AuthToken, + Row::ReadonlyToken, + Row::Insecure, + ]; + let registry = lucarne_remote::builtin(); + if let Some(p) = registry.get(provider) { + for field in p.required_fields() { + rows.push(Row::Field { + key: field.key, + label: field.label, + secret: field.secret, + required: field.required, + }); + } + } + rows +} + +/// Merge `edits` into the parsed `lucarned.yaml` `raw`, PRESERVING every unrelated +/// key/section, and return the re-serialized YAML. Only the `remote` top-level +/// fields owned by the panel and the selected provider's +/// `remote.providers..*` field map are touched; empty field values are +/// removed so a blanked secret never lingers. An empty/blank `raw` starts from +/// an empty mapping (so a missing file is created with only the remote section). +pub fn merge_config_yaml(raw: &str, edits: &ConfigEdits) -> Result { + let mut value: serde_yaml::Value = if raw.trim().is_empty() { + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()) + } else { + serde_yaml::from_str(raw)? + }; + let root = ensure_mapping(&mut value); + let remote = ensure_child_mapping(root, "remote"); + remote.insert( + serde_yaml::Value::String("enabled".to_string()), + serde_yaml::Value::Bool(edits.enabled), + ); + remote.insert( + serde_yaml::Value::String("provider".to_string()), + serde_yaml::Value::String(edits.provider.clone()), + ); + remote.insert( + serde_yaml::Value::String("gateway_addr".to_string()), + serde_yaml::Value::String(format!("127.0.0.1:{}", edits.gateway_port)), + ); + remote.insert( + serde_yaml::Value::String("control_addr".to_string()), + serde_yaml::Value::String(format!("127.0.0.1:{}", edits.control_port)), + ); + set_optional_string(remote, "auth_token", edits.auth_token.as_deref()); + set_optional_string(remote, "readonly_token", edits.readonly_token.as_deref()); + remote.insert( + serde_yaml::Value::String("insecure".to_string()), + serde_yaml::Value::Bool(edits.insecure), + ); + let providers = ensure_child_mapping(remote, "providers"); + let provider_map = ensure_child_mapping(providers, edits.provider.as_str()); + // For every key the selected provider's descriptor advertises, the edit state + // is the source of truth: present (non-empty) → upsert; absent/empty → remove + // (so a cleared secret disappears rather than lingering). Non-descriptor keys + // the user may have set manually are left untouched. + let descriptor_keys: Vec<&'static str> = lucarne_remote::builtin() + .get(edits.provider.as_str()) + .map(|p| p.required_fields().iter().map(|f| f.key).collect()) + .unwrap_or_default(); + for key in descriptor_keys { + let yaml_key = serde_yaml::Value::String(key.to_string()); + match edits.fields.get(key) { + Some(val) if !val.is_empty() => { + provider_map.insert(yaml_key, serde_yaml::Value::String(val.clone())); + } + _ => { + provider_map.remove(&yaml_key); + } + } + } + // Also upsert any extra edited fields that are not descriptor keys (defensive; + // the panel only ever edits descriptor-driven rows, but keep edits authoritative). + for (key, val) in &edits.fields { + if val.is_empty() { + continue; + } + let yaml_key = serde_yaml::Value::String(key.clone()); + provider_map.insert(yaml_key, serde_yaml::Value::String(val.clone())); + } + serde_yaml::to_string(&value) +} + +fn set_optional_string(mapping: &mut serde_yaml::Mapping, key: &str, value: Option<&str>) { + let yaml_key = serde_yaml::Value::String(key.to_string()); + match value.filter(|v| !v.is_empty()) { + Some(value) => { + mapping.insert(yaml_key, serde_yaml::Value::String(value.to_string())); + } + None => { + mapping.remove(&yaml_key); + } + } +} + +/// Look up a child of a YAML mapping by string key. +fn mapping_get<'a>(value: &'a serde_yaml::Value, key: &str) -> Option<&'a serde_yaml::Value> { + value + .as_mapping()? + .get(serde_yaml::Value::String(key.to_string())) +} + +/// Clear `fields` and re-seed it from `remote.providers..*` of a parsed +/// `lucarned.yaml` `value`: opaque `key → value` pairs, never interpreted here. +/// Empty values are dropped. Shared by the initial seed and the provider-change +/// re-seed (COR-001) so both restore the same persisted field set. +fn seed_fields_for_provider( + value: &serde_yaml::Value, + provider: &str, + fields: &mut BTreeMap, +) { + fields.clear(); + if let Some(map) = mapping_get(value, "remote") + .and_then(|r| mapping_get(r, "providers")) + .and_then(|p| mapping_get(p, provider)) + .and_then(serde_yaml::Value::as_mapping) + { + for (k, v) in map { + if let (Some(k), Some(v)) = (k.as_str(), v.as_str()) { + if !v.is_empty() { + fields.insert(k.to_string(), v.to_string()); + } + } + } + } +} + +/// Parse the port out of a `host:port` address string. +fn port_of_addr(addr: &str) -> Option { + addr.rsplit_once(':').and_then(|(_, p)| p.parse().ok()) +} + +/// Force `value` to be a mapping (replacing a non-mapping), returning it. +fn ensure_mapping(value: &mut serde_yaml::Value) -> &mut serde_yaml::Mapping { + if !matches!(value, serde_yaml::Value::Mapping(_)) { + *value = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + } + match value { + serde_yaml::Value::Mapping(mapping) => mapping, + _ => unreachable!("value was forced to mapping"), + } +} + +/// Ensure `mapping[key]` is a child mapping (creating/replacing as needed), +/// returning it. Preserves an existing child mapping's contents. +fn ensure_child_mapping<'a>( + mapping: &'a mut serde_yaml::Mapping, + key: &str, +) -> &'a mut serde_yaml::Mapping { + let yaml_key = serde_yaml::Value::String(key.to_string()); + if !matches!(mapping.get(&yaml_key), Some(serde_yaml::Value::Mapping(_))) { + mapping.insert( + yaml_key.clone(), + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()), + ); + } + match mapping.get_mut(&yaml_key) { + Some(serde_yaml::Value::Mapping(child)) => child, + _ => unreachable!("child was forced to mapping"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_edits() -> ConfigEdits { + ConfigEdits { + enabled: false, + provider: "cloudflared".to_string(), + gateway_port: 7800, + control_port: 7801, + fields: BTreeMap::new(), + auth_token: None, + readonly_token: None, + insecure: false, + } + } + + #[test] + fn rows_built_from_provider_required_fields() { + // The form is driven ENTIRELY by the descriptor: top-level keys + one row + // per required_fields() entry. We assert the descriptor's keys appear as + // Field rows without the panel naming any concrete provider field itself. + let provider = "cloudflared"; + let rows = build_rows(provider); + // First rows are panel-owned daemon-config rows. + assert_eq!(rows[0], Row::Enabled); + assert_eq!(rows[1], Row::Provider); + assert_eq!(rows[2], Row::GatewayPort); + assert_eq!(rows[3], Row::ControlPort); + assert_eq!(rows[4], Row::AuthToken); + assert_eq!(rows[5], Row::ReadonlyToken); + assert_eq!(rows[6], Row::Insecure); + + // The remaining rows must match the provider descriptor exactly (count + + // each key/label/secret/required), proving they come from required_fields(). + let registry = lucarne_remote::builtin(); + let descriptor = registry.get(provider).expect("cloudflared registered"); + let field_rows: Vec<&Row> = rows.iter().skip(7).collect(); + assert_eq!(field_rows.len(), descriptor.required_fields().len()); + for (row, field) in field_rows.iter().zip(descriptor.required_fields()) { + match row { + Row::Field { + key, + label, + secret, + required, + } => { + assert_eq!(*key, field.key); + assert_eq!(*label, field.label); + assert_eq!(*secret, field.secret); + assert_eq!(*required, field.required); + } + other => panic!("expected a Field row, got {other:?}"), + } + } + // At least one secret field exists in the descriptor (the token) so the + // masking test below is meaningful. + assert!( + descriptor.required_fields().iter().any(|f| f.secret), + "descriptor should advertise a secret field" + ); + } + + #[test] + fn secret_field_value_is_masked_never_echoed() { + // A set secret renders masked (fixed token, not the value, not its length). + let secret_value = "super-secret-tunnel-token"; + assert_eq!(mask_value(secret_value, true), "••••"); + assert!(!mask_value(secret_value, true).contains("secret")); + // An empty secret shows the unset marker, not an empty masked string. + assert_eq!(mask_value("", true), "(unset)"); + // Non-secret values are shown verbatim (and unset when empty). + assert_eq!( + mask_value("https://t.example.com", false), + "https://t.example.com" + ); + assert_eq!(mask_value("", false), "(unset)"); + + // End-to-end through display_value: a secret field's display must not leak + // the raw value. + let mut edits = base_edits(); + edits + .fields + .insert("token".to_string(), secret_value.to_string()); + edits.auth_token = Some(secret_value.to_string()); + let secret_row = Row::Field { + key: "token", + label: "Cloudflare Tunnel Token", + secret: true, + required: false, + }; + let shown = display_value(&secret_row, &edits); + assert_eq!(shown, "••••"); + assert!(!shown.contains(secret_value)); + assert_eq!(display_value(&Row::AuthToken, &edits), "••••"); + } + + #[test] + fn merge_preserves_unrelated_keys_and_writes_edited_provider_field() { + // A config with unrelated channels + state sections that must survive a + // merge untouched, plus a pre-existing remote provider field that should + // be updated in place. + let raw = r#" +agents: + - codex + - pi +state: + db: ~/.lucarned/state.sqlite3 +remote: + enabled: true + provider: cloudflared + auth_token: keep-this-token + providers: + cloudflared: + token: old-token + binary_path: /usr/local/bin/cloudflared +channels: + telegram: + enabled: true + token: keep-telegram-token +"#; + + let mut edits = base_edits(); + edits.enabled = false; + edits.gateway_port = 7900; + edits.control_port = 7901; + edits.auth_token = Some("0123456789abcdef0123456789abcdef".to_string()); + edits.readonly_token = Some("readonly-0123456789abcdef0123456789".to_string()); + edits.insecure = true; + edits + .fields + .insert("token".to_string(), "new-token".to_string()); + edits.fields.insert( + "public_url".to_string(), + "https://tunnel.example.com".to_string(), + ); + // binary_path was seeded from the file and left unchanged → it stays in the + // edit set (the panel always saves the full edited field set), so it must be + // preserved on merge. + edits.fields.insert( + "binary_path".to_string(), + "/usr/local/bin/cloudflared".to_string(), + ); + + let merged = merge_config_yaml(raw, &edits).expect("merge yaml"); + let value: serde_json::Value = serde_yaml::from_str(&merged).expect("parse merged"); + + // Unrelated sections survive verbatim. + assert_eq!( + value.pointer("/state/db").and_then(|v| v.as_str()), + Some("~/.lucarned/state.sqlite3") + ); + assert_eq!( + value + .pointer("/channels/telegram/token") + .and_then(|v| v.as_str()), + Some("keep-telegram-token") + ); + assert_eq!( + value.pointer("/remote/auth_token").and_then(|v| v.as_str()), + Some("0123456789abcdef0123456789abcdef") + ); + assert_eq!( + value + .pointer("/remote/readonly_token") + .and_then(|v| v.as_str()), + Some("readonly-0123456789abcdef0123456789") + ); + assert_eq!( + value.pointer("/remote/insecure").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + value.pointer("/remote/enabled").and_then(|v| v.as_bool()), + Some(false) + ); + + // Edited top-level keys + ports. + assert_eq!( + value.pointer("/remote/provider").and_then(|v| v.as_str()), + Some("cloudflared") + ); + assert_eq!( + value + .pointer("/remote/gateway_addr") + .and_then(|v| v.as_str()), + Some("127.0.0.1:7900") + ); + assert_eq!( + value + .pointer("/remote/control_addr") + .and_then(|v| v.as_str()), + Some("127.0.0.1:7901") + ); + + // The edited provider field is written; an unedited pre-existing field is + // preserved; the newly-added field is present. + assert_eq!( + value + .pointer("/remote/providers/cloudflared/token") + .and_then(|v| v.as_str()), + Some("new-token") + ); + assert_eq!( + value + .pointer("/remote/providers/cloudflared/binary_path") + .and_then(|v| v.as_str()), + Some("/usr/local/bin/cloudflared") + ); + assert_eq!( + value + .pointer("/remote/providers/cloudflared/public_url") + .and_then(|v| v.as_str()), + Some("https://tunnel.example.com") + ); + } + + #[test] + fn merge_round_trip_writes_to_temp_file_via_backup() { + // Round-trip the full save path against a TEMP file (never ~/.lucarned): + // an existing config is backed up and atomically replaced with the merged + // contents carrying the edited provider field. + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("lucarned.yaml"); + std::fs::write( + &path, + "remote:\n provider: cloudflared\n providers:\n cloudflared:\n token: old\n", + ) + .expect("write initial config"); + + let mut edits = base_edits(); + edits + .fields + .insert("token".to_string(), "rotated-token".to_string()); + + let merged = merge_config_yaml( + &std::fs::read_to_string(&path).expect("read existing"), + &edits, + ) + .expect("merge"); + crate::onboarding::config::write_config_with_backup(&path, &merged) + .expect("write with backup"); + + // The file now carries the rotated token. + let written = std::fs::read_to_string(&path).expect("read written"); + let value: serde_json::Value = serde_yaml::from_str(&written).expect("parse written"); + assert_eq!( + value + .pointer("/remote/providers/cloudflared/token") + .and_then(|v| v.as_str()), + Some("rotated-token") + ); + + // A backup of the previous contents was created (the old token). + let backups: Vec = std::fs::read_dir(temp.path()) + .expect("read temp dir") + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with("lucarned.yaml.bak-")) + .collect(); + assert_eq!( + backups.len(), + 1, + "exactly one backup of the previous config" + ); + let backup = std::fs::read_to_string(temp.path().join(&backups[0])).expect("read backup"); + assert!( + backup.contains("token: old"), + "backup must hold the previous config, got: {backup}" + ); + } + + #[test] + fn empty_field_is_removed_from_provider_map_on_merge() { + // A blanked field must NOT linger in the YAML (so a cleared secret is gone). + let raw = "remote:\n providers:\n cloudflared:\n token: old\n"; + let edits = base_edits(); // token edited to empty → dropped + let merged = merge_config_yaml(raw, &edits).expect("merge"); + let value: serde_json::Value = serde_yaml::from_str(&merged).expect("parse"); + assert!( + value + .pointer("/remote/providers/cloudflared/token") + .is_none(), + "an empty/blanked field must be removed from the merged config" + ); + } + + #[test] + fn cycle_provider_rebuilds_descriptor_rows() { + // With a single built-in provider, cycling wraps back to it and rebuilds + // the rows from its descriptor (proving rows always follow the selection). + let mut panel = ConfigPanel::new(); + let before = panel.rows.clone(); + let provider_idx = panel + .rows + .iter() + .position(|row| matches!(row, Row::Provider)) + .expect("provider row"); + panel.list.select(Some(provider_idx)); + panel.handle_key(KeyCode::Enter); // cycles provider + assert_eq!(panel.rows, build_rows(panel.edits.provider.as_str())); + // Rows are non-empty and start with the panel-owned keys. + assert_eq!(panel.rows[0], Row::Enabled); + // Single provider → same row shape after wrap. + assert_eq!(panel.rows.len(), before.len()); + } + + #[test] + fn cycle_provider_reseeds_persisted_fields_and_save_preserves_them() { + // COR-001 regression: cycling the provider must RE-SEED the newly selected + // provider's persisted fields from `lucarned.yaml` rather than blanking + // them — otherwise a cycle (even one wrapping back to the same provider) + // followed by a save would wipe the provider's on-disk values. + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("lucarned.yaml"); + std::fs::write( + &path, + "remote:\n provider: cloudflared\n providers:\n cloudflared:\n \ + token: original-token\n public_url: https://named.example.test\n", + ) + .expect("write initial config"); + + // Build a panel pointed at the temp file and seed it from disk (mirroring + // what `load()` does after reading — without resolving the real + // ~/.lucarned path). + let mut panel = ConfigPanel::new(); + panel.config_path = Some(path.clone()); + let raw = std::fs::read_to_string(&path).expect("read seed"); + panel.seed_from_yaml(&raw); + panel.reload_rows(); + assert_eq!( + panel.edits.fields.get("token").map(String::as_str), + Some("original-token"), + "fields seeded from the file on load" + ); + + // Cycle the provider (single built-in → wraps back to cloudflared). The + // pre-fix behavior cleared `fields` here; the fix re-seeds from the file. + let provider_idx = panel + .rows + .iter() + .position(|row| matches!(row, Row::Provider)) + .expect("provider row"); + panel.list.select(Some(provider_idx)); + panel.handle_key(KeyCode::Enter); // cycles provider → re-seeds + assert_eq!( + panel.edits.fields.get("token").map(String::as_str), + Some("original-token"), + "cycling must re-seed persisted fields, not blank them (COR-001)" + ); + assert_eq!( + panel.edits.fields.get("public_url").map(String::as_str), + Some("https://named.example.test") + ); + + // Saving now must preserve the provider's persisted fields. + panel.save(); + let written = std::fs::read_to_string(&path).expect("read written"); + let value: serde_json::Value = serde_yaml::from_str(&written).expect("parse written"); + assert_eq!( + value + .pointer("/remote/providers/cloudflared/token") + .and_then(|v| v.as_str()), + Some("original-token"), + "a cycle then save must not wipe the provider's persisted token (COR-001)" + ); + assert_eq!( + value + .pointer("/remote/providers/cloudflared/public_url") + .and_then(|v| v.as_str()), + Some("https://named.example.test") + ); + } + + #[test] + fn save_rejects_equal_gateway_and_control_ports() { + // COR-007 / SEC-002: a control port equal to the gateway port must be + // rejected inline (status line) and NOT written — mirroring the daemon's + // `remote_config_from_config` distinctness check. + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("lucarned.yaml"); + let mut panel = ConfigPanel::new(); + panel.config_path = Some(path.clone()); + // Force the two ports to collide. + panel.edits.gateway_port = 7800; + panel.edits.control_port = 7800; + + panel.save(); + // The error is surfaced inline and references the SEC-002 rule. + let status = panel.status.as_deref().unwrap_or(""); + assert!( + status.contains("validation failed") && status.contains("SEC-002"), + "equal ports must surface a SEC-002 validation error, got: {status}" + ); + // Nothing was written (the file was never created). + assert!( + !path.exists(), + "an invalid (equal-port) config must NOT be written" + ); + + // A distinct control port passes the port check and writes. + panel.edits.control_port = 7801; + panel.save(); + assert!(path.exists(), "a valid config writes"); + } + + #[test] + fn inline_edit_commits_non_secret_field_and_parses_port() { + let mut panel = ConfigPanel::new(); + // Select the gateway-port row and edit it. + let gateway_idx = panel + .rows + .iter() + .position(|row| matches!(row, Row::GatewayPort)) + .expect("gateway port row"); + panel.list.select(Some(gateway_idx)); + panel.handle_key(KeyCode::Enter); // begin edit + assert!(panel.editing.is_some()); + for c in "9100".chars() { + panel.handle_key(KeyCode::Char(c)); + } + panel.handle_key(KeyCode::Enter); // commit + assert_eq!(panel.edits.gateway_port, 9100); + assert!(panel.editing.is_none()); + + // An invalid port is rejected with a status message (value unchanged). + panel.handle_key(KeyCode::Enter); + for c in "notaport".chars() { + panel.handle_key(KeyCode::Char(c)); + } + panel.handle_key(KeyCode::Enter); + assert_eq!(panel.edits.gateway_port, 9100); + assert!(panel + .status + .as_deref() + .unwrap_or("") + .contains("invalid port")); + } + + #[test] + fn top_level_remote_fields_seed_toggle_validate_and_merge() { + let raw = r#" +remote: + enabled: true + provider: cloudflared + gateway_addr: 127.0.0.1:7900 + control_addr: 127.0.0.1:7901 + auth_token: "0123456789abcdef0123456789abcdef" + readonly_token: "readonly-0123456789abcdef0123456789" + insecure: true + providers: + cloudflared: + public_url: https://named.example.test +"#; + let mut panel = ConfigPanel::new(); + panel.seed_from_yaml(raw); + panel.reload_rows(); + + assert!(panel.edits.enabled); + assert_eq!(panel.edits.gateway_port, 7900); + assert_eq!(panel.edits.control_port, 7901); + assert_eq!( + panel.edits.auth_token.as_deref(), + Some("0123456789abcdef0123456789abcdef") + ); + assert_eq!( + panel.edits.readonly_token.as_deref(), + Some("readonly-0123456789abcdef0123456789") + ); + assert!(panel.edits.insecure); + + let enabled_idx = panel + .rows + .iter() + .position(|row| matches!(row, Row::Enabled)) + .expect("enabled row"); + panel.list.select(Some(enabled_idx)); + panel.handle_key(KeyCode::Enter); + assert!(!panel.edits.enabled, "Enter toggles autostart"); + + let insecure_idx = panel + .rows + .iter() + .position(|row| matches!(row, Row::Insecure)) + .expect("insecure row"); + panel.list.select(Some(insecure_idx)); + panel.handle_key(KeyCode::Enter); + assert!(!panel.edits.insecure, "Enter toggles insecure"); + + panel.edits.auth_token = Some("short".to_string()); + let err = panel.validate().expect_err("weak auth token is rejected"); + assert!( + err.contains("auth_token"), + "token error should name field: {err}" + ); + panel.edits.auth_token = Some("0123456789abcdef0123456789abcdef".to_string()); + panel.edits.readonly_token = Some("short".to_string()); + let err = panel + .validate() + .expect_err("weak readonly token is rejected"); + assert!( + err.contains("readonly_token"), + "readonly token error should name field: {err}" + ); + panel.edits.readonly_token = None; + + let merged = merge_config_yaml(raw, &panel.edits).expect("merge"); + let value: serde_json::Value = serde_yaml::from_str(&merged).expect("parse"); + assert_eq!( + value.pointer("/remote/enabled").and_then(|v| v.as_bool()), + Some(false) + ); + assert_eq!( + value.pointer("/remote/insecure").and_then(|v| v.as_bool()), + Some(false) + ); + assert_eq!( + value.pointer("/remote/auth_token").and_then(|v| v.as_str()), + Some("0123456789abcdef0123456789abcdef") + ); + assert!( + value.pointer("/remote/readonly_token").is_none(), + "cleared read-only token must be removed" + ); + } + + #[test] + fn secret_field_edit_buffer_captures_but_display_masks() { + // Editing a secret field captures chars into the raw buffer (so it commits + // correctly) while the rendered value stays masked — the secret is never + // echoed via the panel's display path. + let mut panel = ConfigPanel::new(); + // Find the secret token field row. + let token_idx = panel + .rows + .iter() + .position(|r| matches!(r, Row::Field { secret: true, .. })) + .expect("a secret field row"); + panel.list.select(Some(token_idx)); + panel.handle_key(KeyCode::Enter); + for c in "tok123".chars() { + panel.handle_key(KeyCode::Char(c)); + } + panel.handle_key(KeyCode::Enter); // commit + let row = panel.rows[token_idx].clone(); + assert_eq!(panel.row_display(&row), "••••"); + // The raw value is stored for save, but never surfaced via display. + if let Row::Field { key, .. } = row { + assert_eq!( + panel.edits.fields.get(key).map(String::as_str), + Some("tok123") + ); + } + } + + #[test] + fn start_params_returns_provider_and_non_empty_fields() { + // PART 1: the Go-Public `s` key starts with the Config panel's live edits. + // `start_params` returns the selected provider id + only its NON-EMPTY + // field values (empties are skipped so they never shadow a daemon default). + let mut panel = ConfigPanel::new(); + panel.edits.provider = "cloudflared".to_string(); + panel + .edits + .fields + .insert("token".to_string(), "tok-123".to_string()); + // A blank field must be skipped, not forwarded as "". + panel + .edits + .fields + .insert("public_url".to_string(), String::new()); + + let (provider, fields) = panel.start_params(); + assert_eq!(provider, "cloudflared"); + assert_eq!(fields.get("token").map(String::as_str), Some("tok-123")); + assert!( + !fields.contains_key("public_url"), + "empty field values must be skipped" + ); + } +} diff --git a/crates/lucarned/src/tui/event.rs b/crates/lucarned/src/tui/event.rs new file mode 100644 index 0000000..7a76d4d --- /dev/null +++ b/crates/lucarned/src/tui/event.rs @@ -0,0 +1,67 @@ +//! Keyboard event handling for the dashboard. +//! +//! Reads one `crossterm` key event and maps it onto an [`App`] transition: +//! Tab/Right and BackTab/Left switch panel focus, Up/Down move the active panel's +//! content selection, and `q` / Esc / Ctrl-C quit. Fixed keybinds for v1 (Free +//! decision: fixed keybind set; customization deferred). Any other key is routed +//! to the active panel (Sessions: Enter=attach, d=detach, k/Del=kill, a=archive, +//! r=refresh); a panel may ask the loop to perform deferred work (the attach +//! pop-out handoff) via the returned [`SessionAction`]. + +use std::time::Duration; + +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; + +use super::app::App; +use super::sessions::SessionAction; + +/// COR-003: poll for a terminal event for up to one second, returning whether one +/// is now ready to [`read`](event::read). The 1s timeout (not a spin) lets the loop +/// redraw periodically (e.g. to reflect an out-of-band status change) without +/// busy-waiting — when this returns `false` the caller simply redraws and polls +/// again; when it returns `true` the caller drives [`handle_next`]. +pub fn poll_ready() -> std::io::Result { + event::poll(Duration::from_millis(1000)) +} + +/// Read the next (already-[`poll_ready`]'d) terminal event and apply it to `app`. +/// Returns the panel [`SessionAction`] the caller must act on (the attach pop-out +/// handoff needs the terminal owner). The caller also checks `app.running` to +/// decide whether to keep looping. Non-key events (resize, mouse) are ignored — +/// the next draw handles a resize. +/// +/// MUST be called only after [`poll_ready`] returned `true` so the underlying +/// [`event::read`] does not block the loop (COR-003). +pub fn handle_next(app: &mut App) -> std::io::Result { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + // Ctrl-C always quits regardless of the focused panel. + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + app.quit(); + return Ok(SessionAction::None); + } + // When the active panel has a modal/editor open (the Go-Public login + // QR, or the Config panel's inline field editor), route the key to the + // panel first so it captures input (typing, commit/cancel, closing the + // QR) instead of the global nav/quit binds acting on it. Ctrl-C above + // is the unconditional escape hatch. + if app.modal_open() { + return Ok(app.handle_panel_key(key.code)); + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => app.quit(), + KeyCode::Tab | KeyCode::Right => app.focus_next(), + KeyCode::BackTab | KeyCode::Left => app.focus_prev(), + KeyCode::Down => app.select_next(), + KeyCode::Up => app.select_previous(), + // Arrow keys move the selection; every other key (Enter, d, k, + // Del, a, r) is routed to the active panel's action handler. We do + // NOT bind vim `j`/`k` to navigation here because the Sessions + // panel claims `k` for kill — arrows are the one nav path. + other => return Ok(app.handle_panel_key(other)), + } + } + _ => {} + } + Ok(SessionAction::None) +} diff --git a/crates/lucarned/src/tui/mod.rs b/crates/lucarned/src/tui/mod.rs new file mode 100644 index 0000000..9f1b467 --- /dev/null +++ b/crates/lucarned/src/tui/mod.rs @@ -0,0 +1,120 @@ +//! Interactive `lucarned tui` dashboard (Decision 1: the single interactive +//! entry; the old `term` binary is removed and its reusable logic migrated here). +//! +//! [`run`] owns the terminal lifecycle (Decision 6: robust restore): +//! 1. enter raw mode + the alternate screen, +//! 2. install a panic hook that restores the terminal (`disable_raw_mode` + +//! `LeaveAlternateScreen`) BEFORE the previously-installed hook runs, so a +//! panic never leaves the user with a broken terminal, +//! 3. run the draw/event loop, +//! 4. ALWAYS restore on every exit path (normal return or error). +//! +//! The submodules house the panel backends: [`sessions`] (rmux session control), +//! [`remote`] (go-public control plane + QR), and [`config`] (provider field +//! collection) — all migrated verbatim from the old `lucarne-termctl` CLI. + +pub mod app; +pub mod config; +pub mod event; +pub mod nav; +pub mod remote; +pub mod sessions; +pub mod ui; + +use std::io::{self, Stdout}; + +use crossterm::{ + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; + +use app::App; + +/// Launch the full-screen dashboard. Returns `Ok(())` on a clean exit, or an +/// error string the CLI surfaces to the user. The terminal is restored on every +/// exit path (including panic, via the installed hook). +pub fn run() -> Result<(), String> { + // Decision 6: install a panic hook that restores the terminal first, so a + // panic from anywhere in the loop does not leave raw mode / the alternate + // screen active. We chain to the previously-installed hook afterwards. + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = restore_terminal(); + previous_hook(info); + })); + + enable_raw_mode().map_err(|e| format!("failed to enable raw mode: {e}"))?; + let mut stdout = io::stdout(); + if let Err(e) = execute!(stdout, EnterAlternateScreen) { + // Roll back raw mode before bailing so we never leave the terminal in a + // half-initialized state. + let _ = disable_raw_mode(); + return Err(format!("failed to enter alternate screen: {e}")); + } + + let backend = CrosstermBackend::new(stdout); + let result = Terminal::new(backend) + .map_err(|e| format!("failed to build terminal: {e}")) + .and_then(|mut terminal| run_loop(&mut terminal)); + + // ALWAYS restore on exit, regardless of how the loop ended. + let restore = restore_terminal().map_err(|e| format!("failed to restore terminal: {e}")); + + // Prefer surfacing the loop error; otherwise surface a restore error. + result.and(restore) +} + +/// The draw/event loop. Draws the FIRST frame BEFORE the initial (blocking) +/// control-plane refresh so startup paints immediately instead of stalling on a +/// cold/missing daemon (COR-003). It then polls for input with a 1s timeout +/// ([`event::poll_ready`]) and only `read`s when an event is ready, redrawing on +/// each timeout, until the app requests to quit — so the loop neither blocks +/// indefinitely on `read()` nor busy-spins. A Sessions-panel attach request +/// triggers the pop-out handoff (suspend → run `rmux attach-session` → resume), +/// which lives in the sessions module so the loop never learns +/// rmux/terminal-handoff specifics. +fn run_loop(terminal: &mut Terminal>) -> Result<(), String> { + let mut app = App::new(); + // Populate the session list before the first draw (construction is I/O-free). + app.sessions.refresh(); + // Resolve the lucarned.yaml path + seed the Config panel from its current + // remote: section (a missing file just opens the form with defaults). + app.config.load(); + + // COR-003: paint the FIRST frame BEFORE the initial blocking control-plane + // refresh, so a cold/unreachable daemon does not stall the very first paint. + terminal + .draw(|frame| ui::draw(frame, &mut app)) + .map_err(|e| format!("draw failed: {e}"))?; + // Now pull the current remote-access status so the Go-Public panel opens + // informed (a missing/cold daemon just lands an error in its message line — + // no panic); the next draw below reflects it. + app.go_public.refresh(); + + while app.running { + terminal + .draw(|frame| ui::draw(frame, &mut app)) + .map_err(|e| format!("draw failed: {e}"))?; + // COR-003: poll with a 1s timeout (blocks, no busy-loop). On timeout we + // fall through and redraw; only on a ready event do we `read` + handle it. + if !event::poll_ready().map_err(|e| format!("event poll failed: {e}"))? { + continue; + } + match event::handle_next(&mut app).map_err(|e| format!("event read failed: {e}"))? { + sessions::SessionAction::Attach(name) => { + sessions::attach_handoff(terminal, &mut app.sessions, &name)? + } + sessions::SessionAction::None => {} + } + } + Ok(()) +} + +/// Restore the terminal: leave the alternate screen and disable raw mode. Safe to +/// call more than once (e.g. from both the panic hook and the normal exit path). +fn restore_terminal() -> io::Result<()> { + let mut stdout = io::stdout(); + execute!(stdout, LeaveAlternateScreen)?; + disable_raw_mode() +} diff --git a/crates/lucarned/src/tui/nav.rs b/crates/lucarned/src/tui/nav.rs new file mode 100644 index 0000000..0d2c761 --- /dev/null +++ b/crates/lucarned/src/tui/nav.rs @@ -0,0 +1,151 @@ +//! Shared list-navigation helpers (MNT-007). +//! +//! Every panel that drives a `ratatui` [`ListState`] over a `Vec` needs the same +//! two operations: STEP the selection one row (clamped to the list bounds) and +//! CLAMP a possibly-stale selection back into range after the list changes. This +//! logic was duplicated in `SessionsPanel` (`select_next`/`select_previous`/ +//! `clamp_selection`), `ConfigPanel` (`select_next`/`select_previous`/ +//! `reload_rows`), and the `ui.rs` render guard; collecting it here removes the +//! drift between those copies and gives one tested seam. +//! +//! Panels differ only in what an EMPTY list means for the selection — Sessions +//! drops to `None` (no row to act on), Config clamps to index 0 when non-empty and +//! `None` when empty. That single difference is captured by [`EmptyPolicy`], so +//! both panels share the same step/clamp body and only pass their policy. + +use ratatui::widgets::ListState; + +/// What the selection becomes when the list is EMPTY. +/// +/// The only behavioral difference between the panels' navigation: an empty list +/// always yields `None` (there is nothing to select) — this enum exists for the +/// NON-empty default that [`clamp`] applies when there is currently no selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EmptyPolicy { + /// A non-empty list with no current selection stays unselected until the user + /// moves (Sessions: navigation seeds index 0 itself, but a bare clamp leaves + /// `None` as `None`). Used by the Sessions panel. + KeepNone, + /// A non-empty list with no current selection clamps to index 0 (Config: the + /// form always has a focused row when it has any rows). + SelectFirst, +} + +/// Move `state`'s selection one row within `[0, len)`, clamped at both ends. +/// +/// `forward` steps down (toward higher indices); `!forward` steps up. An empty +/// list (`len == 0`) clears the selection (`None`). With a selection already set, +/// the step saturates at the first/last row (never wraps, never goes +/// out of bounds). With no selection on a non-empty list, the first step lands on +/// index 0 (so the first arrow press always selects the top row). +pub fn step(state: &mut ListState, len: usize, forward: bool) { + if len == 0 { + state.select(None); + return; + } + let next = match state.selected() { + Some(i) if forward => (i + 1).min(len - 1), + Some(i) => i.saturating_sub(1), + None => 0, + }; + state.select(Some(next)); +} + +/// Clamp `state`'s selection into `[0, len)` after the backing list may have +/// changed length, applying `policy` for the no-selection case. +/// +/// - `len == 0` → always `None` (no row to select). +/// - a selection `>= len` (a stale index after a shrink) → the last valid row. +/// - an in-range selection → unchanged. +/// - no selection on a non-empty list → `policy` decides: [`EmptyPolicy::KeepNone`] +/// leaves it `None`; [`EmptyPolicy::SelectFirst`] selects index 0. +pub fn clamp(state: &mut ListState, len: usize, policy: EmptyPolicy) { + match state.selected() { + _ if len == 0 => state.select(None), + Some(i) if i >= len => state.select(Some(len - 1)), + Some(_) => {} + None => match policy { + EmptyPolicy::KeepNone => {} + EmptyPolicy::SelectFirst => state.select(Some(0)), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn step_on_empty_list_clears_selection() { + let mut state = ListState::default(); + state.select(Some(3)); // a stale selection from a previous, longer list + step(&mut state, 0, true); + assert_eq!(state.selected(), None); + step(&mut state, 0, false); + assert_eq!(state.selected(), None); + } + + #[test] + fn step_seeds_index_zero_when_unselected() { + let mut state = ListState::default(); + // No selection + a non-empty list: the first step (either direction) lands + // on the top row. + step(&mut state, 3, true); + assert_eq!(state.selected(), Some(0)); + let mut state2 = ListState::default(); + step(&mut state2, 3, false); + assert_eq!(state2.selected(), Some(0)); + } + + #[test] + fn step_clamps_at_both_ends() { + let mut state = ListState::default(); + state.select(Some(0)); + // Up at the top stays at 0. + step(&mut state, 3, false); + assert_eq!(state.selected(), Some(0)); + // Down stops at the last index, never past it. + for _ in 0..10 { + step(&mut state, 3, true); + } + assert_eq!(state.selected(), Some(2)); + } + + #[test] + fn clamp_empty_drops_selection_under_both_policies() { + for policy in [EmptyPolicy::KeepNone, EmptyPolicy::SelectFirst] { + let mut state = ListState::default(); + state.select(Some(2)); + clamp(&mut state, 0, policy); + assert_eq!(state.selected(), None, "empty list always clears selection"); + } + } + + #[test] + fn clamp_stale_index_snaps_to_last_row() { + let mut state = ListState::default(); + state.select(Some(5)); // stale index past the new (shorter) list + clamp(&mut state, 3, EmptyPolicy::KeepNone); + assert_eq!(state.selected(), Some(2)); + } + + #[test] + fn clamp_no_selection_follows_policy() { + // KeepNone: a non-empty list with no selection stays None. + let mut keep = ListState::default(); + clamp(&mut keep, 3, EmptyPolicy::KeepNone); + assert_eq!(keep.selected(), None); + // SelectFirst: a non-empty list with no selection clamps to 0. + let mut first = ListState::default(); + clamp(&mut first, 3, EmptyPolicy::SelectFirst); + assert_eq!(first.selected(), Some(0)); + } + + #[test] + fn clamp_in_range_selection_is_unchanged() { + let mut state = ListState::default(); + state.select(Some(1)); + clamp(&mut state, 3, EmptyPolicy::SelectFirst); + assert_eq!(state.selected(), Some(1)); + } +} diff --git a/crates/lucarned/src/tui/remote.rs b/crates/lucarned/src/tui/remote.rs new file mode 100644 index 0000000..b67ae43 --- /dev/null +++ b/crates/lucarned/src/tui/remote.rs @@ -0,0 +1,777 @@ +//! Go-Public panel backend — the tunnel-control-plane helpers now share the +//! `lucarned remote start` control path. The TUI stays a thin wrapper (Locked decision L6): it +//! never spawns a tunnel itself. It picks a built-in provider, collects that +//! provider's required fields (see [`crate::tui::config`]), then drives the +//! daemon's loopback-only control plane (`POST /api/remote/start`) so the daemon +//! — which owns the tunnel lifecycle — brings the tunnel up and hands back the +//! public URL + access token. The TUI then renders the URL, a terminal QR of the +//! login link (Decision 4), and the access key. +//! +//! Panel rendering / interaction wiring lands in TASK-003; this module only +//! houses the reusable go-public primitives + their migrated tests. + +/// Default loopback gateway port the daemon binds in remote mode +/// (`lucarned` `DEFAULT_REMOTE_GATEWAY_ADDR = 127.0.0.1:7800`). Overridable with +/// `--gateway-port

`. +pub const DEFAULT_GATEWAY_PORT: u16 = 7800; + +/// Default loopback CONTROL-plane port (SEC-002): the daemon serves +/// `/api/remote/*` on a DISTINCT loopback port the tunnel never targets +/// (`lucarned` `DEFAULT_REMOTE_CONTROL_ADDR = 127.0.0.1:7801`). When not given +/// explicitly it is derived as `gateway-port + 1`, matching the daemon's default. +/// Overridable with `--control-port

`. +/// +/// L1: uses `checked_add` so a gateway bound to port 65535 does not silently +/// wrap to 0 — `None` means the caller must pass an explicit `--control-port`. +/// `const` so the config panel can derive its `DEFAULT_CONTROL_PORT` from the +/// gateway default at compile time (MNT-004 single source of truth). +pub const fn default_control_port(gateway_port: u16) -> Option { + gateway_port.checked_add(1) +} + +/// What `go-public` resolved before touching the network: the selected provider, +/// the collected (non-secret-aware) field map, and the loopback URL it will POST. +/// Kept as a plain value so the control-plane call (`call_remote_start`) and the +/// panel's `start` path can assemble + send it without a running daemon in tests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GoPublicPlan { + pub provider: String, + pub fields: std::collections::BTreeMap, + pub control_url: String, +} + +/// Render `content` as a small terminal QR code (half-block rows). Mirrors +/// `lucarne-wechat`'s `render_terminal_qr` (`adapter.rs:900-938`) so the visual +/// is identical to the WeChat login QR. +pub fn render_terminal_qr(content: &str) -> Result { + use qrcode::types::Color; + use qrcode::{EcLevel, QrCode}; + + let code = QrCode::with_error_correction_level(content.trim().as_bytes(), EcLevel::L)?; + let module_count = code.width(); + let modules = code.to_colors(); + let color_at = |row: usize, col: usize| modules[row * module_count + col]; + + let odd_row = module_count % 2 == 1; + let output_rows = module_count.div_ceil(2); + let mut output = String::new(); + + output.push_str(&"▄".repeat(module_count + 2)); + output.push('\n'); + + for row in 0..output_rows { + output.push('█'); + for col in 0..module_count { + let top = color_at(row * 2, col); + let bottom = if row * 2 + 1 < module_count { + color_at(row * 2 + 1, col) + } else { + Color::Light + }; + output.push(match (top, bottom) { + (Color::Light, Color::Light) => '█', + (Color::Light, Color::Dark) => '▀', + (Color::Dark, Color::Light) => '▄', + (Color::Dark, Color::Dark) => ' ', + }); + } + output.push('█'); + output.push('\n'); + } + + if !odd_row { + output.push_str(&"▀".repeat(module_count + 2)); + output.push('\n'); + } + + Ok(output) +} + +/// The login URL a remote client opens: the public URL with the access token +/// carried in the fragment (`#token=…`), matching the gateway's +/// `RemoteControlStatus` doc contract. Returns the bare URL when no token. +pub fn login_url(public_url: &str, access_token: Option<&str>) -> String { + match access_token { + Some(token) if !token.is_empty() => format!("{public_url}#token={token}"), + _ => public_url.to_string(), + } +} + +// The `go-public` CLI-entry cluster (arg parsing + the textual `report_tunnel_up` +// reporter) was removed with the standalone `term` binary (Decision 1): the v1 +// TUI panel drives the control plane directly (`GoPublicPanel::start` → +// `call_remote_start`) and never routed through a CLI arg path. The reusable +// control-plane primitives (`call_remote_start`/`status`/`stop`) and the QR/URL +// helpers (`render_terminal_qr`/`login_url`) below are the live surface. + +/// Shared `reqwest::blocking` control-plane round-trip (MNT-003): run the prepared +/// `request`, map a transport error to a "failed to reach daemon at {url}" message, +/// reject a non-2xx with `daemon returned {code}: {detail}`, then parse the body as +/// the `RemoteStartStatus` JSON contract. Every `/api/remote/*` call goes through +/// this one place so the send → status-check → parse boilerplate lives once. +fn send_control( + request: reqwest::blocking::RequestBuilder, + url: &str, +) -> Result { + let resp = request + .send() + .map_err(|e| format!("failed to reach daemon at {url}: {e}"))?; + if !resp.status().is_success() { + let code = resp.status(); + let detail = resp.text().unwrap_or_default(); + return Err(format!("daemon returned {code}: {detail}")); + } + resp.json::() + .map_err(|e| format!("failed to parse daemon response: {e}")) +} + +/// POST `/api/remote/start` to the daemon loopback control plane and parse the +/// `RemoteControlStatus` response. The TUI sends the chosen provider id + that +/// provider's fields as the JSON body ([`RemoteStartParams`]); the daemon uses +/// them to override / merge its pre-configured tunnel (G3) and, on a cold daemon, +/// lazily brings the gateway + tunnel up on this first call. +pub fn call_remote_start( + plan: &GoPublicPlan, +) -> Result { + let body = serde_json::json!({ + "provider": plan.provider, + "fields": plan.fields, + }); + let client = reqwest::blocking::Client::new(); + send_control( + client.post(&plan.control_url).json(&body), + &plan.control_url, + ) +} + +/// The loopback control URL for one `/api/remote/` route on `control_port`. +/// Shared by `start`/`stop`/`status` so the panel never hardcodes the host. +pub fn control_url(control_port: u16, verb: &str) -> String { + format!("http://127.0.0.1:{control_port}/api/remote/{verb}") +} + +/// GET `/api/remote/status` on the loopback control plane and parse the +/// `RemoteControlStatus` response. Reports whether the daemon-owned tunnel is up, +/// its provider, public URL, and (presence of) the access token. +pub fn call_remote_status( + control_port: u16, +) -> Result { + let url = control_url(control_port, "status"); + let client = reqwest::blocking::Client::new(); + send_control(client.get(&url), &url) +} + +/// POST `/api/remote/stop` on the loopback control plane (idempotent: stopping an +/// already-down tunnel succeeds) and parse the resulting `RemoteControlStatus`. +pub fn call_remote_stop( + control_port: u16, +) -> Result { + let url = control_url(control_port, "stop"); + let client = reqwest::blocking::Client::new(); + send_control(client.post(&url), &url) +} + +/// Mirror of `lucarne_termgw::RemoteControlStatus` for deserialization (the TUI +/// does not depend on the gateway crate; the JSON shape is the stable contract). +pub mod lucarne_remote_status { + use serde::Deserialize; + + #[derive(Debug, Clone, Default, Deserialize)] + pub struct RemoteStartStatus { + pub running: bool, + pub provider: Option, + pub public_url: Option, + pub access_token: Option, + } +} + +// ---- Go-Public panel (TASK-003): state + control-plane actions + QR modal ---- + +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Text}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::Frame; + +/// The dimensions of a rendered QR grid (rows × the widest line, in cells). +/// +/// `render_terminal_qr` returns half-block rows of equal width, so the grid is a +/// rectangle: `cols` = the glyph count of any line, `rows` = the line count. Kept +/// as a pure value so the fits-the-modal decision is unit-testable without a +/// terminal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QrGrid { + pub cols: u16, + pub rows: u16, +} + +impl QrGrid { + /// Measure a rendered QR string. `cols` is the widest line's char count (the + /// grid is rectangular, so all rows match) and `rows` is the non-empty line + /// count. Saturates at `u16::MAX` so an absurdly large grid never overflows. + pub fn measure(qr: &str) -> QrGrid { + let mut cols: usize = 0; + let mut rows: usize = 0; + for line in qr.lines() { + if line.is_empty() { + continue; + } + rows += 1; + cols = cols.max(line.chars().count()); + } + QrGrid { + cols: cols.min(u16::MAX as usize) as u16, + rows: rows.min(u16::MAX as usize) as u16, + } + } + + /// True when this grid fits inside `inner` (the modal rect MINUS its border). + /// The fit is exact on both axes — if either dimension is short, the caller + /// falls back to the plain login URL + a "terminal too small" hint so the + /// layout never breaks (Decision 4). + pub fn fits_within(&self, inner: Rect) -> bool { + inner.width >= self.cols && inner.height >= self.rows + } +} + +/// A centered sub-`Rect` of `width`×`height` clamped to `area` (never larger than +/// the available space). Used to float the QR modal over the panel body. +pub fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let w = width.min(area.width); + let h = height.min(area.height); + let x = area.x + (area.width.saturating_sub(w)) / 2; + let y = area.y + (area.height.saturating_sub(h)) / 2; + Rect::new(x, y, w, h) +} + +/// Validate a CLI-supplied provider id + field map against the provider's own +/// descriptor BEFORE sending `/api/remote/start` (PART 1 step 5). The check lives +/// behind `lucarne_remote::builtin()` so the provider boundary (AGENTS.md) stays +/// intact — the panel never enumerates concrete provider fields, it just builds an +/// opaque [`ProviderConfig`] from the (already non-empty) field values and asks the +/// provider to enforce its own rules. Returns `Err(detail)` on an unknown provider +/// or a failed `validate_config`; `Ok(())` when the config is acceptable. +fn validate_start_config( + provider: &str, + fields: &std::collections::BTreeMap, +) -> Result<(), String> { + let registry = lucarne_remote::builtin(); + let descriptor = registry + .get(provider) + .ok_or_else(|| format!("unknown provider `{provider}`"))?; + let mut cfg = lucarne_remote::ProviderConfig::new(); + for (k, v) in fields { + if !v.is_empty() { + cfg.fields.insert(k.clone(), v.clone()); + } + } + descriptor.validate_config(&cfg) +} + +/// Turn a control-plane call error into an actionable panel message. The +/// `call_remote_*` helpers prefix connectivity failures with "failed to reach +/// daemon at" — that almost always means the lucarned daemon isn't running. +/// The daemon owns the tunnel lifecycle and serves the loopback control plane, +/// so `lucarned tui` cannot reach it standalone; surface a next step instead of +/// a raw reqwest error. Non-connectivity errors pass through verbatim. +fn explain_control_error(op: &str, control_port: u16, e: &str) -> String { + if e.contains("failed to reach daemon") { + format!( + "{op}: control plane unreachable on 127.0.0.1:{control_port} — the lucarned \ + daemon isn't running. Start it first (`lucarned autostart start`, or \ + `brew services start lucarned`); it owns the tunnel lifecycle." + ) + } else { + format!("{op}: {e}") + } +} + +/// The Go-Public panel state: the loopback control port it drives, the latest +/// tunnel status, a transient status/error line, and whether the QR login modal +/// is open. All control-plane + QR specifics live here so the shared UI/loop never +/// learns provider details (AGENTS.md boundary). +pub struct GoPublicPanel { + /// Loopback control-plane port (`gateway 7800 + 1 = 7801` by default). + pub control_port: u16, + /// Last `RemoteControlStatus` fetched / returned by start/stop, if any. + pub status: Option, + /// Last action result / error line shown under the panel body. + pub message: Option, + /// Whether the centered login-QR modal is currently shown. + pub qr_open: bool, +} + +impl Default for GoPublicPanel { + fn default() -> Self { + // Default control port = the daemon default (gateway 7800 + 1). 7800 can + // never overflow the +1, so the unwrap is infallible here. + let control_port = default_control_port(DEFAULT_GATEWAY_PORT).unwrap_or(7801); + Self { + control_port, + status: None, + message: None, + qr_open: false, + } + } +} + +impl GoPublicPanel { + /// Build a fresh panel bound to the default loopback control port. Does no + /// I/O (construction is cheap + test-friendly); call [`Self::refresh`] to pull + /// the live status. + pub fn new() -> Self { + Self::default() + } + + /// The login URL (public URL + `#token=` fragment) when a tunnel is up with a + /// public URL, else `None`. Reuses the migrated [`login_url`]. + pub fn login(&self) -> Option { + let status = self.status.as_ref()?; + let public_url = status.public_url.as_deref()?; + Some(login_url(public_url, status.access_token.as_deref())) + } + + /// GET the current tunnel status from the loopback control plane. Errors + /// (daemon unreachable, etc.) land in `message` — never a panic. + pub fn refresh(&mut self) { + match call_remote_status(self.control_port) { + Ok(status) => { + self.message = Some(describe_status(&status)); + self.status = Some(status); + } + Err(e) => { + self.message = Some(explain_control_error( + "status failed", + self.control_port, + &e, + )); + } + } + } + + /// Start remote access via the loopback control plane (`POST /api/remote/start`) + /// using the daemon's PRE-CONFIGURED tunnel: an empty provider + field map tells + /// the daemon to fall back to its `lucarned.yaml` config (G3). Thin wrapper over + /// [`Self::start_with`] with empty params — kept as the "just go public with + /// whatever the daemon already has" convenience. + /// + /// The interactive `s` key no longer calls this directly: the [`App`] bridges + /// the Config panel's live params into [`Self::start_with`] (PART 1), and an + /// empty Config yields empty params (the same daemon-default fallback). This + /// method stays for callers / future entry points that want the daemon default + /// unconditionally without consulting the Config panel. + /// + /// [`App`]: crate::tui::app::App + #[allow(dead_code)] + pub fn start(&mut self) { + self.start_with(String::new(), std::collections::BTreeMap::new()); + } + + /// Start remote access via the loopback control plane (`POST /api/remote/start`) + /// with an EXPLICIT provider + field map (PART 1: wired from the Config panel's + /// live in-TUI edits via [`crate::tui::config::ConfigPanel::start_params`]). The + /// daemon merges these over its pre-config (G3), so "configure in Config, then + /// `s` in Go Public" works without saving `lucarned.yaml` first. + /// + /// An EMPTY `provider` (with empty `fields`) means "use the daemon's + /// pre-configured tunnel" (the v1 behavior). When a provider id IS set it is + /// validated against its descriptor first; an invalid config surfaces inline in + /// `message` and NOTHING is sent. On success the returned public URL + token are + /// stored and the QR modal is opened. Other errors land in `message`. + pub fn start_with( + &mut self, + provider: String, + fields: std::collections::BTreeMap, + ) { + // Validate a non-empty provider's config against its descriptor before + // touching the network, so a missing required field is caught inline rather + // than as a daemon 400 (the provider boundary stays in lucarne_remote). + if !provider.is_empty() { + if let Err(detail) = validate_start_config(&provider, &fields) { + self.message = Some(format!("start blocked — {detail}")); + return; + } + } + let plan = GoPublicPlan { + provider, + fields, + control_url: control_url(self.control_port, "start"), + }; + match call_remote_start(&plan) { + Ok(status) => { + self.message = Some(describe_status(&status)); + let has_login = status.public_url.is_some(); + self.status = Some(status); + // Open the QR modal when there is something to scan. + self.qr_open = has_login; + } + Err(e) => { + self.message = Some(explain_control_error("start failed", self.control_port, &e)); + } + } + } + + /// Stop remote access (`POST /api/remote/stop`). Closes the QR modal and + /// records the resulting status. Errors land in `message`. + pub fn stop(&mut self) { + match call_remote_stop(self.control_port) { + Ok(status) => { + self.message = Some(describe_status(&status)); + self.status = Some(status); + self.qr_open = false; + } + Err(e) => { + self.message = Some(explain_control_error("stop failed", self.control_port, &e)); + } + } + } + + /// Handle a key for the Go-Public panel. `x` stop, `r` refresh status, `Enter` + /// (re)open the login QR modal when a login is available, and `Esc`/`q` close + /// the modal. + /// + /// NOTE: the `s` (start) key is deliberately NOT handled here — the [`App`] owns + /// both this panel and the Config panel, so it intercepts `s` and calls + /// [`Self::start_with`] using the Config panel's live + /// [`start_params`](crate::tui::config::ConfigPanel::start_params) (PART 1). + /// This handler can therefore stay panel-local with no return value (MNT-005: + /// the single-variant `GoPublicAction` is gone). + /// + /// [`App`]: crate::tui::app::App + pub fn handle_key(&mut self, code: crossterm::event::KeyCode) { + use crossterm::event::KeyCode; + + // While the modal is open, Esc/q just closes it (the loop maps q→quit only + // when no panel consumes it; here the panel consumes it to close the QR). + if self.qr_open { + if matches!(code, KeyCode::Esc | KeyCode::Char('q') | KeyCode::Enter) { + self.qr_open = false; + } + return; + } + match code { + KeyCode::Char('x') => self.stop(), + KeyCode::Char('r') => self.refresh(), + KeyCode::Enter => { + if self.login().is_some() { + self.qr_open = true; + } else { + self.message = + Some("no public URL yet — press s to start remote access".to_string()); + } + } + _ => {} + } + } +} + +/// One-line human summary of a tunnel status for the panel body / message line. +fn describe_status(status: &lucarne_remote_status::RemoteStartStatus) -> String { + if !status.running { + return "remote access: stopped".to_string(); + } + let provider = status.provider.as_deref().unwrap_or("(unknown provider)"); + let url = status.public_url.as_deref().unwrap_or("(no public URL)"); + let token = match status.access_token.as_deref() { + Some(t) if !t.is_empty() => "token: set", + _ => "token: none", + }; + format!("remote access: running via {provider} — {url} — {token}") +} + +/// Render the centered login-QR modal over `area` (Decision 4). +/// +/// The QR is drawn with an EXPLICIT high-contrast `Style` — +/// `fg(Color::Black).bg(Color::White)` — so it stays scannable regardless of the +/// terminal theme (never inheriting a dark background that would invert it). If +/// the modal's inner rect (border-stripped) is smaller than the QR grid we FALL +/// BACK to the plain login URL + a "terminal too small to display QR" hint so the +/// layout never breaks. The access token / public URL are shown by the panel body +/// (this function only owns the modal). +pub fn render_qr_modal(frame: &mut Frame, panel: &GoPublicPanel, area: Rect) { + let Some(login) = panel.login() else { + return; + }; + + // High-contrast QR style, independent of the terminal theme (Decision 4). + let qr_style = Style::default().fg(Color::Black).bg(Color::White); + let block = Block::default() + .borders(Borders::ALL) + .title("Scan to open (Esc to close)"); + + match render_terminal_qr(&login) { + Ok(qr) => { + let grid = QrGrid::measure(&qr); + // Modal size = QR grid + the 2-cell border on each axis, clamped to area. + let modal = centered_rect(grid.cols + 2, grid.rows + 2, area); + let inner = modal.inner(ratatui::layout::Margin::new(1, 1)); + + // Always clear the area we draw over so the underlying body never bleeds + // through the white QR background. + frame.render_widget(Clear, modal); + + if grid.fits_within(inner) { + let lines: Vec = qr.lines().map(|l| Line::styled(l, qr_style)).collect(); + let qr_widget = Paragraph::new(Text::from(lines)) + .block(block) + .style(qr_style); + frame.render_widget(qr_widget, modal); + } else { + render_qr_fallback(frame, &login, area, block); + } + } + Err(e) => { + // QR generation failed (e.g. data too long) — show the plain URL. + // COR-004: size the modal by the login's CHAR count (matching + // `QrGrid::measure`), not its byte length — a multi-byte URL would + // otherwise over-size the modal via the lossy `len() as u16`. + let body = format!("QR render failed: {e}\n\nlogin URL:\n{login}"); + let login_cols = login.chars().count().min(u16::MAX as usize) as u16; + let modal = centered_rect(login_cols.saturating_add(4).min(area.width), 6, area); + frame.render_widget(Clear, modal); + let widget = Paragraph::new(body) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: false }); + frame.render_widget(widget, modal); + } + } +} + +/// Fallback modal when the terminal is too small for the QR grid: a centered box +/// showing the plain login URL + a hint to open it manually (Decision 4). Wraps +/// so a long URL never overflows the (small) box. +fn render_qr_fallback(frame: &mut Frame, login: &str, area: Rect, block: Block) { + let body = format!("terminal too small for QR — open this URL:\n\n{login}"); + // A modest box; the URL wraps inside it. + let w = area.width.saturating_sub(2).max(1); + let h = 7u16.min(area.height); + let modal = centered_rect(w, h, area); + frame.render_widget(Clear, modal); + let widget = Paragraph::new(body) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: false }); + frame.render_widget(widget, modal); +} + +#[cfg(test)] +mod go_public_tests { + use super::*; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn blocking_control_plane_call_runs_off_runtime_without_panic() { + // Regression for the `lucarned tui` startup panic: the control-plane + // calls use `reqwest::blocking`, which builds+drops its own Tokio + // runtime. Dropping a runtime inside an async (tokio) context panics + // ("Cannot drop a runtime in a context where blocking is not allowed"). + // Production runs the TUI on a dedicated thread with no ambient runtime; + // mirror that here. No daemon is needed — a connection error is fine, the + // bug was on runtime DROP which happens regardless of success. + let handle = std::thread::spawn(|| { + // Port 1: nothing listening → fast connection error; the point is to + // exercise the reqwest::blocking runtime build+drop, not the response. + let _ = call_remote_status(1); + }); + handle + .join() + .expect("a reqwest::blocking control-plane call on a dedicated thread must not panic"); + } + + #[test] + fn explain_control_error_guides_to_daemon_on_connectivity_failure() { + // Connectivity failure (call_remote_* prefix "failed to reach daemon") → + // actionable hint to start the daemon, not a raw reqwest error. + let connectivity = explain_control_error( + "status failed", + 7801, + "failed to reach daemon at http://127.0.0.1:7801/api/remote/status: error sending request", + ); + assert!(connectivity.contains("daemon isn't running")); + assert!(connectivity.contains("lucarned autostart start")); + assert!(connectivity.contains("7801")); + // Non-connectivity errors pass through verbatim under the op label. + let other = explain_control_error("start failed", 7801, "daemon returned 400: bad config"); + assert_eq!(other, "start failed: daemon returned 400: bad config"); + } + + #[test] + fn control_port_defaults_to_gateway_port_plus_one() { + // SEC-002: the CLI derives the control port from the gateway port + 1, + // matching the daemon's default off-tunnel control listener. + assert_eq!(default_control_port(7800), Some(7801)); + assert_eq!(default_control_port(9000), Some(9001)); + // L1: port 65535 + 1 overflows → None (explicit --control-port required). + assert_eq!(default_control_port(65535), None); + } + + #[test] + fn built_in_providers_are_enumerable() { + // Mirrors the interactive-selection source of truth: the CLI lists what + // RemoteRegistry::builtin() advertises. + let registry = lucarne_remote::builtin(); + assert!(registry.ids().contains(&"cloudflared")); + } + + #[test] + fn login_url_appends_token_fragment() { + assert_eq!( + login_url("https://demo.example.test", Some("secret123")), + "https://demo.example.test#token=secret123" + ); + assert_eq!( + login_url("https://demo.example.test", None), + "https://demo.example.test" + ); + // Empty token is treated as absent. + assert_eq!( + login_url("https://demo.example.test", Some("")), + "https://demo.example.test" + ); + } + + #[test] + fn qr_renders_for_login_url() { + let url = login_url("https://demo.example.test", Some("k")); + let qr = render_terminal_qr(&url).expect("qr renders"); + // Half-block QR uses the block glyphs from the wechat renderer. + assert!(qr.contains('█')); + assert!(qr.lines().count() > 3); + } + + // ---- TASK-003: Go-Public panel pure logic ---- + + fn status( + running: bool, + url: Option<&str>, + token: Option<&str>, + ) -> lucarne_remote_status::RemoteStartStatus { + lucarne_remote_status::RemoteStartStatus { + running, + provider: Some("cloudflared".to_string()), + public_url: url.map(str::to_string), + access_token: token.map(str::to_string), + } + } + + #[test] + fn panel_defaults_to_control_port_7801() { + // Default control port = gateway 7800 + 1, matching the daemon default and + // the migrated derivation. start() must target that loopback /start route. + let panel = GoPublicPanel::new(); + assert_eq!(panel.control_port, 7801); + assert_eq!( + control_url(panel.control_port, "start"), + "http://127.0.0.1:7801/api/remote/start" + ); + assert_eq!( + control_url(panel.control_port, "stop"), + "http://127.0.0.1:7801/api/remote/stop" + ); + assert_eq!( + control_url(panel.control_port, "status"), + "http://127.0.0.1:7801/api/remote/status" + ); + } + + #[test] + fn panel_login_builds_token_fragment_only_when_url_present() { + let mut panel = GoPublicPanel::new(); + // No status yet → no login string. + assert!(panel.login().is_none()); + // Running with a URL + token → the `#token=` fragment login (reusing login_url). + panel.status = Some(status( + true, + Some("https://demo.example.test"), + Some("secret"), + )); + assert_eq!( + panel.login().as_deref(), + Some("https://demo.example.test#token=secret") + ); + // Running but no public URL → no login string. + panel.status = Some(status(true, None, Some("secret"))); + assert!(panel.login().is_none()); + } + + #[test] + fn qr_grid_measure_is_rectangular_and_counts_rows() { + // 3 equal-width rows + a blank line that must be ignored. + let qr = "███\n█ █\n███\n\n"; + let grid = QrGrid::measure(qr); + assert_eq!(grid.rows, 3); + assert_eq!(grid.cols, 3); + // Empty input → zero grid (degenerate but well-defined). + assert_eq!(QrGrid::measure(""), QrGrid { cols: 0, rows: 0 }); + } + + #[test] + fn qr_fits_within_decision_picks_qr_vs_fallback() { + let grid = QrGrid { cols: 10, rows: 8 }; + // Inner rect exactly the grid size → fits (renders the QR). + assert!(grid.fits_within(Rect::new(0, 0, 10, 8))); + // One column short → does NOT fit (falls back to the plain URL). + assert!(!grid.fits_within(Rect::new(0, 0, 9, 8))); + // One row short → does NOT fit. + assert!(!grid.fits_within(Rect::new(0, 0, 10, 7))); + // Larger than the grid → fits. + assert!(grid.fits_within(Rect::new(0, 0, 40, 20))); + } + + #[test] + fn real_login_qr_fit_decision_against_small_and_large_rects() { + // The actual login QR for a representative URL: small terminals fall back, + // a roomy terminal renders the scannable grid. + let login = login_url("https://demo.example.test", Some("k")); + let qr = render_terminal_qr(&login).expect("qr renders"); + let grid = QrGrid::measure(&qr); + assert!(grid.cols > 0 && grid.rows > 0); + // A tiny inner rect cannot hold the grid → fallback. + assert!(!grid.fits_within(Rect::new(0, 0, 4, 4))); + // A generous inner rect holds it → render the QR. + assert!(grid.fits_within(Rect::new(0, 0, grid.cols, grid.rows))); + } + + #[test] + fn centered_rect_centers_and_clamps_to_area() { + let area = Rect::new(0, 0, 80, 24); + // A 20x10 modal centers inside an 80x24 area. + let r = centered_rect(20, 10, area); + assert_eq!((r.width, r.height), (20, 10)); + assert_eq!(r.x, (80 - 20) / 2); + assert_eq!(r.y, (24 - 10) / 2); + // A request larger than the area is clamped to the area (never overflows). + let big = centered_rect(200, 200, area); + assert_eq!((big.width, big.height), (80, 24)); + assert_eq!((big.x, big.y), (0, 0)); + } + + #[test] + fn describe_status_summarizes_running_and_stopped() { + let stopped = describe_status(&status(false, None, None)); + assert_eq!(stopped, "remote access: stopped"); + let running = describe_status(&status(true, Some("https://demo.example.test"), Some("k"))); + assert!(running.contains("running via cloudflared")); + assert!(running.contains("https://demo.example.test")); + assert!(running.contains("token: set")); + // Running without a token reports token: none. + let no_token = describe_status(&status(true, Some("https://demo.example.test"), None)); + assert!(no_token.contains("token: none")); + } + + #[test] + fn modal_key_opens_and_closes_qr() { + use crossterm::event::KeyCode; + let mut panel = GoPublicPanel::new(); + panel.status = Some(status(true, Some("https://demo.example.test"), Some("k"))); + // Enter opens the modal when a login is available. + panel.handle_key(KeyCode::Enter); + assert!(panel.qr_open); + // While open, Esc closes it. + panel.handle_key(KeyCode::Esc); + assert!(!panel.qr_open); + // Enter with no login available sets a hint instead of opening. + let mut empty = GoPublicPanel::new(); + empty.handle_key(KeyCode::Enter); + assert!(!empty.qr_open); + assert!(empty.message.is_some()); + } +} diff --git a/crates/lucarned/src/tui/sessions.rs b/crates/lucarned/src/tui/sessions.rs new file mode 100644 index 0000000..6409d0f --- /dev/null +++ b/crates/lucarned/src/tui/sessions.rs @@ -0,0 +1,607 @@ +//! Sessions panel — the rmux-native session list + action console. +//! +//! The backend helpers (`run`, `rmux_out`, `pane_cwd`, +//! `archive_session`, …) were migrated verbatim from the old `lucarne-termctl` +//! `term` CLI (Decision 2: migrate-don't-rewrite). The gateway-monitored sessions +//! are driven through the SAME system rmux daemon via its own CLI +//! (`list-sessions` / `attach-session` / `detach-client` / `kill-session`), plus +//! the shared [`lucarne_rmux::archive`] store, so no control-plane IPC is introduced +//! (Decision 5). +//! +//! On top of those primitives this module hosts [`SessionsPanel`]: a ratatui +//! `ListState`-driven list of running rmux sessions with key actions Enter=attach +//! (pop-out handoff), `d`=detach, `k`/`Del`=kill, `a`=archive. Attach is a +//! suspend → run → resume terminal handoff (Decision 3); because the TUI owns the +//! terminal we must NOT `exec`-replace the process (that kills the TUI), so attach +//! spawns `rmux attach-session` and WAITS for it, then control returns to the TUI. + +use std::io::{self, Stdout}; + +use lucarne_rmux::{archive, cli, monitor::scrollback_capture_start_arg}; +use ratatui::{backend::CrosstermBackend, widgets::ListState, Terminal}; + +/// Operator hint shown when `rmux list-sessions` could not be reached (COR-005): +/// an empty list then means "rmux is down", not "no sessions". Shared by the +/// refresh status line and the empty-state render so the message is identical. +pub const RMUX_UNREACHABLE_HINT: &str = "rmux unreachable — is the rmux daemon running?"; + +/// Run `rmux ` inheriting stdio; return its exit code. +pub fn run(args: &[&str]) -> i32 { + match cli::run_status(args) { + Ok(status) => status.code().unwrap_or(1), + Err(e) => { + eprintln!("term: failed to run rmux: {e}"); + 1 + } + } +} + +/// Run `rmux ` and capture stdout (None on failure). +pub fn rmux_out(args: &[&str]) -> Option { + let out = cli::output(args).ok()?; + if !out.status.success() { + return None; + } + Some(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +/// The current working directory of a session's active pane (None when absent). +pub fn pane_cwd(name: &str) -> Option { + rmux_out(&["display-message", "-p", "-t", name, "#{pane_current_path}"]) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn capture_scrollback_args<'a>(start: &'a str, name: &'a str) -> [&'a str; 6] { + ["capture-pane", "-p", "-S", start, "-t", name] +} + +fn capture_scrollback(name: &str) -> Option { + let start = scrollback_capture_start_arg(); + rmux_out(&capture_scrollback_args(&start, name)) +} + +/// Capture `rmux list-sessions` output with an EXPLICIT format (COR-006) so the +/// authoritative, delimiter-free session name is available for action targets. +/// +/// We pass `-F '#{session_name}\t'`: the FIRST field (up to the first tab) +/// is the exact `session_name` used for every `-t`/`-s` action — a name that +/// contains `": "` (or other punctuation) therefore no longer corrupts the action +/// target the way scraping the default `": "` output did. The trailing +/// tab-separated text is human metadata for the detail pane only. Returns `None` +/// on failure / no daemon. +pub fn list_sessions_raw() -> Option { + rmux_out(&[ + "list-sessions", + "-F", + "#{session_name}\t#{session_windows} windows (created #{session_created_string})", + ]) +} + +/// Run `rmux attach-session -t ` and WAIT for it to finish, returning the +/// exit code. Unlike the old `term attach` (which `exec`-replaced the process), +/// the TUI spawns + waits so the terminal is handed over for the lifetime of the +/// attach and control RETURNS to the TUI on detach/exit (Decision 3). Callers +/// should drive this through [`attach_handoff`], which suspends/resumes the TUI +/// around it; this raw form exists for the (cfg-guarded) test of the argv. +pub fn attach_session_wait(name: &str) -> i32 { + match cli::run_status_interactive(&["attach-session", "-t", name]) { + Ok(status) => status.code().unwrap_or(1), + Err(e) => { + eprintln!("term: failed to attach rmux session: {e}"); + 1 + } + } +} + +/// Pop-out handoff (Decision 3): SUSPEND the TUI (`LeaveAlternateScreen` + +/// `disable_raw_mode`), hand the REAL terminal to `rmux attach-session -t ` +/// and WAIT for it (spawn+wait — never an `exec`-replace, which would kill the +/// TUI process), then RE-ENTER the TUI (`enable_raw_mode` + `EnterAlternateScreen`) +/// and force a full redraw + session-list refresh so the dashboard reflects any +/// change made while attached. +/// +/// All rmux + terminal-handoff specifics live here in the sessions module so the +/// shared loop never learns provider details (it just forwards the request). +pub fn attach_handoff( + terminal: &mut Terminal>, + panel: &mut SessionsPanel, + name: &str, +) -> Result<(), String> { + use crossterm::{ + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + }; + + // Suspend: hand the terminal back to a normal cooked-mode child. + execute!(io::stdout(), LeaveAlternateScreen) + .map_err(|e| format!("failed to leave alternate screen for attach: {e}"))?; + disable_raw_mode().map_err(|e| format!("failed to disable raw mode for attach: {e}"))?; + + let code = attach_session_wait(name); + panel.status = Some(if code == 0 { + format!("attached to '{name}' (returned)") + } else { + format!("attach '{name}' exited {code}") + }); + + // Re-enter: re-enable raw mode FIRST, then arm an RAII guard (COR-002) so any + // error on the rest of the resume path (EnterAlternateScreen / clear) cannot + // leave the terminal in raw mode without the alternate screen. The guard is + // disarmed on the happy path so the TUI keeps its raw + alt-screen state. + enable_raw_mode().map_err(|e| format!("failed to re-enable raw mode after attach: {e}"))?; + let mut restore_guard = RawModeGuard::armed(); + execute!(io::stdout(), EnterAlternateScreen) + .map_err(|e| format!("failed to re-enter alternate screen after attach: {e}"))?; + terminal + .clear() + .map_err(|e| format!("failed to clear terminal after attach: {e}"))?; + // Resume succeeded: keep the raw + alternate-screen state for the live TUI. + restore_guard.disarm(); + panel.refresh(); + Ok(()) +} + +/// RAII terminal-restore guard for the attach resume path (COR-002). +/// +/// Armed right after `enable_raw_mode()` succeeds: if any later step on the +/// resume path early-returns (an `EnterAlternateScreen` / `clear` error), the +/// guard's `Drop` runs `disable_raw_mode()` + `LeaveAlternateScreen` so the user +/// is never dropped back to a raw terminal with no alternate screen. On the happy +/// path the caller [`disarm`](RawModeGuard::disarm)s it so the TUI keeps running +/// in raw + alt-screen mode. +struct RawModeGuard { + armed: bool, +} + +impl RawModeGuard { + /// An armed guard — its `Drop` will restore the terminal unless disarmed. + fn armed() -> Self { + Self { armed: true } + } + + /// Disarm the guard (the happy path succeeded): `Drop` becomes a no-op. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for RawModeGuard { + fn drop(&mut self) { + if self.armed { + use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen}; + // Best-effort restore on an error path: drop raw mode and leave the + // alternate screen so the terminal is usable again. + let _ = disable_raw_mode(); + let _ = crossterm::execute!(io::stdout(), LeaveAlternateScreen); + } + } +} + +/// Retract: detach clients from a session; the session keeps running. +pub fn detach_client(name: &str) -> i32 { + run(&["detach-client", "-s", name]) +} + +/// Delete (kill) a session. +pub fn kill_session(name: &str) -> i32 { + run(&["kill-session", "-t", name]) +} + +/// Capture a session's content into the shared archive store, then close it. +/// +/// Migrated from `term archive` (`lucarne-termctl/src/main.rs`): the same +/// capture/save/kill flow. Adapted from the CLI's `-> !` exit form to a `Result` +/// so the Sessions panel can surface success/failure inline rather than +/// terminating the process; the archive logic itself is unchanged. +pub fn archive_session(name: &str) -> Result { + let session_id = format!("{name}:0:0"); + let cwd = pane_cwd(name); + let content = capture_scrollback(name).unwrap_or_default(); + match archive::save( + &session_id, + name, + cwd.as_deref(), + &content, + archive::now_epoch(), + ) { + Ok(archive_id) => { + run(&["kill-session", "-t", name]); + Ok(archive_id) + } + Err(e) => Err(format!("term archive: {e}")), + } +} + +/// One parsed row of `rmux list-sessions` output. +/// +/// `name` is the session id used for every action (`-t ` / `-s `); +/// `meta` is the remaining descriptive text (e.g. `1 windows (created ...)`) +/// shown in the detail pane. Parsing is brittle text scraping (Decision 5 +/// tradeoff: reuse the stable CLI, accept text parsing) and is isolated here. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionRow { + /// The rmux session name (everything before the first `": "`). + pub name: String, + /// The trailing descriptive metadata, if any. + pub meta: String, +} + +/// Parse `rmux list-sessions -F '#{session_name}\t'` text into +/// [`SessionRow`]s (COR-006). +/// +/// Each non-empty line is `"\t"`; we split on the FIRST TAB so +/// the authoritative, delimiter-free `session_name` becomes the action target +/// `name` (used for `-t`/`-s`) even when it contains `": "` or other punctuation. +/// The remainder (if any) is the human metadata. Lines with no tab are a bare +/// name with empty metadata. Pure (no I/O) so it is unit-testable. +pub fn parse_sessions(output: &str) -> Vec { + output + .lines() + .map(|line| line.trim_end_matches(['\r', '\n'])) + .filter(|line| !line.trim().is_empty()) + .map(|line| match line.split_once('\t') { + Some((name, meta)) => SessionRow { + name: name.to_string(), + meta: meta.trim().to_string(), + }, + None => SessionRow { + name: line.trim().to_string(), + meta: String::new(), + }, + }) + .collect() +} + +/// What a key press on the Sessions panel asks the event loop to do. Most actions +/// run inline inside [`SessionsPanel`]; `Attach` is special because it needs the +/// terminal owner (the [`crate::tui`] loop) to suspend/resume around the handoff. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionAction { + /// Nothing to do (key not handled by this panel). + None, + /// The loop must suspend the TUI, run `rmux attach-session -t `, then + /// re-enter the TUI and refresh — Decision 3 pop-out handoff. + Attach(String), +} + +/// The Sessions panel state: the parsed session list, its `ListState`, a +/// transient status line for action feedback (archive id, errors, hints), and +/// whether the last `rmux list-sessions` call was UNREACHABLE (COR-005) — so the +/// renderer can distinguish "rmux is down" from "rmux is up but has no sessions". +#[derive(Default)] +pub struct SessionsPanel { + /// Sessions parsed from the last `rmux list-sessions`. + pub sessions: Vec, + /// Selection state for the session list (clamped to `0..sessions.len()`). + pub list: ListState, + /// Last action result / hint, shown in the detail pane. + pub status: Option, + /// COR-005: `true` when the last [`refresh`](Self::refresh) could not reach the + /// rmux daemon (the `list-sessions` call returned `None` / non-zero), so an + /// empty `sessions` means "unreachable", not "no sessions". `false` after a + /// successful call (even one that returns zero sessions). + pub rmux_unreachable: bool, +} + +impl SessionsPanel { + /// Build a fresh, EMPTY panel. Construction does no I/O (so it is cheap and + /// test-friendly); call [`Self::refresh`] to populate it from the live rmux + /// daemon — the event loop does this once before the first draw. + pub fn new() -> Self { + Self::default() + } + + /// Re-run `rmux list-sessions` and re-parse, then re-clamp the selection so an + /// index is never left past the (possibly shorter) new list. COR-005: a + /// successful-but-empty result (`Some(out)` parsing to zero rows) sets + /// [`rmux_unreachable`](Self::rmux_unreachable) `false` (genuinely no sessions), + /// while a failed call (`None` — missing/non-zero rmux) sets it `true` so the + /// renderer can tell "rmux is down" apart from "rmux is up but empty". Never + /// panics. + pub fn refresh(&mut self) { + match list_sessions_raw() { + Some(out) => { + self.sessions = parse_sessions(&out); + self.rmux_unreachable = false; + } + None => { + self.sessions = Vec::new(); + self.rmux_unreachable = true; + } + } + self.clamp_selection(); + } + + /// The currently selected session name, if any (None on an empty list). + pub fn selected_name(&self) -> Option<&str> { + self.list + .selected() + .and_then(|i| self.sessions.get(i)) + .map(|row| row.name.as_str()) + } + + /// Move the selection down one row, clamped to the list length. No-op (and no + /// panic) on an empty list. Delegates to the shared [`super::nav::step`]. + pub fn select_next(&mut self) { + super::nav::step(&mut self.list, self.sessions.len(), true); + } + + /// Move the selection up one row, clamped at the top. No-op on an empty list. + /// Delegates to the shared [`super::nav::step`]. + pub fn select_previous(&mut self) { + super::nav::step(&mut self.list, self.sessions.len(), false); + } + + /// Clamp the selection into `[0, len)` (or `None` on an empty list) so a stale + /// index after a kill/archive can never be rendered out of bounds. Uses the + /// shared [`super::nav::clamp`] with [`super::nav::EmptyPolicy::SelectFirst`] + /// so a refresh of a non-empty list with no selection lands on the first row + /// (preserving the prior behavior of seeding index 0). + fn clamp_selection(&mut self) { + super::nav::clamp( + &mut self.list, + self.sessions.len(), + super::nav::EmptyPolicy::SelectFirst, + ); + } + + /// Handle a key for the Sessions panel. Inline actions (detach/kill/archive) + /// run immediately and refresh; Attach is deferred to the loop via + /// [`SessionAction::Attach`] so the terminal can be suspended/resumed around it. + /// + /// Keys: Enter = attach, `d` = detach, `k`/`Delete` = kill, `a` = archive, + /// `r` = refresh. + pub fn handle_key(&mut self, code: crossterm::event::KeyCode) -> SessionAction { + use crossterm::event::KeyCode; + + match code { + KeyCode::Enter => { + if let Some(name) = self.selected_name() { + return SessionAction::Attach(name.to_string()); + } + self.status = Some("no session selected".to_string()); + } + KeyCode::Char('d') => self.detach_selected(), + KeyCode::Char('k') | KeyCode::Delete => self.kill_selected(), + KeyCode::Char('a') => self.archive_selected(), + KeyCode::Char('r') => { + self.refresh(); + self.status = Some(if self.rmux_unreachable { + RMUX_UNREACHABLE_HINT.to_string() + } else { + format!("refreshed — {} session(s)", self.sessions.len()) + }); + } + _ => return SessionAction::None, + } + SessionAction::None + } + + /// `d` — detach clients from the selected session; the session keeps running. + fn detach_selected(&mut self) { + let Some(name) = self.selected_name().map(str::to_string) else { + self.status = Some("no session selected".to_string()); + return; + }; + let code = detach_client(&name); + self.status = Some(if code == 0 { + format!("detached clients from '{name}'") + } else { + format!("detach '{name}' failed (exit {code})") + }); + self.refresh(); + } + + /// `k` / `Del` — kill (delete) the selected session, then refresh + re-clamp. + fn kill_selected(&mut self) { + let Some(name) = self.selected_name().map(str::to_string) else { + self.status = Some("no session selected".to_string()); + return; + }; + let code = kill_session(&name); + self.status = Some(if code == 0 { + format!("killed '{name}'") + } else { + format!("kill '{name}' failed (exit {code})") + }); + self.refresh(); + } + + /// `a` — archive the selected session (capture → save → kill), then refresh. + fn archive_selected(&mut self) { + let Some(name) = self.selected_name().map(str::to_string) else { + self.status = Some("no session selected".to_string()); + return; + }; + self.status = Some(match archive_session(&name) { + Ok(archive_id) => format!("archived '{name}' -> {archive_id} (closed)"), + Err(e) => e, + }); + self.refresh(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyCode; + + fn panel_with(names: &[&str]) -> SessionsPanel { + let sessions = names + .iter() + .map(|n| SessionRow { + name: (*n).to_string(), + meta: String::new(), + }) + .collect::>(); + let mut list = ListState::default(); + if !sessions.is_empty() { + list.select(Some(0)); + } + SessionsPanel { + sessions, + list, + status: None, + rmux_unreachable: false, + } + } + + #[test] + fn parse_sessions_splits_name_and_meta() { + // COR-006: the format is `#{session_name}\t` — split on the first tab. + let out = "work\t1 windows (created Sat)\nside\t2 windows (created Sun)\n"; + let rows = parse_sessions(out); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].name, "work"); + assert_eq!(rows[0].meta, "1 windows (created Sat)"); + assert_eq!(rows[1].name, "side"); + } + + #[test] + fn parse_sessions_ignores_blank_lines_and_bare_names() { + let out = "\n\nbare\n \nnamed\tmeta here\n"; + let rows = parse_sessions(out); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].name, "bare"); + assert_eq!(rows[0].meta, ""); + assert_eq!(rows[1].name, "named"); + assert_eq!(rows[1].meta, "meta here"); + } + + #[test] + fn parse_sessions_preserves_colon_space_in_authoritative_name() { + // COR-006: a session name containing `": "` must survive intact as the + // action target (the old `": "` split corrupted it). The `-F` name is the + // first tab-delimited field, so the whole name — colons and all — is kept. + let out = "10:30: standup\t1 windows (created Mon)\n"; + let rows = parse_sessions(out); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].name, "10:30: standup", + "the `-F` session_name (with `: `) must be the action key, uncorrupted" + ); + assert_eq!(rows[0].meta, "1 windows (created Mon)"); + } + + #[test] + fn parse_sessions_empty_output_is_empty() { + assert!(parse_sessions("").is_empty()); + assert!(parse_sessions(" \n\t\n").is_empty()); + } + + #[test] + fn archive_capture_args_are_bounded() { + let start = scrollback_capture_start_arg(); + assert_ne!(start, "-", "TUI archive must not capture full pane history"); + assert_eq!( + capture_scrollback_args(&start, "work"), + ["capture-pane", "-p", "-S", "-1000", "-t", "work"] + ); + } + + #[test] + fn empty_list_navigation_never_panics() { + let mut panel = panel_with(&[]); + // Both directions on an empty list must be safe no-ops with no selection. + panel.select_next(); + assert_eq!(panel.list.selected(), None); + panel.select_previous(); + assert_eq!(panel.list.selected(), None); + assert_eq!(panel.selected_name(), None); + } + + #[test] + fn navigation_clamps_to_bounds() { + let mut panel = panel_with(&["a", "b", "c"]); + assert_eq!(panel.list.selected(), Some(0)); + // Walking up past the top stays at 0. + panel.select_previous(); + assert_eq!(panel.list.selected(), Some(0)); + // Walking down stops at the last index, never past it. + for _ in 0..10 { + panel.select_next(); + } + assert_eq!(panel.list.selected(), Some(2)); + assert_eq!(panel.selected_name(), Some("c")); + } + + #[test] + fn clamp_after_shrink_keeps_index_in_range() { + let mut panel = panel_with(&["a", "b", "c"]); + for _ in 0..2 { + panel.select_next(); + } + assert_eq!(panel.list.selected(), Some(2)); + // Simulate a kill/archive shrinking the list under a stale selection. + panel.sessions.truncate(1); + panel.clamp_selection(); + assert_eq!(panel.list.selected(), Some(0)); + // Shrinking to empty drops the selection entirely (no out-of-range index). + panel.sessions.clear(); + panel.clamp_selection(); + assert_eq!(panel.list.selected(), None); + } + + #[test] + fn enter_on_empty_list_does_not_request_attach() { + let mut panel = panel_with(&[]); + assert_eq!(panel.handle_key(KeyCode::Enter), SessionAction::None); + assert!(panel.status.is_some()); + } + + #[test] + fn enter_requests_attach_for_selected_session() { + let mut panel = panel_with(&["work", "side"]); + assert_eq!( + panel.handle_key(KeyCode::Enter), + SessionAction::Attach("work".to_string()) + ); + } + + #[test] + fn unhandled_key_returns_none() { + let mut panel = panel_with(&["work"]); + assert_eq!(panel.handle_key(KeyCode::Char('z')), SessionAction::None); + } + + #[test] + fn empty_distinguishes_reachable_from_unreachable() { + // COR-005: a successful `rmux list-sessions` that parses to zero rows is a + // genuinely-empty list (reachable); a None call (rmux down) is unreachable. + // Both end with an empty `sessions`, but the flag tells them apart so the + // renderer can pick the right message. + + // Reachable-but-empty: a successful call returning no session lines. + assert!( + parse_sessions("").is_empty(), + "a successful empty output parses to zero sessions" + ); + let reachable_empty = { + // Simulate what refresh() does for `Some("")`: empty list + reachable. + let mut p = panel_with(&["stale"]); + p.sessions = parse_sessions(""); + p.rmux_unreachable = false; + p.clamp_selection(); + p + }; + assert!(reachable_empty.sessions.is_empty()); + assert!( + !reachable_empty.rmux_unreachable, + "a reachable-but-empty list must NOT be flagged unreachable" + ); + + // Unreachable: simulate what refresh() does for `None` (rmux down). + let mut unreachable = panel_with(&["stale"]); + unreachable.sessions = Vec::new(); + unreachable.rmux_unreachable = true; + unreachable.clamp_selection(); + assert!(unreachable.sessions.is_empty()); + assert!( + unreachable.rmux_unreachable, + "a failed (None) list call must be flagged unreachable" + ); + // The hint the renderer shows is the shared constant. + assert!(RMUX_UNREACHABLE_HINT.contains("rmux unreachable")); + } +} diff --git a/crates/lucarned/src/tui/ui.rs b/crates/lucarned/src/tui/ui.rs new file mode 100644 index 0000000..a37ad34 --- /dev/null +++ b/crates/lucarned/src/tui/ui.rs @@ -0,0 +1,285 @@ +//! Dashboard rendering (opencode-style layout, Free decision F). +//! +//! Three regions stacked: a left `List` of panels (driven by [`App::list`] / +//! `ListState`), a right detail pane describing the focused panel, and a bottom +//! hint bar. The Sessions panel renders its rmux session list + action status, +//! the GoPublic panel renders tunnel status + the QR login modal, and the Config +//! panel renders the provider field form. + +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::Line, + widgets::{Block, Borders, List, ListItem, Paragraph}, + Frame, +}; + +use super::app::{App, Panel}; + +/// Draw the whole dashboard for one frame. +pub fn draw(frame: &mut Frame, app: &mut App) { + let area = frame.area(); + // Vertical split: body (fills) + a one-line hint bar at the bottom. + let [body, hints] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Fill(1), Constraint::Length(1)]) + .areas(area); + + // Horizontal split inside the body: left panel list + right detail. + let [left, right] = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(20), Constraint::Fill(1)]) + .areas(body); + + draw_panel_list(frame, app, left); + draw_detail(frame, app, right); + draw_hints(frame, app.active, hints); +} + +/// Left `List` of panels with the active one highlighted via `ListState`. +fn draw_panel_list(frame: &mut Frame, app: &mut App, area: Rect) { + let items: Vec = Panel::ALL + .iter() + .map(|panel| ListItem::new(Line::from(panel.title()))) + .collect(); + + // Index-bound guard: never leave a selection past the end of the list. Uses + // the shared nav clamp (KeepNone: the panel selector mirrors `active` and is + // never legitimately unselected, so a bare clamp must not invent a selection). + super::nav::clamp( + &mut app.list, + items.len(), + super::nav::EmptyPolicy::KeepNone, + ); + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("lucarned")) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol("> "); + + frame.render_stateful_widget(list, area, &mut app.list); +} + +/// Right detail pane for the focused panel. Sessions renders the real rmux +/// session list + action status; the others keep placeholder bodies until their +/// tasks land. +fn draw_detail(frame: &mut Frame, app: &mut App, area: Rect) { + match app.active { + Panel::Sessions => draw_sessions(frame, app, area), + Panel::GoPublic => draw_go_public(frame, app, area), + Panel::Config => draw_config(frame, app, area), + } +} + +/// Sessions panel detail: a stateful list of live rmux sessions (or an empty +/// hint), a selected-session detail line, and the last action status. +fn draw_sessions(frame: &mut Frame, app: &mut App, area: Rect) { + // Split the detail pane: the session list (fills) + a 3-line status footer. + let [list_area, footer] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Fill(1), Constraint::Length(3)]) + .areas(area); + + let panel = &mut app.sessions; + let block = Block::default() + .borders(Borders::ALL) + .title(Panel::Sessions.title()); + + if panel.sessions.is_empty() { + // COR-005: distinguish "rmux is down" (the last refresh could not reach the + // daemon) from "rmux is up but has no sessions". Both render an empty list, + // but the message differs so the operator knows whether to start the daemon. + let body = if panel.rmux_unreachable { + format!( + "{}\n\nStart the rmux daemon, then press r to refresh.", + super::sessions::RMUX_UNREACHABLE_HINT + ) + } else { + "No running rmux sessions.\n\nPress r to refresh.".to_string() + }; + let empty = Paragraph::new(body).block(block); + frame.render_widget(empty, list_area); + } else { + let items: Vec = panel + .sessions + .iter() + .map(|row| { + let label = if row.meta.is_empty() { + row.name.clone() + } else { + format!("{} — {}", row.name, row.meta) + }; + ListItem::new(Line::from(label)) + }) + .collect(); + let list = List::new(items) + .block(block) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol("> "); + frame.render_stateful_widget(list, list_area, &mut panel.list); + } + + let selected = panel + .selected_name() + .map(|n| format!("selected: {n}")) + .unwrap_or_else(|| "selected: (none)".to_string()); + let status = panel.status.clone().unwrap_or_default(); + let footer_body = format!("{selected}\n{status}"); + let footer_widget = + Paragraph::new(footer_body).block(Block::default().borders(Borders::ALL).title("status")); + frame.render_widget(footer_widget, footer); +} + +/// Go-Public panel detail: the current tunnel status + last action message, the +/// public URL + access token when up, the START SOURCE line (PART 1: whether `s` +/// will use the Config panel's live provider/fields or the daemon default), and +/// the centered high-contrast login-QR modal overlaid when open (TASK-003). All +/// control-plane / QR specifics live in [`super::remote`]; this only lays out the +/// body and asks it to draw the modal. +fn draw_go_public(frame: &mut Frame, app: &mut App, area: Rect) { + // PART 1: read the Config panel's live start params (read-only, at draw time) + // so the operator sees exactly what `s` will start with — their in-TUI Config + // when a provider is set, or the daemon's pre-configured tunnel when empty. + let (start_provider, start_fields) = app.config.start_params(); + let start_source = if start_provider.is_empty() { + "start uses daemon default (no provider set in Config)".to_string() + } else { + format!( + "start uses Config: provider={start_provider} ({} fields)", + start_fields.len() + ) + }; + + let panel = &app.go_public; + + let running = panel.status.as_ref().map(|s| s.running).unwrap_or(false); + let public_url = panel + .status + .as_ref() + .and_then(|s| s.public_url.as_deref()) + .unwrap_or("(none)"); + let token = match panel + .status + .as_ref() + .and_then(|s| s.access_token.as_deref()) + { + Some(t) if !t.is_empty() => "set (hidden — open the QR to share)", + _ => "(none)", + }; + let message = panel + .message + .as_deref() + .unwrap_or("press r to fetch status"); + + let body = format!( + "Remote access: {state}\n\ + control plane: 127.0.0.1:{port}\n\ + public URL: {public_url}\n\ + access token: {token}\n\ + {start_source}\n\n\ + {message}", + state = if running { "RUNNING" } else { "stopped" }, + port = panel.control_port, + ); + let detail = Paragraph::new(body).block( + Block::default() + .borders(Borders::ALL) + .title(Panel::GoPublic.title()), + ); + frame.render_widget(detail, area); + + // Overlay the centered login-QR modal when open (explicit high-contrast style + // + too-small fallback are handled inside `render_qr_modal`). + if panel.qr_open { + super::remote::render_qr_modal(frame, panel, area); + } +} + +/// Config panel detail: a descriptor-driven provider-field editor. A stateful row +/// list (provider / ports / one row per provider descriptor field, with secret +/// values masked) over a status footer (load/save/validation result + current +/// edit buffer when editing). All provider specifics come from +/// [`super::config::ConfigPanel`] — this only lays out the rows it is handed. +fn draw_config(frame: &mut Frame, app: &mut App, area: Rect) { + use super::config::Row; + + let [list_area, footer] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Fill(1), Constraint::Length(3)]) + .areas(area); + + let panel = &mut app.config; + let block = Block::default() + .borders(Borders::ALL) + .title(Panel::Config.title()); + + // Build one display line per row. The label is panel-owned for the top-level + // keys; provider fields carry the descriptor label + a `*` required marker. + let items: Vec = panel + .rows + .iter() + .map(|row| { + let (label, value) = match row { + Row::Enabled => ("autostart".to_string(), panel.row_display(row)), + Row::Provider => ("provider".to_string(), panel.row_display(row)), + Row::GatewayPort => ("gateway port".to_string(), panel.row_display(row)), + Row::ControlPort => ("control port".to_string(), panel.row_display(row)), + Row::AuthToken => ("auth token".to_string(), panel.row_display(row)), + Row::ReadonlyToken => ("read-only token".to_string(), panel.row_display(row)), + Row::Insecure => ("insecure".to_string(), panel.row_display(row)), + Row::Field { + label, required, .. + } => { + let mark = if *required { " *" } else { "" }; + (format!("{label}{mark}"), panel.row_display(row)) + } + }; + ListItem::new(Line::from(format!("{label}: {value}"))) + }) + .collect(); + + let list = List::new(items) + .block(block) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol("> "); + frame.render_stateful_widget(list, list_area, &mut panel.list); + + // Footer: while editing, show the in-progress buffer MASKED for a secret row + // (the typed secret is never echoed); otherwise show the last status line. + let footer_body = match &panel.editing { + Some(buf) => { + let secret = matches!( + panel.list.selected().and_then(|i| panel.rows.get(i)), + Some(Row::AuthToken | Row::ReadonlyToken | Row::Field { secret: true, .. }) + ); + let shown = if secret { + "•".repeat(buf.chars().count()) + } else { + buf.clone() + }; + format!("editing: {shown}_\n(Enter commit Esc cancel)") + } + None => panel.status.clone().unwrap_or_default(), + }; + let footer_widget = + Paragraph::new(footer_body).block(Block::default().borders(Borders::ALL).title("status")); + frame.render_widget(footer_widget, footer); +} + +/// Bottom hint bar (fixed v1 keybinds). Sessions shows its action keys; the other +/// panels show the shared navigation hints. +fn draw_hints(frame: &mut Frame, active: Panel, area: Rect) { + let text = match active { + Panel::Sessions => { + "Tab/←→ panel ↑↓ move Enter attach d detach k/Del kill a archive r refresh q quit" + } + Panel::GoPublic => { + "Tab/←→ panel s start x stop r status Enter show QR Esc close QR q quit" + } + Panel::Config => { + "Tab/←→ panel ↑↓ row Enter edit/cycle s save Esc cancel q quit" + } + }; + let hints = Paragraph::new(text).style(Style::default()); + frame.render_widget(hints, area); +} diff --git a/crates/lucarned/tests/default_build_fusion.rs b/crates/lucarned/tests/default_build_fusion.rs new file mode 100644 index 0000000..7aaabe4 --- /dev/null +++ b/crates/lucarned/tests/default_build_fusion.rs @@ -0,0 +1,146 @@ +//! Default-build capability-boundary guard. +//! +//! `lucarned` stays the single product entry, but terminal/rmux/remote/TUI +//! capabilities are explicit source features. Release packaging may opt into a +//! bundle; the default source build must keep the base daemon isolated from +//! remote/rmux/TUI drift. + +use std::process::Command; + +const CAPABILITY_CRATES: &[&str] = &[ + "ratatui", + "crossterm", + "lucarne-rmux", + "lucarne-termgw", + "lucarne-remote", + "rmux-sdk", +]; + +fn tree_crate_names(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let trimmed = line.trim_start_matches(|c: char| { + c.is_whitespace() || matches!(c, '|' | '`' | '+' | '-' | '├' | '│' | '└' | '─') + }); + let name = trimmed.split_whitespace().next()?; + (!name.is_empty()).then(|| name.to_string()) + }) + .collect() +} + +#[test] +fn default_lucarned_build_keeps_terminal_gateway_tui_and_rmux_stack_out() { + let output = Command::new("cargo") + .args([ + "+nightly", + "tree", + "-Zbuild-dir-new-layout", + "-p", + "lucarned", + ]) + .output() + .expect("failed to run `cargo +nightly tree -Zbuild-dir-new-layout -p lucarned`"); + + assert!( + output.status.success(), + "cargo tree failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let names = tree_crate_names(&stdout); + let present: Vec<&str> = CAPABILITY_CRATES + .iter() + .copied() + .filter(|capability| names.iter().any(|name| name == capability)) + .collect(); + + assert!( + present.is_empty(), + "default `lucarned` build must not link terminal/remote/TUI capability \ + crates; found {:?}. Full tree:\n{}", + present, + stdout, + ); +} + +#[test] +fn product_terminal_bundle_contains_terminal_gateway_tui_and_rmux_stack() { + let output = Command::new("cargo") + .args([ + "+nightly", + "tree", + "-Zbuild-dir-new-layout", + "-p", + "lucarned", + "--features", + "product-terminal", + ]) + .output() + .expect("failed to run `cargo +nightly tree -Zbuild-dir-new-layout -p lucarned --features product-terminal`"); + + assert!( + output.status.success(), + "cargo tree failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let names = tree_crate_names(&stdout); + let missing: Vec<&str> = CAPABILITY_CRATES + .iter() + .copied() + .filter(|required| !names.iter().any(|name| name == required)) + .collect(); + + assert!( + missing.is_empty(), + "`product-terminal` bundle must include explicit terminal/remote/TUI \ + capabilities; missing {:?}. Full tree:\n{}", + missing, + stdout, + ); +} + +#[test] +fn individual_capability_features_compile_without_accidental_coupling() { + for feature in ["remote-access", "terminal-rmux", "terminal-gateway", "tui"] { + let output = Command::new("cargo") + .args([ + "+nightly", + "check", + "-Zbuild-dir-new-layout", + "-p", + "lucarned", + "--features", + feature, + ]) + .output() + .unwrap_or_else(|err| panic!("failed to run cargo check for {feature}: {err}")); + + assert!( + output.status.success(), + "`lucarned` feature `{feature}` must compile independently:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } +} + +#[test] +fn tree_crate_names_parses_leftmost_name() { + let sample = "\ +lucarned v0.4.2 (/path) +├── lucarne-termgw v0.4.2 (/path) +│ └── crossterm v0.29.0 (*) +└── lucarne-rmux v0.4.2 (/path) +"; + let names = tree_crate_names(sample); + assert!(names.contains(&"lucarned".to_string())); + assert!(names.contains(&"lucarne-termgw".to_string())); + assert!(names.contains(&"crossterm".to_string())); + assert!(names.contains(&"lucarne-rmux".to_string())); +} diff --git a/docs/commands.md b/docs/commands.md index 3396213..7377b15 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -56,3 +56,75 @@ | `/config` | 查看当前 bypass、notifications 状态 | | `/config global notifications on\|off` | 开关全局通知 | | `/config global bypass on\|off` | 开关全局权限绕过 | + +## 终端控制台(`lucarned tui`) + +`lucarned tui` 是本地操作者的唯一交互入口(替代旧的 `term` CLI): + +> 完整说明(面板、键位、Go Public 的守护进程依赖、`term` → `lucarned tui` 迁移)见 [`docs/tui.md`](tui.md)。 + +```bash +lucarned tui +``` + +全局键位: + +| 键位 | 用途 | +|---|---| +| `↑` / `↓` | 在当前面板内上下移动选项 | +| `Tab` / `←` / `→` | 在 Sessions / Go Public / Config 三个面板间切换 | +| `r` | 刷新当前面板 | +| `q` / `Esc` | 关闭模态(如打开)/ 退出控制台 | +| `Ctrl-C` | 强制退出 | + +### Sessions 面板(rmux 会话) + +| 键位 | 用途 | +|---|---| +| `Enter` | attach:把会话弹出到本地终端,detach 后回到控制台 | +| `d` | detach 当前客户端(远程镜像继续运行) | +| `k` / `Del` | kill 选中会话 | +| `a` | archive 选中会话到共享 archive store | +| `r` | 刷新会话列表 | + +### Go Public 面板(远程接入) + +| 键位 | 用途 | +|---|---| +| `s` | 启动远程接入隧道(`/api/remote/start`) | +| `x` | 停止隧道(`/api/remote/stop`) | +| `r` | 查询状态(`/api/remote/status`) | +| `Enter` | 弹出登录 URL 的高对比二维码模态(终端过小时回退为纯 URL) | +| `Esc` / `q` / `Enter` | 关闭二维码模态 | + +## Headless 远程接入(`lucarned remote`) + +`lucarned remote` 是 TUI Go Public 面板的脚本化对等入口。它只调用 daemon 的 +loopback control plane,不直接拥有隧道生命周期;daemon 必须正在运行。 + +```bash +lucarned remote start +lucarned remote start --provider cloudflared --field token=... --field public_url=https://... +lucarned remote status --json +lucarned remote stop +``` + +| 命令 | 用途 | +|---|---| +| `lucarned remote start` | 通过 `/api/remote/start` 启动远程接入隧道 | +| `lucarned remote status` | 通过 `/api/remote/status` 查询隧道状态 | +| `lucarned remote stop` | 通过 `/api/remote/stop` 停止隧道 | +| `--control-port PORT` | 指定 loopback control plane 端口,默认 `7801` | +| `--json` | 输出稳定 JSON,便于脚本消费 | +| `--provider ID` | 仅 `start`:覆盖 daemon 配置中的 provider | +| `--field KEY=VALUE` | 仅 `start`:覆盖/补充 provider 字段 | + +### Config 面板(远程配置) + +| 键位 | 用途 | +|---|---| +| `Enter` | 编辑选中字段 / 切换 provider(密钥字段掩码显示,不回显) | +| 输入字符 / `Backspace` | 编辑进行中的字段值 | +| `Enter`(编辑中) | 提交编辑 | +| `Esc`(编辑中) | 取消编辑 | +| `s` / `S` | 校验并保存回 `lucarned.yaml`(生成带时间戳的备份) | diff --git a/docs/decisions/2026-05-30-remote-access-tunnel-adapter.md b/docs/decisions/2026-05-30-remote-access-tunnel-adapter.md new file mode 100644 index 0000000..e9ee42c --- /dev/null +++ b/docs/decisions/2026-05-30-remote-access-tunnel-adapter.md @@ -0,0 +1,189 @@ +# remote-access tunnel adapter + +## Context + +Lucarne's terminal-monitor subsystem (see +`2026-05-30-rmux-terminal-monitor-subsystem.md`) mirrors live rmux terminal +sessions to a web client through `lucarne-termgw`. Today that gateway binds a +local address with **zero authentication** and is reachable only on the LAN. + +A new capability is required: expose that gateway to the **public internet** so a +user can reach their terminal mirror + agent chat from a phone, away from home. +This is structurally the highest-severity surface in the whole project — the web +client mirrors a live terminal **and feeds keystrokes back in**, so an +unauthenticated public endpoint is effectively remote code execution. + +The first concrete exposure mechanism is Cloudflare Tunnel (`cloudflared`), but +the design must not hardcode it: FRP, a self-hosted lightweight relay, and other +NAT-traversal tunnels are anticipated. We need a transport-agnostic seam plus a +hardened authentication story that is correct by default. + +## Decision + +Add a pluggable **remote-access tunnel adapter** as a rmux-free crate +`lucarne-remote`, wired into `lucarned` behind explicit capability features. +The source default build remains the base daemon. The `remote-access` feature +adds the tunnel provider abstraction, while `terminal-gateway` selects the +terminal gateway as the exposed capability and `product-terminal` is the release +bundle. `remote.enabled` controls autostart behavior after that capability is +built. The design is captured as nine locked decisions (L1–L9). + +### L1 — Adapter abstraction mirrors `AdapterPlugin`/`AdapterRegistry` + +`lucarne-remote` defines a `RemoteAccessProvider` trait +(`id`/`name`/`required_fields`/`start`/`stop`/`health`) plus a `RemoteRegistry` +(register / lookup / enumerate built-ins). This deliberately mirrors the proven +`AdapterPlugin`/`AdapterRegistry` pattern in +`lucarne-adapter/src/lib.rs:575-603`: a `Send + Sync` async trait plus a registry +that registers, looks up, and enumerates implementations. A new backend is one +trait impl in its own module plus one `RemoteRegistry::register` call — zero core +changes. Only **Cloudflared** is implemented this release. + +### L2 — Adapter boundary = pure tunnel; auth never enters a provider + +A `RemoteAccessProvider` is a **pure tunnel**: `start(local_addr, cfg) -> +TunnelHandle { public_url }`, `stop`, and `health`. Authentication, +authorization, and any token/ticket exchange live in the gateway/web layer and +never leak into a provider. This keeps providers single-responsibility and +honors the AGENTS.md provider-boundary rule (provider-specific logic stays at the +provider boundary; common layers orchestrate through typed contracts only). + +### L3 — Gateway always binds loopback; the tunnel dials back in + +In public mode the gateway still binds only `127.0.0.1:` (default +`127.0.0.1:7800`); `cloudflared` connects outbound to the Cloudflare edge and the +edge dials back into the local port. The daemon **never binds `0.0.0.0`**. This +reuses the health subsystem's loopback precedent (`health.rs parse_health_addr` +rejects non-loopback); the gateway bind is hardened through +`lucarne-termgw::parse_gateway_addr`. Even if auth had a bug, there is no +directly reachable public socket — the only ingress is the authenticated tunnel. + +### L4 — Default-deny: public exposure forces authentication + +Enabling `remote` requires an access token. When none is configured, one is +auto-generated at startup and enforced. Exposing the gateway with auth disabled +is **refused** unless the operator explicitly opts out via `insecure: true` (or +`--insecure`), which prints a loud warning. There is no path to "forgot to turn +on auth and went naked." + +### L5 — Auth transport: Bearer for `/api`, single-use short-TTL ticket for ws + +`/api/*` requests carry the long-lived access token as `Authorization: Bearer`. +Browsers cannot set custom headers on a WebSocket and putting the token in the ws +URL leaks it into logs, so ws auth uses a **token → single-use ticket → upgrade** +exchange: the client first calls `POST /auth/ticket` with the Bearer token to +mint a single-use, short-TTL ticket (seed ~30s), and the ws handler validates and +consumes that ticket before `.on_upgrade()`. Comparisons are **constant-time**; +failures are rate-limited with a temporary lockout (seed ~5 failures → ~60s), +mirroring the existing wechat `rate_limit` discipline. These seed values are +fixed runtime constants, not file-config fields. + +### L6 — daemon owns the tunnel lifecycle; CLI is a thin loopback control client + +`lucarned` owns the tunnel so it survives CLI exit and shuts down with the +daemon, mirroring the health subsystem wiring. `lucarned remote +start|stop|status` is a thin headless loopback control client, and `lucarned +tui` provides the interactive Go Public panel. Both drive the daemon over a +loopback-only control API (`POST /api/remote/start`, etc.) and render the +returned public URL + credentials (QR via the existing `qrcode` crate). +Lifecycle ownership is unambiguous and the control plane stays on loopback. + +### L7 — Reserved backends are trait seams only + +FRP, a self-hosted lightweight relay, and other NAT-traversal tunnels are +**reserved**: they are represented as trait seams plus commented `provider` +placeholders in `lucarned.yaml`, and are **not implemented** this release. They +will later implement `RemoteAccessProvider` in their own modules and register +through `RemoteRegistry` with zero changes to the core. + +### L8 — Engineering discipline per AGENTS.md + +Nightly toolchain with `cargo -Zbuild-dir-new-layout`; provider-specific logic +stays in the provider module and does not pollute common/core layers. The remote +provider seam remains isolated, while the user-facing remote/TUI/gateway entry +is directly fused into the default `lucarned` product build. + +### L9 — Definition of done includes real-device reachability + +Done requires a real `cloudflared` tunnel running on the host plus a phone +reaching the public URL and authenticating against the live `termgw` terminal +mirror — not just a green build. + +## Rationale + +The `AdapterPlugin`/`AdapterRegistry` pattern is already proven in this workspace +(filter-enabled → sort priority → spawn), so reusing its shape for tunnels gives +a known-good seam and a registry that naturally supports "pick a built-in backend" +in the CLI and `enable` in config. Keeping the provider a pure tunnel matches both +the single-responsibility principle and the locked transport-agnostic boundary, +and keeps backend churn (e.g. cloudflared CLI quirks) confined to one module. + +Loopback-only binding plus default-deny auth is defense in depth: the network +shape removes the directly-reachable socket, and the auth layer removes +unauthenticated access, so a failure in either alone is not catastrophic. The +ws ticket exchange specifically avoids the classic pitfall of long-lived +credentials leaking into ws URLs and access logs, and single-use + rate-limiting +resist replay and brute force. + +## Consequences + +- The default source `lucarned` build stays a base daemon and does not link + `lucarne-remote`, `lucarne-termgw`, or `lucarne-rmux`. Release packaging opts + into the `product-terminal` bundle so installed users still get the terminal + remote-access product in the single `lucarned` binary. +- `lucarned.yaml` gains a `remote:` section + (`enabled` as autostart, `provider`, `gateway_addr`, `control_addr`, + `auth_token`, `readonly_token`, `insecure`) plus a GENERIC, + transport-agnostic `providers:` map keyed by provider id — e.g. + `providers: { cloudflared: { token, public_url, binary_path } }` (H6c). The + daemon passes the selected provider's field map straight through to the + provider without interpreting any field name, so a new backend is purely a + `providers.` block. Deprecated compatibility sections such as + `remote.cloudflare:` are provider-declared aliases, not daemon-owned concrete + structs; they are merged before `providers.` so the generic map wins per + key. Commented reserved-backend placeholders (`frp`/`relay`) point here. +- Providers self-describe their config rules through the trait, not the daemon: + `RequiredField::required_when` expresses a conditional requirement (e.g. + cloudflared's `public_url` is required-when a named-tunnel `token` is set — + M7), and `RemoteAccessProvider::validate_config` enforces cross-field / + format rules at config-resolution time. The daemon calls `validate_config` + before `start` and surfaces a violation as a typed `BadConfig` (400); it never + branches on a concrete provider id (AGENTS.md boundary). +- A named-tunnel token is handed to `cloudflared` via a `0600` `--token-file` + (removed when the tunnel stops) rather than `--token` on argv, so the secret + is not visible in the process command line (`ps` / `/proc//cmdline`) — + L3. It remains readable by the same local user while the tunnel runs (a + same-user attacker is already inside the daemon's trust boundary). +- The web client and `termgw` gain an auth layer (Bearer + ticket exchange); + `lucarned remote` and the `lucarned tui` Go Public panel drive the loopback + control API. +- Adding FRP / relay / other tunnels later is a localized change: one + `RemoteAccessProvider` impl + one registry registration + a `providers.` + config block — no daemon-config or common-layer change. + +### Residual risks (explicitly accepted this release) + +- **Cloudflare edge sees plaintext.** Traffic is TLS to the CF edge, but the edge + terminates that TLS and sees the tunnel plaintext. End-to-end payload + encryption + pairing SAS (the M2-style model) is **deferred**; this release + relies on CF edge TLS. Operators must understand the CF edge is in the trust + path. (SEC-012) The daemon emits a loud `tracing::warn!` whenever a *quick* + tunnel (no `token`) starts, recommending a **named tunnel** + (`remote.providers.cloudflared.token` + `public_url`) for sensitive sessions. +- **Single shared access token.** The full-access token is shared, not per-user + or per-session. A second, optional **read-only** credential + (`remote.readonly_token`, SEC-013) is supported: its ws sessions may mirror + terminals but are refused all write frames (input / create / close / agent + prompts). Finer-grained per-user / per-session ACL is **deferred**. +- **cloudflared is an external binary.** The backend shells out to a + separately-installed `cloudflared` binary, which is an external supply-chain + dependency outside this repo's build. + +## References + +- `2026-05-30-rmux-terminal-monitor-subsystem.md` — the terminal-monitor + + `termgw` subsystem this exposure surface sits on top of. +- `lucarne-adapter/src/lib.rs:575-603` — the `AdapterPlugin`/`AdapterRegistry` + pattern mirrored by `RemoteAccessProvider`/`RemoteRegistry`. +- `2026-05-24-lazy-control-plane-state.md` — cold-store discipline referenced for + any persisted remote credentials. diff --git a/docs/decisions/2026-05-30-rmux-terminal-monitor-subsystem.md b/docs/decisions/2026-05-30-rmux-terminal-monitor-subsystem.md new file mode 100644 index 0000000..f143e0c --- /dev/null +++ b/docs/decisions/2026-05-30-rmux-terminal-monitor-subsystem.md @@ -0,0 +1,98 @@ +# rmux terminal-monitor subsystem + +## Context + +Lucarne is a structured agent message bridge: it spawns agent CLIs over plain +pipes and parses their stdout as NDJSON/JSON-RPC. It has no terminal emulation, +no PTY, and no multiplexer integration. + +A new capability is required: monitor the terminal sessions on the user's +system-wide rmux daemon (rmux is a daemon-backed, tmux-compatible multiplexer, +v0.3.1), mirror them faithfully and interactively to a web client, allow a +session to be attached locally (popped into the user's default terminal) and +detached again (retracted) while the remote mirror keeps running, and expose a +thin CLI to list / create / attach / detach / kill those sessions. + +The load-bearing mechanic — an SDK control-mode observer coexisting with a CLI +`attach-session` client on the same session, with `detach-client` leaving the +session alive — was validated against the running daemon in +`.workflow/scratch/spike4-attach-handoff.md` (all claims PASS). + +## Decision + +Add the terminal-monitor as a NEW core subsystem, parallel to `AgentRuntime`, +with the terminal capability concentrated in `lucarne-rmux`. + +- **`lucarne-rmux`** — the terminal capability package: stable terminal grid + value types (`Cell`/`Color`/`Style`/`Cursor`/`PaneGrid`/delta), the + self-authored snapshot differ (rmux exposes no native cell/row delta), the + session registry, terminal input, terminal archive helpers, `adapter` (the sole + place that maps `rmux_sdk` value types into Lucarne terminal types), and + `monitor` (connect to the system daemon, adopt sessions, mirror panes, inject + input). It is the ONLY crate that names `rmux_sdk`. + +It is wired into the default `lucarned` product build so release users get the +TUI, terminal gateway, remote control, and rmux live binding from the single +shipped binary. + +### Monitor model + +The monitor connects to the DEFAULT system socket — the same daemon the user's +own `rmux` uses — and observes it. Discovered sessions register as +`Origin::Adopted` (we observe; we do not own them); sessions created via the CLI +register as `Origin::Managed`. The SDK is a control-mode observer that coexists +with a CLI `attach-session` client on the same session, so "pop a session into a +local terminal / retract it" is rmux-native (`attach-session` / `detach-client`) +and needs no new daemon IPC. + +### Boundary rules + +1. `rmux_sdk` names live only in `lucarne-rmux` (`adapter` + `monitor`). Preview + API churn stops at that boundary; nothing else in the workspace deserializes + or matches rmux types directly. +2. The subsystem is NOT an `agent-sessions` provider. That layer parses external + transcript FILES; a live terminal pane is not a transcript and must not be + forced through provider parse/discovery/watch contracts (AGENTS.md). +3. The subsystem is NOT routed through the agent framer/dialect pipeline. + Terminal bytes (a cell grid) and structured agent events are different data + shapes; reusing the NDJSON pipeline would mean ANSI scraping, which is + rejected. +4. Any persisted session metadata is a cold record: it must use + `ControlPlaneSqliteStore` cold read/write APIs, not the startup hot path + (see `2026-05-24-lazy-control-plane-state.md`). +5. Terminal scrollback/history reads must be bounded windows, never whole-pane + scans — consistent with the existing watch/history hot-path discipline. +6. The PTY is never force-resized; viewport changes are hints and the renderer + scales, so multiple mirror clients never fight over pane size. + +## Rationale + +The terminal data shape is fundamentally incompatible with Lucarne's structured +agent pipeline, so reuse there is neither possible nor desirable; a parallel +subsystem with its own typed contract is the clean fit. The original +`lucarne-term` / `lucarne-rmux` split was intentionally collapsed into +`lucarne-rmux` after package-fragmentation review: terminal vocabulary, archive +helpers, adapter, and monitor are one operational capability, while the +important boundary remains that only `lucarne-rmux` names `rmux_sdk`. + +Agent chat reaching a web client is a separate concern, but this fork no longer +ships an internal `lucarne-web` production crate or `/chat` runtime route. +Terminal-bound agent prompts and transcript projection are exposed through +`lucarne-termgw` routes such as `/agent/{id}` and +`/api/sessions/{id}/agent`, which inject into the rmux pane and record terminal +binding context. Any richer web app is an external gateway API consumer; if a +future browser runtime chat is needed, it should be backed by Lucarne core +service APIs instead of reintroducing a split product layer. + +## Consequences + +- `lucarned`'s default product build includes the monitor, terminal gateway, + remote control, and TUI. +- The gateway (`lucarne-termgw`) consumes the monitor's grid fan-out, applies the + per-client differ, and serves an interactive web terminal view + an HTTP + control surface for the CLI. +- Pop-out/retract is implemented with rmux's own `attach-session` / + `detach-client`; the thin CLI wraps the rmux binary + the gateway HTTP — no new + control-plane IPC is introduced. +- The preview SDK is not hidden behind a source-build feature in this fork's + product line; the default `lucarned` build includes it through `lucarne-rmux`. diff --git a/docs/decisions/2026-06-01-lucarned-tui-frontend.md b/docs/decisions/2026-06-01-lucarned-tui-frontend.md new file mode 100644 index 0000000..2244910 --- /dev/null +++ b/docs/decisions/2026-06-01-lucarned-tui-frontend.md @@ -0,0 +1,148 @@ +# `lucarned tui` is the single interactive frontend + +## Context + +The rmux terminal-monitor subsystem (see +`2026-05-30-rmux-terminal-monitor-subsystem.md`) shipped a thin standalone +`term` CLI (`lucarne-termctl`, a `[[bin]] term`) to drive the local operator +flows: list / attach / detach / kill / archive rmux sessions, run `go-public` to +open the remote-access tunnel, and collect the tunnel provider's credential +fields. + +This left the workspace with two binaries — `lucarned` (the resident daemon) and +`term` (the operator CLI) — but the official `cargo-dist` config only ships one: +`[workspace.metadata.dist] packages = ["lucarned"]`. The `term` binary was never +distributed, so users who installed Lucarne had no supported way to reach the +operator flows. Two binaries are also a UX burden: which one do I run, and which +features do I build it with? + +The user clarified, over several rounds, that they want exactly one entry: a +full-screen, arrow-key-navigable dashboard launched as `lucarned tui`, covering +the rmux terminal-forwarding subsystem only — session list / attach (pop-out) / +detach / kill / archive, go-public start/stop/status with a scannable QR, and +remote-access config editing. They explicitly do NOT want a `term`-style command +interface, agent-conversation browsing, or live terminal-content mirroring (live +pane mirroring stays a web-client concern). + +## Decision + +Add a single interactive entry, `lucarned tui`, as the only operator frontend, +and remove the standalone `term` binary. + +### Single entry with explicit capability features + +`lucarned` gains a `tui` subcommand (wired through `lucarned-ctl`'s `Command` +enum + `main` dispatch) that launches a full-screen `ratatui` dashboard. The TUI +is part of the explicit `product-terminal` bundle feature together with the +remote control plane, terminal gateway, and rmux live binding. The default +source build stays a minimal base daemon. Release packaging opts into +`product-terminal`, so installed users run `lucarned tui` directly without a +second binary. + +The dashboard has three panels, switched with `Tab` / `←` `→`: + +- **Sessions** — a list/action console over the system-wide rmux daemon: + `Enter` attach (pop-out + return), `d` detach, `k`/`Del` kill, `a` archive, + `r` refresh. +- **Go Public** — `s`/`x`/`r` drive the daemon's existing + `/api/remote/{start,stop,status}` loopback control plane; `Enter` opens a QR + modal of the login URL. +- **Config** — descriptor-driven provider field forms that save back to + `lucarned.yaml`. + +### Migrate `lucarne-termctl` logic and tests; do not rewrite + +The reusable `term` logic — the go-public control-plane call (`call_remote_start` +and siblings), `render_terminal_qr`, `login_url`, the rmux argv helpers +(`list-sessions` / `attach-session` / `detach-client` / `kill-session`), +`archive_session`, and the provider field collection (`collect_fields` / +`prompt_field`) — was migrated verbatim into `lucarned`'s `tui` submodules, +together with its existing unit tests (the `go_public_tests` module moved over +unchanged). The `lucarne-termctl` crate (and its `[[bin]] term`) was deleted and +removed from the workspace `members` / `default-members`. The former +`lucarne-term` library surface was later folded into `lucarne-rmux`, alongside +the archive helpers and live monitor, so the fork has one terminal capability +package. + +### TUI = list/action console, not a live pane mirror + +The Sessions panel renders only session metadata (name + meta line); it never +pulls a pane cell grid. Live terminal-content mirroring remains the web client's +job (per the terminal-monitor subsystem ADR). Attach suspends the TUI (leaves raw +mode + the alternate screen), spawns and waits on `rmux attach-session`, and +re-enters the TUI on detach/exit — it deliberately does NOT `exec`-replace the +process (which the old `term attach` did), because that would kill the dashboard. + +### QR rendered in a high-contrast modal with a URL fallback + +`render_terminal_qr` produces a half-block Unicode grid. Inside the TUI it is +drawn in a CENTERED `ratatui` `Paragraph` modal with an EXPLICIT high-contrast +`Style` (`fg(Black).bg(White)`) so the QR stays scannable regardless of terminal +theme, with `Clear` rendered underneath. When the modal's inner rect is smaller +than the grid (or QR generation fails), it falls back to the plain login URL plus +a "terminal too small for QR — open this URL" hint. + +### Reuse existing interfaces; zero new control-plane IPC + +All three capabilities ride on interfaces that already exist: + +- sessions → the rmux daemon's own CLI + `lucarne-rmux::archive`; +- go-public → the daemon's loopback `/api/remote/{start,stop,status}` routes + (reusing the migrated `call_remote_start`); +- config → the `lucarne_remote::builtin()` provider registry for field + descriptors + `write_config_with_backup` (reachable in-crate now that the TUI + lives inside `lucarned`). + +No new daemon control-plane IPC was introduced. + +### Amendment of locked merge-scope decision #5 + +The locked merge-scope decision #5 — "self-made TUI dropped" — is **explicitly +amended** by this ADR: a self-authored TUI is, in fact, the chosen single +frontend. Locked decision #6 — "thin wrapper, no new control-plane IPC" — is +**preserved in spirit and in fact**: the TUI still shells the rmux binary and +hits the daemon's existing control plane, adding zero new IPC. Only #5 is +relaxed; the rmux-rc six locked decisions and the rest of the merge scope are +unchanged. + +## Rationale + +One binary in the release package matches what `cargo-dist` actually ships and +removes the "which tool?" ambiguity. A full-screen dashboard is a better fit than +a command CLI for the list-driven, interactive operator flows (browse sessions, +watch tunnel status, scan a QR), and it can reuse the migrated, already-tested +`term` logic without a rewrite. Explicit source features preserve architecture +boundaries: the base daemon can be built and tested without pulling remote/rmux +or TUI dependencies, while release packaging deliberately opts into the terminal +product bundle. The control-plane-only posture keeps the frontend a thin layer +over stable APIs, honoring the no-new-IPC constraint. + +## Consequences + +- `lucarned` exposes explicit capability features: `remote-access`, + `terminal-rmux`, `terminal-gateway`, `tui`, and the release-oriented + `product-terminal` bundle. +- The default `lucarned` source build is the base daemon. The `product-terminal` + bundle includes TUI, gateway, remote control, and rmux live binding. +- A durable fusion guard — `crates/lucarned/tests/default_build_fusion.rs` — + asserts (via `cargo tree -p lucarned`) that the default build excludes + `lucarne-termgw`, `lucarne-rmux`, `lucarne-remote`, `rmux-sdk`, `ratatui`, and + `crossterm`, while `--features product-terminal` includes them. +- The standalone `term` binary, the `lucarne-termctl` crate, and the `term`-style + command docs are removed; `README.md`, `README.cn.md`, and `docs/commands.md` + now document `lucarned tui` and its keybindings. + +## Alternatives rejected + +- **Keep the `term` CLI** (alongside or instead of the daemon) — leaves two + binaries, one of which is never distributed, and keeps the operator flows + unreachable for installed users. +- **`lucarned term ` command-style subcommands** — re-creates the + command interface the user explicitly rejected; worse fit for the interactive, + list-driven flows. +- **`dialoguer`/`inquire`-style line prompts instead of a full-screen TUI** — + cannot host a live session list, tunnel status, or an in-terminal QR modal; the + user asked for an opencode-style dashboard. +- **Web-only (drop the local interactive entry entirely)** — the user wants a + local, keyboard-driven operator console; the web client serves the remote + viewing use case, not local operation. diff --git a/docs/superpowers/plans/2026-05-19-homebrew-binary-release.md b/docs/superpowers/plans/2026-05-19-homebrew-binary-release.md index 32df273..f8d490d 100644 --- a/docs/superpowers/plans/2026-05-19-homebrew-binary-release.md +++ b/docs/superpowers/plans/2026-05-19-homebrew-binary-release.md @@ -1,5 +1,12 @@ # Homebrew Binary Release Implementation Plan +> **Status note:** Superseded by the current cargo-dist release pipeline in +> root `Cargo.toml`. Current releases package only `lucarned`, target macOS, +> Linux, and Windows x86_64/aarch64, emit `.tar.xz` archives, and should be +> created by the release workflow from an annotated tag rather than manual +> `gh release create` commands. The original task plan below is retained as +> historical implementation context only. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make `brew install lucarned` download macOS Apple Silicon and Intel release binaries instead of rebuilding Rust locally. diff --git a/docs/superpowers/plans/2026-05-21-lucarnedctl-install-autostart.md b/docs/superpowers/plans/2026-05-21-lucarnedctl-install-autostart.md index cf030b8..33da4ed 100644 --- a/docs/superpowers/plans/2026-05-21-lucarnedctl-install-autostart.md +++ b/docs/superpowers/plans/2026-05-21-lucarnedctl-install-autostart.md @@ -1,12 +1,12 @@ # lucarned Install Autostart Implementation Plan -> **Status note:** Original plan used a separate `lucarnedctl` binary. Implementation changed after size testing: control commands are now integrated into `lucarned`, backed by a std-only `crates/lucarned-ctl` library crate. This keeps future standalone extraction easy while removing `clap` from `lucarned` and shipping one binary. +> **Status note:** Original plan used a separate `lucarnedctl` binary. Implementation changed after size testing: control commands are now integrated into `lucarned`, backed by `crates/lucarned-ctl` as a library crate. Current releases ship one binary, `lucarned`; `lucarned-ctl` remains small/std-oriented by default, with update-check dependencies isolated behind its `updates` feature. Treat any unchecked `lucarnedctl` binary steps below as historical plan text, not current implementation instructions. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship cargo-dist installers plus built-in `lucarned` commands that manage user-level autostart and diagnostics on macOS, Windows, and Linux. -**Architecture:** `lucarned` remains the only shipped binary. `crates/lucarned-ctl` is a std-only library crate used by `lucarned` for parser, paths, doctor, and autostart commands. Platform autostart code is cfg-isolated and shells out to LaunchAgent/`launchctl`, Task Scheduler/`schtasks`, or systemd user/`systemctl --user`. +**Architecture:** `lucarned` remains the only shipped binary. `crates/lucarned-ctl` is a std-oriented library crate used by `lucarned` for parser, paths, doctor, autostart, and update commands. Platform autostart code is cfg-isolated and shells out to LaunchAgent/`launchctl`, Task Scheduler/`schtasks`, or systemd user/`systemctl --user`. **Tech Stack:** Rust std, Cargo binary targets, cargo-dist shell/PowerShell installers, GitHub Actions, launchctl, schtasks, systemctl. diff --git a/docs/superpowers/plans/2026-06-01-rmux-lucarne-fusion-review.md b/docs/superpowers/plans/2026-06-01-rmux-lucarne-fusion-review.md new file mode 100644 index 0000000..c3c2ce8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-rmux-lucarne-fusion-review.md @@ -0,0 +1,538 @@ +# rmux 能力融合审查与实施方案 + +> **给后续 agentic workers:** 这份文档是把 fork 中的 rmux 终端能力融合回 +> Lucarne 原始 daemon 入口的基准方案。后续实现要保持证据驱动:任务范围变化时更新 +> 本文档,提交时只包含当前任务直接涉及的文件。 + +**日期:** 2026-06-01 + +**审查分支:** `feat/rmux-terminal-monitor`,当前提交 `bf333d1` + +**上游基准:** 本地 `upstream/main`、`origin/main`、`main` 都指向 `63de15e`。 +审查中尝试过 `git fetch upstream --prune`,但网络 TLS 失败: +`LibreSSL SSL_connect: SSL_ERROR_SYSCALL`。因此本文的上游新鲜度以当前 +checkout 里的本地 refs 为准。 + +**差异规模:** 相对 `upstream/main...HEAD`,当前分支共有 +`66 files changed, 17554 insertions(+), 71 deletions(-)`。 + +**Go / No-Go 结论:** fork 产品线可以继续推进直接融合;但仍不建议不分阶段地直接合并 +进上游主线。当前已按最新决策把用户可见入口融合到 `lucarned` 默认构建:remote +control、terminal gateway、TUI 和 rmux live binding 都随 `lucarned` 发布,不再要求安装 +用户 source build feature。`lucarne-rmux` 仍作为独立 crate 保留,用来隔离 preview +`rmux_sdk` 边界。剩余风险集中在更完整的 integration/E2E 证据、remote config typed +service 统一、multi-pane/输入细节、public gateway 行为级隔离 harness 和 public tunnel +acceptance,不是需要重写整个子系统。 + +--- + +## 分析方法 + +本次审查结合了本地代码扫描、现有 ADR、workspace diff、Rust 验证命令,以及并行子代理 +review: + +- `lucarne-arch-origin`:原始 Lucarne 架构、可复用边界和上游 merge 风险。 +- `lucarne-rmux-review`:rmux 终端子系统、gateway 合同和终端协议。 +- `lucarne-cli-fusion`:CLI、daemon 入口、发行和控制面融合。 +- `lucarne-security-review`:认证、隧道、本机信任边界和测试缺口。 +- 追加 3 个只读子代理复核:架构归属、E2E 安全门、rmux/TUI archive 与 release 边界。 + + +## 验证证据 + +2026-06-01 直接融合推进后已通过: + +```bash +cargo +nightly check -Zbuild-dir-new-layout -p lucarned +cargo +nightly test -Zbuild-dir-new-layout -p lucarned-ctl args +cargo +nightly test -Zbuild-dir-new-layout -p lucarne-rmux scrollback_capture_window_is_bounded +cargo +nightly test -Zbuild-dir-new-layout -p lucarned archive_capture_args_are_bounded +cargo +nightly test -Zbuild-dir-new-layout -p lucarned default_lucarned_build_contains_terminal_gateway_tui_and_rmux_stack +cargo +nightly test -Zbuild-dir-new-layout -p lucarned remote::tests +cargo +nightly test -Zbuild-dir-new-layout -p lucarned tui::config +cargo +nightly test -Zbuild-dir-new-layout -p lucarne-termgw sec002_control_plane +cargo +nightly test -Zbuild-dir-new-layout -p lucarne-termgw +cargo +nightly test -Zbuild-dir-new-layout -p lucarned +cargo +nightly test -Zbuild-dir-new-layout -p lucarne --features terminal-agent-bind --test terminal_live_journey_manifest +cargo +nightly check -Zbuild-dir-new-layout --workspace --all-features +git diff --check +``` + +当前状态:`lucarned` 默认构建现在包含 remote/TUI/gateway/rmux 栈;`default_build_fusion` +测试会通过 `cargo tree -p lucarned` 断言 `lucarne-termgw`、`lucarne-rmux`、`lucarne-remote`、 +`rmux-sdk`、`ratatui`、`crossterm` 均存在。`lucarned remote::tests` 还覆盖了 lazy gateway +start、provider start 失败回 Idle、gateway start 失败回 Idle、重复 start 幂等和 stop 回 Idle。 +现在还覆盖了 health=Down 后 status 清理 handle 并允许下次 start relaunch,以及 stop +可恢复失败时保留 handle 供 retry。 +`lucarne-termgw sec002_control_plane` 覆盖了 loopback control router 的真实 Axum 路由测试。 +最新完整结果:`lucarne-termgw` 43 个测试通过;`lucarned` 151 个单元测试和 2 个 +default fusion tests 通过;`tui::config` 12 个测试通过;`lucarned-ctl args` 10 个测试通过; +core `terminal_live_journey_manifest` 2 个测试通过;workspace `--all-features` check 和 +`git diff --check` 均通过。 + +新增 Cloudflare API contract E2E 门禁: + +```bash +LUCARNE_CF_API_E2E=1 \ +CLOUDFLARE_ACCOUNT_ID=... \ +CLOUDFLARE_API_TOKEN=... \ +cargo +nightly test -Zbuild-dir-new-layout -p lucarne-remote --test cloudflare_api_e2e +``` + +该测试依据 Cloudflare 官方 Tunnel API:`POST /accounts/{account_id}/cfd_tunnel` +创建 named tunnel,`GET /accounts/{account_id}/cfd_tunnel/{tunnel_id}/token` 获取 +connector token,最后 `DELETE /accounts/{account_id}/cfd_tunnel/{tunnel_id}` 清理。它 +需要 API token 具备目标 account 的 Cloudflare Tunnel write/edit 权限。默认 +未设置 `LUCARNE_CF_API_E2E=1` 或缺少凭据时只打印 skip 信息并返回,不把缺少真实 +Cloudflare 凭据伪装成 public tunnel acceptance 已通过;显式设置 +`LUCARNE_CF_API_E2E=1` 但缺少 account/token 会失败,避免误以为已经跑过 live API。 +测试也不会打印 connector token。注意:这条验证 Cloudflare REST API contract 与 named +tunnel 凭据链,不覆盖 `lucarne-remote` 生产路径的 `Cloudflared::start` 二进制执行。 + +新增可复跑 Quick Tunnel E2E harness: + +```bash +cargo +nightly build -Zbuild-dir-new-layout -p lucarned +LUCARNE_QUICK_TUNNEL_E2E=1 scripts/remote-quick-tunnel-e2e.sh +``` + +该脚本默认跳过,只有显式设置 `LUCARNE_QUICK_TUNNEL_E2E=1` 才触网。它会启动临时 +`lucarned` daemon,使用 Cloudflare Quick Tunnel 打开免费 `trycloudflare.com` URL, +验证公网 unauth 401、public gateway 不暴露 `/api/remote/*`、full token list 200、 +read-only HTTP create 403、full WS 首帧 `session_list`、read-only WS create 403, +随后调用 `lucarned remote stop` 并关闭临时 daemon。Quick Tunnel 按 Cloudflare 官方口径只用于 +testing/development,不作为 production 发布替代;production 仍推荐 named tunnel。 + +2026-06-01 并行 review 复核结论: + +- 架构、crate 清理、测试/发布、文档幻觉 4 个只读子代理均完成审查。 +- 未发现已删除的 `lucarne-term`、`lucarne-archive`、`lucarne-web`、`lucarne-agentbind` + 仍作为 workspace member、lock package、path dependency 或源码目录存在。 +- `lucarne-fakeagent` 是测试 harness,不在 default members / dist 包中;`lucarned-ctl` + 是原项目 helper library crate;`lucarne-termgw` 和 `lucarne-remote` 是当前 `lucarned` + 默认产品入口的运行时依赖,均不属于 rmux 割裂残留。 +- 已修正复核指出的 stale 文档:remote ADR 不再声称 optional `remote` Cargo feature 或 + `termctl go-public`;terminal monitor ADR 不再描述已删除的 `lucarne-web` runtime bridge; + README 开发路径改为 `cd Lucarne`;旧 Homebrew/autostart plan 加了 superseded/current + reality note;`termgw-dev` 和 `web/js/protocol.js` 的旧 feature/type 注释已对齐。 +- 测试口径需要保持诚实:`terminal_live_journey_manifest` 是 live/E2E 需求清单守卫, + 不是完整真实设备 E2E;`terminal-agent-bind` 的 lib 单测需要用 + `cargo +nightly test -Zbuild-dir-new-layout -p lucarne --features terminal-agent-bind` + 单独执行,不能只用 `--test terminal_live_journey_manifest` 代替。 + +## 架构基线 + +原始 Lucarne 仍然是 structured agent message bridge: + +- `lucarne` 持有核心 `AgentRuntime` 和事件总线。 +- `agent-sessions` 负责解析外部 agent transcript 文件。 +- `lucarne-channel` 定义 channel 风格的投递合同。 +- Telegram / WeChat 仍然是 channel/provider 集成。 +- `lucarned` 是 daemon composition root,也是发行的主二进制。 + +fork 新增的是并行的终端能力,当前已经把小包收敛为较少的稳定边界: + +- `lucarne-rmux`:统一终端能力包,包含终端词汇、grid、diff、input、registry、 + wire protocol、archive helpers、rmux SDK adapter 和 monitor;也是唯一直接命名 + `rmux_sdk` 的 crate。 +- `lucarne-termgw`:Axum terminal gateway,包含认证、WebSocket、terminal HTTP API 和 + loopback remote control routes。 +- 外部 Web app:直接消费 `lucarne-termgw` 的统一 gateway API;不再作为 Lucarne 内部 + production crate。 +- `lucarne-remote`:可插拔 tunnel provider 抽象,当前实现是 `cloudflared`。 +- `lucarne::terminal_agent_bind`:位于 `lucarne` core 的 opt-in feature module, + 负责终端 session cwd 与 agent transcript context 的绑定;不再作为独立 crate 存在。 +- `lucarned tui`:单一交互式 operator frontend,已经直接融合进 `lucarned` 默认构建。 + +主要 live terminal 数据路径: + +```text +system rmux daemon + -> lucarne-rmux::RmuxMonitor + -> broadcast + -> lucarne-termgw /ws + -> browser terminal renderer +``` + +当前不再内置 browser `/chat` runtime 路径。Web app 作为外部消费者接入 +`lucarne-termgw`,terminal-bound agent prompt / transcript 由 `/agent/{id}` 和 +`/api/sessions/{id}/agent` 承担: + +```text +external web app + -> lucarne-termgw /ws, /agent/{id}, /api/* + -> rmux pane + terminal-agent binding projection +``` + +公网访问路径: + +```text +browser / tunnel edge + -> cloudflared + -> loopback lucarne-termgw + -> bearer token / single-use websocket ticket +``` + +## 已经对齐得比较好的部分 + +fork 没有把 terminal pane 强行塞进 Lucarne 原始 transcript parser 或 NDJSON framer。 +这是正确的:terminal grid 和 agent message stream 是不同数据形态。 + +`lucarne-term` / `lucarne-archive` 已并入 `lucarne-rmux`。当前最重要的架构边界不是继续 +维持小包拆分,而是把终端操作能力收敛到一个 crate,同时让 `rmux_sdk` preview API 只在 +`lucarne-rmux` 内部出现;gateway、TUI 和 daemon 只消费 `lucarne_rmux` 的稳定公开类型。 + +remote tunnel 设计复用了原项目已经证明过的 registry 形态: +`RemoteAccessProvider` / `RemoteRegistry` 让后续 FRP、自托管 relay 或其他 NAT +穿透 backend 可以在 provider 边界内扩展,不污染 daemon core。 + +TUI 合并方向解决了早期 delivery split。删除独立 `term` binary,并暴露 +`lucarned tui`,符合当前 cargo-dist 只发布 `lucarned` 的现实。 + +release 入口已经按产品融合口径闭环:默认 `lucarned` 构建直接包含 remote/TUI/gateway/rmux +栈,安装用户不需要知道 Cargo feature。default build 现在由 `default_build_fusion` 测试守护, +证明发行二进制具备统一入口能力。 + +## 原始项目可复用点 + +应该复用的原始 seam: + +- `lucarned` 继续作为唯一 composition root 和产品入口。 +- `AgentRuntime` 应成为 terminal/web 暴露的 agent chat session 的唯一 runtime bus。 +- provider descriptor 模式可复用于 remote provider 表单字段和 config validation。 +- cold control-plane persistence 纪律应复用于 binding、archive metadata、remote config state + 和未来的 session metadata。 +- release/install 继续围绕 cargo-dist 和单一二进制展开。 + +不应该复用到 terminal bytes 的原始 seam: + +- 不要把 live terminal pane 建模成 `agent-sessions` provider。 +- 不要把 terminal grid update 走 channel adapter message contracts。 +- 不要为了复用 agent framer 而做 ANSI/PTY scraping。 + +## 关键问题 + +### P0:release packaging / 默认构建入口已直接融合 + +已按最新决策处理:`lucarned` 默认构建直接包含 remote control、terminal gateway、 +TUI、`lucarne-rmux` live binding、`lucarne-remote` 和共享 archive store。cargo-dist 仍然只 +发布 `packages = ["lucarned"]`,但这个二进制已经包含 `lucarned tui` 与 +`lucarned remote start|stop|status`,安装用户不需要知道 Cargo feature。 + +当前实现: + +- `crates/lucarned/Cargo.toml` 不再把 `remote` / `tui` 作为可选 feature;相关依赖都是 + `lucarned` 的默认依赖。 +- `crates/lucarned/src/main.rs` 不再保留要求用户重新编译才能启用 remote / TUI 的运行时分支。 +- `crates/lucarned/tests/default_build_fusion.rs` 通过 `cargo tree -p lucarned` 断言默认构建 + 必须包含 `lucarne-termgw`、`lucarne-rmux`、`lucarne-remote`、`rmux-sdk`、`ratatui`、 + `crossterm`。 +- workspace `default-members` 已补入 `lucarne-rmux`、`lucarne-termgw`、`lucarne-remote`, + 裸 `cargo test` / `cargo build` 的默认包集合不再漏掉融合产品 crate。 + +结论:fork 发行线已经是单一 `lucarned` 产品入口。若未来向上游拆分,需要另起 upstream +compatibility 分支处理轻量默认构建,而不是在当前 fork 产品线上保留安装用户不可直接获得的 +能力。 + +### P0:非交互 remote lifecycle 已恢复到 `lucarned remote` + +独立 `term go-public` 已删除,`lucarned tui` 负责交互式 start/stop/status。现在已补回 +等价 headless 命令:`lucarned remote start|stop|status`。 + +当前实现:命令解析在 `lucarned-ctl`,执行在 `lucarned` 内,底层复用 +loopback `/api/remote/{start,stop,status}`。支持稳定文本输出、`--json`、`--control-port`, +以及 `start --provider --field KEY=VALUE` provider override。 + +剩余验证方向:后续可增加带 fake daemon/control server 的 CLI integration test,但 +当前 parser、URL 构造、默认构建编译和 daemon remote tests 已覆盖基础路径。 + +### P1:WebSocket ticket cap 会淘汰合法 fresh ticket + +已修复:`TicketStore::issue_scoped` 现在返回 `Result`。 +达到 outstanding cap 或 issue-rate cap 时拒绝签发新 ticket,不再 FIFO 淘汰旧的 live +ticket。HTTP `/auth/ticket` 将拒绝映射为 `429 Too Many Requests`。 + +影响:已认证调用方不能再通过 mint flood 让其他客户端刚拿到、还未使用的 ticket 失效。 + +验证:`lucarne-termgw` 单测覆盖了 outstanding cap 和 rate cap 两条路径,断言拒绝新票 +时已有 fresh ticket 仍可消费。 + +### P1:TUI archive 全量 scrollback 已修复 + +已修复:`lucarne-rmux::monitor::scrollback_capture_start_arg()` 统一生成 bounded +`capture-pane -S -1000` 参数,HTTP gateway archive 和 TUI archive 都不再读取完整 pane +history。`lucarned` TUI 回归测试 `archive_capture_args_are_bounded` 覆盖了确切参数。 + +同时已修复 archive store 分叉:`lucarne-termgw` 删除私有 inline archive module,改为复用 +`lucarne-rmux::archive`,与 TUI 使用同一目录和 schema。 + +### P1:终端 agent binding 已开始并入 Lucarne core + +原先的独立 `lucarne-agentbind` crate 已移除,功能并入 +`lucarne::terminal_agent_bind`,并由 `terminal-agent-bind` feature 隔离。`termgw` +现在通过 daemon 传入的 `ControlPlaneSqliteStore` 记录 terminal-agent binding history, +不再写 `~/.lucarne/agents.db` 旁路数据库。 + +剩余影响:这一步解决了 crate 边界和旁路 DB,但还需要继续把更高层语义纳入 +`LucarneCore` API,例如 terminal-bound agent history 是否应该显示为 workspace/provider +session projection。 + +后续修复:增加 core service 级 API,而不是让 `termgw` 长期直接操作低层 store。 + +### P1:内置 `lucarne-web` 已移除,Web app 降级为外部消费者 + +`lucarne-web` 原先创建或持有自己的 `AgentRuntime`,与 daemon 现有 channel runtime +不够清晰。按最新融合决策,这个独立 production crate 已删除;`lucarned remote` +现在只组合 `lucarne-termgw`。 + +影响:入口层更清楚,Web app 不再是内部架构边界,而是外部消费者。浏览器侧需要的 +terminal、agent transcript 和 prompt 注入能力通过统一 gateway API 接入。 + +后续如果需要 runtime chat over WebSocket,应落在 `lucarne` core service 或 +`lucarne-termgw` feature route 中,不能重新引入独立 `lucarne-web` 产品层。 + +### P1:`lucarned-ctl` 是原项目已有 helper crate,不属于 rmux 割裂层 + +`lucarned-ctl` 已存在于本地 `upstream/main` / `main`,来源是原项目 install、autostart、 +doctor、update 控制面设计。它不是独立发布二进制,用户入口仍只有 `lucarned`。因此本轮 +不把它作为需要融合删除的 rmux 新包处理,只保留必要的 `Command::Remote` / `Command::Tui` +解析扩展。 + +保留理由:它的原始设计目标是 std-only、小依赖、跨平台 autostart / path / doctor 逻辑隔离, +避免控制命令 helper 反向依赖 `lucarne` core、adapter、SQLite 或 async runtime。 + +### P2:gateway 直接依赖 `lucarne-rmux` + +`lucarne-termgw` 的 public constructor 仍直接消费 `lucarne_rmux::RmuxMonitor`,生产边界 +保持 rmux-specific;内部已补一个窄 `TerminalMonitor` seam,生产实现转发到 `RmuxMonitor`, +测试可用 fake monitor 驱动 `/agent/{id}` 等真实 Axum/WebSocket 路径而不启动系统 rmux +daemon。 + +影响:gateway 本身仍不是 rmux-free。对当前 fork 可以接受;当前 test seam 已解决独立 +gateway tests 的主要阻力,但 alternate terminal backend 仍需要正式 public trait 设计。 + +建议:现在不引入对外 `TerminalBackend` trait,避免为了抽象而抽象。后续如果出现第二个 +backend,再把内部 `TerminalMonitor` seam 提升为正式 public trait。 + +### P2:`/chat` 内置 route 已移除,`/agent/{id}` 保留 terminal-bound 语义 + +`/agent/{id}` 是 pane-bound transcript/prompt injection 到 rmux pane。独立 `/chat` +runtime route 已随 `lucarne-web` 删除。 + +影响:用户和维护者不会再把 daemon-owned runtime chat session 与 terminal-bound +prompt injection 混淆。 + +建议:外部 Web app 直接对接 `/ws`、`/agent/{id}`、`/api/*`。如要恢复 runtime chat, +必须走 core service API,并保持 daemon canonical runtime。 + +### P2:remote config 顶层字段已补齐,仍需集中 typed service + +daemon startup 使用 typed `RemoteFileConfig`,TUI config editor 仍用 ad-hoc YAML 操作。 +本轮已把 `enabled`、`auth_token`、`readonly_token`、`insecure` 补入 TUI Config 面板: +seed、toggle/edit、validate、merge/write-back 都已有 `tui::config` 单测覆盖。 + +影响:安全关键字段不再缺失,但 daemon 与 TUI 仍有两套 parse / validate / write-back +实现,未来字段变化仍可能漂移。`remote.enabled` 的语义已经统一为 autostart:`false` 不再 +表示禁用 remote 子系统,只表示 daemon 启动时不自动打开公网 tunnel。 + +后续修复:集中 remote config 的 parse、validate、default、write-back,形成共享 typed +config service。当前文档语义应保持为: + +```text +remote.enabled = false 表示不 autostart;`lucarned` 仍会提供 loopback control plane, +用于 lazy start。 +``` + +### P1:lazy-start state transition 已有 fake-provider integration 覆盖 + +已补测:`lucarned remote::tests` 使用 test-only gateway starter 和 fake +`RemoteAccessProvider`,不连接真实 rmux / cloudflared,覆盖: + +- 首次 start 会先启动 lazy gateway,再启动 provider。 +- provider start 失败后回到 Idle,可再次 start。 +- gateway start 失败后不会启动 provider,并回到 Idle。 +- 已运行且健康时重复 start 幂等,不重复启动 gateway / provider。 +- stop 成功后回 Idle。 +- status 发现 provider health=Down 后清理 stale handle,下一次 start 会 relaunch。 +- stop 遇到 recoverable provider error 时保留 handle,下一次 stop 可 retry。 + +这部分关闭了 daemon-owned remote lifecycle 的核心 state-machine 缺口。 + +### P2:gateway/control router 隔离仍需要更强的 public-router integration harness + +已有 `lucarne-termgw sec002_control_plane` 真实 Axum router 测试,覆盖 control router +loopback 正常访问和非 loopback 拒绝。当前已补内部 fake monitor seam,并新增 `/agent/{id}` +read-only prompt WebSocket integration test,断言 read-only prompt 在 terminal inject 之前被 +拒绝。仍缺 public gateway router 对 `/api/remote/*` 返回未注册 / fallback 的行为级断言; +现在已经可以基于同一个 fake monitor seam 补上,不再需要真实 rmux。 + +建议:下一步把 public gateway router 的 `/api/remote/*` 隔离断言改成纯 router +integration test,替换目前的源码结构检查。 + +### P2:rmux binary resolution 重复且校验较弱 + +`lucarne-rmux` 和 TUI session code 都在解析 `rmux` binary:优先 `~/.cargo/bin/rmux`,否则 +退回 `$PATH`。 + +影响:维护容易漂移,也存在本机 trust-boundary 风险。它不是远程未认证漏洞,但 archive/control +路径确实会 shell out 到该 binary。 + +建议:提取一个 rmux binary resolver,记录 resolved path,并在可行范围内拒绝 world-writable +目录解析结果。 + +### P2:monitor 目前实际只覆盖 pane `(0,0)` + +session id 形态暗示 `{session}:{window}:{pane}`,但 monitor 逻辑实际仍固定到首个 pane。 + +影响:多 window / 多 pane rmux session 的行为会让用户困惑。 + +建议:要么当前产品明确限制 single-pane session,要么补齐 window/pane selection 和 switching。 + +### P3:WebSocket resync 与 modifier input 未完整接入 + +`ClientFrame::Resync.have_rev` 未使用,`TermInput::Key.mods` 在 monitor injection 中被忽略。 + +影响:主要是 UX 完整度,不是架构阻塞。 + +建议:在把 web terminal 标为生产级之前,补齐 revision-aware resync 和 modifier-aware input +injection。 + +## 融合策略 + +推荐的融合方向是:**单一产品入口、共享核心 composition、隔离 terminal backend**。 + +1. `lucarned` 保持唯一安装二进制和 composition root。 +2. terminal vocabulary、archive helpers 和 rmux live binding 统一留在 `lucarne-rmux`。 +3. `lucarne-termgw` 作为 API/gateway 层,不承担产品 lifecycle owner。 +4. operator flows 收敛到 `lucarned`: + - `lucarned tui`:交互式操作。 + - `lucarned remote start|stop|status`:headless automation。 + - 未来可选 `lucarned terminal list|attach|detach|kill|archive`:给 TUI action 做脚本化对等入口。 +5. 删除独立 `lucarne-web` 产品层;Web app 是外部消费者,直接消费 `lucarne-termgw` API。 +6. persistence 对齐原项目 cold control-plane store 纪律。 +7. 用 default build fusion tests 守住发行入口:默认 `lucarned` 必须包含 TUI、gateway、 + remote 和 rmux live binding。 + +## 分阶段计划 + +### Phase 0:Merge Readiness Guardrails + +- [x] 明确并修复 `lucarned tui` 的 release/distribution 决策:默认 `lucarned` 直接融合。 +- [x] 增加 `lucarned remote start|stop|status`。 +- [x] 修复 ticket mint flood 语义。 +- [x] 用 bounded capture 替换 TUI whole-pane archive。 +- [x] 更新 `remote.enabled` 文档语义。 +- [x] 运行核心验证: + +```bash +cargo +nightly check -Zbuild-dir-new-layout -p lucarned +cargo +nightly test -Zbuild-dir-new-layout -p lucarned-ctl args +cargo +nightly test -Zbuild-dir-new-layout -p lucarne-termgw +cargo +nightly test -Zbuild-dir-new-layout -p lucarned +cargo +nightly check -Zbuild-dir-new-layout --workspace --all-features +``` + +退出条件:发行入口明确,headless remote control 恢复,P1 安全问题关闭,default fusion +tests 通过。 + +### Phase 1:Core Fusion + +- [x] 移除独立 `lucarne-web` crate,将 Web app 定位为外部 gateway API consumer。 +- [x] 移除独立 `lucarne-agentbind` crate,将终端 agent binding 融入 `lucarne` core feature。 +- [x] 将 terminal-agent binding history 从 `~/.lucarne/agents.db` 迁入 `ControlPlaneSqliteStore` cold entity。 +- [x] `lucarne-termgw` 和 TUI 归档统一复用 `lucarne-rmux::archive`,不再维护两份 archive schema。 +- [x] 增加 terminal-adjacent live/E2E journey manifest gate,覆盖 open/resume、prompt、streaming、 + approvals/interrupts、close、remote read-only refusal、reconnect/disconnect、archive-and-close 不污染 + `LucarneCore.live_sessions`。 +- [x] TUI Config 补齐 `enabled`、`auth_token`、`readonly_token`、`insecure` 的 seed / edit / + validate / merge 覆盖。 +- [x] 集中 daemon remote config read/validation/default/env merge 到 `remote_config` typed service。 +- [x] 明确 `/chat` 内置 route 删除,`/agent/{id}` 保留 terminal-bound prompt/transcript 语义。 +- [x] 增加 remote lazy-start state transition integration tests。 +- [x] 增加不依赖真实 rmux monitor 的 public gateway router 隔离 integration test。 + +退出条件:daemon、TUI、remote control 和 gateway 都使用共享 lifecycle、config 和 +persistence 合同;外部 Web app 只消费公开 gateway API。 + +### Phase 2:Terminal Backend Hardening + +- [x] 决定当前 `lucarne-termgw` 保持 rmux-specific;仅保留内部 `TerminalMonitor` test seam, + 不把 `TerminalBackend` 提升成 public abstraction。 +- [x] 明确限制 pane `(0,0)`,并对非 primary pane id 返回显式错误。 +- [x] 接入 `Resync.have_rev`,Resync 前比较客户端 rev 与服务端 baseline 并重新发 full snapshot。 +- [x] 保留 key modifiers 并映射到 rmux/tmux `send-keys` token。 +- [x] 将 rmux CLI shell-out 收敛到统一 resolver,并给非交互调用加 timeout。 + +退出条件:terminal 行为与 API 暴露模型一致,hot path 没有明显可避免的 blocking work。 + +### Phase 3:Public Access Productization + +- [x] 把 real Quick Tunnel acceptance test 沉淀为 env-gated harness 和 release checklist。 +- [x] 补齐 read-only token 在 `/ws`、`/agent`、write frames 上的覆盖:`/ws` 覆盖 + write-frame refusal,`/agent/{id}` 覆盖 prompt refusal before terminal inject。 +- [x] 增加 env-gated Cloudflare API contract E2E,覆盖 named tunnel create/token/delete, + 不依赖外部设备或真实采集。 +- [x] 在环境允许时增加 cloudflared binary/provider Quick Tunnel smoke harness。 +- [x] 在用户文档中说明 Cloudflare Quick Tunnel testing/development 边界和 named tunnel 推荐。 + `docs/tui.md` 现在包含 release smoke checklist;harness 也把 `HOME` / + `XDG_CONFIG_HOME` / `XDG_CACHE_HOME` 隔离到临时目录,避免读取开发机既有 + cloudflared 配置。 + +退出条件:public remote access 有明确认证语义、信任边界和可复现 operator docs。 + +## 建议下一组命令 + +```bash +cargo +nightly test -Zbuild-dir-new-layout -p lucarne-termgw +cargo +nightly test -Zbuild-dir-new-layout -p lucarned +cargo +nightly test -Zbuild-dir-new-layout -p lucarne-remote +cargo +nightly test -Zbuild-dir-new-layout --workspace --all-features --exclude agent-sessions +cargo +nightly clippy -Zbuild-dir-new-layout --workspace --all-targets --all-features --exclude agent-sessions --no-deps -- -D warnings +LUCARNE_QUICK_TUNNEL_E2E=1 scripts/remote-quick-tunnel-e2e.sh +``` + +Clippy matrix 说明:为让 `-D warnings` 在当前 workspace 基线上可复跑,`lucarne`、 +`lucarne-adapter`、`lucarne-telegram`、`lucarne-wechat` 和少量 integration test target +加入了显式 clippy baseline allow。它只冻结既有结构/测试风格 lint,不代表已经完成 +core runtime / channel adapter 的 clippy 驱动重构;rmux/remote 本轮新增路径仍按具体 lint +修复收敛。 + +## 当前剩余缺口 + +- `remote` config daemon read/default/env/validation 已集中到 `remote_config` typed service; + TUI write-back 仍保留 YAML merge 层,但 seed 顶层字段复用 typed parse。 +- public gateway router 的 `/api/remote/*` 隔离已有 fake-monitor behavior integration harness。 +- terminal live/E2E 目前有 manifest gate;`/agent/{id}` read-only prompt 已有本地 + WebSocket integration 覆盖,Cloudflare API create/token/delete 已补 env-gated contract + E2E,Quick Tunnel 真实公网 harness 已沉淀为 `scripts/remote-quick-tunnel-e2e.sh`。 +- rmux binary resolver 已统一到 `lucarne-rmux::cli`,TUI 和 monitor 复用同一路径。 +- multi-window / multi-pane 当前明确限制 primary pane `(0,0)`;`Resync.have_rev`、 + modifier-aware input injection 和 rmux CLI timeout 均已补齐。 + +## 上游合并风险图 + +高冲突文件: + +- `Cargo.toml` +- `Cargo.lock` +- `crates/lucarned/src/main.rs` +- `crates/lucarned-ctl/src/args.rs` +- `.github/workflows/release.yml` / cargo-dist 配置 + +低风险 additive 区域: + +- `crates/lucarne-rmux` +- `crates/lucarne-termgw` +- `crates/lucarne-remote` +- `crates/lucarne/src/terminal_agent_bind.rs`,作为 core feature module,不再是独立 crate + +推荐 merge shape: + +1. 先落统一终端能力包:`lucarne-rmux`(term 类型、archive、adapter、monitor)。 +2. 落带 integration tests 的 gateway/auth:`lucarne-termgw`。 +3. 落 remote provider seam:`lucarne-remote`。 +4. 在 `lucarned` 中接入 remote/TUI/control commands,保持单一产品入口。 +5. 将 terminal-agent binding 作为 `lucarne` core opt-in feature 落地,而不是独立 crate。 +6. 在 release packaging 验证后,把 TUI 作为默认 `lucarned` 产品 frontend 落地。 + +这个顺序能把与上游 daemon/channel code 的冲突压到最低,并保留清晰 fallback 点。 diff --git a/docs/superpowers/specs/2026-05-19-homebrew-binary-release-design.md b/docs/superpowers/specs/2026-05-19-homebrew-binary-release-design.md index 1ad72fa..6b1efe2 100644 --- a/docs/superpowers/specs/2026-05-19-homebrew-binary-release-design.md +++ b/docs/superpowers/specs/2026-05-19-homebrew-binary-release-design.md @@ -1,7 +1,13 @@ # Homebrew Binary Release Design Date: 2026-05-19 -Status: approved for implementation planning +Status: superseded by cargo-dist release packaging + +> Current release reality: root `Cargo.toml` delegates packaging to +> `cargo-dist`; release archives are `.tar.xz`, cover macOS, Linux, and Windows +> x86_64/aarch64 targets, and package only the single `lucarned` binary. Keep the +> historical design below as context for the Homebrew transition, but do not use +> its macOS-only `.tar.gz` workflow as the current release procedure. ## Problem diff --git a/docs/superpowers/specs/2026-05-21-lucarnedctl-install-autostart-design.md b/docs/superpowers/specs/2026-05-21-lucarnedctl-install-autostart-design.md index 794d3e2..b1afcdb 100644 --- a/docs/superpowers/specs/2026-05-21-lucarnedctl-install-autostart-design.md +++ b/docs/superpowers/specs/2026-05-21-lucarnedctl-install-autostart-design.md @@ -4,7 +4,12 @@ Provide a first-party install and user-level background startup experience for `lucarned` on macOS, Windows, and Linux without requiring Homebrew, winget, choco, Scoop, or system package managers. -Use `cargo-dist` as the release packager and official installer generator. Keep one installed binary, `lucarned`, with built-in control commands backed by a small std-only helper library crate, `lucarned-ctl`. +Use `cargo-dist` as the release packager and official installer generator. Keep one installed binary, `lucarned`, with built-in control commands backed by a small std-oriented helper library crate, `lucarned-ctl`. + +> Current note: `lucarned-ctl` remains a library crate, not a shipped +> `lucarnedctl` binary. Its default build stays small and std-oriented, while +> the intentional `updates` feature adds `reqwest`, `semver`, `serde`, +> `serde_json`, and `tokio`; `lucarned` enables that feature for update checks. ## Goals @@ -18,7 +23,8 @@ Use `cargo-dist` as the release packager and official installer generator. Keep - macOS: LaunchAgent. - Windows: Task Scheduler logon task. - Linux: systemd user service. -- `lucarned-ctl` stays std-only and small. +- `lucarned-ctl` stays small and std-oriented in its default build; update + checks are isolated behind its `updates` feature. - `lucarned` uses the ctl crate for command parsing/diagnostics/autostart but does not import adapter/core logic into that crate. - Platform code is compiled only for its target with `#[cfg]`. - Agent CLI installation and authentication stay out of scope. Diagnostics may report missing CLIs only. @@ -30,7 +36,8 @@ Use `cargo-dist` as the release packager and official installer generator. Keep - No Windows system service in the first version. - No root-owned launch daemon or systemd system unit in the first version. - No agent installation, agent login, token copy, or credential migration. -- No `clap`, `tokio`, `reqwest`, `serde`, `rusqlite`, `lucarne`, or adapter crates in `lucarned-ctl`. +- No `clap`, `rusqlite`, `lucarne`, or adapter crates in `lucarned-ctl`; async + HTTP/update dependencies are allowed only behind the `updates` feature. - No separate `lucarnedctl` release binary in the first version. The separate crate preserves the option to add one later. ## Release/install architecture @@ -90,9 +97,13 @@ New library crate: - Path: `crates/lucarned-ctl` - Package: `lucarned-ctl` - Library import: `lucarned_ctl` -- Source imports: none outside Rust `std` +- Source imports: Rust `std` by default; optional update modules may import the + dependencies gated by the `updates` feature. -`lucarned` depends on `lucarned-ctl` and routes command-line control commands through it. The ctl crate must not depend on `lucarned`, `lucarne`, channel adapters, async runtimes, HTTP clients, or SQLite. +`lucarned` depends on `lucarned-ctl` and routes command-line control commands +through it. The ctl crate must not depend on `lucarned`, `lucarne`, channel +adapters, or SQLite; async runtime and HTTP client dependencies are confined to +the `updates` feature. Command parser: @@ -274,7 +285,7 @@ cargo +nightly test -Zbuild-dir-new-layout -p lucarned ## Expected binary size -Merging ctl command handling into `lucarned` while removing `clap` is expected to be size-neutral or smaller than the prior `lucarned` binary. The ctl crate itself remains std-only and can later grow a standalone binary if needed. +Merging ctl command handling into `lucarned` while removing `clap` is expected to be size-neutral or smaller than the prior `lucarned` binary. The ctl crate itself remains std-oriented by default and can later grow a standalone binary if needed. ## Acceptance criteria diff --git a/docs/tui.md b/docs/tui.md new file mode 100644 index 0000000..d048a2b --- /dev/null +++ b/docs/tui.md @@ -0,0 +1,182 @@ +# Lucarne TUI (`lucarned tui`) + +An interactive, full-screen terminal dashboard for the rmux terminal-monitor +subsystem — opencode-style arrow-key navigation over the same thin operations the +daemon already exposes. It is the **single** interactive entry point for managing +mirrored rmux sessions and the public-access tunnel. + +> **What changed:** the standalone `term` binary (`lucarne-termctl`) has been +> **removed**. Everything it did is now reachable through `lucarned tui`, and its +> reusable logic (control-plane calls, terminal QR rendering, rmux argv, archive, +> provider field collection) was migrated into `lucarned` — not rewritten. + +--- + +## Build & launch + +The TUI is part of the explicit `lucarned` terminal product bundle +(`product-terminal`). The default source build stays a minimal base daemon; +release packaging enables the bundle so the shipped daemon binary includes the +TUI, remote control plane, terminal gateway, and rmux live binding. + +```bash +# from a release build +lucarned tui + +# from source (AGENTS.md build discipline: nightly + new build-dir layout) +cargo +nightly run -Zbuild-dir-new-layout -p lucarned --features product-terminal -- tui +``` + +--- + +## Layout + +``` +┌─lucarned────────┐┌───────────────────────────────────┐ +│ Sessions ││ │ +│> Go Public ││ │ +│ Config ││ │ +└─────────────────┘└──────────────────────────────────────────┘ + Tab/←→ panel q quit ← bottom hint bar +``` + +Left = panel list, right = the focused panel, bottom = a one-line hint bar that +always shows the active panel's keys. If the window is very short, enlarge it (or +reduce the font) so the hint bar is visible. + +`Tab` / `←` `→` switch panels; `q` quits; the terminal is always restored on exit +(including on panic, via a process-level hook). + +--- + +## Panels & keybindings + +### Sessions — manage system rmux sessions + +Lists live sessions on your **system rmux daemon** and acts on them by shelling the +native `rmux` CLI (no new IPC). Works standalone (no `lucarned` daemon needed). + +| Key | Action | +|-----|--------| +| `↑` `↓` | Move selection | +| `Enter` | **Attach (pop-out):** suspends the TUI, hands the current terminal to `rmux attach-session`; on exit the TUI re-enters | +| `d` | Detach clients (the session keeps running) | +| `k` / `Del` | Kill the session | +| `a` | Archive: capture content into the shared store, then close | +| `r` | Refresh the list | + +### Go Public — start/stop the public-access tunnel + +Drives the daemon's loopback control plane (`/api/remote/{start,stop,status}` on +`127.0.0.1:7801` by default) and renders the login QR. + +| Key | Action | +|-----|--------| +| `s` | **Start** remote access (go public) — uses the **Config panel's live fields** | +| `x` | Stop remote access | +| `r` | Refresh status | +| `Enter` | Show the login **QR** (when a tunnel is up) — scannable, high-contrast; falls back to the plain login URL if the terminal is too small | +| `Esc` | Close the QR modal | + +> **`s` uses your in-TUI Config — no `lucarned.yaml` edit needed.** Pressing `s` +> starts the tunnel with the **provider + fields currently set in the Config +> panel** (its live edit buffer), not whatever is saved on disk. The workflow is: +> configure the provider/fields in **Config**, switch to **Go Public**, press `s`. +> The daemon merges those over its pre-configured tunnel, so you can "configure + +> go public" entirely inside the TUI without saving first. The Go Public body shows +> the source on a `start uses Config: provider= ( fields)` line. +> +> If a provider IS set, its config is validated against the provider descriptor +> before sending; an invalid config (e.g. a missing required field) is surfaced +> inline (`start blocked — …`) and nothing is sent. If the **Config panel is empty** +> (no provider selected) the body shows `start uses daemon default (no provider set +> in Config)` and `s` falls back to the daemon's **pre-configured** tunnel +> (`lucarned.yaml`'s `remote:` section). + +> **Requires the `lucarned` daemon to be running.** The daemon owns the tunnel +> lifecycle (it serves the loopback control plane); the TUI is a thin front-end and +> never opens a tunnel itself. If the daemon is not running, the panel shows an +> actionable hint instead of a raw error: +> +> ``` +> control plane unreachable on 127.0.0.1:7801 — the lucarned daemon isn't running. +> Start it first (`lucarned autostart start`, or `brew services start lucarned`). +> ``` +> +> Actually opening a tunnel also needs `cloudflared` installed/configured (see the +> `remote:` section of `lucarned.yaml`). + +Cloudflare Quick Tunnel is the zero-config testing/development path used when +the Cloudflared token is blank. It creates an ephemeral `trycloudflare.com` +hostname and should not be treated as production availability. For sensitive or +repeatable access, configure a named tunnel with `token` and `public_url`. + +End-to-end (two terminals): + +```bash +# Terminal A — run the daemon. It serves the loopback control plane from boot; +# the tunnel starts lazily when the TUI sends start. +cargo +nightly run -Zbuild-dir-new-layout -p lucarned + +# Terminal B — open the TUI, go to "Go Public", press `s` +cargo +nightly run -Zbuild-dir-new-layout -p lucarned -- tui +``` + +Release smoke checklist for public access: + +```bash +cargo +nightly build -Zbuild-dir-new-layout -p lucarned +LUCARNE_QUICK_TUNNEL_E2E=1 scripts/remote-quick-tunnel-e2e.sh +``` + +The harness is env-gated and skips by default. When enabled, it starts a +temporary daemon, opens a Quick Tunnel, verifies public auth/read-only/router +isolation through the `trycloudflare.com` URL, calls `lucarned remote stop`, and +then tears the daemon down. + +### Config — edit remote-access config + +A provider-config editor driven entirely by the `lucarne_remote` provider +descriptors (no hardcoded provider/field names). Edits are written back to +`lucarned.yaml` with a timestamped backup and an atomic temp+rename; on unix the +config and backup are created `0o600`. Secret fields are masked on screen. + +| Key | Action | +|-----|--------| +| `↑` `↓` | Move between fields | +| `Enter` | Edit the field / cycle the selected provider | +| `s` | Save (validates, e.g. rejects gateway port == control port) | +| `Esc` | Cancel the current edit | + +--- + +## Architecture & boundaries + +- **Single entry, one binary.** Only `lucarned` ships (the release installer + packages `lucarned`); the TUI is `lucarned tui`. No second binary to install. +- **Zero new daemon IPC.** The three panels reuse existing surfaces only: + the native `rmux` CLI + the shared archive store (Sessions), the loopback + `/api/remote/*` control plane (Go Public), and `lucarne_remote::builtin()` + + `write_config_with_backup` (Config). +- **Not a live mirror.** The TUI is a list/action console; it does not render live + terminal content. The full interactive mirror lives in the web terminal view. +- **Provider boundary (AGENTS.md).** Provider specifics stay behind + `lucarne_remote` descriptors; the TUI/common layers route opaque ids only. +- **Explicit capability features.** The source default build keeps + `ratatui`/`crossterm`/`lucarne-termgw`/`lucarne-rmux` out of the base daemon. + The `product-terminal` bundle opts into the terminal gateway, remote access, + TUI, and rmux stack for release packaging. + +See the decision record: +[`docs/decisions/2026-06-01-lucarned-tui-frontend.md`](decisions/2026-06-01-lucarned-tui-frontend.md). + +## Migration from the old `term` CLI + +| Old `term` command | Now | +|--------------------|-----| +| `term ls` | Sessions panel (list) | +| `term attach ` / `term enter ` | Sessions panel → `Enter` | +| `term detach ` | Sessions panel → `d` | +| `term kill ` | Sessions panel → `k` / `Del` | +| `term archive ` | Sessions panel → `a` | +| `term go-public` | Go Public panel → `s` | diff --git a/examples/lucarned.yaml b/examples/lucarned.yaml index 6b70115..2831631 100644 --- a/examples/lucarned.yaml +++ b/examples/lucarned.yaml @@ -34,6 +34,49 @@ updates: remind_interval_hours: 24 # Minimum hours between reminders for the same version. repository: tuchg/Lucarne # GitHub repository to check. +remote: + # Public-internet exposure of the loopback terminal gateway via a pluggable + # tunnel backend. Default-off; this is the highest-severity surface in the + # project (live terminal mirror + input). See ADR + # docs/decisions/2026-05-30-remote-access-tunnel-adapter.md. + enabled: false # Autostart gate. Off → control plane stays ready; tunnel starts lazily via `lucarned remote start`. + provider: cloudflared # Tunnel backend id. Only `cloudflared` is implemented this release. + gateway_addr: 127.0.0.1:7800 # Gateway bind (the ONLY port the tunnel targets). Forced loopback; the tunnel dials back in. + control_addr: 127.0.0.1:7801 # Loopback-only control plane (/api/remote/*). DISTINCT port the tunnel never targets (SEC-002); returns the access token. + auth_token: "" # Gateway access token. Blank → auto-generated at startup. If set, must be ≥32 chars / non-whitespace or the daemon refuses to start (SEC-008). + readonly_token: "" # Optional read-only access token (SEC-013). Blank → no read-only tier. If set, must be ≥32 chars / non-whitespace; its sessions may mirror terminals but are refused all write frames (input / create / close / agent prompts). + insecure: false # NEVER expose without auth. Set true only to deliberately disable auth (prints a loud warning). + # Runtime auth hardening (fixed seed values, not configurable here): + # ws auth (incl. web /chat) = token → single-use ticket (~30s TTL) → validated/consumed before upgrade; /api uses Bearer. + # auth failures are rate-limited per real client identity (Cf-Connecting-Ip trusted only from the loopback tunnel); + # the shared loopback key is never hard-locked (SEC-005) — only soft-delayed — so one attacker can't lock out everyone. + # ws connections are capped (global semaphore, 503 on excess) with per-connection session + frame-rate caps (SEC-004), + # and each ws session has an idle + max-lifetime close (SEC-006). Comparisons are constant-time. + cloudflare: + token: "" # Named-tunnel token. Blank → quick tunnel (zero-config, random trycloudflare.com URL). SEC-012: a quick tunnel is plaintext at the Cloudflare edge (the daemon warns on start) — prefer a named tunnel for sensitive sessions. L3: the token is passed to cloudflared via a 0600 --token-file (kept off the process argv), removed when the tunnel stops. + public_url: "" # Stable public URL for a named tunnel (required with `token`; the provider validates this — M7). Blank for quick tunnels. + binary_path: "" # Absolute path to the cloudflared binary to pin it (SEC-003). Blank → resolved on $PATH to an absolute path; world-writable dirs / missing binary are refused. + + # H6c: GENERIC per-provider field maps keyed by provider id. The selected + # `provider` above picks which block is used; the daemon passes the fields + # straight through to the provider without interpreting any field, so adding a + # backend (FRP / relay) is just a new providers. block — no daemon change. + # The legacy `cloudflare:` block above is still accepted (back-compat) and is + # merged UNDER providers.cloudflared (the generic map wins per key); new + # configs should prefer `providers:`. + providers: + cloudflared: + token: "" # Named-tunnel token (same as the legacy cloudflare.token); blank → quick tunnel. + public_url: "" # Named-tunnel public URL (required-when a token is set — M7). + binary_path: "" # Pin the cloudflared binary (SEC-003); blank → resolve on $PATH. + # # Reserved tunnel backends (NOT implemented this release — adapter seams only). + # # Each requires implementing `RemoteAccessProvider` in lucarne-remote and + # # registering it; see ADR 2026-05-30-remote-access-tunnel-adapter.md. Once a + # # backend exists, configure it purely via its providers. field map — + # # NO daemon-config change is needed (H6c). + # frp: { server_addr: "", token: "" } # reserved: FRP self-hosted tunnel + # relay: { url: "" } # reserved: lightweight self-hosted relay + channels: telegram: enabled: true # Enable Telegram channel. diff --git a/scripts/remote-quick-tunnel-e2e.sh b/scripts/remote-quick-tunnel-e2e.sh new file mode 100755 index 0000000..5903ca2 --- /dev/null +++ b/scripts/remote-quick-tunnel-e2e.sh @@ -0,0 +1,312 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Env-gated real Cloudflare Quick Tunnel smoke. +# +# Quick Tunnels are the official zero-config Cloudflare development/testing path: +# cloudflared tunnel --url http://localhost:8080 +# They are intentionally not a production acceptance substitute. This harness +# starts a temporary lucarned daemon, opens a free trycloudflare.com tunnel via +# `lucarned remote start`, validates auth/read-only isolation through the public +# URL, then stops the gateway/tunnel and tears down the daemon. + +if [[ "${LUCARNE_QUICK_TUNNEL_E2E:-}" != "1" ]]; then + echo "skip: set LUCARNE_QUICK_TUNNEL_E2E=1 to run the real Quick Tunnel E2E" + exit 0 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN="${LUCARNED_BIN:-$ROOT/target/debug/lucarned}" +if [[ ! -x "$BIN" ]]; then + echo "error: lucarned binary not found at $BIN; run cargo +nightly build -Zbuild-dir-new-layout -p lucarned" >&2 + exit 1 +fi + +for tool in curl jq node cloudflared; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "error: required tool not found: $tool" >&2 + exit 1 + fi +done + +if ! command -v rmux >/dev/null 2>&1 && [[ ! -x "${HOME:-}/.cargo/bin/rmux" ]]; then + echo "error: rmux not found on PATH or ~/.cargo/bin/rmux" >&2 + exit 1 +fi + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/lucarne-quick-e2e.XXXXXX")" +DAEMON_PID="" +public_url="" +host="" +GATEWAY_PORT="${LUCARNE_QUICK_TUNNEL_GATEWAY_PORT:-19092}" +CONTROL_PORT="${LUCARNE_QUICK_TUNNEL_CONTROL_PORT:-19093}" +FULL_TOKEN="${LUCARNE_QUICK_TUNNEL_FULL_TOKEN:-full-0123456789abcdef0123456789abcdef}" +READONLY_TOKEN="${LUCARNE_QUICK_TUNNEL_READONLY_TOKEN:-readonly-0123456789abcdef0123456789}" +READY_RETRIES="${LUCARNE_QUICK_TUNNEL_READY_RETRIES:-120}" + +cleanup() { + local status=$? + set +e + if [[ "$status" -ne 0 ]]; then + echo "diagnostics: Quick Tunnel E2E failed; workdir was $WORK" >&2 + if [[ -n "${public_url:-}" ]]; then + echo "diagnostics: public_url=$public_url" >&2 + if [[ -n "${host:-}" ]]; then + echo "diagnostics: host=$host" >&2 + if command -v dig >/dev/null 2>&1; then + echo "diagnostics: dig +short $host" >&2 + dig +time=2 +tries=1 +short "$host" >&2 || true + fi + if command -v dscacheutil >/dev/null 2>&1; then + echo "diagnostics: dscacheutil -q host -a name $host" >&2 + dscacheutil -q host -a name "$host" >&2 || true + fi + fi + fi + if [[ -f "$WORK/curl.err" && -s "$WORK/curl.err" ]]; then + echo "diagnostics: last curl stderr:" >&2 + tail -n 20 "$WORK/curl.err" >&2 || true + fi + if [[ -f "$WORK/daemon.log" ]]; then + echo "diagnostics: daemon log tail:" >&2 + tail -n 120 "$WORK/daemon.log" >&2 || true + fi + if [[ -n "$DAEMON_PID" ]] && kill -0 "$DAEMON_PID" >/dev/null 2>&1; then + echo "diagnostics: remote status before cleanup:" >&2 + "$BIN" remote status --control-port "$CONTROL_PORT" --json >&2 || true + fi + fi + if [[ -n "$DAEMON_PID" ]] && kill -0 "$DAEMON_PID" >/dev/null 2>&1; then + "$BIN" remote stop --control-port "$CONTROL_PORT" --json >/dev/null 2>&1 || true + kill -INT "$DAEMON_PID" >/dev/null 2>&1 || true + for _ in $(seq 1 40); do + kill -0 "$DAEMON_PID" >/dev/null 2>&1 || break + sleep 0.25 + done + kill "$DAEMON_PID" >/dev/null 2>&1 || true + fi + if [[ "$status" -ne 0 && "${LUCARNE_QUICK_TUNNEL_KEEP_FAILURE:-}" == "1" ]]; then + echo "diagnostics: preserving failed workdir because LUCARNE_QUICK_TUNNEL_KEEP_FAILURE=1: $WORK" >&2 + else + rm -rf "$WORK" + fi + return "$status" +} +trap cleanup EXIT + +mkdir -p "$WORK/home" "$WORK/xdg-config" "$WORK/xdg-cache" +export HOME="$WORK/home" +export XDG_CONFIG_HOME="$WORK/xdg-config" +export XDG_CACHE_HOME="$WORK/xdg-cache" + +cat >"$WORK/lucarned.yaml" <"$WORK/daemon.log" 2>&1 & +DAEMON_PID="$!" + +for _ in $(seq 1 80); do + if "$BIN" remote status --control-port "$CONTROL_PORT" --json >/dev/null 2>"$WORK/status.err"; then + break + fi + sleep 0.25 +done + +status_json="$("$BIN" remote status --control-port "$CONTROL_PORT" --json)" +if [[ "$(jq -r '.running' <<<"$status_json")" != "false" ]]; then + echo "error: expected cold daemon remote status running=false, got: $status_json" >&2 + exit 1 +fi + +start_json="$("$BIN" remote start --control-port "$CONTROL_PORT" --json)" +public_url="$(jq -r '.public_url // empty' <<<"$start_json")" +if [[ -z "$public_url" || "$public_url" != https://*.trycloudflare.com/* ]]; then + echo "error: expected trycloudflare public URL, got: $start_json" >&2 + exit 1 +fi +public_url="${public_url%/}" +host="$(node -e 'console.log(new URL(process.argv[1]).host)' "$public_url")" + +curl_cf() { + : >"$WORK/resp.body" + : >"$WORK/curl.err" + curl -sS -o "$WORK/resp.body" -w "%{http_code}" \ + --connect-timeout 10 \ + --max-time 30 \ + 2>"$WORK/curl.err" \ + "$@" +} + +wait_for_http_status() { + local expected="$1" + shift + local code="" + for _ in $(seq 1 "$READY_RETRIES"); do + code="$(curl_cf "$@" || true)" + if [[ "$code" == "$expected" ]]; then + return 0 + fi + sleep 1 + done + echo "error: expected HTTP $expected from $*, got $code" >&2 + if [[ -s "$WORK/curl.err" ]]; then + cat "$WORK/curl.err" >&2 || true + fi + cat "$WORK/resp.body" >&2 || true + return 1 +} + +wait_for_http_status 401 "$public_url/api/sessions" + +code="$(curl_cf "$public_url/api/remote/status" || true)" +if [[ "$code" == "200" ]]; then + echo "error: public gateway exposed /api/remote/status" >&2 + cat "$WORK/resp.body" >&2 || true + exit 1 +fi + +wait_for_http_status 200 -H "Authorization: Bearer $FULL_TOKEN" "$public_url/api/sessions" + +wait_for_http_status 403 -X POST -H "Authorization: Bearer $READONLY_TOKEN" -H "content-type: application/json" \ + --data '{"title":"should-not-create"}' "$public_url/api/sessions" + +wait_for_http_status 200 -X POST -H "Authorization: Bearer $FULL_TOKEN" "$public_url/auth/ticket" +ticket_json="$(cat "$WORK/resp.body")" +ticket="$(jq -r '.ticket' <<<"$ticket_json")" +wait_for_http_status 200 -X POST -H "Authorization: Bearer $READONLY_TOKEN" "$public_url/auth/ticket" +ro_ticket_json="$(cat "$WORK/resp.body")" +ro_ticket="$(jq -r '.ticket' <<<"$ro_ticket_json")" + +HOST="$host" TICKET="$ticket" RO_TICKET="$ro_ticket" node <<'NODE' +const tls = require("tls"); +const crypto = require("crypto"); + +function ws(path, ticket, sendFrame) { + return new Promise((resolve, reject) => { + const key = crypto.randomBytes(16).toString("base64"); + const socket = tls.connect({ + host: process.env.HOST, + port: 443, + servername: process.env.HOST, + ALPNProtocols: ["http/1.1"], + }); + let raw = Buffer.alloc(0); + let upgraded = false; + let done = false; + const timer = setTimeout(() => { + if (!done) reject(new Error("websocket timeout")); + socket.destroy(); + }, 15000); + socket.on("secureConnect", () => { + socket.write( + `GET ${path}?ticket=${encodeURIComponent(ticket)} HTTP/1.1\r\n` + + `Host: ${process.env.HOST}\r\n` + + `Upgrade: websocket\r\nConnection: Upgrade\r\n` + + `Sec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n` + ); + }); + socket.on("data", (chunk) => { + raw = Buffer.concat([raw, chunk]); + if (!upgraded) { + const sep = raw.indexOf("\r\n\r\n"); + if (sep === -1) return; + const head = raw.slice(0, sep).toString(); + if (!head.startsWith("HTTP/1.1 101")) { + done = true; + clearTimeout(timer); + socket.destroy(); + reject(new Error(`upgrade failed: ${head.split("\r\n")[0]}`)); + return; + } + upgraded = true; + raw = raw.slice(sep + 4); + if (sendFrame) socket.write(encodeFrame(JSON.stringify(sendFrame))); + } + while (raw.length >= 2) { + const b1 = raw[0], b2 = raw[1]; + let len = b2 & 0x7f, offset = 2; + if (len === 126) { + if (raw.length < 4) return; + len = raw.readUInt16BE(2); offset = 4; + } else if (len === 127) { + if (raw.length < 10) return; + len = Number(raw.readBigUInt64BE(2)); offset = 10; + } + if (raw.length < offset + len) return; + const payload = raw.slice(offset, offset + len).toString(); + raw = raw.slice(offset + len); + if ((b1 & 0x0f) === 1) { + const msg = JSON.parse(payload); + done = true; + clearTimeout(timer); + socket.end(); + resolve(msg); + return; + } + } + }); + socket.on("error", reject); + }); +} + +function encodeFrame(text) { + const payload = Buffer.from(text); + const mask = crypto.randomBytes(4); + const head = payload.length < 126 + ? Buffer.from([0x81, 0x80 | payload.length]) + : Buffer.from([0x81, 0x80 | 126, payload.length >> 8, payload.length & 0xff]); + const masked = Buffer.alloc(payload.length); + for (let i = 0; i < payload.length; i++) masked[i] = payload[i] ^ mask[i % 4]; + return Buffer.concat([head, mask, masked]); +} + +(async () => { + const first = await ws("/ws", process.env.TICKET); + if (first.type !== "session_list") throw new Error(`expected session_list, got ${JSON.stringify(first)}`); + const refused = await ws("/ws", process.env.RO_TICKET, { type: "create_session", title: "blocked" }); + if (refused.type !== "error" || refused.code !== 403) { + throw new Error(`expected readonly create refusal, got ${JSON.stringify(refused)}`); + } +})().catch((err) => { + console.error(err.stack || err.message); + process.exit(1); +}); +NODE + +stop_json="$("$BIN" remote stop --control-port "$CONTROL_PORT" --json)" +if [[ "$(jq -r '.running' <<<"$stop_json")" != "false" ]]; then + echo "error: expected stop running=false, got: $stop_json" >&2 + exit 1 +fi + +echo "ok: Quick Tunnel E2E passed for $public_url" diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..adb231c --- /dev/null +++ b/web/index.html @@ -0,0 +1,125 @@ + + + + + + Lucarne · workbench + + + +

+
+ + + + connecting… +
+
+
+ +
+
+ + + + connecting… +
+
+
+ +
+
+
+ + + managed agent (new session) +
+
+
+ + +
+
+
+
+ + + diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..7d1df13 --- /dev/null +++ b/web/js/app.js @@ -0,0 +1,43 @@ +// Dual-mode web shell. One converter, multiple surfaces: +// Terminal (rmux mirror /ws) + cwd file tree (drag/right-click → pane) +// Chat (managed /chat, bound /agent/, or read-only history) +// All connect to `location.host`, so local (direct) and remote (cloudflared +// tunnel) are byte-identical to this code. + +import { initTerminal } from "./terminal.js"; +import { initChat } from "./chat.js"; +import { initFileTree } from "./filetree.js"; + +const tabTerminal = document.getElementById("tab-terminal"); +const tabChat = document.getElementById("tab-chat"); +const termView = document.getElementById("terminal-view"); +const chatView = document.getElementById("chat-view"); + +const chat = initChat(); + +function show(mode) { + const term = mode === "terminal"; + tabTerminal.classList.toggle("active", term); + tabChat.classList.toggle("active", !term); + termView.classList.toggle("active", term); + chatView.classList.toggle("active", !term); + if (term) document.getElementById("screen").focus(); + else { chat.loadPickers(); document.getElementById("chat-input").focus(); } +} + +tabTerminal.onclick = () => show("terminal"); +tabChat.onclick = () => show("chat"); + +const fileTree = initFileTree({ + onInsert: (path) => terminal.insertText(path), +}); + +const terminal = initTerminal({ + // A pane with a bound agent → open its transcript in the chat tab. + onAgent: (sessionId) => { + chat.connectTo(`/agent/${encodeURIComponent(sessionId)}`); + show("chat"); + }, + // Selecting a session loads its cwd file tree. + onSelect: (sessionId) => fileTree.load(sessionId), +}); diff --git a/web/js/auth.js b/web/js/auth.js new file mode 100644 index 0000000..2e6e3a3 --- /dev/null +++ b/web/js/auth.js @@ -0,0 +1,251 @@ +// Client-side auth for the terminal gateway (TASK-003 contract). +// +// The gateway's long-lived access token is a Bearer credential. Browsers can't +// set an Authorization header on a WebSocket and putting the token in the ws URL +// leaks it into logs, so ws connects use a two-step exchange: +// 1. POST /auth/ticket with `Authorization: Bearer ` → single-use ticket +// 2. open the ws with `?ticket=` (the token NEVER goes in the ws URL) +// All /api/* calls carry `Authorization: Bearer `. +// +// Token handling (minimize residual exposure): +// - read from the URL fragment `#token=` on load (fragment, not query, so +// it never reaches server logs), stash in sessionStorage, then strip it from +// the visible URL via history.replaceState. +// - if absent, show a minimal login surface asking the user to paste the key. +// +// Backward-compat (local / no-auth mode): when the gateway runs with auth +// disabled, /auth/ticket still returns a ticket (the bearer guard is a no-op) so +// the flow is identical. If the server has no auth at all (404 on /auth/ticket) +// or signals auth-disabled, ws still connects tokenless and /api works without a +// header — local dev stays zero-friction. + +const STORAGE_KEY = "lucarne.token"; + +// Once we learn the server enforces no auth, skip the ticket dance entirely. +let authDisabled = false; + +/** The current access token from sessionStorage, or null. */ +function getToken() { + try { + return sessionStorage.getItem(STORAGE_KEY); + } catch (e) { + return null; + } +} + +function setToken(token) { + try { + if (token) sessionStorage.setItem(STORAGE_KEY, token); + else sessionStorage.removeItem(STORAGE_KEY); + } catch (e) {} +} + +/** Read `#token=` from the URL fragment, store it, then scrub the hash so + * the credential doesn't linger in the visible URL / history. */ +function readTokenFromUrl() { + const hash = location.hash || ""; + const m = hash.match(/[#&]token=([^&]+)/); + if (!m) return null; + const token = decodeURIComponent(m[1]); + setToken(token); + // Strip the token from the visible URL without adding a history entry. + const clean = location.pathname + location.search; + try { + history.replaceState(null, "", clean); + } catch (e) { + location.hash = ""; + } + return token; +} + +// Read the fragment token immediately at import time (before any logs/render). +readTokenFromUrl(); + +/** Headers for fetch() to /api/* — adds Bearer when a token is present. */ +export function authHeaders(extra = {}) { + const token = getToken(); + const headers = { ...extra }; + if (token) headers["Authorization"] = "Bearer " + token; + return headers; +} + +/** fetch() wrapper for /api/* that injects the Bearer header. On 401 it surfaces + * the login prompt (token missing/invalid) and the caller sees a failed + * response. Local no-auth mode works because the server ignores the header. */ +export async function apiFetch(path, opts = {}) { + const headers = authHeaders(opts.headers || {}); + const res = await fetch(path, { ...opts, headers }); + if (res.status === 401) { + requireLogin("Access denied — paste your access key"); + } + return res; +} + +/** Exchange the access token for a single-use ws ticket. + * + * Returns: + * - a ticket string when auth is enforced and the exchange succeeds + * - "" (empty) when the server signals auth is disabled or has no /auth/ticket + * route (404) — caller then connects tokenless (local backward-compat) + * - null when auth is required but we have no/invalid token (login surfaced) + */ +export async function wsTicket() { + if (authDisabled) return ""; + let res; + try { + res = await fetch("/auth/ticket", { + method: "POST", + headers: authHeaders(), + }); + } catch (e) { + // Network/transport error — let the ws attempt proceed tokenless so the + // existing reconnect loop can surface the failure. + return ""; + } + // No auth route at all (older/no-auth server) → tokenless ws is fine. + if (res.status === 404) { + authDisabled = true; + return ""; + } + if (res.status === 401) { + requireLogin("Enter the gateway access key to connect"); + return null; + } + if (!res.ok) return ""; + let data = {}; + try { + data = await res.json(); + } catch (e) { + return ""; + } + // A gateway with auth disabled still returns a ticket; either way `ticket` + // (when present) is what we pass as ?ticket=. + return typeof data.ticket === "string" ? data.ticket : ""; +} + +/** Append the ws ticket to a ws URL. Empty ticket (local no-auth) → unchanged. */ +export function wsUrlWithTicket(baseUrl, ticket) { + if (!ticket) return baseUrl; + const sep = baseUrl.includes("?") ? "&" : "?"; + return `${baseUrl}${sep}ticket=${encodeURIComponent(ticket)}`; +} + +// ---- minimal login surface ---- + +let loginOverlay = null; + +/** True if we already hold a token. Used by views to decide whether to prompt + * up-front (a missing token only matters once the server says 401). */ +export function hasToken() { + return !!getToken(); +} + +/** Render a minimal, lightweight login overlay asking for the access key. When + * the user submits a key it is stored and `onSubmit` (e.g. a reconnect) runs. + * Idempotent: only one overlay at a time. */ +export function requireLogin(message, onSubmit) { + if (loginOverlay) { + if (message) { + const msg = loginOverlay.querySelector(".login-msg"); + if (msg) msg.textContent = message; + } + if (onSubmit) loginOverlay.__onSubmit = onSubmit; + return; + } + + const overlay = document.createElement("div"); + overlay.id = "login-overlay"; + overlay.__onSubmit = onSubmit; + // Inline styles only (the app keeps all CSS in index.html; this is an + // injected surface, so it carries its own minimal look matching the palette). + Object.assign(overlay.style, { + position: "fixed", + inset: "0", + background: "rgba(16,16,20,0.92)", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: "9999", + font: '13px "SF Mono", Menlo, Consolas, monospace', + color: "#d8d8d8", + }); + + const card = document.createElement("div"); + Object.assign(card.style, { + background: "#16161c", + border: "1px solid #26262e", + borderRadius: "8px", + padding: "22px 24px", + width: "340px", + maxWidth: "90vw", + boxShadow: "0 8px 30px rgba(0,0,0,0.5)", + }); + + const title = document.createElement("div"); + title.textContent = "Lucarne · access key"; + Object.assign(title.style, { fontWeight: "bold", marginBottom: "6px" }); + + const msg = document.createElement("div"); + msg.className = "login-msg"; + msg.textContent = message || "Paste your gateway access key to connect."; + Object.assign(msg.style, { color: "#8a8a96", fontSize: "12px", marginBottom: "12px" }); + + const input = document.createElement("input"); + input.type = "password"; + input.placeholder = "access key"; + input.autocomplete = "off"; + input.spellcheck = false; + Object.assign(input.style, { + width: "100%", + background: "#101014", + color: "#d8d8d8", + border: "1px solid #26262e", + borderRadius: "6px", + padding: "8px", + font: "inherit", + marginBottom: "12px", + }); + + const btn = document.createElement("button"); + btn.textContent = "Connect"; + Object.assign(btn.style, { + background: "#4e9a06", + color: "#fff", + border: "0", + borderRadius: "6px", + padding: "7px 14px", + cursor: "pointer", + font: "inherit", + width: "100%", + }); + + const submit = () => { + const key = input.value.trim(); + if (!key) return; + setToken(key); + authDisabled = false; + const cb = overlay.__onSubmit; + closeLogin(); + if (cb) cb(); + }; + btn.onclick = submit; + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + submit(); + } + }); + + card.append(title, msg, input, btn); + overlay.appendChild(card); + document.body.appendChild(overlay); + loginOverlay = overlay; + input.focus(); +} + +function closeLogin() { + if (loginOverlay && loginOverlay.parentNode) { + loginOverlay.parentNode.removeChild(loginOverlay); + } + loginOverlay = null; +} diff --git a/web/js/chat.js b/web/js/chat.js new file mode 100644 index 0000000..bd58e84 --- /dev/null +++ b/web/js/chat.js @@ -0,0 +1,197 @@ +// Chat view — one converter, three sources via a picker (each labelled with a +// human name = the session's first user message). Assistant output is rendered +// as markdown. +// ➕ New agent → /chat (fresh managed agent) +// bound: → /agent/ (drives the live pane) +// history: → read-only transcript replay +// +// Auth (TASK-003 contract, via ./auth.js): /api/* calls carry +// `Authorization: Bearer ` (apiFetch). The ws never carries the token — +// connect() first POSTs to /auth/ticket for a single-use ticket, then opens the +// chat/agent ws with `?ticket=`. + +import { renderMarkdown } from "./markdown.js"; +import { apiFetch, wsTicket, wsUrlWithTicket, requireLogin } from "./auth.js"; + +export function initChat() { + const logEl = document.getElementById("chat-log"); + const inputEl = document.getElementById("chat-input"); + const sendBtn = document.getElementById("chat-send"); + const sourceEl = document.getElementById("chat-source"); + const newBtn = document.getElementById("chat-new"); + const picker = document.getElementById("chat-picker"); + + let ws = null; + let streamBubble = null; + let path = "/chat"; + let readonly = false; + + const setSource = (t) => { if (sourceEl) sourceEl.textContent = t; }; + + function bubble(cls, text, md) { + const d = document.createElement("div"); + d.className = `bubble ${cls}`; + if (md) d.innerHTML = renderMarkdown(text); + else d.textContent = text; + logEl.appendChild(d); + logEl.scrollTop = logEl.scrollHeight; + return d; + } + + function closeWs() { + if (ws) { try { ws.onclose = null; ws.close(); } catch (e) {} ws = null; } + } + + async function connect() { + closeWs(); + const proto = location.protocol === "https:" ? "wss" : "ws"; + const connectPath = path; + // Exchange the long-lived token for a single-use ws ticket before opening + // the chat/agent ws. The token NEVER goes in the ws URL — only ?ticket=. + const ticket = await wsTicket(); + if (ticket === null) { + requireLogin("Enter the gateway access key to connect", connect); + return; + } + // The source may have switched while awaiting the ticket — bail if stale. + if (connectPath !== path) return; + const url = wsUrlWithTicket(`${proto}://${location.host}${path}`, ticket); + ws = new WebSocket(url); + ws.onmessage = (ev) => handle(JSON.parse(ev.data)); + ws.onclose = () => { streamBubble = null; if (ws && ws.__path === path && !readonly) setTimeout(connect, 2000); }; + ws.onerror = () => {}; + ws.__path = path; + } + + /** Switch to a live source (/chat or /agent/), with a display name. */ + function connectTo(nextPath, name) { + readonly = false; + inputEl.disabled = false; + path = nextPath; + logEl.innerHTML = ""; + streamBubble = null; + setSource(name || (nextPath === "/chat" ? "managed agent (new session)" : "bound · " + decodeURIComponent(nextPath.replace("/agent/", "")))); + connect(); + } + + /** Show a read-only history transcript (no live pane to drive). */ + async function showHistory(session, name) { + readonly = true; + inputEl.disabled = true; + closeWs(); + logEl.innerHTML = ""; + streamBubble = null; + setSource("history (read-only) · " + (name || session.slice(0, 8))); + try { + const o = await (await apiFetch(`/api/agent-history/${encodeURIComponent(session)}`)).json(); + for (const m of o.messages || []) bubble(m.role === "user" ? "user" : "assistant", m.text, m.role !== "user"); + if (!(o.messages || []).length) bubble("meta", "(empty transcript)"); + } catch (e) { + bubble("meta", "failed to load history"); + } + } + + function handle(f) { + switch (f.type) { + case "ready": + bubble("meta", "agent ready" + (f.provider ? " · " + f.provider : "") + (f.session_id ? " · " + f.session_id.slice(0, 8) : "")); + break; + case "message": + if (f.role === "user") { + bubble("user", f.text); + } else if (f.streaming) { + if (!streamBubble) { streamBubble = bubble("assistant", "", true); streamBubble.__raw = ""; } + streamBubble.__raw += f.text; + streamBubble.innerHTML = renderMarkdown(streamBubble.__raw); + logEl.scrollTop = logEl.scrollHeight; + } else { + if (streamBubble) streamBubble = null; + else bubble("assistant", f.text, true); + } + break; + case "reasoning": bubble("reasoning", f.text); break; + case "tool": bubble("meta", "⚙ " + (f.name || "tool")); break; + case "turn_complete": streamBubble = null; break; + case "turn_failed": bubble("meta", "turn failed: " + (f.error || "")); streamBubble = null; break; + case "approval": renderApproval(f); break; + case "error": bubble("meta", "error: " + (f.msg || "")); break; + } + } + + function renderApproval(f) { + const d = bubble("assistant", "🔐 approval requested: " + (f.tool || "") + (f.message ? "\n" + f.message : "")); + const row = document.createElement("div"); + row.className = "approve"; + const allow = document.createElement("button"); + allow.className = "allow"; allow.textContent = "Approve"; + const deny = document.createElement("button"); + deny.className = "deny"; deny.textContent = "Deny"; + allow.onclick = () => { send({ type: "approve", req_id: f.req_id, allow: true }); row.remove(); }; + deny.onclick = () => { send({ type: "approve", req_id: f.req_id, allow: false }); row.remove(); }; + row.append(allow, deny); + d.appendChild(row); + } + + function send(f) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(f)); } + + function submit() { + if (readonly) { bubble("meta", "this is a read-only history view"); return; } + const text = inputEl.value.trim(); + if (!text) return; + bubble("user", text); + send({ type: "prompt", text }); + inputEl.value = ""; + streamBubble = null; + } + + sendBtn.onclick = submit; + inputEl.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); } + }); + newBtn.onclick = () => { picker.value = ""; connectTo("/chat"); }; + + // Picker: bound terminal agents + recent rmux-related history, each named by + // its first user message so they're easy to tell apart. + async function loadPickers() { + let opts = ''; + try { + const a = await (await apiFetch("/api/agents")).json(); + if ((a.agents || []).length) { + opts += ''; + for (const g of a.agents) { + const name = g.summary ? `“${g.summary}”` : g.agent_session_id.slice(0, 8); + opts += ``; + } + opts += ""; + } + } catch (e) {} + try { + const h = await (await apiFetch("/api/agent-history")).json(); + if ((h.sessions || []).length) { + opts += ''; + for (const g of h.sessions.slice(0, 20)) { + const name = g.summary ? `“${g.summary}”` : g.session_id.slice(0, 8); + const where = g.title || g.rmux_session || ""; + opts += ``; + } + opts += ""; + } + } catch (e) {} + picker.innerHTML = opts; + } + + picker.onchange = () => { + const v = picker.value; + if (!v) return; + const name = picker.options[picker.selectedIndex] ? picker.options[picker.selectedIndex].text : ""; + if (v.startsWith("agent:")) { + connectTo(`/agent/${v.slice("agent:".length)}`, name); + } else if (v.startsWith("hist:")) { + showHistory(decodeURIComponent(v.slice("hist:".length)), name); + } + }; + + setSource("managed agent (new session)"); + connect(); + return { connectTo, loadPickers }; +} diff --git a/web/js/filetree.js b/web/js/filetree.js new file mode 100644 index 0000000..849bd57 --- /dev/null +++ b/web/js/filetree.js @@ -0,0 +1,91 @@ +// File tree panel (right side) — lazy-loads directories under the selected +// session's pane cwd. Files are draggable onto the terminal and right-clickable +// to insert their absolute path into the live pane. + +import { apiFetch } from "./auth.js"; + +export function initFileTree({ onInsert } = {}) { + const rootEl = document.getElementById("filetree"); const panel = document.getElementById("filetree-panel"); + const collapseBtn = document.getElementById("ft-collapse"); + let session = null; + let cwd = ""; + + collapseBtn.onclick = () => { + panel.classList.toggle("collapsed"); + collapseBtn.textContent = panel.classList.contains("collapsed") ? "⮜" : "⮞"; + }; + + async function fetchDir(path) { + if (!session) return null; + try { + const url = `/api/sessions/${encodeURIComponent(session)}/files?path=${encodeURIComponent(path)}`; + const r = await apiFetch(url); + if (!r.ok) return null; + return await r.json(); + } catch (e) { + return null; + } + } + + function absPath(relPath) { + const base = cwd.replace(/\/+$/, ""); + return relPath ? `${base}/${relPath}` : base; + } + + function node(entry, parentPath, container) { + const li = document.createElement("li"); + const row = document.createElement("div"); + row.className = "tnode" + (entry.dir ? "" : " file"); + const label = (open) => `${entry.dir ? (open ? "▾ 📁" : "▸ 📁") : " 📄"} ${entry.name}`; + row.textContent = label(false); + const path = parentPath ? `${parentPath}/${entry.name}` : entry.name; + li.appendChild(row); + + if (entry.dir) { + let childUl = null; + let open = false; + row.onclick = async () => { + if (childUl) { + open = !open; + childUl.style.display = open ? "" : "none"; + row.textContent = label(open); + return; + } + const data = await fetchDir(path); + if (!data) return; + childUl = document.createElement("ul"); + childUl.className = "tree"; + for (const e of data.entries) node(e, path, childUl); + li.appendChild(childUl); + open = true; + row.textContent = label(true); + }; + } else { + // Files: drag onto terminal, or right-click to insert the absolute path. + row.draggable = true; + row.addEventListener("dragstart", (e) => { + e.dataTransfer.setData("text/plain", absPath(path)); + }); + row.oncontextmenu = (e) => { + e.preventDefault(); + if (onInsert) onInsert(absPath(path)); + }; + row.title = "drag to terminal, or right-click to insert path"; + } + container.appendChild(li); + } + + async function load(sessionId) { + session = sessionId; + rootEl.innerHTML = ""; + const data = await fetchDir(""); + if (!data) { rootEl.textContent = "(no cwd for this session)"; return; } + cwd = data.cwd || ""; + const ul = document.createElement("ul"); + ul.className = "tree"; + for (const e of data.entries) node(e, "", ul); + rootEl.appendChild(ul); + } + + return { load }; +} diff --git a/web/js/markdown.js b/web/js/markdown.js new file mode 100644 index 0000000..a57dcdf Binary files /dev/null and b/web/js/markdown.js differ diff --git a/web/js/protocol.js b/web/js/protocol.js new file mode 100644 index 0000000..0997b96 --- /dev/null +++ b/web/js/protocol.js @@ -0,0 +1,27 @@ +// Style bit values aligned to the Rust `lucarne_rmux::term::Style` (u16 bitflags, +// serialized as raw bits). Mirror of the server-side names. +export const StyleBits = { + BOLD: 0x0001, + DIM: 0x0002, + UNDERLINE: 0x0004, + BLINK: 0x0008, + REVERSE: 0x0010, + HIDDEN: 0x0020, + ITALIC: 0x0040, + CHARSET: 0x0080, + STRIKETHROUGH: 0x0100, + DOUBLE_UNDERLINE: 0x0200, + CURLY_UNDERLINE: 0x0400, + DOTTED_UNDERLINE: 0x0800, + DASHED_UNDERLINE: 0x1000, + OVERLINE: 0x2000, + NO_ATTRIBUTES: 0x4000, +}; + +// Any underline variant — collapsed to a single underline by the renderer. +export const ANY_UNDERLINE = + StyleBits.UNDERLINE | + StyleBits.DOUBLE_UNDERLINE | + StyleBits.CURLY_UNDERLINE | + StyleBits.DOTTED_UNDERLINE | + StyleBits.DASHED_UNDERLINE; diff --git a/web/js/renderer.js b/web/js/renderer.js new file mode 100644 index 0000000..11c440f --- /dev/null +++ b/web/js/renderer.js @@ -0,0 +1,193 @@ +// 2D Canvas dirty-cell renderer — zoom-aware snapshot viewer (spike3 fidelity +// M1–M5). Trusts server-provided cell.width/padding (no JS wcwidth recompute). + +import { StyleBits, ANY_UNDERLINE } from "./protocol.js"; + +const BASE_FONT_PX = 14; +const FONT_FAMILY = '"SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace'; +const DEFAULT_FG = "#d8d8d8"; +const DEFAULT_BG = "#101014"; +const CURSOR_COLOR = "#d8d8d8"; + +const ANSI_BASE = ["#000000", "#cc0000", "#4e9a06", "#c4a000", "#3465a4", "#75507b", "#06989a", "#d3d7cf"]; +const ANSI_BRIGHT = ["#555753", "#ef2929", "#8ae234", "#fce94f", "#729fcf", "#ad7fa8", "#34e2e2", "#eeeeec"]; + +export function colorToCss(c, fallback) { + if (typeof c === "string") return fallback; + if ("ansi" in c) return ANSI_BASE[c.ansi & 7] ?? fallback; + if ("bright_ansi" in c) return ANSI_BRIGHT[c.bright_ansi & 7] ?? fallback; + if ("indexed" in c) return indexed256ToCss(c.indexed); + if ("rgb" in c) { const [r, g, b] = c.rgb; return `rgb(${r},${g},${b})`; } + return fallback; +} + +function indexed256ToCss(i) { + if (i < 8) return ANSI_BASE[i]; + if (i < 16) return ANSI_BRIGHT[i - 8]; + if (i < 232) { + const n = i - 16; + const r = Math.floor(n / 36), g = Math.floor((n % 36) / 6), b = n % 6; + const conv = (v) => (v === 0 ? 0 : 55 + v * 40); + return `rgb(${conv(r)},${conv(g)},${conv(b)})`; + } + const level = 8 + (i - 232) * 10; + return `rgb(${level},${level},${level})`; +} + +function styleToAttrs(style) { + return { + bold: (style & StyleBits.BOLD) !== 0, + italic: (style & StyleBits.ITALIC) !== 0, + underline: (style & ANY_UNDERLINE) !== 0, + reverse: (style & StyleBits.REVERSE) !== 0, + dim: (style & StyleBits.DIM) !== 0, + strikethrough: (style & StyleBits.STRIKETHROUGH) !== 0, + }; +} + +export class CanvasRenderer { + constructor(canvas) { + this.canvas = canvas; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("2d canvas context unavailable"); + this.ctx = ctx; + this.grid = null; + this.cursor = null; + this.dpr = 1; + this.zoom = 1; + this.recomputeMetrics(); + } + + recomputeMetrics() { + this.fontPx = Math.max(6, Math.round(BASE_FONT_PX * this.zoom)); + this.cellH = Math.round(this.fontPx * 1.4); + this.baseFont = `${this.fontPx}px ${FONT_FAMILY}`; + this.ctx.font = this.baseFont; + const advance = this.ctx.measureText("M").width; + this.cellW = Math.max(1, Math.ceil(advance)); + } + + /** Set zoom (0.5–3×), recompute metrics, repaint. Returns the applied zoom. */ + setZoom(z) { + this.zoom = Math.max(0.5, Math.min(3, z)); + this.recomputeMetrics(); + if (this.grid) this.renderSnapshot(this.grid, this.cursor); + return this.zoom; + } + + resizeBacking(cols, rows) { + const cssW = cols * this.cellW; + const cssH = rows * this.cellH; + this.dpr = window.devicePixelRatio || 1; + this.canvas.width = Math.round(cssW * this.dpr); + this.canvas.height = Math.round(cssH * this.dpr); + this.canvas.style.width = `${cssW}px`; + this.canvas.style.height = `${cssH}px`; + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.ctx.textBaseline = "top"; + this.ctx.font = this.baseFont; + } + + rev() { return this.grid?.rev ?? 0; } + + renderSnapshot(grid, cursor) { + this.grid = grid; + this.cursor = cursor; + this.resizeBacking(grid.cols, grid.rows); + this.ctx.fillStyle = DEFAULT_BG; + this.ctx.fillRect(0, 0, grid.cols * this.cellW, grid.rows * this.cellH); + for (let row = 0; row < grid.rows; row++) this.paintRow(row, 0, grid.cols); + this.drawCursor(); + } + + renderDelta(delta, rev, cursor) { + const grid = this.grid; + if (!grid) return; + for (const rowDelta of delta.rows) { + const y = rowDelta.y; + if (y >= grid.rows) continue; + for (const span of rowDelta.spans) { + for (let i = 0; i < span.cells.length; i++) { + const col = span.x + i; + if (col >= grid.cols) break; + grid.cells[y * grid.cols + col] = span.cells[i]; + } + const endCol = Math.min(span.x + span.cells.length, grid.cols); + this.paintRow(y, span.x, endCol); + } + } + grid.rev = rev; + this.cursor = cursor; + this.drawCursor(); + } + + paintRow(row, startCol, endCol) { + const grid = this.grid; + if (!grid) return; + let col = startCol; + while (col < endCol) { + const cell = grid.cells[row * grid.cols + col]; + if (!cell) { col += 1; continue; } + if (cell.padding) { col += 1; continue; } + const span = cell.width >= 2 ? 2 : 1; + this.paintCell(cell, row, col, span); + col += span; + } + } + + paintCell(cell, row, col, span) { + const ctx = this.ctx; + const attrs = styleToAttrs(cell.style); + let fg = colorToCss(cell.fg, DEFAULT_FG); + let bg = colorToCss(cell.bg, DEFAULT_BG); + if (attrs.reverse) { const t = fg; fg = bg; bg = t; } + + const x = col * this.cellW; + const y = row * this.cellH; + const w = this.cellW * span; + + ctx.fillStyle = bg; + ctx.fillRect(x, y, w, this.cellH); + + if ((cell.style & StyleBits.HIDDEN) !== 0) return; + if (cell.text.length === 0) return; + + const weight = attrs.bold ? "bold" : "normal"; + const fontStyle = attrs.italic ? "italic" : "normal"; + ctx.font = `${fontStyle} ${weight} ${this.fontPx}px ${FONT_FAMILY}`; + ctx.globalAlpha = attrs.dim ? 0.6 : 1.0; + ctx.fillStyle = fg; + ctx.fillText(cell.text, x, y + 2); + ctx.globalAlpha = 1.0; + + ctx.strokeStyle = fg; + ctx.lineWidth = 1; + if (attrs.underline) { + const uy = y + this.cellH - 2; + ctx.beginPath(); ctx.moveTo(x, uy); ctx.lineTo(x + w, uy); ctx.stroke(); + } + if (attrs.strikethrough) { + const sy = y + Math.floor(this.cellH / 2); + ctx.beginPath(); ctx.moveTo(x, sy); ctx.lineTo(x + w, sy); ctx.stroke(); + } + } + + drawCursor() { + const cursor = this.cursor; + const grid = this.grid; + if (!cursor || !grid || !cursor.visible) return; + if (cursor.row >= grid.rows || cursor.col >= grid.cols) return; + const x = cursor.col * this.cellW; + const y = cursor.row * this.cellH; + this.ctx.save(); + this.ctx.fillStyle = CURSOR_COLOR; + this.ctx.fillRect(x, y, this.cellW, this.cellH); + const cell = grid.cells[cursor.row * grid.cols + cursor.col]; + if (cell && cell.text.length > 0 && !cell.padding) { + this.ctx.font = `${this.fontPx}px ${FONT_FAMILY}`; + this.ctx.fillStyle = DEFAULT_BG; + this.ctx.fillText(cell.text, x, y + 2); + } + this.ctx.restore(); + } +} diff --git a/web/js/terminal.js b/web/js/terminal.js new file mode 100644 index 0000000..0d33f52 --- /dev/null +++ b/web/js/terminal.js @@ -0,0 +1,179 @@ +// Terminal view — rmux mirror over /ws, with zoom controls, a cwd-aware file +// tree drop target, and the agent/archive affordances. Exposes insertText() so +// the file tree can write a path into the live pane. +// +// Auth (TASK-003 contract, via ./auth.js): the access token is read from the +// `#token=` URL fragment (then the hash is cleared via history.replaceState), +// stored in sessionStorage, and presented as `Authorization: Bearer ` on +// every /api/* call (apiFetch). The ws never carries the token: connect() first +// POSTs to /auth/ticket for a single-use ticket and opens /ws?ticket=. + +import { CanvasRenderer } from "./renderer.js"; +import { apiFetch, wsTicket, wsUrlWithTicket, requireLogin } from "./auth.js"; + +const esc = (s) => + s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); + +export function initTerminal({ onAgent, onSelect } = {}) { + const canvas = document.getElementById("screen"); + const wrap = document.getElementById("screenwrap"); + const renderer = new CanvasRenderer(canvas); + const sessionsEl = document.getElementById("sessions"); + const statusEl = document.getElementById("term-status"); + const connEl = document.getElementById("conn"); + + let ws = null; + let current = null; + let statusText = "connecting…"; + let agentInfo = null; + + const setConn = (t) => { if (connEl) connEl.textContent = t; }; + const send = (f) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(f)); }; + + function renderStatus() { + statusEl.textContent = statusText; + if (agentInfo && agentInfo.bound && onAgent) { + const b = document.createElement("button"); + b.className = "agentbtn"; + b.textContent = `🤖 ${agentInfo.kind} · open chat`; + b.onclick = () => onAgent(current); + statusEl.appendChild(b); + } + if (current) { + const ab = document.createElement("button"); + ab.className = "agentbtn"; + ab.textContent = "🗄 archive"; + ab.onclick = archiveCurrent; + statusEl.appendChild(ab); + } + } + const setStatus = (t) => { statusText = t; renderStatus(); }; + + async function archiveCurrent() { + if (!current) return; + if (!confirm("Archive (close) this terminal? Its content is preserved.")) return; + try { await apiFetch(`/api/sessions/${encodeURIComponent(current)}/archive`, { method: "POST" }); } catch (e) {} + current = null; agentInfo = null; + setStatus("archived — terminal closed, content preserved"); + refreshSessions(); + } + async function refreshSessions() { + try { renderSessions(await (await apiFetch("/api/sessions")).json()); } catch (e) {} + } + + async function connect() { + const proto = location.protocol === "https:" ? "wss" : "ws"; + // Exchange the long-lived token for a single-use ws ticket. null = auth + // required but no/invalid token (login surfaced) — retry once submitted. + const ticket = await wsTicket(); + if (ticket === null) { + setStatus("waiting for access key…"); setConn("○ login required"); + requireLogin("Enter the gateway access key to connect", connect); + return; + } + const url = wsUrlWithTicket(`${proto}://${location.host}/ws`, ticket); + ws = new WebSocket(url); + ws.onopen = () => { setStatus("connected"); setConn("● connected"); }; + ws.onmessage = (ev) => handle(JSON.parse(ev.data)); + ws.onerror = () => setStatus("ws error"); + ws.onclose = () => { setStatus("disconnected — reconnecting…"); setConn("○ reconnecting…"); setTimeout(connect, 1500); }; + } + + function handle(f) { + switch (f.type) { + case "session_list": renderSessions(f.sessions); break; + case "snapshot": if (f.session === current) renderer.renderSnapshot(f.grid, f.cursor); break; + case "snapshot_delta": if (f.session === current) renderer.renderDelta(f.delta, f.rev, f.cursor); break; + case "session_created": current = f.session; break; + case "session_closed": if (f.session === current) current = null; break; + case "error": setStatus("error: " + f.msg); break; + } + } + + function renderSessions(sessions) { + sessionsEl.innerHTML = ""; + for (const s of sessions) { + const li = document.createElement("li"); + const badge = s.origin === "managed" ? "M" : "A"; + li.innerHTML = + `${esc(s.title)} [${badge}]` + + (s.cwd ? `${esc(s.cwd)}` : ""); + if (s.id === current) li.className = "active"; + li.onclick = () => subscribe(s.id, li); + sessionsEl.appendChild(li); + } + } + + async function subscribe(id, li) { + current = id; + agentInfo = null; + send({ type: "subscribe", session: id }); + [...sessionsEl.children].forEach((el) => el.classList.remove("active")); + if (li) li.classList.add("active"); + canvas.focus(); + setStatus("session " + id); + if (onSelect) onSelect(id); + try { + const a = await (await apiFetch(`/api/sessions/${encodeURIComponent(id)}/agent`)).json(); + if (current === id) { agentInfo = a; renderStatus(); } + } catch (e) {} + } + + /** Write text into the current pane (used by file-tree drag/right-click). */ + function insertText(text) { + if (!current || !text) return; + send({ type: "input", session: current, event: { kind: "text", text } }); + setStatus("inserted: " + text); + } + + // ---- zoom ---- + document.getElementById("zoom-in").onclick = () => renderer.setZoom(renderer.zoom + 0.1); + document.getElementById("zoom-out").onclick = () => renderer.setZoom(renderer.zoom - 0.1); + document.getElementById("zoom-fit").onclick = () => { + const w = canvas.offsetWidth; + if (w > 0) renderer.setZoom(renderer.zoom * ((wrap.clientWidth - 24) / w)); + }; + + // ---- file drop → insert path ---- + wrap.addEventListener("dragover", (e) => { e.preventDefault(); }); + wrap.addEventListener("drop", (e) => { + e.preventDefault(); + const path = e.dataTransfer.getData("text/plain"); + if (path) insertText(path); + }); + + // ---- keyboard ---- + const NAMED = { + Enter: "enter", Tab: "tab", Backspace: "backspace", Escape: "escape", + ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", + Home: "home", End: "end", PageUp: "page_up", PageDown: "page_down", Delete: "delete", + }; + function mods(e) { + return { ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey }; + } + function keyToInput(e) { + const key = e.key; + if (e.ctrlKey && key.length === 1 && /[a-zA-Z]/.test(key)) { + return { kind: "control", key: { ctrl_char: key.toLowerCase() } }; + } + if (e.altKey || e.metaKey || (e.shiftKey && NAMED[key])) { + return { kind: "key", code: key, mods: mods(e) }; + } + if (NAMED[key]) return { kind: "control", key: NAMED[key] }; + if (key.length === 1 && !e.metaKey) return { kind: "text", text: key }; + return null; + } + canvas.addEventListener("keydown", (e) => { + if (!current) return; + const event = keyToInput(e); + if (event) { e.preventDefault(); send({ type: "input", session: current, event }); } + }); + + document.getElementById("new-session").onclick = () => { + const title = prompt("New session title:", "shell"); + if (title !== null) send({ type: "create_session", title: title || null }); + }; + + connect(); + return { insertText }; +}