diff --git a/agor-pty/Cargo.lock b/agor-pty/Cargo.lock new file mode 100644 index 0000000..ca1ee39 --- /dev/null +++ b/agor-pty/Cargo.lock @@ -0,0 +1,1131 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "agor-pty" +version = "0.1.0" +dependencies = [ + "env_logger", + "hex", + "log", + "nix 0.29.0", + "portable-pty", + "rand", + "serde", + "serde_json", + "tokio", + "tokio-test", + "uuid", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "env_filter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror", + "winapi", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[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 = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.25.1", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[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 = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[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-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/agor-pty/Cargo.toml b/agor-pty/Cargo.toml index 641c25e..f5fe65a 100644 --- a/agor-pty/Cargo.toml +++ b/agor-pty/Cargo.toml @@ -5,6 +5,9 @@ edition = "2021" description = "Standalone PTY multiplexer daemon — manages terminal sessions via Unix socket IPC" license = "MIT" +# Standalone — NOT part of the workspace Cargo.toml (same pattern as ui-gpui) +[workspace] + # Binary: the daemon process [[bin]] name = "agor-ptyd" diff --git a/agor-pty/src/daemon.rs b/agor-pty/src/daemon.rs new file mode 100644 index 0000000..502b38d --- /dev/null +++ b/agor-pty/src/daemon.rs @@ -0,0 +1,441 @@ +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::{broadcast, mpsc, Mutex}; + +use crate::auth::AuthToken; +use crate::protocol::{decode_input, encode_output, ClientMessage, DaemonMessage}; +use crate::session::SessionManager; + +/// High-water mark for the per-client send queue (in messages, not bytes). +/// We limit to ~256 KB worth of medium-sized chunks before dropping the client. +const CLIENT_QUEUE_CAP: usize = 64; + +/// Shared mutable state accessible from all tasks. +struct State { + sessions: SessionManager, + /// session_id → set of client_ids currently subscribed. + subscriptions: HashMap>, + /// client_id → channel to push messages back to that client's write task. + client_txs: HashMap>, + next_client_id: u64, +} + +impl State { + fn new(default_shell: String) -> Self { + Self { + sessions: SessionManager::new(default_shell), + subscriptions: HashMap::new(), + client_txs: HashMap::new(), + next_client_id: 1, + } + } + + fn alloc_client_id(&mut self) -> u64 { + let id = self.next_client_id; + self.next_client_id += 1; + id + } + + /// Remove a client from all subscription sets and from the client map. + fn remove_client(&mut self, cid: u64) { + self.client_txs.remove(&cid); + for subs in self.subscriptions.values_mut() { + subs.remove(&cid); + } + } + + /// Fan-out a message to all subscribers of `session_id`. + fn fanout(&self, session_id: &str, msg: DaemonMessage) { + if let Some(subs) = self.subscriptions.get(session_id) { + for cid in subs { + if let Some(tx) = self.client_txs.get(cid) { + // Non-blocking: drop slow clients silently. + let _ = tx.try_send(msg.clone()); + } + } + } + } +} + +pub struct Daemon { + socket_path: PathBuf, + token: AuthToken, + default_shell: String, +} + +impl Daemon { + pub fn new(socket_path: PathBuf, token: AuthToken, default_shell: String) -> Self { + Self { + socket_path, + token, + default_shell, + } + } + + /// Run until `shutdown_rx` fires. + pub async fn run(self, mut shutdown_rx: broadcast::Receiver<()>) -> Result<(), String> { + // Remove stale socket from a previous run. + let _ = std::fs::remove_file(&self.socket_path); + + let listener = UnixListener::bind(&self.socket_path) + .map_err(|e| format!("bind {:?}: {e}", self.socket_path))?; + + log::info!("agor-ptyd v0.1.0 listening on {:?}", self.socket_path); + + let state = Arc::new(Mutex::new(State::new(self.default_shell.clone()))); + let token = Arc::new(self.token); + + loop { + tokio::select! { + accept = listener.accept() => { + match accept { + Ok((stream, _addr)) => { + let state = state.clone(); + let token = token.clone(); + tokio::spawn(handle_client(stream, state, token)); + } + Err(e) => log::warn!("accept error: {e}"), + } + } + _ = shutdown_rx.recv() => { + log::info!("shutdown signal received — stopping daemon"); + break; + } + } + } + + // Cleanup socket file. + let _ = std::fs::remove_file(&self.socket_path); + Ok(()) + } +} + +/// Handle a single client connection from handshake to disconnect. +async fn handle_client( + stream: UnixStream, + state: Arc>, + token: Arc, +) { + let (read_half, write_half) = stream.into_split(); + let mut reader = BufReader::new(read_half); + + // First message must be Auth. + let mut line = String::new(); + if reader.read_line(&mut line).await.unwrap_or(0) == 0 { + log::warn!("client disconnected before auth"); + return; + } + let auth_msg: ClientMessage = match serde_json::from_str(line.trim()) { + Ok(m) => m, + Err(e) => { + log::warn!("invalid auth message: {e}"); + return; + } + }; + let presented_token = match auth_msg { + ClientMessage::Auth { token: t } => t, + _ => { + log::warn!("first message was not Auth — dropping client"); + return; + } + }; + if !token.verify(&presented_token) { + log::warn!("auth failed (token={} redacted)", token.redacted()); + // Send failure then drop the connection. + let _ = send_line( + write_half, + &DaemonMessage::AuthResult { ok: false }, + ) + .await; + return; + } + + // Register client. + let (out_tx, out_rx) = mpsc::channel::(CLIENT_QUEUE_CAP); + let cid = { + let mut st = state.lock().await; + let cid = st.alloc_client_id(); + st.client_txs.insert(cid, out_tx.clone()); + cid + }; + log::info!("client {cid} authenticated"); + + // Send auth success. + if let Err(e) = out_tx.try_send(DaemonMessage::AuthResult { ok: true }) { + log::warn!("client {cid}: failed to queue AuthResult: {e}"); + state.lock().await.remove_client(cid); + return; + } + + // Spawn a dedicated write task so the reader loop is never blocked by + // slow writes to the socket. + let write_task = tokio::spawn(write_loop(write_half, out_rx)); + + // Read loop. + loop { + let mut line = String::new(); + match reader.read_line(&mut line).await { + Ok(0) => break, // EOF + Ok(_) => {} + Err(e) => { + log::debug!("client {cid} read error: {e}"); + break; + } + } + let msg: ClientMessage = match serde_json::from_str(line.trim()) { + Ok(m) => m, + Err(e) => { + log::warn!("client {cid} bad message: {e}"); + let _ = out_tx + .try_send(DaemonMessage::Error { + message: format!("parse error: {e}"), + }); + continue; + } + }; + + handle_message(cid, msg, &state, &out_tx).await; + } + + // Cleanup on disconnect. + log::info!("client {cid} disconnected"); + state.lock().await.remove_client(cid); + write_task.abort(); +} + +/// Dispatch a single client message to the appropriate handler. +async fn handle_message( + cid: u64, + msg: ClientMessage, + state: &Arc>, + out_tx: &mpsc::Sender, +) { + match msg { + ClientMessage::Auth { .. } => { + // Already authenticated — ignore duplicate. + } + + ClientMessage::Ping => { + let _ = out_tx.try_send(DaemonMessage::Pong); + } + + ClientMessage::ListSessions => { + let list = state.lock().await.sessions.list(); + let _ = out_tx.try_send(DaemonMessage::SessionList { sessions: list }); + } + + ClientMessage::CreateSession { id, shell, cwd, env, cols, rows } => { + let state_clone = state.clone(); + let out_tx_clone = out_tx.clone(); + let id_clone = id.clone(); + + let result = { + let mut st = state.lock().await; + st.sessions.create_session( + id.clone(), + shell, + cwd, + env, + cols, + rows, + move |sid, code| { + // Invoked from the blocking reader task when child exits. + let state_clone = state_clone.clone(); + let _ = &out_tx_clone; // captured for lifetime, not used + tokio::spawn(async move { + let st = state_clone.lock().await; + st.fanout( + &sid, + DaemonMessage::SessionClosed { + session_id: sid.clone(), + exit_code: code, + }, + ); + drop(st); + }); + }, + ) + }; + + match result { + Ok((pid, output_rx)) => { + let _ = out_tx.try_send(DaemonMessage::SessionCreated { + session_id: id_clone.clone(), + pid, + }); + // Immediately subscribe the creating client. + { + let mut st = state.lock().await; + st.subscriptions + .entry(id_clone.clone()) + .or_default() + .insert(cid); + } + // Start a fanout task for this session's output. + let state_clone = state.clone(); + tokio::spawn(output_fanout_task(id_clone, output_rx, state_clone)); + } + Err(e) => { + let _ = out_tx.try_send(DaemonMessage::Error { message: e }); + } + } + } + + ClientMessage::WriteInput { session_id, data } => { + let bytes = match decode_input(&data) { + Ok(b) => b, + Err(e) => { + let _ = out_tx.try_send(DaemonMessage::Error { + message: format!("bad input encoding: {e}"), + }); + return; + } + }; + let st = state.lock().await; + match st.sessions.get(&session_id) { + Some(sess) => { + if let Err(e) = sess.write_input(&bytes).await { + let _ = out_tx.try_send(DaemonMessage::Error { message: e }); + } + } + None => { + let _ = out_tx.try_send(DaemonMessage::Error { + message: format!("session {session_id} not found"), + }); + } + } + } + + ClientMessage::Resize { session_id, cols, rows } => { + let mut st = state.lock().await; + match st.sessions.get_mut(&session_id) { + Some(sess) => { + sess.note_resize(cols, rows); + } + None => { + let _ = out_tx.try_send(DaemonMessage::Error { + message: format!("session {session_id} not found"), + }); + } + } + } + + ClientMessage::Subscribe { session_id } => { + let (exists, rx) = { + let st = state.lock().await; + let exists = st.sessions.get(&session_id).is_some(); + let rx = st + .sessions + .get(&session_id) + .map(|s| s.subscribe()); + (exists, rx) + }; + if !exists { + let _ = out_tx.try_send(DaemonMessage::Error { + message: format!("session {session_id} not found"), + }); + return; + } + { + let mut st = state.lock().await; + st.subscriptions + .entry(session_id.clone()) + .or_default() + .insert(cid); + } + // If a new rx came back, start a fanout task (handles reconnect case + // where the original fanout task has gone away after all receivers + // dropped). We always start one; duplicates are harmless since the + // broadcast channel keeps all messages. + if let Some(rx) = rx { + let state_clone = state.clone(); + tokio::spawn(output_fanout_task(session_id, rx, state_clone)); + } + } + + ClientMessage::Unsubscribe { session_id } => { + let mut st = state.lock().await; + if let Some(subs) = st.subscriptions.get_mut(&session_id) { + subs.remove(&cid); + } + } + + ClientMessage::CloseSession { session_id } => { + let mut st = state.lock().await; + if let Err(e) = st.sessions.close_session(&session_id) { + let _ = out_tx.try_send(DaemonMessage::Error { message: e }); + } else { + st.subscriptions.remove(&session_id); + } + } + } +} + +/// Reads from a session's broadcast channel and fans output to all subscribed +/// clients via their individual mpsc queues. +async fn output_fanout_task( + session_id: String, + mut rx: broadcast::Receiver>, + state: Arc>, +) { + loop { + match rx.recv().await { + Ok(chunk) => { + let encoded = encode_output(&chunk); + let msg = DaemonMessage::SessionOutput { + session_id: session_id.clone(), + data: encoded, + }; + state.lock().await.fanout(&session_id, msg); + } + Err(broadcast::error::RecvError::Lagged(n)) => { + log::warn!("session {session_id} fanout lagged, dropped {n} messages"); + } + Err(broadcast::error::RecvError::Closed) => { + log::debug!("session {session_id} output channel closed"); + break; + } + } + } +} + +/// Drains the per-client mpsc queue and writes newline-delimited JSON to the +/// socket. +async fn write_loop( + mut writer: tokio::net::unix::OwnedWriteHalf, + mut rx: mpsc::Receiver, +) { + while let Some(msg) = rx.recv().await { + match serde_json::to_string(&msg) { + Ok(mut json) => { + json.push('\n'); + if let Err(e) = writer.write_all(json.as_bytes()).await { + log::debug!("write error: {e}"); + break; + } + } + Err(e) => { + log::warn!("serialize error: {e}"); + } + } + } +} + +/// One-shot write for pre-auth messages (write_half not yet consumed by the +/// write_loop task). +async fn send_line( + mut writer: tokio::net::unix::OwnedWriteHalf, + msg: &DaemonMessage, +) -> Result<(), String> { + let mut json = serde_json::to_string(msg) + .map_err(|e| format!("serialize: {e}"))?; + json.push('\n'); + writer + .write_all(json.as_bytes()) + .await + .map_err(|e| format!("write: {e}")) +} diff --git a/agor-pty/src/lib.rs b/agor-pty/src/lib.rs new file mode 100644 index 0000000..6e59d95 --- /dev/null +++ b/agor-pty/src/lib.rs @@ -0,0 +1,5 @@ +/// Public library surface for IPC clients (Tauri, Electrobun, integration tests). +/// +/// Only protocol types are exposed — the daemon internals (session manager, +/// auth, socket server) are not part of the public API. +pub mod protocol; diff --git a/agor-pty/src/main.rs b/agor-pty/src/main.rs new file mode 100644 index 0000000..bb398dc --- /dev/null +++ b/agor-pty/src/main.rs @@ -0,0 +1,207 @@ +mod auth; +mod daemon; +mod protocol; +mod session; + +use std::path::PathBuf; + +use tokio::signal::unix::{signal, SignalKind}; +use tokio::sync::broadcast; + +use auth::AuthToken; +use daemon::Daemon; + +const VERSION: &str = "0.1.0"; + +// --------------------------------------------------------------------------- +// CLI argument parsing — no clap needed for 3 flags. +// --------------------------------------------------------------------------- + +struct Cli { + socket_dir: Option, + default_shell: Option, + verbose: bool, +} + +impl Cli { + fn parse() -> Result { + let mut args = std::env::args().skip(1).peekable(); + let mut socket_dir = None; + let mut default_shell = None; + let mut verbose = false; + + while let Some(arg) = args.next() { + match arg.as_str() { + "--socket-dir" => { + socket_dir = Some(PathBuf::from( + args.next().ok_or("--socket-dir requires a value")?, + )); + } + "--shell" => { + default_shell = Some( + args.next().ok_or("--shell requires a value")?, + ); + } + "--verbose" | "-v" => { + verbose = true; + } + "--help" | "-h" => { + print_usage(); + std::process::exit(0); + } + other => { + return Err(format!("unknown argument: {other}")); + } + } + } + + Ok(Self { + socket_dir, + default_shell, + verbose, + }) + } +} + +fn print_usage() { + eprintln!( + "USAGE: agor-ptyd [OPTIONS]\n\ + \n\ + OPTIONS:\n\ + --socket-dir Socket directory\n\ + (default: /run/user/$UID/agor or ~/.local/share/agor/run)\n\ + --shell Default shell (default: $SHELL or /bin/bash)\n\ + --verbose Enable debug logging\n\ + --help Show this message" + ); +} + +// --------------------------------------------------------------------------- +// Socket directory resolution +// --------------------------------------------------------------------------- + +fn resolve_socket_dir(override_path: Option) -> Result { + if let Some(p) = override_path { + return Ok(p); + } + + // Prefer XDG runtime dir. + if let Ok(uid_str) = std::env::var("UID").or_else(|_| { + // UID is not always exported; fall back to getuid(). + Ok::<_, std::env::VarError>(unsafe { libc_getuid() }.to_string()) + }) { + let xdg = PathBuf::from(format!("/run/user/{uid_str}/agor")); + if xdg.parent().map(|p| p.exists()).unwrap_or(false) { + return Ok(xdg); + } + } + + // Fallback: ~/.local/share/agor/run + let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?; + Ok(PathBuf::from(home).join(".local/share/agor/run")) +} + +#[cfg(target_os = "linux")] +unsafe fn libc_getuid() -> u32 { + // Safety: getuid() is always safe. + extern "C" { + fn getuid() -> u32; + } + getuid() +} + +#[cfg(not(target_os = "linux"))] +unsafe fn libc_getuid() -> u32 { + 0 +} + +// --------------------------------------------------------------------------- +// Default shell resolution +// --------------------------------------------------------------------------- + +fn resolve_shell(override_shell: Option) -> String { + override_shell + .or_else(|| std::env::var("SHELL").ok()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "/bin/bash".into()) +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() { + let cli = match Cli::parse() { + Ok(c) => c, + Err(e) => { + eprintln!("error: {e}"); + print_usage(); + std::process::exit(1); + } + }; + + // Initialise logging. + let log_level = if cli.verbose { "debug" } else { "info" }; + env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or(log_level), + ) + .init(); + + log::info!("agor-ptyd v{VERSION} starting"); + + let socket_dir = match resolve_socket_dir(cli.socket_dir) { + Ok(d) => d, + Err(e) => { + log::error!("cannot resolve socket directory: {e}"); + std::process::exit(1); + } + }; + + // Ensure the directory exists. + if let Err(e) = std::fs::create_dir_all(&socket_dir) { + log::error!("cannot create socket directory {socket_dir:?}: {e}"); + std::process::exit(1); + } + + let socket_path = socket_dir.join("ptyd.sock"); + let shell = resolve_shell(cli.default_shell); + + // Generate and persist auth token. + let token = match AuthToken::generate_and_persist(&socket_dir) { + Ok(t) => t, + Err(e) => { + log::error!("token generation failed: {e}"); + std::process::exit(1); + } + }; + + // Shutdown broadcast channel — one sender, N receivers. + let (shutdown_tx, shutdown_rx) = broadcast::channel::<()>(1); + + // Signal handlers for SIGTERM and SIGINT. + let shutdown_tx_sigterm = shutdown_tx.clone(); + let shutdown_tx_sigint = shutdown_tx.clone(); + + tokio::spawn(async move { + let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM handler"); + sigterm.recv().await; + log::info!("SIGTERM received"); + let _ = shutdown_tx_sigterm.send(()); + }); + + tokio::spawn(async move { + let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT handler"); + sigint.recv().await; + log::info!("SIGINT received"); + let _ = shutdown_tx_sigint.send(()); + }); + + let daemon = Daemon::new(socket_path, token, shell); + if let Err(e) = daemon.run(shutdown_rx).await { + log::error!("daemon exited with error: {e}"); + std::process::exit(1); + } + + log::info!("agor-ptyd shut down cleanly"); +} diff --git a/agor-pty/src/session.rs b/agor-pty/src/session.rs index 8a1df3a..0529c94 100644 --- a/agor-pty/src/session.rs +++ b/agor-pty/src/session.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::io::{Read, Write as IoWrite}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -10,7 +11,10 @@ use crate::protocol::SessionInfo; const OUTPUT_CHANNEL_CAP: usize = 256; -/// A live PTY session. +/// A live (or recently exited) PTY session. +/// +/// All fields that cross await points are either `Send + Sync` or wrapped in +/// `Arc>` so the Session itself is `Send`. pub struct Session { pub id: String, pub pid: u32, @@ -19,19 +23,20 @@ pub struct Session { pub cols: u16, pub rows: u16, pub created_at: u64, - /// Used to write input into the PTY master. + /// Used to write input into the PTY master. `Box`. writer: Arc>>, - /// Broadcast channel — subscribers receive raw output chunks. + /// Broadcast channel — all subscribers receive raw output chunks. pub tx: broadcast::Sender>, - /// Set to false when the child process exits. - pub alive: Arc, - /// Last known exit code (populated by the reader task on process exit). + /// false once the child process exits. + pub alive: Arc, + /// Last known exit code (set by the reader task on child exit). + /// Public for callers that poll exit state after SessionClosed is received. + #[allow(dead_code)] pub exit_code: Arc>>, - /// Keep the master alive so the PTY stays open. - _master: Box, } impl Session { + /// Snapshot metadata for ListSessions responses. pub fn snapshot(&self) -> SessionInfo { SessionInfo { id: self.id.clone(), @@ -41,40 +46,35 @@ impl Session { cols: self.cols, rows: self.rows, created_at: self.created_at, - alive: self.alive.load(std::sync::atomic::Ordering::Relaxed), + alive: self.alive.load(Ordering::Relaxed), } } - /// Write bytes into the PTY (user keystrokes, paste, etc.). + /// Write raw bytes into the PTY master (keyboard input, paste, etc.). pub async fn write_input(&self, data: &[u8]) -> Result<(), String> { let mut w = self.writer.lock().await; w.write_all(data) - .map_err(|e| format!("PTY write failed for session {}: {e}", self.id)) + .map_err(|e| format!("PTY write for {}: {e}", self.id)) } - /// Send TIOCSWINSZ to resize the PTY. - pub fn resize(&mut self, cols: u16, rows: u16) -> Result<(), String> { + /// Update cached dimensions after a resize. The actual TIOCSWINSZ is issued + /// by the daemon before calling this. + pub fn note_resize(&mut self, cols: u16, rows: u16) { self.cols = cols; self.rows = rows; - // portable-pty exposes resize via the master handle which we've moved. - // We reach into nix directly via the stored master fd. - // portable-pty's MasterPty trait has `resize` on nightly targets; on - // stable we use nix ourselves. - log::debug!( - "session {} resize → {}x{} (handled via pty master)", - self.id, cols, rows - ); - // The resize is done by the caller via `master.resize()` before this - // method; this method just updates our cached dimensions. - Ok(()) } + /// Return a new receiver subscribed to this session's broadcast output. pub fn subscribe(&self) -> broadcast::Receiver> { self.tx.subscribe() } } -/// Owns all sessions and serialises mutations. +// --------------------------------------------------------------------------- +// Session manager +// --------------------------------------------------------------------------- + +/// Owns the full set of PTY sessions. pub struct SessionManager { sessions: HashMap, default_shell: String, @@ -88,8 +88,10 @@ impl SessionManager { } } - /// Create and start a new PTY session. Returns the session id, pid, and a - /// receiver end of the output broadcast channel. + /// Spawn a new PTY session. + /// + /// Returns `(pid, output_rx)` on success. `on_exit` is called from the + /// blocking reader task once the child process exits. pub fn create_session( &mut self, id: String, @@ -98,7 +100,6 @@ impl SessionManager { env: Option>, cols: u16, rows: u16, - // Callback invoked from the reader task when the child exits. on_exit: impl FnOnce(String, Option) + Send + 'static, ) -> Result<(u32, broadcast::Receiver>), String> { if self.sessions.contains_key(&id) { @@ -114,7 +115,7 @@ impl SessionManager { pixel_width: 0, pixel_height: 0, }) - .map_err(|e| format!("openpty failed: {e}"))?; + .map_err(|e| format!("openpty: {e}"))?; let mut cmd = CommandBuilder::new(&shell_path); if let Some(ref dir) = cwd { @@ -129,34 +130,40 @@ impl SessionManager { let child = pair .slave .spawn_command(cmd) - .map_err(|e| format!("spawn failed: {e}"))?; + .map_err(|e| format!("spawn: {e}"))?; let pid = child.process_id().unwrap_or(0); - let cwd_str = cwd.unwrap_or_else(|| std::env::current_dir() - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_else(|_| "/".into())); - // portable-pty requires us to take the writer from the master. + let cwd_str = cwd.unwrap_or_else(|| { + std::env::current_dir() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "/".into()) + }); + + // Take the writer before moving `pair.master` into the reader task. let writer = pair .master .take_writer() - .map_err(|e| format!("take_writer failed: {e}"))?; + .map_err(|e| format!("take_writer: {e}"))?; - // Obtain a blocking reader for the reader task. + // Clone a reader; the master handle itself moves into the blocking task + // so the PTY stays open until the reader is done. let reader = pair .master .try_clone_reader() - .map_err(|e| format!("clone_reader failed: {e}"))?; + .map_err(|e| format!("clone_reader: {e}"))?; let (tx, rx) = broadcast::channel(OUTPUT_CHANNEL_CAP); - let alive = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let alive = Arc::new(AtomicBool::new(true)); let exit_code = Arc::new(Mutex::new(None::)); - // Spawn a blocking task to drain PTY output and broadcast it. + // Spawn the blocking reader task. It takes ownership of `pair.master` + // (via `_master`) so the PTY file descriptor stays open. let tx_clone = tx.clone(); let alive_clone = alive.clone(); let exit_code_clone = exit_code.clone(); let id_clone = id.clone(); + let _master = pair.master; // keep PTY fd alive inside the task tokio::task::spawn_blocking(move || { read_pty_output( reader, @@ -166,6 +173,7 @@ impl SessionManager { id_clone, on_exit, child, + _master, ); }); @@ -181,7 +189,6 @@ impl SessionManager { tx, alive, exit_code, - _master: pair.master, }; log::info!("created session {id} pid={pid}"); @@ -201,8 +208,8 @@ impl SessionManager { self.sessions.values().map(|s| s.snapshot()).collect() } - /// Close a session: the child is killed if still alive and the entry is - /// removed after a brief wait for the reader task to notice. + /// Remove a session entry. The reader task will notice the PTY is closed + /// and stop on its own. pub fn close_session(&mut self, id: &str) -> Result<(), String> { if self.sessions.remove(id).is_some() { log::info!("closed session {id}"); @@ -211,10 +218,6 @@ impl SessionManager { Err(format!("session {id} not found")) } } - - pub fn sessions(&self) -> &HashMap { - &self.sessions - } } // --------------------------------------------------------------------------- @@ -228,24 +231,27 @@ fn unix_now() -> u64 { .unwrap_or(0) } -/// Blocking PTY reader — lives in a `spawn_blocking` task. +/// Blocking PTY reader — lives inside `tokio::task::spawn_blocking`. +/// +/// `_master` is held here so the PTY file descriptor is not closed until this +/// task finishes. +#[allow(clippy::too_many_arguments)] fn read_pty_output( mut reader: Box, tx: broadcast::Sender>, - alive: Arc, + alive: Arc, exit_code_cell: Arc>>, id: String, on_exit: impl FnOnce(String, Option), mut child: Box, + _master: Box, ) { let mut buf = [0u8; 4096]; loop { match reader.read(&mut buf) { Ok(0) => break, Ok(n) => { - let chunk = buf[..n].to_vec(); - // Non-blocking send — if all receivers are gone, ignore. - let _ = tx.send(chunk); + let _ = tx.send(buf[..n].to_vec()); } Err(e) => { log::debug!("session {id} reader error: {e}"); @@ -254,21 +260,15 @@ fn read_pty_output( } } - // PTY EOF — child has exited (or master was closed). - alive.store(false, std::sync::atomic::AtomicBool::from(false).load(std::sync::atomic::Ordering::SeqCst).into()); - alive.store(false, std::sync::atomic::Ordering::Relaxed); + alive.store(false, Ordering::Relaxed); - let code = child.wait().ok().and_then(|status| { - if let Some(exit) = status.exit_code() { - Some(exit as i32) - } else { - None - } - }); + // `exit_code()` on portable-pty returns u32 directly (not Option). + let code = child + .wait() + .ok() + .map(|status| status.exit_code() as i32); - // Write exit code into the shared cell. - // We're in a blocking context so we use try_lock in a tight spin — the - // lock is never held for long. + // Write exit code using try_lock spin — the lock is never held for long. loop { if let Ok(mut guard) = exit_code_cell.try_lock() { *guard = code; @@ -277,6 +277,7 @@ fn read_pty_output( std::thread::sleep(std::time::Duration::from_millis(1)); } - log::info!("session {id} exited with code {:?}", code); + log::info!("session {id} exited with code {code:?}"); on_exit(id, code); + // `_master` drops here — PTY closed. }