diff --git a/Cargo.lock b/Cargo.lock index 9e30277..ffd3391 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,8 +14,18 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", - "generic-array", + "crypto-common 0.1.7", + "generic-array 0.14.7", +] + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", ] [[package]] @@ -25,8 +35,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", + "zeroize", ] [[package]] @@ -35,20 +57,35 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", "subtle", ] +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "cipher 0.5.2", + "ctr 0.10.1", + "ctutils", + "ghash 0.6.0", + "zeroize", +] + [[package]] name = "agent-browser" version = "0.25.0" source = "git+https://github.com/steel-dev/agent-browser?branch=as-lib#eb8e722560432a2ad0229604d3db82c16cda1253" dependencies = [ - "aes-gcm", + "aes-gcm 0.10.3", "async-trait", "base64", "chrono", @@ -56,14 +93,14 @@ dependencies = [ "futures-util", "getrandom 0.2.17", "hex", - "hmac", + "hmac 0.12.1", "image", "libc", "reqwest", "rust-embed", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "similar", "socket2", "time", @@ -185,6 +222,18 @@ dependencies = [ "syn", ] +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.1", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -276,12 +325,35 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" +dependencies = [ + "blowfish", + "pbkdf2 0.13.0", + "sha2 0.11.0", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -318,13 +390,32 @@ dependencies = [ "core2", ] +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", ] [[package]] @@ -333,7 +424,26 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "generic-array", + "generic-array 0.14.7", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher 0.5.2", ] [[package]] @@ -378,7 +488,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", ] [[package]] @@ -405,6 +524,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cipher 0.5.2", + "cpufeatures 0.3.1", + "rand_core 0.10.1", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.44" @@ -424,8 +556,20 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", + "zeroize", ] [[package]] @@ -477,6 +621,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "color_quant" version = "1.1.0" @@ -501,6 +651,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -516,6 +672,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -525,6 +687,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -565,24 +736,109 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.2", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.2", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core 0.10.1", +] + [[package]] name = "ctr" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -609,6 +865,28 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -618,6 +896,15 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "des" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "dialoguer" version = "0.12.0" @@ -636,11 +923,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dirs" version = "5.0.1" @@ -694,18 +993,93 @@ dependencies = [ "syn", ] +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hkdf", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encode_unicode" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equator" version = "0.4.2" @@ -804,6 +1178,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -946,6 +1336,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "generic-array" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -980,10 +1381,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -993,7 +1397,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", - "polyval", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.3", + "zeroize", ] [[package]] @@ -1006,6 +1420,17 @@ dependencies = [ "weezl", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -1081,13 +1506,37 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + [[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -1135,6 +1584,18 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "ctutils", + "subtle", + "typenum", + "zeroize", +] + [[package]] name = "hyper" version = "1.8.1" @@ -1388,8 +1849,18 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", - "generic-array", + "block-padding 0.3.3", + "generic-array 0.14.7", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array", ] [[package]] @@ -1520,6 +1991,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1540,9 +2031,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libfuzzer-sys" @@ -1626,6 +2117,12 @@ dependencies = [ "rayon", ] +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + [[package]] name = "memchr" version = "2.8.0" @@ -1669,6 +2166,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8", + "rand_core 0.10.1", + "sha3 0.11.0", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -1685,6 +2207,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "8.0.0" @@ -1710,6 +2244,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.10.2", + "rand_core 0.10.1", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -1742,7 +2288,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-integer", "num-traits", ] @@ -1801,6 +2347,67 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" +dependencies = [ + "ecdsa", + "elliptic-curve", + "fiat-crypto", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "p521" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + +[[package]] +name = "pageant" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8eab09a361a4afe0b1668be978cd97e4f052e927a92b0b608cf902965d49ce" +dependencies = [ + "base16ct", + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.10.2", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "windows", + "windows-strings", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1824,6 +2431,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "phc", +] + [[package]] name = "paste" version = "1.0.15" @@ -1848,15 +2464,44 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", - "hmac", + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", ] [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", +] [[package]] name = "pin-project-lite" @@ -1870,6 +2515,45 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" +dependencies = [ + "aes 0.9.3", + "aes-gcm 0.11.1", + "cbc 0.2.1", + "der", + "pbkdf2 0.13.0", + "rand_core 0.10.1", + "scrypt", + "sha2 0.11.0", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "pkcs5", + "rand_core 0.10.1", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -1889,6 +2573,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "universal-hash 0.6.1", + "zeroize", +] + [[package]] name = "polyval" version = "0.6.2" @@ -1896,9 +2591,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", + "zeroize", ] [[package]] @@ -1950,6 +2657,33 @@ dependencies = [ "syn", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2132,6 +2866,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2170,6 +2915,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -2351,6 +3102,16 @@ dependencies = [ "webpki-roots 1.0.6", ] +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac 0.13.0", +] + [[package]] name = "rgb" version = "0.8.53" @@ -2371,6 +3132,25 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid", + "crypto-bigint", + "crypto-primes", + "digest 0.11.3", + "pkcs1", + "pkcs8", + "rand_core 0.10.1", + "sha2 0.11.0", + "signature", + "spki", + "zeroize", +] + [[package]] name = "rsqlite-vfs" version = "0.1.0" @@ -2396,6 +3176,101 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "russh" +version = "0.63.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "036204edbd199552a5b3832f63c60dcdf395dc44c7f06b4af1c0e8139cc11bce" +dependencies = [ + "aes 0.9.3", + "bitflags", + "block-padding 0.4.2", + "byteorder", + "bytes", + "cbc 0.2.1", + "cipher 0.5.2", + "crypto-bigint", + "ctr 0.10.1", + "curve25519-dalek", + "data-encoding", + "delegate", + "der", + "digest 0.11.3", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "enum_dispatch", + "flate2", + "futures", + "generic-array 1.4.5", + "getrandom 0.4.2", + "ghash 0.6.0", + "hex-literal", + "hmac 0.13.0", + "inout 0.2.2", + "keccak", + "log", + "md5", + "ml-kem", + "module-lattice", + "num-bigint 0.5.1", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2 0.13.0", + "pkcs1", + "pkcs5", + "pkcs8", + "polyval 0.7.3", + "rand 0.10.2", + "rand_core 0.10.1", + "ring", + "rsa", + "russh-cryptovec", + "russh-util", + "salsa20", + "scrypt", + "sec1", + "sha1 0.11.0", + "sha2 0.11.0", + "sha3 0.12.0", + "signature", + "spki", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror 2.0.18", + "tokio", + "typenum", + "universal-hash 0.6.1", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438" +dependencies = [ + "log", + "nix", + "ssh-encoding", + "windows-sys 0.61.2", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "rust-embed" version = "8.11.0" @@ -2426,7 +3301,7 @@ version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ - "sha2", + "sha2 0.10.9", "walkdir", ] @@ -2436,6 +3311,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[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" @@ -2508,6 +3392,16 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", +] + [[package]] name = "same-file" version = "1.0.6" @@ -2523,6 +3417,32 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2 0.13.0", + "salsa20", + "sha2 0.11.0", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.27" @@ -2597,6 +3517,16 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2604,8 +3534,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -2615,8 +3556,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", +] + +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", ] [[package]] @@ -2641,6 +3614,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -2684,6 +3667,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "sqlite-wasm-rs" version = "0.5.2" @@ -2696,6 +3695,65 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ssh-cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "aes-gcm 0.11.1", + "chacha20", + "cipher 0.5.2", + "ctutils", + "des", + "poly1305", + "ssh-encoding", + "zeroize", +] + +[[package]] +name = "ssh-encoding" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e" +dependencies = [ + "base64ct", + "bytes", + "crypto-bigint", + "ctutils", + "digest 0.11.3", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "ssh-key" +version = "0.7.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3" +dependencies = [ + "argon2", + "bcrypt-pbkdf", + "ctutils", + "ed25519-dalek", + "hex", + "hmac 0.13.0", + "p256", + "p384", + "p521", + "rand_core 0.10.1", + "rsa", + "sec1", + "sha1 0.11.0", + "sha2 0.11.0", + "signature", + "ssh-cipher", + "ssh-encoding", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2704,35 +3762,37 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "steel-cli" -version = "0.4.4" +version = "0.5.0-preview.1" dependencies = [ - "aes", - "aes-gcm", + "aes 0.8.4", + "aes-gcm 0.10.3", "agent-browser", "anyhow", "base64", - "cbc", + "cbc 0.1.2", "clap", "clap_complete", "dialoguer", "dirs 6.0.0", "futures-util", "getrandom 0.4.2", - "hmac", + "hmac 0.12.1", "jiff", "libc", "open", "parking_lot", - "pbkdf2", + "pbkdf2 0.12.2", "proptest", "proptest-derive", "reqwest", "rusqlite", + "russh", + "rustix", "serde", "serde_json", "serde_yaml", - "sha1", - "sha2", + "sha1 0.10.6", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tokio", @@ -3077,7 +4137,7 @@ dependencies = [ "rand 0.8.5", "rustls", "rustls-pki-types", - "sha1", + "sha1 0.10.6", "thiserror 1.0.69", "utf-8", ] @@ -3096,7 +4156,7 @@ dependencies = [ "rand 0.9.2", "rustls", "rustls-pki-types", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", ] @@ -3108,9 +4168,9 @@ checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unarray" @@ -3148,10 +4208,20 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -3445,6 +4515,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3458,6 +4549,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3486,6 +4588,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -3588,6 +4700,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3837,6 +4958,18 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.2" @@ -3915,9 +5048,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 5e29e27..79b3018 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "steel-cli" -version = "0.4.4" +version = "0.5.0-preview.1" edition = "2024" description = "Steel CLI - Browser automation for AI agents" license = "MIT" @@ -44,6 +44,7 @@ aes-gcm = "0.10" getrandom = "0.4" libc = "0.2" +rustix = { version = "1", features = ["termios"] } tempfile = "3" jiff = "0.2" parking_lot = "0.12" @@ -51,6 +52,7 @@ parking_lot = "0.12" serde_yaml = "0.9" tokio-tungstenite = { version = "0.29.0", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3.32" +russh = { version = "0.63", default-features = false, features = ["ring", "flate2", "rsa"] } [dev-dependencies] wiremock = "0.6" diff --git a/README.md b/README.md index ac203a0..c0c7c28 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ Supported shells: `bash`, `zsh`, `fish`, `powershell`, `elvish`. | API tools | `scrape`, `screenshot`, `pdf` | | Local runtime | `dev install`, `dev start`, `dev stop` | | Credentials | `credentials list`, `credentials create`, `credentials update`, `credentials delete` | +| Cloud computers | `computer create`, `computer exec`, `computer ssh`, `computer list`, `computer use` | | Account and utility | `login`, `logout`, `config`, `doctor`, `cache`, `update`, `completion` | Full flags and schemas: [CLI reference](docs/cli-reference.md). diff --git a/docs/references/steel-cli.md b/docs/references/steel-cli.md index d45673f..e834517 100644 --- a/docs/references/steel-cli.md +++ b/docs/references/steel-cli.md @@ -26,6 +26,15 @@ For generated flags and argument schemas, use [../cli-reference.md](../cli-refer - `steel profile list`: list all saved Steel browser profiles. - `steel profile delete`: delete a saved Steel profile (local metadata only). +### Computer Commands + +- `steel computer create`: create a cloud computer from a template. +- `steel computer list`, `steel computer get`, `steel computer delete`: inspect and remove computers. +- `steel computer pause`, `steel computer resume`: pause and wake a computer. +- `steel computer use`: remember a default computer so other commands need no id. +- `steel computer exec -- `: run one command; output streams and the exit code is returned. +- `steel computer ssh`: open an SSH shell, or run a command over SSH with `-- `. + ### Credentials Commands - `steel credentials create`: store a new credential for a given origin. diff --git a/scripts/generate-api.mjs b/scripts/generate-api.mjs index b71340f..964b4cf 100644 --- a/scripts/generate-api.mjs +++ b/scripts/generate-api.mjs @@ -75,7 +75,7 @@ function collectOperations(spec) { requestPath: stripV1(path), summary: operation.summary || operation.operationId, command: cli.command.join(" "), - example: exampleFor(cli.command, operation), + example: exampleFor(cli.command, operation, path), streaming: cli.follow ? { transport: cli.follow.transport || "websocket", @@ -91,17 +91,25 @@ function collectOperations(spec) { return operations; } -function exampleFor(command, operation) { +function exampleFor(command, operation, path) { + const cli = operation["x-steel-cli"] || {}; + if (cli.example) return cli.example; const base = `steel ${command.join(" ")}`; + const id = idPlaceholder(path); if (command.includes("list")) return `${base} --status live --limit 20`; - if (command.includes("agent-logs")) return `${base} --limit 100`; - if (operation["x-steel-cli"]?.follow) return `${base} --follow`; + if (command.includes("agent-logs")) return `${base} ${id} --limit 100`; + if (cli.follow) return `${base} ${id} --follow`; if (operation.parameters?.some((parameter) => parameter.in === "path" && parameter.name === "id")) { - return `${base} `; + return `${base} ${id}`; } return base; } +function idPlaceholder(path) { + const resource = stripV1(path).split("/").filter(Boolean)[0] || "sessions"; + return `<${resource.replace(/s$/, "")}-id>`; +} + function rustQueryType(parameter) { const schema = parameter.schema || {}; if (schema.type === "array") return "Vec"; @@ -264,6 +272,7 @@ mod tests { cursor_id: Some("abc".into()), limit: Some(25), status: Some("live".into()), + ..Default::default() }); assert_eq!(path, "/sessions?cursorId=abc&limit=25&status=live"); } diff --git a/src/api/client.rs b/src/api/client.rs index c2a1f94..6782340 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -27,6 +27,13 @@ pub enum ApiError { body: Option, }, + #[error("Steel API request failed ({status}): {message}")] + RetryLater { + status: u16, + message: Cow<'static, str>, + seconds: u64, + }, + #[error(transparent)] Other(#[from] reqwest::Error), } @@ -58,6 +65,12 @@ fn extract_error_message(body: &Value, status_text: &str) -> Cow<'static, str> { return Cow::Owned(msg.to_string()); } + if let Some(msg) = body.get("error").and_then(|v| v.as_str()) + && !msg.trim().is_empty() + { + return Cow::Owned(msg.to_string()); + } + if !status_text.is_empty() { return Cow::Owned(status_text.to_string()); } @@ -133,6 +146,67 @@ impl SteelClient { Ok(response_data) } + + pub async fn request_raw( + &self, + base_url: &str, + mode: ApiMode, + method: reqwest::Method, + path: &str, + body: Option, + auth: &Auth, + ) -> Result { + if mode == ApiMode::Cloud && auth.api_key.is_none() { + return Err(ApiError::MissingAuth); + } + + let url = format!("{base_url}{path}"); + let mut req = self.http.request(method, &url); + req = req.header("Content-Type", "application/json"); + if let Some(key) = &auth.api_key { + req = req.header("Steel-Api-Key", key); + } + if let Some(body) = body { + req = req.json(&body); + } + + let resp = req.send().await.map_err(|e| ApiError::Unreachable { + url: url.clone(), + source: e, + })?; + + let status = resp.status(); + if status.is_success() { + return Ok(resp); + } + + let status_code = status.as_u16(); + let status_text = status.canonical_reason().unwrap_or("").to_string(); + let retry_after = resp + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse::().ok()); + let response_text = resp.text().await.map_err(ApiError::Other)?; + let response_data: Value = if response_text.trim().is_empty() { + Value::Null + } else { + serde_json::from_str(&response_text).unwrap_or(Value::String(response_text)) + }; + let message = extract_error_message(&response_data, &status_text); + if let Some(seconds) = retry_after { + return Err(ApiError::RetryLater { + status: status_code, + message, + seconds, + }); + } + Err(ApiError::RequestFailed { + status: status_code, + message, + body: Some(response_data), + }) + } } #[cfg(test)] diff --git a/src/api/computers.rs b/src/api/computers.rs new file mode 100644 index 0000000..e365a86 --- /dev/null +++ b/src/api/computers.rs @@ -0,0 +1,218 @@ +use serde_json::{Value, json}; + +use crate::api::client::{ApiError, SteelClient}; +use crate::config::auth::Auth; +use crate::config::settings::ApiMode; + +pub fn computer_path(id: &str) -> String { + format!("/computers/{}", urlencoding::encode(id)) +} + +#[derive(Debug, Default, Clone)] +pub struct CreateComputer { + pub template: String, + pub region: Option, + pub vcpu: Option, + pub memory_mib: Option, + pub disk_mib: Option, + pub timeout_seconds: Option, + pub auto_pause: Option, +} + +impl CreateComputer { + pub fn body(&self) -> Value { + let mut body = json!({ "template": self.template }); + if let Some(region) = &self.region { + body["region"] = json!(region); + } + if let Some(vcpu) = self.vcpu { + body["vcpu"] = json!(vcpu); + } + if let Some(memory) = self.memory_mib { + body["memoryMib"] = json!(memory); + } + if let Some(disk) = self.disk_mib { + body["diskMib"] = json!(disk); + } + if let Some(timeout) = self.timeout_seconds { + body["timeoutSeconds"] = json!(timeout); + } + if let Some(auto_pause) = self.auto_pause { + body["autoPause"] = json!(auto_pause); + } + body + } +} + +impl SteelClient { + pub async fn list_computers( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::GET, + "/computers", + None, + auth, + ) + .await + } + + pub async fn get_computer( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + id: &str, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::GET, + &computer_path(id), + None, + auth, + ) + .await + } + + pub async fn create_computer( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + request: &CreateComputer, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::POST, + "/computers", + Some(request.body()), + auth, + ) + .await + } + + pub async fn delete_computer( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + id: &str, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::DELETE, + &computer_path(id), + None, + auth, + ) + .await + } + + pub async fn pause_computer( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + id: &str, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::POST, + &format!("{}/pause", computer_path(id)), + None, + auth, + ) + .await + } + + pub async fn resume_computer( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + id: &str, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::POST, + &format!("{}/resume", computer_path(id)), + None, + auth, + ) + .await + } + + pub async fn exec_computer( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + id: &str, + body: Value, + ) -> Result { + self.request_raw( + base_url, + mode, + reqwest::Method::POST, + &format!("{}/exec", computer_path(id)), + Some(body), + auth, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_body_only_carries_given_fields() { + let minimal = CreateComputer { + template: "steel".into(), + ..Default::default() + }; + assert_eq!(minimal.body(), json!({ "template": "steel" })); + + let full = CreateComputer { + template: "steel".into(), + region: Some("us-east".into()), + vcpu: Some(4), + memory_mib: Some(4096), + disk_mib: Some(10240), + timeout_seconds: Some(600), + auto_pause: Some(true), + }; + assert_eq!( + full.body(), + json!({ + "template": "steel", + "region": "us-east", + "vcpu": 4, + "memoryMib": 4096, + "diskMib": 10240, + "timeoutSeconds": 600, + "autoPause": true, + }) + ); + } + + #[test] + fn computer_paths_escape_ids() { + assert_eq!( + computer_path("cmp_0000123456789abcdefghjkmnpqrs"), + "/computers/cmp_0000123456789abcdefghjkmnpqrs" + ); + assert_eq!(computer_path("a/b"), "/computers/a%2Fb"); + } +} diff --git a/src/api/generated.rs b/src/api/generated.rs index 0a75f60..68b8a3c 100644 --- a/src/api/generated.rs +++ b/src/api/generated.rs @@ -31,6 +31,29 @@ pub struct StreamingMetadata { } pub const CLI_OPERATION_METADATA: &[OperationMetadata] = &[ + OperationMetadata { + id: "exec_computer", + command: "computer exec", + status: "implemented", + method: "POST", + path: "/v1/computers/{id}/exec", + summary: "Run one command in a computer", + example: "steel computer exec -- ", + streaming: None, + }, + OperationMetadata { + id: "attach_computer_ssh", + command: "computer ssh", + status: "implemented", + method: "GET", + path: "/v1/computers/{id}/ssh", + summary: "Open an SSH connection to a computer", + example: "steel computer ssh ", + streaming: Some(StreamingMetadata { + transport: "websocket", + path: "/v1/computers/{id}/ssh", + }), + }, OperationMetadata { id: "get_session_agent_logs", command: "sessions agent-logs", @@ -126,6 +149,7 @@ pub struct GetSessionsQuery { pub cursor_id: Option, pub limit: Option, pub status: Option, + pub project_id: Option, } #[derive(Debug, Default, Clone, Serialize, Deserialize)] @@ -139,6 +163,12 @@ pub struct GetSessionLogsQuery { pub offset: Option, } +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReleaseAllSessionsQuery { + pub project_id: Option, +} + #[derive(Debug, Default, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetSessionAgentTracesQuery { @@ -225,6 +255,13 @@ fn build_get_sessions_path(query: &GetSessionsQuery) -> String { { push_query(&mut path, "status", value.trim()); } + if let Some(value) = query + .project_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + push_query(&mut path, "projectId", value.trim()); + } path } @@ -271,6 +308,18 @@ fn build_release_session_path(session_id: &str) -> String { format!("/sessions/{session_id}/release") } +fn build_release_all_sessions_path(query: &ReleaseAllSessionsQuery) -> String { + let mut path = "/sessions/release".to_string(); + if let Some(value) = query + .project_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + push_query(&mut path, "projectId", value.trim()); + } + path +} + fn build_get_session_agent_traces_path( session_id: &str, query: &GetSessionAgentTracesQuery, @@ -308,6 +357,40 @@ fn build_get_session_agent_traces_path( } impl SteelClient { + pub async fn cli_exec_computer( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::POST, + "/computers/{id}/exec", + None, + auth, + ) + .await + } + + pub async fn cli_attach_computer_ssh( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::GET, + "/computers/{id}/ssh", + None, + auth, + ) + .await + } + pub async fn cli_get_session_agent_logs( &self, base_url: &str, @@ -405,12 +488,13 @@ impl SteelClient { base_url: &str, mode: ApiMode, auth: &Auth, + query: &ReleaseAllSessionsQuery, ) -> Result { self.request( base_url, mode, reqwest::Method::POST, - "/sessions/release", + &build_release_all_sessions_path(query), None, auth, ) @@ -447,6 +531,7 @@ mod tests { cursor_id: Some("abc".into()), limit: Some(25), status: Some("live".into()), + ..Default::default() }); assert_eq!(path, "/sessions?cursorId=abc&limit=25&status=live"); } diff --git a/src/api/mod.rs b/src/api/mod.rs index 00ceb84..4126f12 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,4 +1,5 @@ pub mod client; +pub mod computers; pub mod generated; pub mod projects; pub mod session; diff --git a/src/browser/lifecycle.rs b/src/browser/lifecycle.rs index 4033cb3..1bedf97 100644 --- a/src/browser/lifecycle.rs +++ b/src/browser/lifecycle.rs @@ -582,7 +582,10 @@ mod tests { fn session_timeout_preserves_nonzero() { // Sanity: the filter only affects the zero case. assert_eq!(get_session_timeout(&json!({"timeout": 1})), Some(1)); - assert_eq!(get_session_timeout(&json!({"timeout": u64::MAX})), Some(u64::MAX)); + assert_eq!( + get_session_timeout(&json!({"timeout": u64::MAX})), + Some(u64::MAX) + ); } #[test] diff --git a/src/commands/computer/exec.rs b/src/commands/computer/exec.rs new file mode 100644 index 0000000..19cbbe0 --- /dev/null +++ b/src/commands/computer/exec.rs @@ -0,0 +1,329 @@ +use std::collections::BTreeMap; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use serde::Deserialize; +use serde_json::{Value, json}; +use tokio::io::AsyncWriteExt; + +use super::resolve_computer_id; +use crate::api::client::{ApiError, SteelClient}; +use crate::config::auth::Auth; +use crate::config::settings::ApiMode; +use crate::status; +use crate::util::api; +use crate::util::output::{self, SilentExit}; + +pub const TIMEOUT_EXIT_CODE: i32 = 124; +const MAX_WAKE_ATTEMPTS: u32 = 8; +const MAX_WAKE_DELAY: Duration = Duration::from_secs(10); + +#[derive(Parser)] +pub struct Args { + /// Computer ID (defaults to STEEL_COMPUTER_ID or `steel computer use`) + pub computer_id: Option, + + /// Shell command string, run by /bin/sh -c + #[arg(short = 'c', long = "command", conflicts_with = "argv")] + pub command: Option, + + /// Working directory inside the computer + #[arg(long)] + pub cwd: Option, + + /// Environment variable for the command, repeatable + #[arg(long = "env", value_name = "KEY=VALUE")] + pub env: Vec, + + /// Kill the command after this many seconds (at most 3600) + #[arg(long = "timeout", value_name = "SECONDS")] + pub timeout_seconds: Option, + + /// Program and arguments, given after `--` + #[arg(last = true, value_name = "ARGV")] + pub argv: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "event", rename_all = "lowercase")] +pub enum Event { + Start, + Output { + data: String, + }, + Keepalive, + Exit { + #[serde(rename = "exitCode")] + exit_code: i32, + #[serde(rename = "timedOut")] + timed_out: bool, + }, +} + +#[derive(Debug, Deserialize)] +pub struct ExecResult { + pub output: String, + #[serde(rename = "exitCode")] + pub exit_code: i32, + #[serde(rename = "timedOut")] + pub timed_out: bool, + #[serde(default)] + pub truncated: bool, +} + +pub fn build_body(args: &Args, stream: bool) -> Result { + let mut body = match (&args.command, args.argv.is_empty()) { + (Some(command), true) => json!({ "command": command }), + (None, false) => json!({ "argv": args.argv }), + (None, true) => bail!("Give a command after `--` or with -c."), + (Some(_), false) => bail!("Give either a command after `--` or -c, not both."), + }; + if let Some(cwd) = &args.cwd { + body["cwd"] = json!(cwd); + } + if !args.env.is_empty() { + body["env"] = json!(parse_env(&args.env)?); + } + if let Some(timeout) = args.timeout_seconds { + if timeout == 0 || timeout > 3600 { + bail!("--timeout must be between 1 and 3600 seconds."); + } + body["timeoutSeconds"] = json!(timeout); + } + body["stream"] = json!(stream); + Ok(body) +} + +pub fn parse_env(entries: &[String]) -> Result> { + let mut env = BTreeMap::new(); + for entry in entries { + let Some((key, value)) = entry.split_once('=') else { + bail!("--env expects KEY=VALUE, got {entry:?}."); + }; + if key.is_empty() { + bail!("--env expects KEY=VALUE, got {entry:?}."); + } + env.insert(key.to_string(), value.to_string()); + } + Ok(env) +} + +pub fn parse_event(line: &[u8]) -> Result> { + let text = std::str::from_utf8(line).context("the computer sent a non-UTF-8 event")?; + if text.trim().is_empty() { + return Ok(None); + } + let event = serde_json::from_str(text).context("the computer sent a malformed event")?; + Ok(Some(event)) +} + +pub const fn exit_code(code: i32, timed_out: bool) -> i32 { + if timed_out { + TIMEOUT_EXIT_CODE + } else if code < 0 { + 1 + } else { + code + } +} + +pub async fn run(args: Args) -> Result<()> { + let id = resolve_computer_id(args.computer_id.as_deref())?; + let stream = !output::is_json(); + let body = build_body(&args, stream)?; + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let response = send(&client, &base_url, mode, &auth, &id, body).await?; + let code = if stream { + stream_output(response).await? + } else { + collect_output(response).await? + }; + if code != 0 { + return Err(SilentExit(code).into()); + } + Ok(()) +} + +async fn send( + client: &SteelClient, + base_url: &str, + mode: ApiMode, + auth: &Auth, + id: &str, + body: Value, +) -> Result { + let mut attempt = 0; + loop { + match client + .exec_computer(base_url, mode, auth, id, body.clone()) + .await + { + Ok(response) => return Ok(response), + Err(ApiError::RetryLater { seconds, .. }) if attempt < MAX_WAKE_ATTEMPTS => { + attempt += 1; + let delay = Duration::from_secs(seconds.max(1)).min(MAX_WAKE_DELAY); + status!( + "Computer {id} is not ready yet, retrying in {}s.", + delay.as_secs() + ); + tokio::time::sleep(delay).await; + } + Err(err) => return Err(err.into()), + } + } +} + +async fn stream_output(mut response: reqwest::Response) -> Result { + let mut stdout = tokio::io::stdout(); + let mut buffer: Vec = Vec::new(); + let mut result: Option = None; + while let Some(chunk) = response + .chunk() + .await + .context("the connection to the computer dropped")? + { + buffer.extend_from_slice(&chunk); + while let Some(end) = buffer.iter().position(|byte| *byte == b'\n') { + let line: Vec = buffer.drain(..=end).collect(); + if let Some(event) = parse_event(&line[..line.len() - 1])? { + handle_event(event, &mut stdout, &mut result).await?; + } + } + } + if let Some(event) = parse_event(&buffer)? { + handle_event(event, &mut stdout, &mut result).await?; + } + match result { + Some(code) => Ok(code), + None => bail!("The connection closed before the command finished."), + } +} + +async fn handle_event( + event: Event, + stdout: &mut tokio::io::Stdout, + result: &mut Option, +) -> Result<()> { + match event { + Event::Output { data } => { + stdout.write_all(data.as_bytes()).await?; + stdout.flush().await?; + } + Event::Exit { + exit_code: code, + timed_out, + } => *result = Some(exit_code(code, timed_out)), + Event::Start | Event::Keepalive => {} + } + Ok(()) +} + +async fn collect_output(response: reqwest::Response) -> Result { + let text = response + .text() + .await + .context("the connection to the computer dropped")?; + let value: Value = serde_json::from_str(text.trim_start()) + .context("the computer returned a malformed result")?; + let result: ExecResult = serde_json::from_value(value.clone()) + .context("the computer returned a malformed result")?; + output::success_data(value); + Ok(exit_code(result.exit_code, result.timed_out)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(command: Option<&str>, argv: &[&str]) -> Args { + Args { + computer_id: None, + command: command.map(str::to_string), + cwd: None, + env: vec![], + timeout_seconds: None, + argv: argv.iter().map(|s| s.to_string()).collect(), + } + } + + #[test] + fn body_takes_exactly_one_command_form() { + assert_eq!( + build_body(&args(Some("echo hi"), &[]), true).unwrap(), + json!({ "command": "echo hi", "stream": true }) + ); + assert_eq!( + build_body(&args(None, &["ls", "-la"]), false).unwrap(), + json!({ "argv": ["ls", "-la"], "stream": false }) + ); + assert!(build_body(&args(None, &[]), true).is_err()); + assert!(build_body(&args(Some("id"), &["id"]), true).is_err()); + } + + #[test] + fn body_carries_cwd_env_and_timeout() { + let mut a = args(Some("id"), &[]); + a.cwd = Some("/tmp".into()); + a.env = vec!["A=1".into(), "B=x=y".into()]; + a.timeout_seconds = Some(30); + assert_eq!( + build_body(&a, true).unwrap(), + json!({ + "command": "id", + "cwd": "/tmp", + "env": { "A": "1", "B": "x=y" }, + "timeoutSeconds": 30, + "stream": true + }) + ); + a.timeout_seconds = Some(0); + assert!(build_body(&a, true).is_err()); + a.timeout_seconds = Some(3601); + assert!(build_body(&a, true).is_err()); + } + + #[test] + fn env_entries_need_a_key() { + assert!(parse_env(&["=1".into()]).is_err()); + assert!(parse_env(&["novalue".into()]).is_err()); + assert_eq!(parse_env(&["K=".into()]).unwrap()["K"], ""); + } + + #[test] + fn events_parse_and_map_to_exit_codes() { + assert!(matches!( + parse_event(br#"{"event":"start"}"#).unwrap(), + Some(Event::Start) + )); + assert!(matches!( + parse_event(br#"{"event":"output","data":"hi\n"}"#).unwrap(), + Some(Event::Output { data }) if data == "hi\n" + )); + assert!(matches!( + parse_event(br#"{"event":"exit","exitCode":3,"timedOut":false}"#).unwrap(), + Some(Event::Exit { + exit_code: 3, + timed_out: false + }) + )); + assert!(parse_event(b" ").unwrap().is_none()); + assert!(parse_event(b"not json").is_err()); + + assert_eq!(exit_code(0, false), 0); + assert_eq!(exit_code(3, false), 3); + assert_eq!(exit_code(-1, false), 1); + assert_eq!(exit_code(0, true), TIMEOUT_EXIT_CODE); + } + + #[test] + fn collected_results_deserialize() { + let result: ExecResult = serde_json::from_str( + r#"{"output":"hi\n","exitCode":0,"timedOut":false,"truncated":false}"#, + ) + .unwrap(); + assert_eq!(result.output, "hi\n"); + assert!(!result.truncated); + } +} diff --git a/src/commands/computer/mod.rs b/src/commands/computer/mod.rs new file mode 100644 index 0000000..3641892 --- /dev/null +++ b/src/commands/computer/mod.rs @@ -0,0 +1,420 @@ +pub mod exec; +pub mod ssh; +pub mod wsio; + +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; +use clap::{Parser, Subcommand}; +use serde_json::Value; + +use crate::api::client::SteelClient; +use crate::api::computers::CreateComputer; +use crate::config::settings::{self, ComputerConfig}; +use crate::status; +use crate::util::{api, output}; + +const WAIT_POLL_INTERVAL: Duration = Duration::from_secs(1); +const WAIT_TIMEOUT: Duration = Duration::from_secs(180); + +#[derive(Subcommand)] +pub enum Command { + /// Create a computer + Create(CreateArgs), + + /// List computers + List, + + /// Get one computer + Get(IdArgs), + + /// Delete a computer + Delete(IdArgs), + + /// Pause a running computer + Pause(IdArgs), + + /// Resume a paused computer + Resume(ResumeArgs), + + /// Remember a computer as the default for other commands + Use(UseArgs), + + /// Run one command in a computer + Exec(exec::Args), + + /// Open an SSH session to a computer + Ssh(ssh::Args), +} + +impl Command { + pub const fn telemetry_name(&self) -> &'static str { + match self { + Self::Create(_) => "create", + Self::List => "list", + Self::Get(_) => "get", + Self::Delete(_) => "delete", + Self::Pause(_) => "pause", + Self::Resume(_) => "resume", + Self::Use(_) => "use", + Self::Exec(_) => "exec", + Self::Ssh(_) => "ssh", + } + } +} + +#[derive(Parser)] +pub struct CreateArgs { + /// Template name + #[arg(long)] + pub template: String, + + /// Region, for example us-east + #[arg(long)] + pub region: Option, + + /// Number of vCPUs + #[arg(long)] + pub vcpu: Option, + + /// Memory in MiB + #[arg(long = "memory", value_name = "MIB")] + pub memory_mib: Option, + + /// Disk in MiB + #[arg(long = "disk", value_name = "MIB")] + pub disk_mib: Option, + + /// Stop the computer after this many seconds of running time + #[arg(long = "timeout", value_name = "SECONDS")] + pub timeout_seconds: Option, + + /// Pause instead of stopping when the timeout is reached + #[arg(long = "auto-pause")] + pub auto_pause: bool, + + /// Wait until the computer is running + #[arg(long)] + pub wait: bool, + + /// Make the new computer the default for other commands + #[arg(long = "use")] + pub use_as_default: bool, +} + +#[derive(Parser)] +pub struct IdArgs { + /// Computer ID (defaults to STEEL_COMPUTER_ID or `steel computer use`) + pub computer_id: Option, +} + +#[derive(Parser)] +pub struct ResumeArgs { + /// Computer ID (defaults to STEEL_COMPUTER_ID or `steel computer use`) + pub computer_id: Option, + + /// Wait until the computer is running + #[arg(long)] + pub wait: bool, +} + +#[derive(Parser)] +pub struct UseArgs { + /// Computer ID to remember + pub computer_id: Option, + + /// Forget the remembered computer + #[arg(long, conflicts_with = "computer_id")] + pub clear: bool, +} + +pub async fn run(command: Command) -> Result<()> { + match command { + Command::Create(args) => run_create(args).await, + Command::List => run_list().await, + Command::Get(args) => run_get(args).await, + Command::Delete(args) => run_delete(args).await, + Command::Pause(args) => run_pause(args).await, + Command::Resume(args) => run_resume(args).await, + Command::Use(args) => run_use(args), + Command::Exec(args) => exec::run(args).await, + Command::Ssh(args) => ssh::run(args).await, + } +} + +pub fn resolve_computer_id(explicit: Option<&str>) -> Result { + let env = std::env::var("STEEL_COMPUTER_ID").ok(); + let stored = settings::read_config() + .ok() + .and_then(|config| config.default_computer_id().map(str::to_string)); + choose_computer_id(explicit, env.as_deref(), stored.as_deref()) +} + +pub fn choose_computer_id( + explicit: Option<&str>, + env: Option<&str>, + stored: Option<&str>, +) -> Result { + for candidate in [explicit, env, stored] { + if let Some(id) = candidate.map(str::trim).filter(|id| !id.is_empty()) { + return Ok(id.to_string()); + } + } + bail!("No computer given. Pass an id, set STEEL_COMPUTER_ID, or run `steel computer use `.") +} + +fn remember_computer(id: Option<&str>) -> Result<()> { + let mut config = settings::read_config().unwrap_or_default(); + config.computer = id.map(|id| ComputerConfig { + default_id: Some(id.to_string()), + }); + settings::write_config(&config).context("Failed to save the default computer") +} + +fn status_of(computer: &Value) -> &str { + computer["status"].as_str().unwrap_or("unknown") +} + +fn id_of(computer: &Value) -> Result { + computer["id"] + .as_str() + .map(str::to_string) + .context("the API returned a computer without an id") +} + +async fn run_create(args: CreateArgs) -> Result<()> { + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let request = CreateComputer { + template: args.template, + region: args.region, + vcpu: args.vcpu, + memory_mib: args.memory_mib, + disk_mib: args.disk_mib, + timeout_seconds: args.timeout_seconds, + auto_pause: args.auto_pause.then_some(true), + }; + let created = client + .create_computer(&base_url, mode, &auth, &request) + .await?; + let id = id_of(&created)?; + let computer = if args.wait { + status!("Created {id}, waiting until it is running."); + wait_for(&client, &base_url, mode, &auth, &id, "running").await? + } else { + created + }; + if args.use_as_default { + remember_computer(Some(&id))?; + } + if output::is_json() { + output::success_data(computer); + } else { + println!("{id} is {}.", status_of(&computer)); + if args.use_as_default { + println!("It is now the default computer."); + } + } + Ok(()) +} + +async fn run_list() -> Result<()> { + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let data = client.list_computers(&base_url, mode, &auth).await?; + if output::is_json() { + output::success_data(data); + } else { + print_computers(&data); + } + Ok(()) +} + +async fn run_get(args: IdArgs) -> Result<()> { + let id = resolve_computer_id(args.computer_id.as_deref())?; + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let data = client.get_computer(&base_url, mode, &auth, &id).await?; + output::success_data(data); + Ok(()) +} + +async fn run_delete(args: IdArgs) -> Result<()> { + let id = resolve_computer_id(args.computer_id.as_deref())?; + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let data = client.delete_computer(&base_url, mode, &auth, &id).await?; + if settings::read_config() + .ok() + .and_then(|config| config.default_computer_id().map(str::to_string)) + .is_some_and(|default| default == id) + { + remember_computer(None)?; + } + if output::is_json() { + output::success_data(data); + } else { + println!("Deleted {id}."); + } + Ok(()) +} + +async fn run_pause(args: IdArgs) -> Result<()> { + let id = resolve_computer_id(args.computer_id.as_deref())?; + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let data = client.pause_computer(&base_url, mode, &auth, &id).await?; + if output::is_json() { + output::success_data(data); + } else { + println!("{id} is {}.", status_of(&data)); + } + Ok(()) +} + +async fn run_resume(args: ResumeArgs) -> Result<()> { + let id = resolve_computer_id(args.computer_id.as_deref())?; + let (mode, base_url, auth) = api::resolve_with_auth(); + let client = SteelClient::new()?; + let resumed = client.resume_computer(&base_url, mode, &auth, &id).await?; + let data = if args.wait { + wait_for(&client, &base_url, mode, &auth, &id, "running").await? + } else { + resumed + }; + if output::is_json() { + output::success_data(data); + } else { + println!("{id} is {}.", status_of(&data)); + } + Ok(()) +} + +fn run_use(args: UseArgs) -> Result<()> { + if args.clear { + remember_computer(None)?; + if output::is_json() { + output::success_silent(); + } else { + println!("Forgot the default computer."); + } + return Ok(()); + } + let Some(id) = args + .computer_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + else { + bail!("Give a computer id, or --clear to forget the current one."); + }; + remember_computer(Some(id))?; + if output::is_json() { + output::success_data(serde_json::json!({ "computerId": id })); + } else { + println!("{id} is now the default computer."); + } + Ok(()) +} + +async fn wait_for( + client: &SteelClient, + base_url: &str, + mode: crate::config::settings::ApiMode, + auth: &crate::config::auth::Auth, + id: &str, + target: &str, +) -> Result { + let started = Instant::now(); + loop { + let computer = client.get_computer(base_url, mode, auth, id).await?; + let status = status_of(&computer); + if status == target { + return Ok(computer); + } + if matches!(status, "failed" | "deleted" | "deleting" | "stopped") { + bail!("{id} is {status}, it will not become {target}."); + } + if started.elapsed() > WAIT_TIMEOUT { + bail!("{id} is still {status} after {}s.", WAIT_TIMEOUT.as_secs()); + } + tokio::time::sleep(WAIT_POLL_INTERVAL).await; + } +} + +fn print_computers(data: &Value) { + let computers = data["computers"].as_array().cloned().unwrap_or_default(); + if computers.is_empty() { + println!("No computers."); + return; + } + let rows: Vec<[String; 6]> = computers + .iter() + .map(|computer| { + [ + computer["id"].as_str().unwrap_or("").to_string(), + status_of(computer).to_string(), + computer["region"].as_str().unwrap_or("-").to_string(), + computer["template"].as_str().unwrap_or("-").to_string(), + format!( + "{}/{}", + computer["vcpu"].as_u64().unwrap_or(0), + computer["memoryMib"].as_u64().unwrap_or(0) + ), + computer["statusChangedAt"] + .as_str() + .unwrap_or("") + .to_string(), + ] + }) + .collect(); + let header = ["ID", "STATUS", "REGION", "TEMPLATE", "VCPU/MIB", "SINCE"]; + let widths: Vec = (0..header.len()) + .map(|column| { + rows.iter() + .map(|row| row[column].len()) + .chain(std::iter::once(header[column].len())) + .max() + .unwrap_or(0) + }) + .collect(); + let line = |cells: [&str; 6]| { + cells + .iter() + .enumerate() + .map(|(column, cell)| format!("{cell:>() + .join(" ") + .trim_end() + .to_string() + }; + println!("{}", line(header)); + for row in &rows { + println!( + "{}", + line([&row[0], &row[1], &row[2], &row[3], &row[4], &row[5]]) + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn explicit_id_wins_and_blank_ids_are_ignored() { + assert_eq!( + choose_computer_id(Some(" cmp_a "), Some("cmp_env"), Some("cmp_cfg")).unwrap(), + "cmp_a" + ); + assert_eq!( + choose_computer_id(Some(" "), Some("cmp_env"), Some("cmp_cfg")).unwrap(), + "cmp_env" + ); + assert_eq!( + choose_computer_id(None, Some(""), Some("cmp_cfg")).unwrap(), + "cmp_cfg" + ); + assert!(choose_computer_id(None, None, None).is_err()); + } +} diff --git a/src/commands/computer/ssh.rs b/src/commands/computer/ssh.rs new file mode 100644 index 0000000..0230f91 --- /dev/null +++ b/src/commands/computer/ssh.rs @@ -0,0 +1,308 @@ +use std::io::IsTerminal; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use russh::client::{self, AuthResult, Handler}; +use russh::keys::PublicKeyOrCertificate; +use russh::{Channel, ChannelMsg, Disconnect}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio_tungstenite::MaybeTlsStream; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; + +use super::resolve_computer_id; +use super::wsio::WsIo; +use crate::api::client::ApiError; +use crate::config::auth::Auth; +use crate::config::settings::ApiMode; +use crate::util::api; +use crate::util::output::SilentExit; + +pub const SUBPROTOCOL: &str = "steel-ssh-v1"; +const REMOTE_USER: &str = "root"; + +#[derive(Parser)] +pub struct Args { + /// Computer ID (defaults to STEEL_COMPUTER_ID or `steel computer use`) + pub computer_id: Option, + + /// Run this command instead of opening a shell, given after `--` + #[arg(last = true, value_name = "COMMAND")] + pub command: Vec, +} + +struct Client; + +impl Handler for Client { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + _key: &PublicKeyOrCertificate, + ) -> Result { + Ok(true) + } +} + +pub async fn run(args: Args) -> Result<()> { + let id = resolve_computer_id(args.computer_id.as_deref())?; + let (mode, base_url, auth) = api::resolve_with_auth(); + if mode == ApiMode::Cloud && auth.api_key.is_none() { + return Err(ApiError::MissingAuth.into()); + } + let url = ssh_url(&base_url, &id)?; + let transport = connect(&url, &auth).await?; + + let config = Arc::new(client::Config { + keepalive_interval: Some(Duration::from_secs(15)), + ..Default::default() + }); + let mut handle = client::connect_stream(config, transport, Client) + .await + .context("The SSH handshake with the computer failed")?; + let mut auth_result = handle.authenticate_password(REMOTE_USER, "").await?; + if !matches!(auth_result, AuthResult::Success) { + auth_result = handle.authenticate_none(REMOTE_USER).await?; + } + if !matches!(auth_result, AuthResult::Success) { + bail!("The computer refused the SSH login."); + } + + let mut channel = handle.channel_open_session().await?; + let code = if args.command.is_empty() { + shell(&mut channel).await? + } else { + exec(&mut channel, &args.command).await? + }; + let _ = handle.disconnect(Disconnect::ByApplication, "", "en").await; + if code != 0 { + return Err(SilentExit(code).into()); + } + Ok(()) +} + +pub fn ssh_url(base_url: &str, id: &str) -> Result { + let mut url = + url::Url::parse(base_url).with_context(|| format!("Invalid API URL: {base_url}"))?; + let scheme = match url.scheme() { + "https" | "wss" => "wss", + "http" | "ws" => "ws", + other => bail!("Unsupported API URL scheme for SSH: {other}"), + }; + url.set_scheme(scheme) + .map_err(|_| anyhow::anyhow!("Failed to set the WebSocket scheme"))?; + let base_path = url.path().trim_end_matches('/').to_string(); + let base_path = if base_path.ends_with("/v1") { + base_path + } else { + format!("{base_path}/v1") + }; + url.set_path(&format!( + "{base_path}/computers/{}/ssh", + urlencoding::encode(id) + )); + url.set_query(None); + Ok(url.to_string()) +} + +async fn connect(url: &str, auth: &Auth) -> Result>> { + let mut request = url + .into_client_request() + .with_context(|| format!("Invalid SSH URL: {url}"))?; + request.headers_mut().insert( + "Sec-WebSocket-Protocol", + HeaderValue::from_static(SUBPROTOCOL), + ); + if let Some(key) = auth.api_key.as_deref().filter(|key| !key.trim().is_empty()) { + request.headers_mut().insert( + "Steel-Api-Key", + HeaderValue::from_str(key.trim()).context("The API key is not a valid header value")?, + ); + } + let (stream, response) = connect_async(request) + .await + .context("Failed to open the SSH bridge to the computer")?; + let accepted = response + .headers() + .get("Sec-WebSocket-Protocol") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.eq_ignore_ascii_case(SUBPROTOCOL)); + if !accepted { + bail!("The gateway did not accept the {SUBPROTOCOL} subprotocol."); + } + Ok(WsIo::new(stream)) +} + +async fn shell(channel: &mut Channel) -> Result { + let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); + if interactive { + let (cols, rows) = terminal::size().unwrap_or((80, 24)); + let term = std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string()); + channel + .request_pty(true, &term, cols, rows, 0, 0, &[]) + .await?; + } + channel.request_shell(true).await?; + let _raw = if interactive { + Some(terminal::RawMode::enable()?) + } else { + None + }; + pump(channel, interactive).await +} + +async fn exec(channel: &mut Channel, command: &[String]) -> Result { + channel.exec(true, shell_join(command)).await?; + pump(channel, false).await +} + +pub fn shell_join(argv: &[String]) -> String { + argv.iter() + .map(|arg| format!("'{}'", arg.replace('\'', "'\\''"))) + .collect::>() + .join(" ") +} + +async fn pump(channel: &mut Channel, forward_resize: bool) -> Result { + let mut stdin = tokio::io::stdin(); + let mut stdout = tokio::io::stdout(); + let mut stderr = tokio::io::stderr(); + let mut resize = terminal::resize_signal(forward_resize)?; + let mut buffer = [0u8; 8192]; + let mut code = 0; + let mut stdin_open = true; + loop { + tokio::select! { + message = channel.wait() => match message { + None | Some(ChannelMsg::Close) | Some(ChannelMsg::Eof) => break, + Some(ChannelMsg::Data { data }) => { + stdout.write_all(&data).await?; + stdout.flush().await?; + } + Some(ChannelMsg::ExtendedData { data, .. }) => { + stderr.write_all(&data).await?; + stderr.flush().await?; + } + Some(ChannelMsg::ExitStatus { exit_status }) => code = exit_status as i32, + Some(_) => {} + }, + read = stdin.read(&mut buffer), if stdin_open => match read? { + 0 => { + stdin_open = false; + channel.eof().await?; + } + n => channel.data(&buffer[..n]).await?, + }, + _ = terminal::resized(&mut resize) => { + if let Some((cols, rows)) = terminal::size() { + channel.window_change(cols, rows, 0, 0).await?; + } + } + } + } + Ok(code) +} + +#[cfg(unix)] +mod terminal { + use std::io; + + use rustix::termios::{self, OptionalActions, Termios}; + use tokio::signal::unix::{Signal, SignalKind, signal}; + + pub struct RawMode { + original: Termios, + } + + impl RawMode { + pub fn enable() -> io::Result { + let stdin = io::stdin(); + let original = termios::tcgetattr(&stdin)?; + let mut raw = original.clone(); + raw.make_raw(); + termios::tcsetattr(&stdin, OptionalActions::Now, &raw)?; + Ok(Self { original }) + } + } + + impl Drop for RawMode { + fn drop(&mut self) { + let _ = termios::tcsetattr(io::stdin(), OptionalActions::Now, &self.original); + } + } + + pub fn size() -> Option<(u32, u32)> { + let size = termios::tcgetwinsize(io::stdout()).ok()?; + (size.ws_col > 0).then(|| (u32::from(size.ws_col), u32::from(size.ws_row))) + } + + pub fn resize_signal(enabled: bool) -> io::Result> { + if enabled { + Ok(Some(signal(SignalKind::window_change())?)) + } else { + Ok(None) + } + } + + pub async fn resized(signal: &mut Option) { + match signal { + Some(signal) => { + signal.recv().await; + } + None => std::future::pending().await, + } + } +} + +#[cfg(not(unix))] +mod terminal { + use std::io; + + pub struct RawMode; + + impl RawMode { + pub fn enable() -> io::Result { + Ok(Self) + } + } + + pub fn size() -> Option<(u32, u32)> { + None + } + + pub fn resize_signal(_enabled: bool) -> io::Result> { + Ok(None) + } + + pub async fn resized(_signal: &mut Option<()>) { + std::future::pending().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ssh_urls_follow_the_api_host() { + assert_eq!( + ssh_url("https://api.steel.dev/v1", "cmp_a").unwrap(), + "wss://api.steel.dev/v1/computers/cmp_a/ssh" + ); + assert_eq!( + ssh_url("http://localhost:3000", "cmp_a").unwrap(), + "ws://localhost:3000/v1/computers/cmp_a/ssh" + ); + assert!(ssh_url("ftp://x", "cmp_a").is_err()); + } + + #[test] + fn remote_commands_are_quoted_for_the_shell() { + let argv = vec!["echo".to_string(), "it's here".to_string()]; + assert_eq!(shell_join(&argv), "'echo' 'it'\\''s here'"); + } +} diff --git a/src/commands/computer/wsio.rs b/src/commands/computer/wsio.rs new file mode 100644 index 0000000..1574920 --- /dev/null +++ b/src/commands/computer/wsio.rs @@ -0,0 +1,98 @@ +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use futures_util::{Sink, Stream}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::{Bytes, Message}; + +pub struct WsIo { + inner: WebSocketStream, + pending: Bytes, + closed: bool, +} + +impl WsIo { + pub const fn new(inner: WebSocketStream) -> Self { + Self { + inner, + pending: Bytes::new(), + closed: false, + } + } +} + +impl AsyncRead for WsIo { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + if !self.pending.is_empty() { + let n = buf.remaining().min(self.pending.len()); + let chunk = self.pending.split_to(n); + buf.put_slice(&chunk); + return Poll::Ready(Ok(())); + } + if self.closed { + return Poll::Ready(Ok(())); + } + if let Poll::Ready(Err(err)) = Pin::new(&mut self.inner).poll_flush(cx) { + return Poll::Ready(Err(io::Error::other(err))); + } + match Pin::new(&mut self.inner).poll_next(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(None) => { + self.closed = true; + return Poll::Ready(Ok(())); + } + Poll::Ready(Some(Err(err))) => return Poll::Ready(Err(io::Error::other(err))), + Poll::Ready(Some(Ok(message))) => match message { + Message::Binary(bytes) => self.pending = bytes, + Message::Text(text) => self.pending = Bytes::from(text), + Message::Close(_) => { + self.closed = true; + return Poll::Ready(Ok(())); + } + _ => {} + }, + } + } + } +} + +impl AsyncWrite for WsIo { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + data: &[u8], + ) -> Poll> { + match Pin::new(&mut self.inner).poll_ready(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(err)) => Poll::Ready(Err(io::Error::other(err))), + Poll::Ready(Ok(())) => { + Pin::new(&mut self.inner) + .start_send(Message::Binary(Bytes::copy_from_slice(data))) + .map_err(io::Error::other)?; + if let Poll::Ready(Err(err)) = Pin::new(&mut self.inner).poll_flush(cx) { + return Poll::Ready(Err(io::Error::other(err))); + } + Poll::Ready(Ok(data.len())) + } + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner) + .poll_flush(cx) + .map_err(io::Error::other) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner) + .poll_close(cx) + .map_err(io::Error::other) + } +} diff --git a/src/commands/describe.rs b/src/commands/describe.rs index 582fe61..fd6d36c 100644 --- a/src/commands/describe.rs +++ b/src/commands/describe.rs @@ -578,4 +578,31 @@ mod tests { .any(|x| x == "no-update-check") ); } + + #[test] + fn computer_exec_lists_its_api_operation() { + let v = describe_path(&["computer", "exec"]); + let ops = v["api_operations"].as_array().unwrap(); + assert_eq!(ops.len(), 1); + assert_eq!(ops[0]["operation_id"], "exec_computer"); + assert_eq!(ops[0]["method"], "POST"); + assert_eq!(ops[0]["path"], "/v1/computers/{id}/exec"); + assert_eq!( + ops[0]["example"], + "steel computer exec -- " + ); + assert!(ops[0].get("streaming").is_none()); + } + + #[test] + fn computer_ssh_is_a_websocket_operation() { + let v = describe_path(&["computer", "ssh"]); + let ops = v["api_operations"].as_array().unwrap(); + assert_eq!(ops.len(), 1); + assert_eq!(ops[0]["operation_id"], "attach_computer_ssh"); + assert_eq!(ops[0]["method"], "GET"); + assert_eq!(ops[0]["example"], "steel computer ssh "); + assert_eq!(ops[0]["streaming"]["transport"], "websocket"); + assert_eq!(ops[0]["streaming"]["path"], "/v1/computers/{id}/ssh"); + } } diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index 1cd9dd6..c647fbc 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -215,6 +215,9 @@ async fn check_auth_and_api(mode: ApiMode, base_url: &str, auth: &Auth) -> Vec { checks.push(Check { category: "api", diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 37d0325..16d592f 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod browser; pub mod cache; pub mod completion; +pub mod computer; pub mod config; pub mod credentials; pub mod describe; @@ -290,6 +291,12 @@ pub enum Command { command: sessions::Command, }, + /// Cloud computers: create, run commands, ssh + Computer { + #[command(subcommand)] + command: computer::Command, + }, + /// One-command onboarding: login + verify + install agent skills Init(init::Args), @@ -351,6 +358,7 @@ fn telemetry_command_path(command: &Command) -> Option { Command::Pdf(_) => Some("pdf".to_string()), Command::Browser(args) => Some(format!("browser.{}", args.command.telemetry_name())), Command::Sessions { command } => Some(format!("sessions.{}", command.telemetry_name())), + Command::Computer { command } => Some(format!("computer.{}", command.telemetry_name())), Command::Init(_) => Some("init".to_string()), Command::Login(_) => Some("login".to_string()), Command::Logout(_) => Some("logout".to_string()), @@ -398,6 +406,7 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> { Command::Pdf(args) => pdf::run(args).await, Command::Browser(args) => browser::run(args).await, Command::Sessions { command } => sessions::run(command).await, + Command::Computer { command } => computer::run(command).await, Command::Init(args) => init::run(args).await, Command::Login(args) => login::run(args).await, Command::Logout(args) => logout::run(args).await, diff --git a/src/commands/sessions.rs b/src/commands/sessions.rs index e9da261..73a0917 100644 --- a/src/commands/sessions.rs +++ b/src/commands/sessions.rs @@ -10,6 +10,7 @@ use tokio_tungstenite::tungstenite::Message; use crate::api::client::SteelClient; use crate::api::generated::{ GetSessionAgentLogsQuery, GetSessionAgentTracesQuery, GetSessionLogsQuery, GetSessionsQuery, + ReleaseAllSessionsQuery, }; use crate::browser::daemon::client::DaemonClient; use crate::browser::daemon::protocol::{DaemonCommand, SessionInfo}; @@ -196,6 +197,7 @@ async fn run_list(args: ListArgs) -> Result<()> { cursor_id: args.cursor_id, limit: args.limit, status: args.status, + project_id: None, }, ) .await?; @@ -227,7 +229,7 @@ async fn run_release(args: ReleaseArgs) -> Result<()> { if args.all { let data = client - .cli_release_all_sessions(&base_url, mode, &auth) + .cli_release_all_sessions(&base_url, mode, &auth, &ReleaseAllSessionsQuery::default()) .await?; if output::is_json() { output::success_data(data); diff --git a/src/config/settings.rs b/src/config/settings.rs index b084110..3e54e31 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -112,6 +112,15 @@ pub struct Config { pub browser: Option, #[serde(skip_serializing_if = "Option::is_none")] pub telemetry: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub computer: Option, +} + +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ComputerConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub default_id: Option, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] @@ -143,6 +152,13 @@ impl Config { .and_then(|t| t.disabled) .unwrap_or(false) } + + pub fn default_computer_id(&self) -> Option<&str> { + self.computer + .as_ref() + .and_then(|c| c.default_id.as_deref()) + .filter(|s| !s.trim().is_empty()) + } } pub fn read_config_from(path: &Path) -> Result { @@ -200,6 +216,7 @@ mod tests { telemetry: Some(TelemetryConfig { disabled: Some(true), }), + computer: None, }; write_config_to(&path, &config).unwrap(); diff --git a/src/telemetry.rs b/src/telemetry.rs index 5728626..3b47526 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -377,6 +377,7 @@ fn error_class(err: &anyhow::Error) -> &'static str { s if s >= 500 => "api_server_error", _ => "api_request_failed", }, + crate::api::client::ApiError::RetryLater { .. } => "api_not_ready", crate::api::client::ApiError::Other(_) => "network_error", }; } diff --git a/src/util/output.rs b/src/util/output.rs index 8d8c08d..a4a8f30 100644 --- a/src/util/output.rs +++ b/src/util/output.rs @@ -225,6 +225,11 @@ fn classify_error(err: &anyhow::Error) -> (i32, &'static str, Option<&'static st "unreachable", Some("Check your network connection and API URL."), ), + crate::api::client::ApiError::RetryLater { .. } => ( + exit_code::API_SERVER, + "not_ready", + Some("The computer is not ready. Try again shortly."), + ), crate::api::client::ApiError::RequestFailed { status, .. } => match *status { 401 => ( exit_code::AUTH, diff --git a/tests/cli-spec.json b/tests/cli-spec.json index 561c769..4fe647e 100644 --- a/tests/cli-spec.json +++ b/tests/cli-spec.json @@ -18,6 +18,20 @@ ] }, { "name": "cache" }, + { + "name": "computer", + "subcommands": [ + { "name": "create" }, + { "name": "delete" }, + { "name": "exec" }, + { "name": "get" }, + { "name": "list" }, + { "name": "pause" }, + { "name": "resume" }, + { "name": "ssh" }, + { "name": "use" } + ] + }, { "name": "config" }, { "name": "credentials", diff --git a/tests/cli_blackbox.rs b/tests/cli_blackbox.rs index 0c43f51..b048a1b 100644 --- a/tests/cli_blackbox.rs +++ b/tests/cli_blackbox.rs @@ -715,3 +715,66 @@ fn help_flag_exits_with_code_0() { let code = output.status.code().expect("should have exit code"); assert_eq!(code, 0, "--help should exit with code 0, got: {code}"); } + +// ─── Computers ────────────────────────────────────────────────────── + +fn run_without_computer_context(args: &[&str]) -> Output { + let (mut cmd, _tmp) = steel_cmd(); + cmd.env_remove("STEEL_COMPUTER_ID"); + cmd.args(args); + cmd.output().expect("failed to execute steel binary") +} + +#[test] +fn computer_help_lists_subcommands() { + let output = run(&["computer", "--help"]); + assert!( + output.status.success(), + "steel computer --help should exit 0" + ); + let out = stdout(&output); + for sub in &[ + "create", "list", "get", "delete", "pause", "resume", "use", "exec", "ssh", + ] { + assert!( + out.contains(sub), + "computer help should list '{sub}', got: {out}" + ); + } +} + +#[test] +fn computer_exec_help_shows_expected_flags() { + let output = run(&["computer", "exec", "--help"]); + assert!( + output.status.success(), + "steel computer exec --help should exit 0" + ); + let out = stdout(&output); + for flag in &["--command", "--cwd", "--env", "--timeout", "ARGV"] { + assert!( + out.contains(flag), + "computer exec help should mention '{flag}', got: {out}" + ); + } +} + +#[test] +fn computer_exec_without_a_computer_explains_how_to_pick_one() { + let output = run_without_computer_context(&["computer", "exec", "--", "ls"]); + assert!( + !output.status.success(), + "exec without a computer should fail" + ); + let text = format!("{}{}", stdout(&output), stderr(&output)); + assert!( + text.contains("steel computer use"), + "the error should point at `steel computer use`, got: {text}" + ); +} + +#[test] +fn computer_use_without_an_id_fails() { + let output = run_without_computer_context(&["computer", "use"]); + assert!(!output.status.success(), "use without an id should fail"); +} diff --git a/tests/computer_exec.rs b/tests/computer_exec.rs new file mode 100644 index 0000000..cec731f --- /dev/null +++ b/tests/computer_exec.rs @@ -0,0 +1,304 @@ +//! End-to-end tests for `steel computer exec` against a fake API host. +//! +//! The real `steel` binary runs against a wiremock server that plays the +//! box gateway, so the request shape, the ndjson stream, the collect mode, +//! the wake retry and the exit codes are all exercised as a user sees them. + +use std::process::{Command, Output}; + +use serde_json::json; +use wiremock::matchers::{body_partial_json, header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const COMPUTER: &str = "cmp_0000123456789abcdefghjkmnpqrs"; + +fn exec_path() -> String { + format!("/v1/computers/{COMPUTER}/exec") +} + +async fn run_steel(server: &MockServer, args: &[&str], env: &[(&str, &str)]) -> Output { + let tmp = tempfile::tempdir().expect("temp dir"); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_steel")); + cmd.env("STEEL_CONFIG_DIR", tmp.path()); + cmd.env("STEEL_API_URL", format!("{}/v1", server.uri())); + cmd.env("STEEL_API_KEY", "ste-test-key"); + cmd.env("STEEL_TELEMETRY_DISABLED", "1"); + cmd.env("STEEL_FORCE_TTY", "1"); + cmd.env_remove("STEEL_COMPUTER_ID"); + for (key, value) in env { + cmd.env(key, value); + } + cmd.arg("--no-update-check"); + cmd.args(args); + tokio::task::spawn_blocking(move || { + let output = cmd.output().expect("failed to execute steel binary"); + drop(tmp); + output + }) + .await + .expect("steel process") +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).to_string() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).to_string() +} + +fn ndjson(events: &[serde_json::Value]) -> ResponseTemplate { + let body = events + .iter() + .map(|event| event.to_string() + "\n") + .collect::(); + ResponseTemplate::new(200).set_body_raw(body, "application/x-ndjson") +} + +#[tokio::test(flavor = "multi_thread")] +async fn exec_streams_output_and_returns_the_remote_exit_code() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(exec_path())) + .and(header("steel-api-key", "ste-test-key")) + .and(body_partial_json(json!({ + "argv": ["sh", "-c", "echo hi; exit 3"], + "cwd": "/tmp", + "env": { "A": "1" }, + "timeoutSeconds": 30, + "stream": true + }))) + .respond_with(ndjson(&[ + json!({"event": "start"}), + json!({"event": "output", "data": "hi\n"}), + json!({"event": "keepalive"}), + json!({"event": "output", "data": "and more"}), + json!({"event": "exit", "exitCode": 3, "timedOut": false}), + ])) + .expect(1) + .mount(&server) + .await; + + let output = run_steel( + &server, + &[ + "computer", + "exec", + COMPUTER, + "--cwd", + "/tmp", + "--env", + "A=1", + "--timeout", + "30", + "--", + "sh", + "-c", + "echo hi; exit 3", + ], + &[], + ) + .await; + + assert_eq!(stdout(&output), "hi\nand more"); + assert_eq!(output.status.code(), Some(3)); + assert_eq!(stderr(&output), ""); +} + +#[tokio::test(flavor = "multi_thread")] +async fn exec_uses_the_default_computer_and_maps_timeouts_to_124() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(exec_path())) + .and(body_partial_json( + json!({ "command": "sleep 999", "stream": true }), + )) + .respond_with(ndjson(&[ + json!({"event": "start"}), + json!({"event": "exit", "exitCode": -1, "timedOut": true}), + ])) + .expect(1) + .mount(&server) + .await; + + let output = run_steel( + &server, + &["computer", "exec", "-c", "sleep 999"], + &[("STEEL_COMPUTER_ID", COMPUTER)], + ) + .await; + + assert_eq!(stdout(&output), ""); + assert_eq!(output.status.code(), Some(124)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn exec_in_json_mode_collects_one_result() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(exec_path())) + .and(body_partial_json( + json!({ "argv": ["id"], "stream": false }), + )) + .respond_with(ResponseTemplate::new(200).set_body_raw( + r#" {"output":"uid=0(root)\n","exitCode":0,"timedOut":false,"truncated":false}"#, + "application/json", + )) + .expect(1) + .mount(&server) + .await; + + let output = run_steel( + &server, + &["--json", "computer", "exec", COMPUTER, "--", "id"], + &[], + ) + .await; + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let parsed: serde_json::Value = serde_json::from_str(stdout(&output).trim()).unwrap(); + assert_eq!(parsed["success"], json!(true)); + assert_eq!(parsed["data"]["output"], json!("uid=0(root)\n")); + assert_eq!(parsed["data"]["exitCode"], json!(0)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn exec_retries_while_the_computer_wakes() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(exec_path())) + .respond_with( + ResponseTemplate::new(503) + .insert_header("Retry-After", "1") + .set_body_json(json!({"error": "computer is not available"})), + ) + .up_to_n_times(1) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path(exec_path())) + .respond_with(ndjson(&[ + json!({"event": "start"}), + json!({"event": "output", "data": "awake\n"}), + json!({"event": "exit", "exitCode": 0, "timedOut": false}), + ])) + .expect(1) + .mount(&server) + .await; + + let output = run_steel(&server, &["computer", "exec", COMPUTER, "--", "true"], &[]).await; + + assert_eq!(stdout(&output), "awake\n"); + assert!(output.status.success()); + assert!( + stderr(&output).contains("not ready yet"), + "stderr: {}", + stderr(&output) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn exec_reports_an_unknown_computer() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(exec_path())) + .respond_with( + ResponseTemplate::new(404).set_body_json(json!({"error": "computer not found"})), + ) + .mount(&server) + .await; + + let output = run_steel(&server, &["computer", "exec", COMPUTER, "--", "true"], &[]).await; + + assert_eq!(output.status.code(), Some(5)); + assert!( + stderr(&output).contains("computer not found"), + "stderr: {}", + stderr(&output) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn exec_fails_when_the_stream_ends_early() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(exec_path())) + .respond_with(ndjson(&[ + json!({"event": "start"}), + json!({"event": "output", "data": "partial"}), + ])) + .mount(&server) + .await; + + let output = run_steel(&server, &["computer", "exec", COMPUTER, "--", "true"], &[]).await; + + assert_eq!(stdout(&output), "partial"); + assert!(!output.status.success()); + assert!( + stderr(&output).contains("before the command finished"), + "stderr: {}", + stderr(&output) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_prints_a_table_and_create_remembers_the_default() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/computers")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "computers": [{ + "id": COMPUTER, "status": "running", "region": "us-east", "template": "steel", + "vcpu": 2, "memoryMib": 2048, "diskMib": 25600, "timeoutSeconds": 3600, + "autoPause": false, "checkpointId": null, "statusChangedAt": "2026-09-10T00:00:00Z" + }] + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/computers")) + .and(body_partial_json(json!({ "template": "steel", "vcpu": 4 }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "id": COMPUTER, "status": "creating", "region": null, "template": "steel", + "vcpu": 4, "memoryMib": 2048, "diskMib": 25600, "timeoutSeconds": 3600, + "autoPause": false, "checkpointId": null, "statusChangedAt": "2026-09-10T00:00:00Z" + }))) + .mount(&server) + .await; + + let output = run_steel(&server, &["computer", "list"], &[]).await; + let text = stdout(&output); + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert!(text.starts_with("ID"), "table header missing: {text}"); + assert!( + text.contains(COMPUTER) && text.contains("running"), + "{text}" + ); + + let output = run_steel( + &server, + &[ + "computer", + "create", + "--template", + "steel", + "--vcpu", + "4", + "--use", + ], + &[], + ) + .await; + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert!( + stdout(&output).contains("is creating"), + "{}", + stdout(&output) + ); + assert!( + stdout(&output).contains("default computer"), + "{}", + stdout(&output) + ); +} diff --git a/tests/computer_ssh.rs b/tests/computer_ssh.rs new file mode 100644 index 0000000..942c553 --- /dev/null +++ b/tests/computer_ssh.rs @@ -0,0 +1,210 @@ +//! End-to-end tests for `steel computer ssh` against an in-process bridge. +//! +//! A WebSocket server plays the box gateway and an SSH server sits behind it, +//! so the real `steel` binary exercises the handshake headers, the WebSocket +//! transport, the SSH login, exec, shell and the exit status. + +use std::process::{Command, Output}; +use std::sync::{Arc, Mutex}; + +use russh::keys::PrivateKey; +use russh::keys::ssh_key::private::{Ed25519Keypair, KeypairData}; +use russh::server::{self, Auth, ChannelOpenHandle, Msg, Session}; +use russh::{Channel, ChannelId}; +use steel_cli::commands::computer::wsio::WsIo; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response}; +use tokio_tungstenite::tungstenite::http::HeaderValue; + +const COMPUTER: &str = "cmp_0000123456789abcdefghjkmnpqrs"; +const SUBPROTOCOL: &str = "steel-ssh-v1"; + +#[derive(Default, Debug, Clone)] +struct Observed { + path: String, + subprotocol: String, + api_key: String, + user: String, + password: String, + exec: Option, + shell: bool, +} + +type Shared = Arc>; + +struct Bridge { + observed: Shared, + exit_status: u32, +} + +impl server::Handler for Bridge { + type Error = russh::Error; + + async fn auth_password(&mut self, user: &str, password: &str) -> Result { + { + let mut observed = self.observed.lock().unwrap(); + observed.user = user.to_string(); + observed.password = password.to_string(); + } + Ok(Auth::Accept) + } + + async fn channel_open_session( + &mut self, + _channel: Channel, + reply: ChannelOpenHandle, + _session: &mut Session, + ) -> Result<(), Self::Error> { + reply.accept().await; + Ok(()) + } + + async fn exec_request( + &mut self, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) -> Result<(), Self::Error> { + let command = String::from_utf8_lossy(data).to_string(); + self.observed.lock().unwrap().exec = Some(command.clone()); + session.channel_success(channel)?; + session.data(channel, format!("ran {command}\n").into_bytes())?; + finish(session, channel, self.exit_status) + } + + async fn shell_request( + &mut self, + channel: ChannelId, + session: &mut Session, + ) -> Result<(), Self::Error> { + self.observed.lock().unwrap().shell = true; + session.channel_success(channel)?; + session.data(channel, b"shell ready\n".to_vec())?; + finish(session, channel, self.exit_status) + } +} + +fn finish(session: &mut Session, channel: ChannelId, exit_status: u32) -> Result<(), russh::Error> { + session.exit_status_request(channel, exit_status)?; + session.eof(channel)?; + session.close(channel)?; + Ok(()) +} + +async fn start_bridge(exit_status: u32) -> (u16, Shared) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let observed: Shared = Arc::default(); + let shared = observed.clone(); + tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let handshake = shared.clone(); + let callback = + move |request: &Request, mut response: Response| -> Result { + let header = |name: &str| { + request + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_string() + }; + { + let mut observed = handshake.lock().unwrap(); + observed.path = request.uri().path().to_string(); + observed.subprotocol = header("Sec-WebSocket-Protocol"); + observed.api_key = header("Steel-Api-Key"); + } + response.headers_mut().insert( + "Sec-WebSocket-Protocol", + HeaderValue::from_static(SUBPROTOCOL), + ); + Ok(response) + }; + let ws = tokio_tungstenite::accept_hdr_async(tcp, callback) + .await + .unwrap(); + let host_key = KeypairData::Ed25519(Ed25519Keypair::from_seed(&[7u8; 32])); + let config = Arc::new(server::Config { + keys: vec![PrivateKey::try_from(host_key).unwrap()], + ..Default::default() + }); + let handler = Bridge { + observed: shared, + exit_status, + }; + let session = server::run_stream(config, WsIo::new(ws), handler) + .await + .unwrap(); + let _ = session.await; + }); + (port, observed) +} + +async fn run_steel(port: u16, args: &[&str]) -> Output { + let tmp = tempfile::tempdir().expect("temp dir"); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_steel")); + cmd.env("STEEL_CONFIG_DIR", tmp.path()); + cmd.env("STEEL_API_URL", format!("http://127.0.0.1:{port}/v1")); + cmd.env("STEEL_API_KEY", "ste-test-key"); + cmd.env("STEEL_TELEMETRY_DISABLED", "1"); + cmd.env_remove("STEEL_COMPUTER_ID"); + cmd.arg("--no-update-check"); + cmd.args(args); + tokio::task::spawn_blocking(move || { + let output = cmd.output().expect("failed to execute steel binary"); + drop(tmp); + output + }) + .await + .expect("steel process") +} + +fn text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).to_string() +} + +#[tokio::test(flavor = "multi_thread")] +async fn ssh_runs_a_command_through_the_bridge_and_returns_its_exit_status() { + let (port, observed) = start_bridge(7).await; + + let output = run_steel( + port, + &["computer", "ssh", COMPUTER, "--", "echo", "hi there"], + ) + .await; + + assert_eq!( + text(&output.stdout), + "ran 'echo' 'hi there'\n", + "stderr: {}", + text(&output.stderr) + ); + assert_eq!(output.status.code(), Some(7)); + let observed = observed.lock().unwrap().clone(); + assert_eq!(observed.path, format!("/v1/computers/{COMPUTER}/ssh")); + assert_eq!(observed.subprotocol, SUBPROTOCOL); + assert_eq!(observed.api_key, "ste-test-key"); + assert_eq!(observed.user, "root"); + assert_eq!(observed.password, ""); + assert_eq!(observed.exec.as_deref(), Some("'echo' 'hi there'")); + assert!(!observed.shell); +} + +#[tokio::test(flavor = "multi_thread")] +async fn ssh_opens_a_shell_when_no_command_is_given() { + let (port, observed) = start_bridge(0).await; + + let output = run_steel(port, &["computer", "ssh", COMPUTER]).await; + + assert_eq!( + text(&output.stdout), + "shell ready\n", + "stderr: {}", + text(&output.stderr) + ); + assert!(output.status.success()); + let observed = observed.lock().unwrap().clone(); + assert!(observed.shell); + assert!(observed.exec.is_none()); +}