diff --git a/Cargo.lock b/Cargo.lock index a6a5981..af4ab01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,16 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "atoi" version = "2.0.0" @@ -219,6 +229,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -273,6 +289,23 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clawdforge" +version = "0.1.0" +dependencies = [ + "bytes", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing", + "url", + "wiremock", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -349,6 +382,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "der" version = "0.7.10" @@ -436,6 +487,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -453,6 +510,12 @@ dependencies = [ "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -468,6 +531,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -512,6 +590,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -530,8 +619,10 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -556,8 +647,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] @@ -568,11 +675,30 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -605,6 +731,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -693,6 +825,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -701,6 +834,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", ] [[package]] @@ -709,13 +859,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -863,6 +1021,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -936,6 +1100,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -957,6 +1127,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1016,6 +1192,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.2.0" @@ -1047,7 +1233,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -1082,6 +1268,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1226,6 +1422,61 @@ dependencies = [ "version_check", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -1235,6 +1486,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1248,8 +1505,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1259,7 +1526,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1271,6 +1548,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1318,6 +1604,48 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 1.0.7", +] + [[package]] name = "ring" version = "0.17.14" @@ -1345,13 +1673,32 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.40" @@ -1372,6 +1719,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -1530,7 +1878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1559,11 +1907,12 @@ version = "0.0.1" dependencies = [ "anyhow", "chrono", + "clawdforge", "regex", "serde", "serde_json", "sqlx", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "uuid", @@ -1655,7 +2004,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -1732,7 +2081,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.6", "rsa", "serde", "sha1", @@ -1740,7 +2089,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -1772,14 +2121,14 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.6", "serde", "serde_json", "sha2", "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -1805,7 +2154,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "tracing", "url", "uuid", @@ -1856,6 +2205,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1868,13 +2220,46 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1950,6 +2335,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -1961,6 +2356,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -1985,13 +2393,16 @@ checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags", "bytes", + "futures-util", "http", "http-body", "http-body-util", "pin-project-lite", + "tower", "tower-layer", "tower-service", "tracing", + "url", ] [[package]] @@ -2082,12 +2493,24 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -2181,6 +2604,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2224,6 +2656,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.121" @@ -2278,6 +2720,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -2290,6 +2745,26 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2525,6 +3000,29 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/README.md b/README.md index 2b16cd5..8dbd386 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,24 @@ What's wired: - `skald serve` exposes `/health` and runs migrations on boot - Single-container deploy: postgres + skald in one image +Wired (this commit): + +- clawdforge Rust SDK vendored at `vendor/clawdforge/` (upstream: + `Sulkta-Coop/clawdforge` `clients/rust/`) +- `skald-core::forge` — three-pass orchestration shell (gen / cleanup / + audit). Prompts are TODO stubs; pipeline plumbing is in place. + Not yet wired: - Web UI (the inbox + browse + queue surface) -- clawdforge calls (the actual generate / cleanup / canon-audit - pipeline) -- Embeddings + similarity search -- TTS sidecar +- Prompt templates for the three passes (heavy prompt-engineering + work — own session) +- `skald-core::context` — assemble the LLM context blob from DB rows + (bible + characters + parent prose summaries + similarity-matched + passages) +- Embeddings backfill + ivfflat index +- TTS sidecar container + post-render audit chain (see + `docs/tts-pipeline.md`) ## v0.1 smoke diff --git a/docs/tts-pipeline.md b/docs/tts-pipeline.md new file mode 100644 index 0000000..28cbb67 --- /dev/null +++ b/docs/tts-pipeline.md @@ -0,0 +1,146 @@ +# TTS pipeline (v0.2 plan) + +Generation lands in v0.2; narration follows. This doc captures the +shape so the v0.1 schema decisions don't drift away from the v0.2 +implementation. + +## Render path + +``` +chapter row (chapters.body_md) + │ + ▼ +1. pronunciation pre-pass + │ pull pronunciation_overrides for (story_id, *) UNION global + │ substitute proper nouns with phoneme markers F5 understands + ▼ +2. F5-TTS render + │ reference: voices.reference_path + voices.reference_text + │ output: .wav, 24 kHz, mono + ▼ +3. narration_runs row + │ chapter_id, voice_id, engine="f5-tts", engine_version, seed, + │ output_path, duration_seconds, status="succeeded" + ▼ +4. POST-RENDER AUDIT CHAIN ◄── this is the cwho-shape audit we use + to catch "spoken word that would + wake you up at 2am" +``` + +## Audit chain + +### Tier 1 — Whisper-large-v3 STT + word-diff (text-to-text) + +``` +chapter.wav + │ + ▼ +ffmpeg -i chapter.wav -af "silenceremove=..." + -f segment -segment_time 30 -reset_timestamps 1 chunk%04d.wav + │ + ▼ +for each chunk: + whisper-large-v3 → transcript + word-level diff vs (source_text + pronunciation_overrides applied) + │ + ▼ +substitutions / drops / inserts + │ + ▼ +narration_findings rows: + kind = pronunciation | skip | insert + timestamp_start/end (chunk-relative + chunk offset) + expected_text, heard_text + severity: + pronunciation: warn (one wrong word — annoyance) + skip: crit (line silently dropped — listener loses thread) + insert: warn (extra word, usually a glitch) + detector = "whisper-large-v3" +``` + +Whisper runs locally on Lucy's 8GB GPU (~3GB VRAM). Free, fast. +Captures mispronunciations that come out as a different real word. + +### Tier 2 — audio-native LLM review (subjective) + +Optional, more expensive, covers what Whisper can't: + +``` +chapter.wav + source_text + │ + ▼ +clawdforge.run({ + model: "gemini-flash-audio" | "gpt-4o-audio", + files: [chapter.wav], + prompt: "...listen to this rendered audiobook chapter...flag + spans where (a) inflection breaks meaning, (b) pacing + makes the listener lose the thread, (c) any audio + glitch or dropout, (d) emotional tone is wrong..." +}) + │ + ▼ +narration_findings rows: + kind = prosody | tone | glitch + severity per the model's call + detector = "gemini-flash-audio" (etc) +``` + +Claude (the model behind clawdforge's default) doesn't have audio +modality (mid-2026). Audio-native models route through clawdforge +to Gemini Flash / GPT-4o-audio when needed. Cost: ~$0.05/chapter +for Gemini Flash. + +### Reroll loop + +``` +SELECT count(*) FROM narration_findings + WHERE run_id = $1 AND severity = 'crit' AND NOT resolved + │ + ▼ if > 0: +update narration_runs.status = 'rerolled' +queue a new render with seed = $old_seed + 1 + │ + ▼ +narration_findings rows referencing the old run_id stay +(audit trail) — the new run gets its own findings set +``` + +After two reroll attempts with crit findings still present, the +chapter is marked for operator review (out-of-band — e.g., +re-edit the source text, add more pronunciation overrides, hand- +patch the audio). + +## Why Whisper for text-to-text verification + +Whisper's word-error-rate on clean audiobook audio is ~3-5%. That +means *most* of what it transcribes back will match the source +exactly. The *deltas* are precisely the words the TTS got wrong. + +False-positive rate for true Whisper transcription errors (Whisper +heard it right, called something different) is small enough to +treat as noise — those findings get autoresolved if they don't +reproduce on a reroll. + +Whisper's proper-noun WER is HIGHER than overall WER — which is +actually what we want here. The harder Whisper finds it to +transcribe a name back from F5's output, the more likely F5 got +the name wrong in the first place. + +## What this doc is NOT + +- Not a prompt template for the audio-LLM call (TBD in v0.2) +- Not a Whisper config file (TBD: which Whisper, language, beam size, VAD) +- Not the TTS sidecar container shape (separate spec) + +## When this gets wired + +After the generation pipeline (gen → cleanup → canon-audit) is +working end-to-end. TTS is downstream of "we have a story to +render." + +Order of v0.2 work: + +1. forge.rs prompt templates (gen/cleanup/audit) — fill in stubs +2. context.rs — assemble blob from DB for a given story_id +3. web UI for queueing + status +4. TTS sidecar container — F5 + ffmpeg + Whisper + this audit chain diff --git a/skald-core/Cargo.toml b/skald-core/Cargo.toml index 81695bb..295a29c 100644 --- a/skald-core/Cargo.toml +++ b/skald-core/Cargo.toml @@ -18,6 +18,7 @@ tracing = { workspace = true } chrono = { workspace = true } uuid = { workspace = true } regex = { workspace = true } +clawdforge = { path = "../vendor/clawdforge" } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/skald-core/src/config.rs b/skald-core/src/config.rs new file mode 100644 index 0000000..4a67907 --- /dev/null +++ b/skald-core/src/config.rs @@ -0,0 +1,33 @@ +//! Configuration for skald-core consumers. +//! +//! Configs are passed in explicitly by the calling binary, not loaded +//! from disk here — the lib stays env-agnostic. (skald-the-binary +//! reads env vars + maps them into these structs.) + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ForgeConfig { + /// Base URL of the clawdforge HTTP service. Defaults to + /// `http://clawdforge.sulkta.lan:8800` in production; override + /// for tests via env. + pub base_url: String, + + /// App-level bearer token. Resolved by the binary from + /// `CLAWDFORGE_TOKEN`; should never be logged or `Display`ed. + pub app_token: String, + + /// Model alias passed to clawdforge → `claude -p --model`. Skald + /// is opinionated: always opus max effort. Default reflects that. + pub model: String, +} + +impl Default for ForgeConfig { + fn default() -> Self { + Self { + base_url: "http://clawdforge.sulkta.lan:8800".into(), + app_token: String::new(), + model: "opus".into(), + } + } +} diff --git a/skald-core/src/forge.rs b/skald-core/src/forge.rs new file mode 100644 index 0000000..1e1700b --- /dev/null +++ b/skald-core/src/forge.rs @@ -0,0 +1,203 @@ +//! clawdforge wiring. Three passes per chapter; the actual prompt +//! templates are TODO (v0.2 prompt-engineering sprint) — this module +//! ships the plumbing so prompts can be filled in without +//! refactoring. +//! +//! The three passes: +//! +//! 1. **gen** — produces a new chapter draft from an assembled +//! context blob (parent prose + bible + characters + similarity- +//! matched passages, all from the database). Opus, max effort. +//! +//! 2. **cleanup** — polishes the draft for prose quality, voice +//! consistency, dialogue rhythm, pacing dead spots. Same Opus, +//! fresh eyes; sees gen pass output + same context. +//! +//! 3. **audit** — third Opus reads parent prose + sequel prose + +//! bible, returns structured findings: dropped threads, character +//! voice drift, retconned facts, timeline contradictions. Output +//! parses into rows for the `audit_findings` table. +//! +//! Every pass is logged as a `generation_runs` row before / after +//! for cost tracking, replay, and forensics. +//! +//! ## Naming context +//! +//! The Rust binding for clawdforge is the upstream `clawdforge` crate +//! (vendored at `vendor/clawdforge`). This module is the skald-side +//! glue: turn a story-id + a pass-kind into the right RunRequest + +//! parse the response into the right shape. + +use std::time::Duration; + +use clawdforge::{Client, ClientBuilder, RunRequest, RunResult}; +use serde::{Deserialize, Serialize}; + +use crate::config::ForgeConfig; + +/// Thin wrapper around the clawdforge `Client`. Configured once, +/// cheap to clone — each pass just calls `.run()` with a different +/// prompt. +#[derive(Clone)] +pub struct Forge { + client: Client, + /// The model alias we pass to clawdforge. Skald is opinionated: + /// always opus max effort. (See `project_story_writer_container.md`.) + /// `clawdforge` resolves the alias to the actual claude CLI flag. + model: String, +} + +/// Per-pass output. `result` is the raw response from clawdforge. +/// Callers parse it into the shape they need. +#[derive(Debug, Clone)] +pub struct PassOutput { + pub kind: PassKind, + pub result: RunResult, + pub duration_ms: u64, +} + +/// What a given pass over the model is for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PassKind { + /// First-pass long-form draft from prompt + context. + Gen, + /// Polish + humanize the gen pass output. + Cleanup, + /// Canon audit across parent + sequel. Outputs findings JSON. + Audit, +} + +impl PassKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Gen => "gen", + Self::Cleanup => "cleanup", + Self::Audit => "audit", + } + } +} + +impl Forge { + pub fn new(cfg: &ForgeConfig) -> anyhow::Result { + let client = ClientBuilder::default() + .base_url(&cfg.base_url) + .token(&cfg.app_token) + // Generation passes are slow — 600s is the clawdforge + // server-side max anyway, and gen passes routinely hit + // 5+ minutes on opus max-effort. Default 120s would + // strand them. + .timeout(Duration::from_secs(600)) + .user_agent(concat!("skald/", env!("CARGO_PKG_VERSION"))) + .build()?; + Ok(Self { + client, + model: cfg.model.clone(), + }) + } + + /// First-pass draft. `prompt` is the user-supplied story prompt; + /// `context` is the full assembled blob (bible + characters + + /// parent prose summaries + passages). + /// + /// Prompt template is TODO (v0.2). Stub builds the simplest + /// possible request shape so the wiring compiles. + pub async fn generate(&self, prompt: &str, context: &str) -> anyhow::Result { + let body = build_request( + &self.model, + PassKind::Gen, + prompt, + context, + SYSTEM_GEN_TODO, + ); + let r = self.client.run(body).await?; + let duration_ms = r.duration_ms; + Ok(PassOutput { kind: PassKind::Gen, result: r, duration_ms }) + } + + /// Cleanup / humanize pass over the gen draft. + pub async fn cleanup(&self, draft: &str, context: &str) -> anyhow::Result { + let body = build_request( + &self.model, + PassKind::Cleanup, + draft, + context, + SYSTEM_CLEANUP_TODO, + ); + let r = self.client.run(body).await?; + let duration_ms = r.duration_ms; + Ok(PassOutput { kind: PassKind::Cleanup, result: r, duration_ms }) + } + + /// Canon audit comparing parent + sequel against the bible. + /// Expected to return structured JSON parseable into + /// `Vec`. + pub async fn audit(&self, parent_prose: &str, sequel_prose: &str, bible: &str) -> anyhow::Result { + let body = build_audit_request( + &self.model, + parent_prose, + sequel_prose, + bible, + ); + let r = self.client.run(body).await?; + let duration_ms = r.duration_ms; + Ok(PassOutput { kind: PassKind::Audit, result: r, duration_ms }) + } +} + +fn build_request(model: &str, kind: PassKind, primary: &str, context: &str, system: &str) -> RunRequest { + let prompt = format!( + "# Pass: {kind}\n\n## Context\n\n{context}\n\n## Input\n\n{primary}", + kind = kind.as_str(), + ); + RunRequest { + prompt, + model: Some(model.to_string()), + system: Some(system.to_string()), + timeout_secs: Some(600), + ..Default::default() + } +} + +fn build_audit_request(model: &str, parent: &str, sequel: &str, bible: &str) -> RunRequest { + let prompt = format!( + "## Bible\n\n{bible}\n\n## Parent story prose\n\n{parent}\n\n## Sequel story prose\n\n{sequel}\n\nReturn JSON: {{ \"findings\": [ {{ \"severity\": \"info|warn|crit\", \"area\": \"character|continuity|tone|fact|timeline|other\", \"body\": \"...\" }} ] }}" + ); + RunRequest { + prompt, + model: Some(model.to_string()), + system: Some(SYSTEM_AUDIT_TODO.to_string()), + timeout_secs: Some(600), + ..Default::default() + } +} + +// ─── Prompt templates (TODO v0.2 — these are placeholder stubs) ─── + +const SYSTEM_GEN_TODO: &str = "You are a long-form fiction author. \ +Write in measured, literary prose. Honor the bible and character voices \ +exactly. (Full prompt template: TODO v0.2.)"; + +const SYSTEM_CLEANUP_TODO: &str = "You are a copy editor for long-form fiction. \ +Polish the draft for prose quality, tighten dialogue, fix pacing dead \ +spots, keep voice consistent. Do not add new plot. (Full prompt template: TODO v0.2.)"; + +const SYSTEM_AUDIT_TODO: &str = "You are a canon auditor. Compare the parent \ +and sequel against the bible. Flag contradictions, character voice drift, \ +retconned facts, dropped threads, timeline issues. Output structured \ +JSON only — no commentary. (Full prompt template: TODO v0.2.)"; + +/// Audit finding shape returned by the audit pass. Parses out of the +/// `result` field on the audit pass's [`RunResult`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditFinding { + pub severity: String, + pub area: String, + pub body: String, +} + +/// Wrapper shape for the audit response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditResponse { + pub findings: Vec, +} diff --git a/skald-core/src/lib.rs b/skald-core/src/lib.rs index d0ff16e..5f4bff9 100644 --- a/skald-core/src/lib.rs +++ b/skald-core/src/lib.rs @@ -4,7 +4,9 @@ //! assembly for LLM calls. The story-independence rule: nothing in //! this crate knows about any specific story. Every story is rows. +pub mod config; pub mod db; +pub mod forge; pub mod ingest; pub mod models; diff --git a/vendor/clawdforge/Cargo.toml b/vendor/clawdforge/Cargo.toml new file mode 100644 index 0000000..1b02e50 --- /dev/null +++ b/vendor/clawdforge/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "clawdforge" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Async Rust client for the clawdforge HTTP service (a LAN bearer-token-gated wrapper around `claude -p`)." +repository = "https://gitea.sulkta.com/Sulkta-Coop/clawdforge" +readme = "README.md" +keywords = ["claude", "http-client", "sdk", "async"] +categories = ["api-bindings", "asynchronous"] + +[dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "stream", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +tokio = { version = "1", features = ["fs", "io-util", "rt"] } +tokio-util = { version = "0.7", features = ["io"] } +tracing = { version = "0.1", default-features = false, features = ["std"] } +url = "2" +bytes = "1" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs", "io-util", "time"] } +wiremock = "0.6" +tempfile = "3" + +[[example]] +name = "basic" +path = "examples/basic.rs" diff --git a/vendor/clawdforge/README.md b/vendor/clawdforge/README.md new file mode 100644 index 0000000..830b24b --- /dev/null +++ b/vendor/clawdforge/README.md @@ -0,0 +1,270 @@ +# clawdforge — Rust client + +Async Rust SDK for [clawdforge], a small LAN-only HTTP service that wraps +`claude -p` subprocess calls behind a bearer-token-gated REST API. + +[clawdforge]: https://gitea.sulkta.com/Sulkta-Coop/clawdforge + +- Tokio + reqwest under the hood +- `serde` + `serde_json` types +- Streaming multipart upload (`tokio::fs::File`, no full-file buffer) +- Builder pattern for configuration +- Typed `RunResult::as_json::()` and `as_text()` helpers over a + `serde_json::Value` payload + +## Install + +This crate is not on crates.io. Pull it directly from the upstream git host: + +```sh +cargo add clawdforge --git https://gitea.sulkta.com/Sulkta-Coop/clawdforge --rev +``` + +Or pin manually in `Cargo.toml`: + +```toml +[dependencies] +clawdforge = { git = "https://gitea.sulkta.com/Sulkta-Coop/clawdforge", rev = "" } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +``` + +For an in-repo workspace consumer, point at the `clients/rust/` path: + +```toml +clawdforge = { path = "../clawdforge/clients/rust" } +``` + +## Quickstart + +```rust +use clawdforge::{Client, RunRequest}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = Client::builder() + .base_url("http://localhost:8800") + .token("cf_xxxxxxxxxxxxxxxx") + .build()?; + + // Liveness — does not require a token, but sends one if configured. + let h = client.healthz().await?; + println!("claude present: {} version: {:?}", h.claude_present, h.claude_version); + + // Run a prompt. `result` is a serde_json::Value — narrow via .as_json::(). + let r = client.run(RunRequest { + prompt: "Reply with JSON: {\"hello\":\"world\"}".into(), + model: Some("sonnet".into()), + timeout_secs: Some(30), + ..Default::default() + }).await?; + + #[derive(serde::Deserialize)] + struct Hello { hello: String } + let typed: Hello = r.as_json()?; + println!("{}", typed.hello); + + // Upload a file, then attach it to a follow-up run. + let ft = client.upload_file("./recipe.png", Some(3600)).await?; + let r2 = client.run(RunRequest { + prompt: "extract recipe data".into(), + files: Some(vec![ft.file_token]), + ..Default::default() + }).await?; + println!("{:?}", r2.result); + + Ok(()) +} +``` + +## Multi-turn / Sessions (v0.2) + +v0.1 `Client::run` is a single-turn shot. v0.2 adds a parallel session API +backed by the server's [ACPX]-driven `/sessions/*` surface for back-and-forth +agent flows that need context across turns. + +[ACPX]: https://github.com/openclaw/acpx + +```rust +use clawdforge::{Client, SessionOptions}; + +#[tokio::main] +async fn main() -> Result<(), clawdforge::Error> { + let client = Client::builder() + .base_url("http://localhost:8800") + .token("cf_xxxxxxxxxxxxxxxx") + .build()?; + + let mut s = client.new_session(SessionOptions::default()).await?; + + let r1 = s.turn("Read README.md and summarize it").await?; + println!("{}", r1.text()); + + // Attach files uploaded via Client::upload_file. + let r2 = s + .turn_with_files( + "Now look at the auth flow", + &["ff_xyz".into()], + ) + .await?; + println!("turn {}: {}", r2.turn_index, r2.text()); + + // Explicit close consumes `s` — using it after this is a compile error. + s.close().await?; + Ok(()) +} +``` + +### Lifecycle + +| API | Purpose | +|---|---| +| `Client::new_session(SessionOptions)` | `POST /sessions` — returns a `Session`. | +| `Session::turn(prompt)` | `POST /sessions/{id}/turn` with no files. | +| `Session::turn_with_files(prompt, &[token, ...])` | `POST /sessions/{id}/turn` with `ff_*` tokens from `upload_file`. | +| `Session::close(self)` | `DELETE /sessions/{id}`. **Consumes `self`** — use-after-close is a compile error. | +| `Client::list_sessions()` | `GET /sessions` — sessions visible to the calling token. | +| `Client::get_session(id)` | `GET /sessions/{id}` — current state. | + +### Drop fallback + +If a `Session` is dropped without an explicit `close().await?`, `Drop` +spawns a best-effort async DELETE via `tokio::spawn` to release the +server-side session. This is **best-effort**: + +- The spawned future is not awaited — the calling task continues immediately. +- Failures are logged via `tracing::warn!` (target `clawdforge::session`), + not panicked. +- If `Session` is dropped outside any tokio runtime, the close is skipped + with a warning rather than panicking on `tokio::spawn`. +- If `close().await?` already ran, `Drop` short-circuits without a second + network call (an `AtomicBool` flag tracks closed state). + +For deterministic cleanup, **prefer `s.close().await?`**. The Drop path is a +backstop for panics / early returns, not a primary lifecycle hook. + +### `TurnResult::text()` + +Concatenates all `"text"` events into one string. `"thinking"` and +`"tool_call"` events are skipped — inspect `result.events` directly if you +need them. + +```rust +let r = s.turn("hi").await?; +let answer: String = r.text(); +let n_tool_calls = r + .events + .iter() + .filter(|e| e.event_type == "tool_call") + .count(); +``` + +### v0.1 compatibility + +The v0.1 surface (`Client::run`, `Client::upload_file`, +`Client::create_token`, etc.) is byte-identical. v0.2 is purely additive. v0.1 +callers do not need to change anything to upgrade. + +## Public API + +### `Client::builder()` + +Builder for the HTTP client. + +| Method | Purpose | +|---|---| +| `.base_url(url)` | Required. e.g. `"http://localhost:8800"`. | +| `.token(t)` | App bearer for `/run`, `/files`. | +| `.admin_token(t)` | Admin bearer for `/admin/*`. | +| `.timeout(Duration)` | Per-request timeout (default 120 s). | +| `.user_agent(s)` | Override `User-Agent` header. | +| `.danger_accept_invalid_certs(bool)` | Skip TLS verify (off by default). | +| `.build()` | Returns `Result`. | + +### `Client` async methods + +| Method | Endpoint | Notes | +|---|---|---| +| `healthz()` | `GET /healthz` | Returns `Healthz`. | +| `run(RunRequest)` | `POST /run` | Returns `RunResult`. 502 surfaces as `Error::Api`. | +| `upload_file(path, ttl_secs)` | `POST /files` | Streams from disk; returns `FileToken`. | +| `create_token(TokenCreateRequest)` | `POST /admin/tokens` | Admin only. Returns `AppToken`. | +| `list_tokens()` | `GET /admin/tokens` | Admin only. Returns `TokenList`. | +| `revoke_token(name)` | `DELETE /admin/tokens/{name}` | Admin only. | +| `new_session(opts)` | `POST /sessions` | v0.2. Returns `Session`. | +| `list_sessions()` | `GET /sessions` | v0.2. Returns `SessionList`. | +| `get_session(id)` | `GET /sessions/{id}` | v0.2. Returns `SessionState`. | + +### `RunResult` helpers + +```rust +let r = client.run(req).await?; + +// Try a typed shape. +#[derive(serde::Deserialize)] +struct Recipe { name: String, qty: u32 } +let recipe: Recipe = r.as_json()?; + +// Or fall back to a string when the model declined to emit JSON. +if let Some(text) = r.as_text() { + println!("{text}"); +} +``` + +`r.result` itself is `serde_json::Value` if you need to branch on shape. + +### Error model + +```rust +pub enum Error { + Auth(String), // missing/invalid bearer, 401, 403 + Api { status: u16, body: String }, // any other non-2xx + Transport(reqwest::Error), // connect, TLS, read, request timeout + Json(serde_json::Error), // decode failure on a 2xx body + Io(std::io::Error), // local file open in upload_file + Timeout(String), // explicit deadline (reserved) + Config(String), // builder misconfiguration +} +``` + +A 502 from `/run` lands in `Error::Api { status: 502, body }` — the body is +the JSON failure envelope. Recover the structured form with: + +```rust +let parsed: clawdforge::RunFailure = serde_json::from_str(&body)?; +``` + +## Wire format + +clawdforge speaks **snake_case JSON** end-to-end. The structs in this crate +match that without `#[serde(rename_all = "camelCase")]`. If a future endpoint +exposes camelCase, prefer per-field `#[serde(rename = "...")]` over a blanket +container attribute so both styles can coexist. + +## Examples + +```sh +CLAWDFORGE_URL=http://localhost:8800 \ +CLAWDFORGE_TOKEN=cf_xxxx \ +cargo run --example basic +``` + +Optional file demo: + +```sh +CLAWDFORGE_DEMO_FILE=./some.png cargo run --example basic +``` + +## Development + +```sh +cargo build --release +cargo test --all +cargo clippy --all-targets -- -D warnings +cargo build --examples +``` + +Tests use [`wiremock`](https://docs.rs/wiremock) — no live clawdforge needed. + +## License + +MIT. diff --git a/vendor/clawdforge/examples/basic.rs b/vendor/clawdforge/examples/basic.rs new file mode 100644 index 0000000..e039aa9 --- /dev/null +++ b/vendor/clawdforge/examples/basic.rs @@ -0,0 +1,77 @@ +//! End-to-end usage example. +//! +//! Run against a live clawdforge: +//! +//! ```sh +//! CLAWDFORGE_URL=http://localhost:8800 \ +//! CLAWDFORGE_TOKEN=cf_xxxxxxxxxxxxxxxx \ +//! cargo run --example basic +//! ``` + +use clawdforge::{Client, RunRequest}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +struct Hello { + hello: String, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let url = + std::env::var("CLAWDFORGE_URL").unwrap_or_else(|_| "http://localhost:8800".to_string()); + let token = std::env::var("CLAWDFORGE_TOKEN") + .map_err(|_| "set CLAWDFORGE_TOKEN to a cf_... bearer minted via /admin/tokens")?; + + let client = Client::builder().base_url(url).token(token).build()?; + + // 1) liveness + let h = client.healthz().await?; + println!( + "healthz: ok={} claude_present={} version={:?}", + h.ok, h.claude_present, h.claude_version + ); + + // 2) JSON-shaped run + let r = client + .run(RunRequest { + prompt: r#"Reply with JSON: {"hello": "world"}"#.into(), + model: Some("sonnet".into()), + timeout_secs: Some(30), + ..Default::default() + }) + .await?; + + println!( + "duration_ms={} stop_reason={:?}", + r.duration_ms, r.stop_reason + ); + + match r.as_json::() { + Ok(v) => println!("parsed: hello = {}", v.hello), + Err(_) => match r.as_text() { + Some(t) => println!("text reply: {t}"), + None => println!("unparseable reply: {:?}", r.result), + }, + } + + // 3) optional file upload — only if a path is given. + if let Ok(path) = std::env::var("CLAWDFORGE_DEMO_FILE") { + let ft = client.upload_file(&path, Some(3600)).await?; + println!( + "uploaded {} bytes -> {} (ttl {}s)", + ft.size, ft.file_token, ft.ttl_secs + ); + + let r2 = client + .run(RunRequest { + prompt: "Describe the attached file in one sentence.".into(), + files: Some(vec![ft.file_token]), + ..Default::default() + }) + .await?; + println!("file-run reply: {:?}", r2.result); + } + + Ok(()) +} diff --git a/vendor/clawdforge/src/client.rs b/vendor/clawdforge/src/client.rs new file mode 100644 index 0000000..950e1f4 --- /dev/null +++ b/vendor/clawdforge/src/client.rs @@ -0,0 +1,515 @@ +//! HTTP client for clawdforge. + +use std::path::Path; +use std::time::Duration; + +use reqwest::header::{HeaderMap, HeaderValue}; +use reqwest::multipart::{Form, Part}; +use reqwest::{Body, Method, Response, StatusCode}; +use serde::de::DeserializeOwned; +use tokio_util::io::ReaderStream; +use url::Url; + +use crate::error::Error; +use crate::session::{ + Session, SessionCloseResponse, SessionCreateResponse, SessionList, SessionOptions, + SessionState, TurnResult, +}; +use crate::types::{ + AppToken, FileToken, Healthz, RunRequest, RunResult, TokenCreateRequest, TokenList, +}; + +/// Default request timeout if neither the builder nor the per-call helper sets +/// one. 120 s leaves headroom over the server's default 60 s `claude` timeout +/// without making `/healthz` callers wait forever on a dead host. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120); + +/// Async client for the clawdforge HTTP API. +/// +/// Construct via [`Client::builder`]. `Client` is cheap to clone — internally +/// it wraps an `Arc`-backed `reqwest::Client`. +/// +/// `Debug` is hand-written to redact bearer tokens — `format!("{:?}", client)` +/// will never expose `app_token` or `admin_token` plaintext. +#[derive(Clone)] +pub struct Client { + inner: reqwest::Client, + base: Url, + /// App-level bearer (used for `/run`, `/files`, `/healthz`). Optional so + /// admin-only callers don't have to mint a worthless app token. + app_token: Option, + /// Admin bootstrap token (used for `/admin/*`). Optional. + admin_token: Option, + /// Optional cap on `upload_file` size in bytes — `None` = no cap. + max_upload_bytes: Option, +} + +impl std::fmt::Debug for Client { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Client") + .field("base_url", &self.base.as_str()) + .field("app_token", &self.app_token.as_ref().map(|_| "")) + .field( + "admin_token", + &self.admin_token.as_ref().map(|_| ""), + ) + .field("max_upload_bytes", &self.max_upload_bytes) + .finish_non_exhaustive() + } +} + +/// Builder for [`Client`]. +/// +/// `Debug` is hand-written to redact bearer tokens. +#[derive(Default)] +pub struct ClientBuilder { + base_url: Option, + app_token: Option, + admin_token: Option, + timeout: Option, + user_agent: Option, + danger_accept_invalid_certs: bool, + max_upload_bytes: Option, +} + +impl std::fmt::Debug for ClientBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ClientBuilder") + .field("base_url", &self.base_url) + .field("app_token", &self.app_token.as_ref().map(|_| "")) + .field( + "admin_token", + &self.admin_token.as_ref().map(|_| ""), + ) + .field("timeout", &self.timeout) + .field("user_agent", &self.user_agent) + .field( + "danger_accept_invalid_certs", + &self.danger_accept_invalid_certs, + ) + .field("max_upload_bytes", &self.max_upload_bytes) + .finish_non_exhaustive() + } +} + +impl Client { + /// Start building a client. + pub fn builder() -> ClientBuilder { + ClientBuilder::default() + } + + /// Base URL the client was configured with (trailing slash trimmed). + pub fn base_url(&self) -> &str { + self.base.as_str().trim_end_matches('/') + } + + // ---- public API -------------------------------------------------------- + + /// `GET /healthz`. Does not require an app token, but the server still + /// enforces the global IP allowlist. + pub async fn healthz(&self) -> Result { + let url = self.url("/healthz")?; + let req = self.inner.request(Method::GET, url); + // healthz works without a token, but if we have one, send it — the + // server treats it as a no-op. + let req = match &self.app_token { + Some(t) => req.bearer_auth(t), + None => req, + }; + let resp = req.send().await?; + json_or_error(resp).await + } + + /// `POST /run`. Returns the parsed [`RunResult`] on success. On HTTP 502 + /// the body is surfaced as [`Error::Api`] with `status = 502` and the + /// failure JSON in `body` — see [`crate::types::RunFailure`] for the + /// structured form. + pub async fn run(&self, body: RunRequest) -> Result { + let token = self + .app_token + .as_deref() + .ok_or_else(|| Error::Auth("no app token configured".into()))?; + let url = self.url("/run")?; + let resp = self + .inner + .post(url) + .bearer_auth(token) + .json(&body) + .send() + .await?; + json_or_error(resp).await + } + + /// `POST /files`. Streams the file from disk via `tokio::fs::File`; large + /// uploads do not buffer fully in memory. + /// + /// `ttl_secs` defaults to the server's 3600 if `None`. Server clamps to + /// `60..=86400`. + /// + /// If [`ClientBuilder::max_upload_bytes`] was set and the file's size on + /// disk exceeds it, returns [`Error::Config`] before opening any network + /// connection. + pub async fn upload_file( + &self, + path: impl AsRef, + ttl_secs: Option, + ) -> Result { + let token = self + .app_token + .as_deref() + .ok_or_else(|| Error::Auth("no app token configured".into()))?; + let path = path.as_ref(); + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("upload") + .to_string(); + + let file = tokio::fs::File::open(path).await?; + let len = file.metadata().await?.len(); + + if let Some(max) = self.max_upload_bytes { + if len > max { + return Err(Error::Config(format!( + "file size {len} bytes exceeds max_upload_bytes={max}" + ))); + } + } + + let stream = ReaderStream::new(file); + let body = Body::wrap_stream(stream); + + let part = Part::stream_with_length(body, len) + .file_name(file_name) + .mime_str("application/octet-stream") + .map_err(|e| Error::Config(format!("invalid mime: {e}")))?; + + let mut form = Form::new().part("file", part); + if let Some(t) = ttl_secs { + form = form.text("ttl_secs", t.to_string()); + } + + let url = self.url("/files")?; + let resp = self + .inner + .post(url) + .bearer_auth(token) + .multipart(form) + .send() + .await?; + json_or_error(resp).await + } + + /// `POST /admin/tokens`. Requires an admin token on the client. + pub async fn create_token(&self, body: TokenCreateRequest) -> Result { + let token = self.require_admin()?; + let url = self.url("/admin/tokens")?; + let resp = self + .inner + .post(url) + .bearer_auth(token) + .json(&body) + .send() + .await?; + json_or_error(resp).await + } + + /// `GET /admin/tokens`. Requires an admin token on the client. + pub async fn list_tokens(&self) -> Result { + let token = self.require_admin()?; + let url = self.url("/admin/tokens")?; + let resp = self.inner.get(url).bearer_auth(token).send().await?; + json_or_error(resp).await + } + + /// `DELETE /admin/tokens/{name}`. Requires an admin token on the client. + /// Returns `Ok(())` on success, [`Error::Api`] with status 404 if the + /// token does not exist. + /// + /// `name` is validated client-side to match the server's + /// `[a-z0-9][a-z0-9_-]{0,63}` constraint — anything containing `/`, `?`, + /// `#`, `..`, or empty short-circuits with [`Error::Config`] before a + /// request is sent. This is defense-in-depth against path traversal via + /// `Url::join` (which honors RFC 3986 `..` resolution). + pub async fn revoke_token(&self, name: &str) -> Result<(), Error> { + if name.is_empty() + || name.contains('/') + || name.contains('?') + || name.contains('#') + || name.contains("..") + { + return Err(Error::Config(format!("invalid token name: {name:?}"))); + } + let token = self.require_admin()?; + let url = self.url(&format!("/admin/tokens/{name}"))?; + let resp = self.inner.delete(url).bearer_auth(token).send().await?; + // 2xx is success regardless of body — RFC-correct DELETE may return + // 204 No Content with no body. + if resp.status().is_success() { + return Ok(()); + } + // Non-2xx: route through json_or_error to get Auth/Api mapping. + // Discard the (already-non-success) deserialization slot. + let _: serde_json::Value = json_or_error(resp).await?; + Ok(()) + } + + // ---- v0.2 multi-turn / sessions --------------------------------------- + + /// `POST /sessions`. Create a new multi-turn session and return a + /// [`Session`] handle bound to this client. + /// + /// The handle owns a clone of the client; dropping it without an explicit + /// `Session::close().await?` triggers a best-effort async DELETE via + /// `tokio::spawn`. See [`Session`] for the full lifecycle contract. + pub async fn new_session(&self, opts: SessionOptions) -> Result { + let token = self.require_app()?; + let url = self.url("/sessions")?; + let resp = self + .inner + .post(url) + .bearer_auth(token) + .json(&opts) + .send() + .await?; + let created: SessionCreateResponse = json_or_error(resp).await?; + Ok(Session { + client: self.clone(), + session_id: created.session_id, + agent: created.agent, + created_at: created.created_at, + closed: std::sync::atomic::AtomicBool::new(false), + }) + } + + /// `GET /sessions`. List all sessions visible to the calling app token. + pub async fn list_sessions(&self) -> Result { + let token = self.require_app()?; + let url = self.url("/sessions")?; + let resp = self.inner.get(url).bearer_auth(token).send().await?; + json_or_error(resp).await + } + + /// `GET /sessions/{id}`. Fetch the current state of a session. + /// + /// `id` is validated client-side against the same path-traversal guard as + /// [`Self::revoke_token`] — anything containing `/`, `?`, `#`, `..`, or + /// empty short-circuits with [`Error::Config`]. + pub async fn get_session(&self, id: &str) -> Result { + validate_session_id(id)?; + let token = self.require_app()?; + let url = self.url(&format!("/sessions/{id}"))?; + let resp = self.inner.get(url).bearer_auth(token).send().await?; + json_or_error(resp).await + } + + /// Internal helper used by both [`Session::close`] and [`Session`]'s + /// `Drop` impl to issue `DELETE /sessions/{id}`. + pub(crate) async fn close_session_internal(&self, id: &str) -> Result<(), Error> { + validate_session_id(id)?; + let token = self.require_app()?; + let url = self.url(&format!("/sessions/{id}"))?; + let resp = self.inner.delete(url).bearer_auth(token).send().await?; + if resp.status().is_success() { + // Body is informational — `{ ok, already_closed? }`. Drain and + // ignore decode failure (server may legitimately 204). + let bytes = resp.bytes().await?; + if !bytes.is_empty() { + let _ = serde_json::from_slice::(&bytes); + } + return Ok(()); + } + // Funnel non-2xx through json_or_error for Auth/Api mapping. + let _: serde_json::Value = json_or_error(resp).await?; + Ok(()) + } + + /// Internal helper used by [`Session::turn`] / + /// [`Session::turn_with_files`] to dispatch to + /// `POST /sessions/{id}/turn`. + pub(crate) async fn turn_internal( + &self, + id: &str, + body: &B, + ) -> Result { + validate_session_id(id)?; + let token = self.require_app()?; + let url = self.url(&format!("/sessions/{id}/turn"))?; + let resp = self + .inner + .post(url) + .bearer_auth(token) + .json(body) + .send() + .await?; + json_or_error(resp).await + } + + // ---- internal ---------------------------------------------------------- + + fn require_app(&self) -> Result<&str, Error> { + self.app_token + .as_deref() + .ok_or_else(|| Error::Auth("no app token configured".into())) + } + + fn require_admin(&self) -> Result<&str, Error> { + self.admin_token + .as_deref() + .ok_or_else(|| Error::Auth("no admin token configured".into())) + } + + fn url(&self, path: &str) -> Result { + let trimmed = path.strip_prefix('/').unwrap_or(path); + self.base + .join(trimmed) + .map_err(|e| Error::Config(format!("bad path {path:?}: {e}"))) + } +} + +impl ClientBuilder { + /// Set the base URL (e.g. `http://localhost:8800`). Required. + pub fn base_url(mut self, url: impl Into) -> Self { + self.base_url = Some(url.into()); + self + } + + /// Set the app bearer token used for `/run`, `/files`, and `/healthz`. + pub fn token(mut self, token: impl Into) -> Self { + self.app_token = Some(token.into()); + self + } + + /// Set the admin bootstrap token used for `/admin/*`. May be set + /// alongside [`Self::token`]. + pub fn admin_token(mut self, token: impl Into) -> Self { + self.admin_token = Some(token.into()); + self + } + + /// Per-request timeout for the underlying `reqwest::Client`. Defaults to + /// 120 s. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Override the `User-Agent` header. Defaults to + /// `clawdforge-rs/`. + pub fn user_agent(mut self, ua: impl Into) -> Self { + self.user_agent = Some(ua.into()); + self + } + + /// Skip TLS certificate verification. Off by default. Only useful against + /// self-signed local deployments. + pub fn danger_accept_invalid_certs(mut self, enable: bool) -> Self { + self.danger_accept_invalid_certs = enable; + self + } + + /// Maximum file size (in bytes) accepted by [`Client::upload_file`]. + /// Files exceeding this cap fail with [`Error::Config`] before any + /// network I/O. Default `None` = no client-side cap (the server's own + /// limit still applies). + pub fn max_upload_bytes(mut self, max: u64) -> Self { + self.max_upload_bytes = Some(max); + self + } + + /// Finalize. Errors if `base_url` is missing or unparseable. + pub fn build(self) -> Result { + let base_raw = self + .base_url + .ok_or_else(|| Error::Config("base_url is required".into()))?; + // Ensure trailing slash so `Url::join` treats the base as a directory. + let base_str = if base_raw.ends_with('/') { + base_raw + } else { + format!("{base_raw}/") + }; + let base = + Url::parse(&base_str).map_err(|e| Error::Config(format!("invalid base_url: {e}")))?; + if !matches!(base.scheme(), "http" | "https") { + return Err(Error::Config(format!( + "unsupported scheme: {}", + base.scheme() + ))); + } + + let ua = self + .user_agent + .unwrap_or_else(|| format!("clawdforge-rs/{}", env!("CARGO_PKG_VERSION"))); + + let mut headers = HeaderMap::new(); + headers.insert( + reqwest::header::ACCEPT, + HeaderValue::from_static("application/json"), + ); + // We don't preset Authorization here — per-call helpers do it because + // the right token depends on which endpoint is being hit. + + let inner = reqwest::Client::builder() + .timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT)) + .user_agent(ua) + .default_headers(headers) + .danger_accept_invalid_certs(self.danger_accept_invalid_certs) + .build()?; + + Ok(Client { + inner, + base, + app_token: self.app_token, + admin_token: self.admin_token, + max_upload_bytes: self.max_upload_bytes, + }) + } +} + +/// Decode `T` from a successful 2xx response, otherwise lift to [`Error`]. +async fn json_or_error(resp: Response) -> Result { + let status = resp.status(); + if status.is_success() { + let bytes = resp.bytes().await?; + return Ok(serde_json::from_slice::(&bytes)?); + } + + // Non-2xx: capture body lossily then translate. + let body = resp.text().await.unwrap_or_default(); + match status { + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(Error::Auth(format!( + "{}: {}", + status.as_u16(), + truncate(&body, 500) + ))), + _ => Err(Error::api(status.as_u16(), body)), + } +} + +/// Defense-in-depth path validator for session ids in `/sessions/{id}*`. Same +/// shape as [`Client::revoke_token`]'s name guard — RFC 3986 dot-segment +/// resolution inside `Url::join` is the threat model here. +fn validate_session_id(id: &str) -> Result<(), Error> { + if id.is_empty() + || id.contains('/') + || id.contains('?') + || id.contains('#') + || id.contains("..") + { + return Err(Error::Config(format!("invalid session id: {id:?}"))); + } + Ok(()) +} + +/// Truncate `s` to at most `max` bytes, snapping down to the nearest UTF-8 +/// codepoint boundary so we never panic on multibyte sequences. Appends `…` +/// to the truncated form. `str::floor_char_boundary` (stable 1.80+) does the +/// boundary math. +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + let safe = s.floor_char_boundary(max); + format!("{}…", &s[..safe]) + } +} diff --git a/vendor/clawdforge/src/error.rs b/vendor/clawdforge/src/error.rs new file mode 100644 index 0000000..74edaca --- /dev/null +++ b/vendor/clawdforge/src/error.rs @@ -0,0 +1,68 @@ +//! Error types for the clawdforge client. + +use thiserror::Error; + +/// All errors surfaced by the client. +/// +/// Variants are deliberately coarse — the underlying transport / serde errors +/// are preserved as `source()` for callers who want to dig in. +#[derive(Debug, Error)] +pub enum Error { + /// 401/403 from the server, or a missing/empty bearer token configured on + /// the client. + #[error("authentication failed: {0}")] + Auth(String), + + /// Any non-2xx response that wasn't an auth error. The response body is + /// captured as a UTF-8 string (lossy if the server returned binary). + #[error("api error: status={status} body={body}")] + Api { + /// HTTP status code. + status: u16, + /// Response body (best-effort UTF-8). + body: String, + }, + + /// Request never completed: DNS, connect, TLS, body-read, etc. + #[error("transport error: {0}")] + Transport(reqwest::Error), + + /// JSON decode failed on a successful HTTP response. + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + /// Local I/O error — currently only emitted by `upload_file` when opening + /// the source file. + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + /// The configured request timeout elapsed before the server replied. + /// Mapped from `reqwest::Error::is_timeout()` so callers can match on + /// timeouts specifically without inspecting the inner transport error. + #[error("timeout: {0}")] + Timeout(String), + + /// Misconfigured client (e.g. invalid base URL). + #[error("invalid configuration: {0}")] + Config(String), +} + +impl From for Error { + fn from(e: reqwest::Error) -> Self { + if e.is_timeout() { + Self::Timeout(e.to_string()) + } else { + Self::Transport(e) + } + } +} + +impl Error { + /// Build an [`Error::Api`] from a status code and body string. + pub(crate) fn api(status: u16, body: impl Into) -> Self { + Self::Api { + status, + body: body.into(), + } + } +} diff --git a/vendor/clawdforge/src/lib.rs b/vendor/clawdforge/src/lib.rs new file mode 100644 index 0000000..59d8313 --- /dev/null +++ b/vendor/clawdforge/src/lib.rs @@ -0,0 +1,58 @@ +//! Async Rust client for the [clawdforge] HTTP service. +//! +//! clawdforge is a small LAN-only service that wraps `claude -p` subprocess +//! calls behind a bearer-token-gated REST API. This crate is a thin, +//! ergonomic Rust SDK for it. +//! +//! # Quickstart +//! +//! ```no_run +//! use clawdforge::{Client, RunRequest}; +//! +//! # async fn run() -> Result<(), Box> { +//! let client = Client::builder() +//! .base_url("http://localhost:8800") +//! .token("cf_xxxxxxxxxxxxxxxx") +//! .build()?; +//! +//! let h = client.healthz().await?; +//! println!("claude present: {}", h.claude_present); +//! +//! let r = client.run(RunRequest { +//! prompt: "Reply with JSON: {\"hello\":\"world\"}".into(), +//! model: Some("sonnet".into()), +//! ..Default::default() +//! }).await?; +//! +//! #[derive(serde::Deserialize)] +//! struct Hello { hello: String } +//! let typed: Hello = r.as_json()?; +//! println!("{}", typed.hello); +//! # Ok(()) } +//! ``` +//! +//! [clawdforge]: https://gitea.sulkta.com/Sulkta-Coop/clawdforge +//! +//! # Field naming +//! +//! The clawdforge wire format is snake_case end-to-end (Python / Pydantic +//! conventions), so structs in [`crate::types`] do **not** carry +//! `#[serde(rename_all = "camelCase")]`. If a future endpoint exposes +//! camelCase, prefer per-field `#[serde(rename = "...")]` over a blanket +//! container attribute. + +#![deny(rust_2018_idioms)] +#![deny(missing_docs)] + +mod client; +mod error; +pub mod session; +pub mod types; + +pub use client::{Client, ClientBuilder}; +pub use error::Error; +pub use session::{Session, SessionList, SessionOptions, SessionState, TurnEvent, TurnResult}; +pub use types::{ + AppToken, AppTokenInfo, FileToken, Healthz, RunFailure, RunRequest, RunResult, + TokenCreateRequest, TokenList, +}; diff --git a/vendor/clawdforge/src/session.rs b/vendor/clawdforge/src/session.rs new file mode 100644 index 0000000..915cf5b --- /dev/null +++ b/vendor/clawdforge/src/session.rs @@ -0,0 +1,281 @@ +//! Multi-turn session API (v0.2). +//! +//! v0.2 adds a parallel `/sessions/*` surface to clawdforge backed by ACPX. A +//! [`Session`] is a handle to one server-side session; [`Session::turn`] +//! dispatches a single prompt+files turn and returns the structured event +//! batch. Sessions are explicitly closed via [`Session::close`] (consumes the +//! handle, preventing use-after-close at compile time) or — as a last-resort +//! fallback — best-effort closed by [`Drop`] via `tokio::spawn`. +//! +//! v0.1 single-turn `Client::run` is unchanged; the v0.2 surface is purely +//! additive. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use serde::{Deserialize, Serialize}; + +use crate::client::Client; +use crate::error::Error; + +/// Options passed to [`Client::new_session`]. +/// +/// `Default` produces `agent = None` (server picks `"claude"`) and `meta = +/// None`. +#[derive(Debug, Default, Clone, Serialize)] +pub struct SessionOptions { + /// Agent slug to dispatch to. `None` falls back to the server-side default + /// (`"claude"`). + #[serde(skip_serializing_if = "Option::is_none")] + pub agent: Option, + + /// Free-form metadata stored alongside the session ledger row. + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// Reply body from `POST /sessions`. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct SessionCreateResponse { + pub session_id: String, + pub agent: String, + pub created_at: i64, +} + +/// One event in a turn's structured output. +/// +/// `event_type` is one of `"thinking"`, `"text"`, `"tool_call"`, etc. (server +/// is the authority on the set). +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TurnEvent { + /// Event discriminator (`"text"`, `"thinking"`, `"tool_call"`, ...). + #[serde(rename = "type")] + pub event_type: String, + /// Text content for `"text"` and `"thinking"` events. + #[serde(default)] + pub content: Option, + /// Tool name for `"tool_call"` events. + #[serde(default)] + pub name: Option, + /// Tool arguments for `"tool_call"` events. + #[serde(default)] + pub args: Option, + /// Tool result for `"tool_call"` events. + #[serde(default)] + pub result: Option, +} + +/// Successful response body from `POST /sessions/{id}/turn`. +#[derive(Debug, Clone, Deserialize)] +pub struct TurnResult { + /// Always `true` on a 200 reply. + pub ok: bool, + /// The session this turn belongs to. + pub session_id: String, + /// 1-based index of this turn within the session. + pub turn_index: i32, + /// Structured events emitted during the turn. + pub events: Vec, + /// Reason the agent stopped (`"end_turn"`, `"max_tokens"`, ...). + pub stop_reason: String, + /// Wall-clock duration of the turn. + pub duration_ms: i64, +} + +impl TurnResult { + /// Concatenate all `"text"` event contents into a single string. + /// + /// Non-text events (`thinking`, `tool_call`, ...) are skipped. If an event + /// is `"text"` but `content` is `None`, it contributes the empty string. + pub fn text(&self) -> String { + let mut out = String::new(); + for ev in &self.events { + if ev.event_type == "text" { + if let Some(c) = ev.content.as_deref() { + out.push_str(c); + } + } + } + out + } +} + +/// Reply body from `GET /sessions/{id}` and entries in `GET /sessions`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SessionState { + /// Server-issued session id. + pub session_id: String, + /// Agent slug bound to the session. + pub agent: String, + /// App / consumer name that owns the session. + pub app_name: String, + /// Unix epoch seconds when created. + pub created_at: i64, + /// Unix epoch seconds of the last successful turn (or `None` if zero turns + /// have been dispatched yet). + #[serde(default)] + pub last_turn_at: Option, + /// Number of turns dispatched. + pub turn_count: i32, + /// Unix epoch seconds when closed (or `None` if still open). + #[serde(default)] + pub closed_at: Option, +} + +/// Reply body from `DELETE /sessions/{id}`. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct SessionCloseResponse { + #[allow(dead_code)] + pub ok: bool, + #[serde(default)] + #[allow(dead_code)] + pub already_closed: Option, +} + +/// Reply body from `GET /sessions`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SessionList { + /// All sessions visible to the calling token. + pub sessions: Vec, +} + +/// Request body for `POST /sessions/{id}/turn`. +#[derive(Debug, Serialize)] +struct TurnRequest<'a> { + prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + files: Option<&'a [String]>, +} + +/// A handle to one server-side multi-turn session. +/// +/// Construct via [`Client::new_session`]. Drop or [`Session::close`] to +/// release the server-side session. `close` consumes the value so use-after- +/// close is a compile error; `Drop` is a best-effort backstop that fires an +/// async DELETE via `tokio::spawn` and logs (does not panic) on failure. +/// +/// `Debug` is hand-written and explicitly excludes the embedded [`Client`] so +/// no bearer can leak through `{:?}` formatting. +pub struct Session { + pub(crate) client: Client, + pub(crate) session_id: String, + pub(crate) agent: String, + pub(crate) created_at: i64, + pub(crate) closed: AtomicBool, +} + +impl std::fmt::Debug for Session { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Deliberately omit `client` — its Debug already redacts tokens, but + // the spec-mandated shape is `Session { session_id, agent, closed }` + // to keep the surface minimal and audit-friendly. + f.debug_struct("Session") + .field("session_id", &self.session_id) + .field("agent", &self.agent) + .field("created_at", &self.created_at) + .field("closed", &self.closed.load(Ordering::Acquire)) + .finish() + } +} + +impl Session { + /// Server-issued session id. + pub fn id(&self) -> &str { + &self.session_id + } + + /// Agent slug the server bound to this session. + pub fn agent(&self) -> &str { + &self.agent + } + + /// Unix epoch seconds when the session was created server-side. + pub fn created_at(&self) -> i64 { + self.created_at + } + + /// Whether the session has been explicitly closed already. Sessions closed + /// only via `Drop`'s spawn are still reported as closed once that future + /// has run; this getter reflects the in-memory flag. + pub fn is_closed(&self) -> bool { + self.closed.load(Ordering::Acquire) + } + + /// Send a turn with no attached files. + /// + /// Equivalent to `turn_with_files(prompt, &[])` but skips serializing the + /// `files` field on the wire. + pub async fn turn(&mut self, prompt: impl Into) -> Result { + self.dispatch_turn(prompt.into(), None).await + } + + /// Send a turn that references previously uploaded file tokens. + /// + /// `files` is the list of `ff_*` tokens returned by [`Client::upload_file`]. + /// + /// [`Client::upload_file`]: crate::Client::upload_file + pub async fn turn_with_files( + &mut self, + prompt: impl Into, + files: &[String], + ) -> Result { + self.dispatch_turn(prompt.into(), Some(files)).await + } + + async fn dispatch_turn( + &mut self, + prompt: String, + files: Option<&[String]>, + ) -> Result { + if self.closed.load(Ordering::Acquire) { + return Err(Error::Config("session is closed".into())); + } + self.client + .turn_internal(&self.session_id, &TurnRequest { prompt, files }) + .await + } + + /// Explicitly close the session. Consumes `self` — use-after-close is a + /// compile error. + /// + /// If the session is already closed in memory (e.g. via a prior failed + /// close or a prior dispatch path that flagged it), this short-circuits + /// without contacting the server. + pub async fn close(self) -> Result<(), Error> { + // Mark closed before the network call so a panic-mid-await on the + // request future cannot trigger Drop's spawn into a double-close. + if self.closed.swap(true, Ordering::AcqRel) { + return Ok(()); + } + self.client.close_session_internal(&self.session_id).await + } +} + +impl Drop for Session { + fn drop(&mut self) { + // If close() already ran, nothing to do. + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + // tokio::spawn panics if no runtime is current. Guard against being + // dropped from a sync context (e.g. a forgotten value at the end of a + // sync `main`). + if tokio::runtime::Handle::try_current().is_err() { + tracing::warn!( + session_id = %self.session_id, + "Session dropped outside a tokio runtime; server-side session not closed" + ); + return; + } + let client = self.client.clone(); + let id = self.session_id.clone(); + tokio::spawn(async move { + if let Err(e) = client.close_session_internal(&id).await { + tracing::warn!( + session_id = %id, + error = %e, + "best-effort drop close failed" + ); + } + }); + } +} diff --git a/vendor/clawdforge/src/types.rs b/vendor/clawdforge/src/types.rs new file mode 100644 index 0000000..1774539 --- /dev/null +++ b/vendor/clawdforge/src/types.rs @@ -0,0 +1,186 @@ +//! Wire types matching the clawdforge HTTP API. +//! +//! Field naming note: clawdforge uses snake_case on the wire (matches Python / +//! Pydantic conventions), so these structs use plain `#[derive(Serialize, +//! Deserialize)]` without `rename_all`. If a future endpoint surfaces +//! camelCase, opt in per-field with `#[serde(rename = "...")]`. + +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +use crate::error::Error; + +/// `GET /healthz` response body. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Healthz { + /// Always `true` if the server replied. + pub ok: bool, + /// Whether the `claude` binary was discovered on `PATH`. + pub claude_present: bool, + /// First line of `claude --version` (or `null` if not present). + pub claude_version: Option, +} + +/// Request body for `POST /run`. +/// +/// All optional fields default to `None` — use `..Default::default()` to fill +/// the rest: +/// +/// ```no_run +/// use clawdforge::RunRequest; +/// let r = RunRequest { +/// prompt: "say hi".into(), +/// model: Some("sonnet".into()), +/// ..Default::default() +/// }; +/// ``` +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RunRequest { + /// Prompt text. Must be non-empty server-side. + pub prompt: String, + + /// Model alias passed to `claude -p --model`. `None` falls back to the + /// server-side default (typically `sonnet`). + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// System prompt appended via `claude -p --append-system-prompt`. + #[serde(skip_serializing_if = "Option::is_none")] + pub system: Option, + + /// File tokens previously returned from [`Client::upload_file`]. + /// + /// [`Client::upload_file`]: crate::Client::upload_file + #[serde(skip_serializing_if = "Option::is_none")] + pub files: Option>, + + /// Subprocess timeout in seconds. Server clamps to `5..=600`. + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_secs: Option, +} + +/// Successful response body from `POST /run`. +/// +/// `result` is intentionally a [`serde_json::Value`] — clawdforge auto-parses +/// the `claude` reply as JSON when possible and falls back to a raw string +/// otherwise. Use [`RunResult::as_json`] to deserialize into a typed struct +/// or [`RunResult::as_text`] when you expect a string. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RunResult { + /// Always `true` for a 200 response (the server returns 502 on failure). + pub ok: bool, + /// Parsed claude output. JSON object/array/number/bool when the model + /// emitted JSON; string otherwise. + pub result: serde_json::Value, + /// Wall-clock duration of the subprocess. + pub duration_ms: u64, + /// `claude` stop reason, e.g. `"end_turn"`. Sometimes `None` on edge cases. + pub stop_reason: Option, +} + +impl RunResult { + /// Deserialize `result` as `T`. Fails with [`Error::Json`] if the server + /// returned a string or a JSON shape that doesn't match `T`. + pub fn as_json(&self) -> Result { + Ok(serde_json::from_value(self.result.clone())?) + } + + /// Borrow `result` as a string slice if it was a JSON string. + /// Returns `None` for objects, arrays, numbers, etc. + pub fn as_text(&self) -> Option<&str> { + self.result.as_str() + } +} + +/// Failure body from `POST /run` (HTTP 502). +/// +/// Surfaced inside [`Error::Api`] via the `body` field as JSON text. Provided +/// here so callers can `serde_json::from_str::(&body)` to recover +/// structured data. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RunFailure { + /// Always `false` for a failure body. + pub ok: bool, + /// Short error label set by the runner (e.g. `"timeout"`, `"non_zero_exit"`). + pub error: Option, + /// Last 4 KB of `claude` stderr, when available. + pub stderr: Option, + /// Wall-clock duration of the (failed) subprocess. + pub duration_ms: u64, + /// `claude` stop reason if the failure produced one. + pub stop_reason: Option, +} + +/// Response body from `POST /files`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct FileToken { + /// Opaque token, prefix `ff_`. Pass to [`RunRequest::files`]. + pub file_token: String, + /// TTL the server registered (clamped to 60..=86400). + pub ttl_secs: u32, + /// Bytes written to the staging dir. + pub size: u64, +} + +/// Response body from `POST /admin/tokens`. +/// +/// `token` is the plaintext bearer — only returned at creation time. +/// +/// `Debug` is hand-written to redact `token` (the plaintext bearer); `name` +/// and `ip_cidrs` print verbatim. +#[derive(Clone, Deserialize, Serialize)] +pub struct AppToken { + /// App / consumer name. + pub name: String, + /// Plaintext bearer (`cf_...`). Save it now; the server stores only the + /// SHA-256. + pub token: String, + /// CIDRs the token is restricted to. Empty = unrestricted (still subject + /// to the global allowlist). + pub ip_cidrs: Vec, +} + +impl std::fmt::Debug for AppToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AppToken") + .field("name", &self.name) + .field("token", &"") + .field("ip_cidrs", &self.ip_cidrs) + .finish() + } +} + +/// Request body for `POST /admin/tokens`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TokenCreateRequest { + /// `[a-z0-9][a-z0-9_-]{0,63}` — server enforces. + pub name: String, + /// Optional CIDR allowlist for this token. + #[serde(default)] + pub ip_cidrs: Vec, +} + +/// One row of `GET /admin/tokens`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AppTokenInfo { + /// App / consumer name. + pub name: String, + /// CIDRs this token is restricted to. Empty = no per-token CIDR restriction. + #[serde(default)] + pub ip_cidrs: Vec, + /// Unix epoch seconds (server-controlled field shape — extra fields are + /// captured by `extra`). + #[serde(default)] + pub created_at: Option, + + /// Catch-all for any future fields the server adds. + #[serde(flatten)] + pub extra: serde_json::Map, +} + +/// Response body from `GET /admin/tokens`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TokenList { + /// All registered app tokens (hashes only — plaintext is shown once at + /// creation). + pub tokens: Vec, +} diff --git a/vendor/clawdforge/tests/client.rs b/vendor/clawdforge/tests/client.rs new file mode 100644 index 0000000..aefa741 --- /dev/null +++ b/vendor/clawdforge/tests/client.rs @@ -0,0 +1,517 @@ +//! Integration tests against an in-process wiremock server. + +use std::io::Write; +use std::time::Duration; + +use clawdforge::{Client, Error, RunRequest, TokenCreateRequest}; +use serde_json::json; +use wiremock::matchers::{body_json, body_string_contains, header, method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn make_client(server: &MockServer) -> Client { + Client::builder() + .base_url(server.uri()) + .token("cf_test_token") + .admin_token("admin_test_token") + .timeout(Duration::from_secs(5)) + .build() + .expect("client builds") +} + +#[tokio::test] +async fn healthz_returns_payload() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/healthz")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "claude_present": true, + "claude_version": "claude 1.2.3" + }))) + .mount(&server) + .await; + + let c = make_client(&server); + let h = c.healthz().await.unwrap(); + assert!(h.ok); + assert!(h.claude_present); + assert_eq!(h.claude_version.as_deref(), Some("claude 1.2.3")); +} + +#[tokio::test] +async fn run_success_with_json_result() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/run")) + .and(header("authorization", "Bearer cf_test_token")) + .and(body_json(json!({ + "prompt": "give me json", + "model": "sonnet" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "result": {"hello": "world", "n": 42}, + "duration_ms": 1234, + "stop_reason": "end_turn" + }))) + .mount(&server) + .await; + + let c = make_client(&server); + let r = c + .run(RunRequest { + prompt: "give me json".into(), + model: Some("sonnet".into()), + ..Default::default() + }) + .await + .unwrap(); + + assert!(r.ok); + assert_eq!(r.duration_ms, 1234); + assert_eq!(r.stop_reason.as_deref(), Some("end_turn")); + + #[derive(serde::Deserialize)] + struct Reply { + hello: String, + n: i32, + } + let parsed: Reply = r.as_json().unwrap(); + assert_eq!(parsed.hello, "world"); + assert_eq!(parsed.n, 42); + assert!(r.as_text().is_none()); +} + +#[tokio::test] +async fn run_success_with_text_result() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/run")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "result": "plain string reply", + "duration_ms": 50, + "stop_reason": "end_turn" + }))) + .mount(&server) + .await; + + let c = make_client(&server); + let r = c + .run(RunRequest { + prompt: "say hi".into(), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(r.as_text(), Some("plain string reply")); + let json_attempt: Result, _> = r.as_json(); + assert!( + json_attempt.is_err(), + "string should not deserialize as map" + ); +} + +#[tokio::test] +async fn run_502_surfaces_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/run")) + .respond_with(ResponseTemplate::new(502).set_body_json(json!({ + "ok": false, + "error": "claude exited 1", + "stderr": "boom", + "duration_ms": 10, + "stop_reason": null + }))) + .mount(&server) + .await; + + let c = make_client(&server); + let err = c + .run(RunRequest { + prompt: "fail".into(), + ..Default::default() + }) + .await + .expect_err("should fail"); + + match err { + Error::Api { status, body } => { + assert_eq!(status, 502); + assert!(body.contains("claude exited 1"), "body was {body}"); + // Demonstrate caller-side recovery via RunFailure. + let parsed: clawdforge::RunFailure = + serde_json::from_str(&body).expect("body is RunFailure JSON"); + assert!(!parsed.ok); + assert_eq!(parsed.error.as_deref(), Some("claude exited 1")); + } + other => panic!("unexpected error variant: {other:?}"), + } +} + +#[tokio::test] +async fn run_with_files_passes_through() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/run")) + .and(body_json(json!({ + "prompt": "use the file", + "files": ["ff_abc", "ff_def"] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "result": "saw 2 files", + "duration_ms": 100, + "stop_reason": "end_turn" + }))) + .mount(&server) + .await; + + let c = make_client(&server); + let r = c + .run(RunRequest { + prompt: "use the file".into(), + files: Some(vec!["ff_abc".into(), "ff_def".into()]), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(r.as_text(), Some("saw 2 files")); +} + +#[tokio::test] +async fn upload_file_streams_multipart() { + let server = MockServer::start().await; + + // wiremock can't easily decode multipart, so we fingerprint the bytes: + // the file's contents (as a UTF-8 substring) and the form field names. + Mock::given(method("POST")) + .and(path("/files")) + .and(header("authorization", "Bearer cf_test_token")) + .and(body_string_contains("hello-from-rust-test")) + .and(body_string_contains("name=\"file\"")) + .and(body_string_contains("name=\"ttl_secs\"")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "file_token": "ff_xyz", + "ttl_secs": 1800, + "size": 20 + }))) + .mount(&server) + .await; + + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!(tmp, "hello-from-rust-test").unwrap(); + tmp.flush().unwrap(); + + let c = make_client(&server); + let ft = c.upload_file(tmp.path(), Some(1800)).await.unwrap(); + assert_eq!(ft.file_token, "ff_xyz"); + assert_eq!(ft.ttl_secs, 1800); + assert_eq!(ft.size, 20); +} + +#[tokio::test] +async fn admin_create_token() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/admin/tokens")) + .and(header("authorization", "Bearer admin_test_token")) + .and(body_json(json!({ + "name": "cauldron", + "ip_cidrs": ["172.24.0.0/16"] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": "cauldron", + "token": "cf_brandnew", + "ip_cidrs": ["172.24.0.0/16"] + }))) + .mount(&server) + .await; + + let c = make_client(&server); + let t = c + .create_token(TokenCreateRequest { + name: "cauldron".into(), + ip_cidrs: vec!["172.24.0.0/16".into()], + }) + .await + .unwrap(); + assert_eq!(t.name, "cauldron"); + assert_eq!(t.token, "cf_brandnew"); + assert_eq!(t.ip_cidrs, vec!["172.24.0.0/16".to_string()]); +} + +#[tokio::test] +async fn admin_list_tokens() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/admin/tokens")) + .and(header("authorization", "Bearer admin_test_token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "tokens": [ + {"name": "cauldron", "ip_cidrs": ["172.24.0.0/16"], "created_at": 1700000000}, + {"name": "petalparse", "ip_cidrs": [], "created_at": 1700000100, "last_seen": 1700001000} + ] + }))) + .mount(&server) + .await; + + let c = make_client(&server); + let list = c.list_tokens().await.unwrap(); + assert_eq!(list.tokens.len(), 2); + assert_eq!(list.tokens[0].name, "cauldron"); + // unknown server-added field captured by `extra`. + assert!(list.tokens[1].extra.contains_key("last_seen")); +} + +#[tokio::test] +async fn admin_revoke_token() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path_regex(r"^/admin/tokens/.+")) + .and(header("authorization", "Bearer admin_test_token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .mount(&server) + .await; + + let c = make_client(&server); + c.revoke_token("cauldron").await.unwrap(); +} + +#[tokio::test] +async fn unauthorized_response_maps_to_auth_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/run")) + .respond_with(ResponseTemplate::new(401).set_body_string("missing token")) + .mount(&server) + .await; + + let c = make_client(&server); + let err = c + .run(RunRequest { + prompt: "nope".into(), + ..Default::default() + }) + .await + .expect_err("should fail"); + assert!(matches!(err, Error::Auth(_))); +} + +#[tokio::test] +async fn missing_app_token_short_circuits_run() { + // Build a client without an app token but with admin set. + let server = MockServer::start().await; + let c = Client::builder() + .base_url(server.uri()) + .admin_token("admin_only") + .build() + .unwrap(); + + let err = c + .run(RunRequest { + prompt: "x".into(), + ..Default::default() + }) + .await + .expect_err("should fail without app token"); + match err { + Error::Auth(msg) => assert!(msg.contains("no app token")), + other => panic!("unexpected: {other:?}"), + } +} + +#[tokio::test] +async fn error_timeout_constructed_on_reqwest_timeout() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/healthz")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_millis(2_000)) + .set_body_json(json!({ + "ok": true, + "claude_present": true, + "claude_version": "x" + })), + ) + .mount(&server) + .await; + + let c = Client::builder() + .base_url(server.uri()) + .token("cf_x") + .timeout(Duration::from_millis(150)) + .build() + .unwrap(); + let err = c.healthz().await.expect_err("should time out"); + assert!(matches!(err, Error::Timeout(_)), "got {err:?}"); +} + +#[tokio::test] +async fn builder_rejects_missing_base_url() { + let err = Client::builder().build().expect_err("should fail"); + assert!(matches!(err, Error::Config(_))); +} + +#[tokio::test] +async fn builder_rejects_bad_scheme() { + let err = Client::builder() + .base_url("ftp://nope") + .build() + .expect_err("should fail"); + assert!(matches!(err, Error::Config(_))); +} + +// ---- audit-driven regression tests -------------------------------------- + +/// H1: 4xx body with multibyte char straddling the truncation cutoff must +/// not panic. Build a 503-byte string where `ü` (2 bytes UTF-8) lands at +/// offset 499..501, so byte 500 is mid-codepoint. +#[tokio::test] +async fn truncate_handles_multibyte_boundary() { + let server = MockServer::start().await; + + let mut body = String::new(); + for _ in 0..499 { + body.push('a'); + } + body.push('ü'); // bytes 499 and 500 + for _ in 0..2 { + body.push('b'); + } + assert_eq!(body.len(), 503); + assert!(!body.is_char_boundary(500)); + + Mock::given(method("POST")) + .and(path("/run")) + .respond_with(ResponseTemplate::new(401).set_body_string(body.clone())) + .mount(&server) + .await; + + let c = make_client(&server); + let err = c + .run(RunRequest { + prompt: "x".into(), + ..Default::default() + }) + .await + .expect_err("should fail"); + // Just having reached this line — without panicking — is the assertion. + assert!(matches!(err, Error::Auth(_)), "got {err:?}"); +} + +/// H2: `Debug` on `Client` must not leak app or admin tokens. +#[tokio::test] +async fn client_debug_redacts_bearer() { + let server = MockServer::start().await; + let c = Client::builder() + .base_url(server.uri()) + .token("cf_super_secret_app_bearer") + .admin_token("admin_super_secret_bearer") + .build() + .unwrap(); + let dbg = format!("{c:?}"); + assert!( + !dbg.contains("cf_super_secret_app_bearer"), + "app token leaked: {dbg}" + ); + assert!( + !dbg.contains("admin_super_secret_bearer"), + "admin token leaked: {dbg}" + ); + assert!(dbg.contains(""), "no redaction marker: {dbg}"); + + // ClientBuilder Debug also redacts. + let builder = Client::builder() + .base_url("http://x") + .token("cf_builder_secret"); + let bdbg = format!("{builder:?}"); + assert!( + !bdbg.contains("cf_builder_secret"), + "builder token leaked: {bdbg}" + ); + assert!(bdbg.contains(""), "no redaction marker: {bdbg}"); +} + +/// H2: `Debug` on `AppToken` must not leak the plaintext `token` field. +#[test] +fn app_token_debug_redacts_token() { + let t = clawdforge::AppToken { + name: "cauldron".into(), + token: "cf_should_not_appear".into(), + ip_cidrs: vec!["172.24.0.0/16".into()], + }; + let dbg = format!("{t:?}"); + assert!(!dbg.contains("cf_should_not_appear"), "leaked: {dbg}"); + assert!(dbg.contains(""), "no marker: {dbg}"); + // name + ip_cidrs are non-secret and should still print. + assert!(dbg.contains("cauldron")); + assert!(dbg.contains("172.24.0.0/16")); +} + +/// H3: `revoke_token` must reject path-traversal sequences before issuing +/// any HTTP request. +#[tokio::test] +async fn revoke_token_rejects_path_traversal() { + let server = MockServer::start().await; + // No mock — if a request escaped client-side validation, wiremock would + // 404 and we'd see Error::Api, not Error::Config. + let c = make_client(&server); + for bad in [ + "../foo", "..", "foo/bar", "foo?x=1", "foo#frag", "", "a/../b", + ] { + let err = c + .revoke_token(bad) + .await + .expect_err(&format!("revoke_token({bad:?}) should reject")); + assert!( + matches!(err, Error::Config(_)), + "{bad:?} produced wrong variant: {err:?}" + ); + } +} + +/// M2: a 204 No Content response from `revoke_token` must Ok-out. +#[tokio::test] +async fn revoke_token_accepts_204_no_content() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path_regex(r"^/admin/tokens/.+")) + .and(header("authorization", "Bearer admin_test_token")) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; + + let c = make_client(&server); + c.revoke_token("cauldron") + .await + .expect("204 No Content should be Ok"); +} + +/// M4: `upload_file` with a `max_upload_bytes` cap rejects oversized files +/// before any network I/O. +#[tokio::test] +async fn upload_file_respects_max_upload_bytes() { + let server = MockServer::start().await; + // No /files mock — if the cap fails to short-circuit, the test will see + // a 404 from wiremock instead of Error::Config. + + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + // Write 1024 bytes; cap at 512. + write!(tmp, "{}", "x".repeat(1024)).unwrap(); + tmp.flush().unwrap(); + + let c = Client::builder() + .base_url(server.uri()) + .token("cf_test_token") + .max_upload_bytes(512) + .build() + .unwrap(); + let err = c + .upload_file(tmp.path(), Some(1800)) + .await + .expect_err("should reject oversize"); + assert!(matches!(err, Error::Config(_)), "got {err:?}"); +} diff --git a/vendor/clawdforge/tests/sessions.rs b/vendor/clawdforge/tests/sessions.rs new file mode 100644 index 0000000..915ca9d --- /dev/null +++ b/vendor/clawdforge/tests/sessions.rs @@ -0,0 +1,425 @@ +//! Integration tests for the v0.2 multi-turn Session API. +//! +//! All tests run against an in-process `wiremock` server — no live clawdforge +//! required. + +use std::time::Duration; + +use clawdforge::{Client, Error, SessionOptions, TurnEvent, TurnResult}; +use serde_json::json; +use wiremock::matchers::{body_json, header, method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn make_client(server: &MockServer) -> Client { + Client::builder() + .base_url(server.uri()) + .token("cf_test_token") + .timeout(Duration::from_secs(5)) + .build() + .expect("client builds") +} + +fn mock_create(session_id: &str) -> Mock { + Mock::given(method("POST")) + .and(path("/sessions")) + .and(header("authorization", "Bearer cf_test_token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "session_id": session_id, + "agent": "claude", + "created_at": 1_700_000_000_i64, + }))) +} + +fn mock_delete_ok(session_id: &str) -> Mock { + Mock::given(method("DELETE")) + .and(path(format!("/sessions/{session_id}"))) + .and(header("authorization", "Bearer cf_test_token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) +} + +#[tokio::test] +async fn test_new_session_and_close() { + let server = MockServer::start().await; + mock_create("sess_abc").expect(1).mount(&server).await; + mock_delete_ok("sess_abc").expect(1).mount(&server).await; + + let c = make_client(&server); + let s = c + .new_session(SessionOptions::default()) + .await + .expect("new_session"); + assert_eq!(s.id(), "sess_abc"); + assert_eq!(s.agent(), "claude"); + assert_eq!(s.created_at(), 1_700_000_000); + assert!(!s.is_closed()); + + s.close().await.expect("close"); + // wiremock verifies expectations on Drop of the server. +} + +#[tokio::test] +async fn test_turn_round_trip() { + let server = MockServer::start().await; + mock_create("sess_t1").mount(&server).await; + Mock::given(method("POST")) + .and(path("/sessions/sess_t1/turn")) + .and(header("authorization", "Bearer cf_test_token")) + .and(body_json(json!({"prompt": "hello"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "session_id": "sess_t1", + "turn_index": 1, + "events": [ + {"type": "thinking", "content": "..."}, + {"type": "text", "content": "hi back"} + ], + "stop_reason": "end_turn", + "duration_ms": 250 + }))) + .expect(1) + .mount(&server) + .await; + // Allow drop-close to land without failing other assertions. + Mock::given(method("DELETE")) + .and(path("/sessions/sess_t1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .mount(&server) + .await; + + let c = make_client(&server); + let mut s = c.new_session(SessionOptions::default()).await.unwrap(); + let r: TurnResult = s.turn("hello").await.unwrap(); + assert!(r.ok); + assert_eq!(r.session_id, "sess_t1"); + assert_eq!(r.turn_index, 1); + assert_eq!(r.events.len(), 2); + assert_eq!(r.stop_reason, "end_turn"); + assert_eq!(r.duration_ms, 250); + assert_eq!(r.text(), "hi back"); + + // Drive the turn_with_files path too. + Mock::given(method("POST")) + .and(path("/sessions/sess_t1/turn")) + .and(body_json( + json!({"prompt": "next", "files": ["ff_one", "ff_two"]}), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "ok": true, + "session_id": "sess_t1", + "turn_index": 2, + "events": [{"type": "text", "content": "ok"}], + "stop_reason": "end_turn", + "duration_ms": 10 + }))) + .expect(1) + .mount(&server) + .await; + let r2 = s + .turn_with_files("next", &["ff_one".into(), "ff_two".into()]) + .await + .unwrap(); + assert_eq!(r2.turn_index, 2); + assert_eq!(r2.text(), "ok"); + + s.close().await.unwrap(); +} + +#[tokio::test] +async fn test_close_idempotent_short_circuits() { + let server = MockServer::start().await; + mock_create("sess_idem").mount(&server).await; + // Expect EXACTLY ONE delete — second close() is in-memory. + mock_delete_ok("sess_idem").expect(1).mount(&server).await; + + let c = make_client(&server); + let s = c.new_session(SessionOptions::default()).await.unwrap(); + let id = s.id().to_string(); + s.close().await.unwrap(); + + // Second close-equivalent: rebuild a Session-shape via reconstructing the + // closed state would require private constructors. Instead, drive the + // semantic check via close_session_internal contract: a fresh Session from + // a *new* create that we close twice. But since `close` consumes self, + // "second close" semantically means a Drop after an explicit close — and + // that path is covered by `test_drop_after_explicit_close_no_double_call`. + // + // What this test asserts is the wiremock `expect(1)` on the DELETE: one + // close => one DELETE => idempotency at the network layer holds. + let _ = id; +} + +#[tokio::test] +async fn test_drop_fires_async_close() { + let server = MockServer::start().await; + mock_create("sess_drop").mount(&server).await; + mock_delete_ok("sess_drop").expect(1).mount(&server).await; + + let c = make_client(&server); + { + let _s = c.new_session(SessionOptions::default()).await.unwrap(); + // _s drops here — Drop spawns the async close. + } + // Yield repeatedly so the spawned future has a chance to run + the HTTP + // request lands at wiremock. wiremock asserts `expect(1)` on Drop of the + // server. + for _ in 0..50 { + tokio::task::yield_now().await; + } + tokio::time::sleep(Duration::from_millis(200)).await; +} + +#[tokio::test] +async fn test_drop_after_explicit_close_no_double_call() { + let server = MockServer::start().await; + mock_create("sess_once").mount(&server).await; + // Exactly ONE delete — explicit close fires it, Drop should short-circuit. + mock_delete_ok("sess_once").expect(1).mount(&server).await; + + let c = make_client(&server); + let s = c.new_session(SessionOptions::default()).await.unwrap(); + s.close().await.unwrap(); + // Yield to give any erroneous spawn a chance to land — if Drop spawned a + // second DELETE, wiremock's expect(1) would fail. + for _ in 0..20 { + tokio::task::yield_now().await; + } + tokio::time::sleep(Duration::from_millis(100)).await; +} + +#[tokio::test] +async fn test_list_sessions() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/sessions")) + .and(header("authorization", "Bearer cf_test_token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "sessions": [ + { + "session_id": "sess_a", + "agent": "claude", + "app_name": "cauldron", + "created_at": 1_700_000_000_i64, + "last_turn_at": 1_700_000_500_i64, + "turn_count": 3, + "closed_at": null + }, + { + "session_id": "sess_b", + "agent": "claude", + "app_name": "cauldron", + "created_at": 1_700_000_100_i64, + "last_turn_at": null, + "turn_count": 0, + "closed_at": 1_700_001_000_i64 + } + ] + }))) + .mount(&server) + .await; + + let c = make_client(&server); + let list = c.list_sessions().await.unwrap(); + assert_eq!(list.sessions.len(), 2); + assert_eq!(list.sessions[0].session_id, "sess_a"); + assert_eq!(list.sessions[0].turn_count, 3); + assert_eq!(list.sessions[0].last_turn_at, Some(1_700_000_500)); + assert_eq!(list.sessions[1].closed_at, Some(1_700_001_000)); +} + +#[tokio::test] +async fn test_get_session() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/sessions/sess_q")) + .and(header("authorization", "Bearer cf_test_token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "session_id": "sess_q", + "agent": "claude", + "app_name": "cauldron", + "created_at": 1_700_000_000_i64, + "last_turn_at": 1_700_000_900_i64, + "turn_count": 7, + "closed_at": null + }))) + .mount(&server) + .await; + + let c = make_client(&server); + let st = c.get_session("sess_q").await.unwrap(); + assert_eq!(st.session_id, "sess_q"); + assert_eq!(st.agent, "claude"); + assert_eq!(st.app_name, "cauldron"); + assert_eq!(st.turn_count, 7); + assert!(st.closed_at.is_none()); +} + +#[tokio::test] +async fn test_cross_token_404() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path_regex(r"^/sessions/sess_other")) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({ + "detail": "session not found" + }))) + .mount(&server) + .await; + + let c = make_client(&server); + let err = c + .get_session("sess_other") + .await + .expect_err("cross-token must 404"); + match err { + Error::Api { status, body } => { + assert_eq!(status, 404); + assert!(body.contains("session not found"), "body was {body}"); + } + other => panic!("expected Error::Api {{ status: 404, .. }}, got {other:?}"), + } +} + +#[tokio::test] +async fn test_turn_result_text_concat() { + let r = TurnResult { + ok: true, + session_id: "sess".into(), + turn_index: 1, + events: vec![ + TurnEvent { + event_type: "thinking".into(), + content: Some("ignored".into()), + name: None, + args: None, + result: None, + }, + TurnEvent { + event_type: "text".into(), + content: Some("hello ".into()), + name: None, + args: None, + result: None, + }, + TurnEvent { + event_type: "tool_call".into(), + content: None, + name: Some("Read".into()), + args: Some(json!({"path": "/x"})), + result: Some(json!({"ok": true})), + }, + TurnEvent { + event_type: "text".into(), + content: Some("world".into()), + name: None, + args: None, + result: None, + }, + TurnEvent { + // text event with None content — must contribute the empty + // string, not panic, not stringify "None". + event_type: "text".into(), + content: None, + name: None, + args: None, + result: None, + }, + ], + stop_reason: "end_turn".into(), + duration_ms: 99, + }; + assert_eq!(r.text(), "hello world"); +} + +#[tokio::test] +async fn test_session_debug_does_not_leak_token() { + let server = MockServer::start().await; + let secret = "cf_super_secret_session_bearer"; + Mock::given(method("POST")) + .and(path("/sessions")) + .and(header("authorization", format!("Bearer {secret}").as_str())) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "session_id": "sess_dbg", + "agent": "claude", + "created_at": 1_700_000_000_i64, + }))) + .mount(&server) + .await; + Mock::given(method("DELETE")) + .and(path("/sessions/sess_dbg")) + .and(header("authorization", format!("Bearer {secret}").as_str())) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .mount(&server) + .await; + + let c = Client::builder() + .base_url(server.uri()) + .token(secret) + .build() + .unwrap(); + let s = c.new_session(SessionOptions::default()).await.unwrap(); + let dbg = format!("{s:?}"); + assert!( + !dbg.contains(secret), + "token leaked through Session Debug: {dbg}" + ); + // The Session Debug should print these visible bits. + assert!(dbg.contains("sess_dbg"), "session_id missing: {dbg}"); + assert!(dbg.contains("agent"), "agent field missing: {dbg}"); + assert!(dbg.contains("closed"), "closed field missing: {dbg}"); + s.close().await.unwrap(); +} + +/// Regression: `new_session` with options serializes the `agent` and `meta` +/// fields when set, omits them when None. +#[tokio::test] +async fn test_new_session_options_serialize() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/sessions")) + .and(body_json(json!({ + "agent": "claude", + "meta": {"trace_id": "t-123"} + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "session_id": "sess_opts", + "agent": "claude", + "created_at": 1 + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("DELETE")) + .and(path("/sessions/sess_opts")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .mount(&server) + .await; + + let c = make_client(&server); + let s = c + .new_session(SessionOptions { + agent: Some("claude".into()), + meta: Some(json!({"trace_id": "t-123"})), + }) + .await + .unwrap(); + assert_eq!(s.id(), "sess_opts"); + s.close().await.unwrap(); +} + +/// `get_session` / `close` / `turn` must reject path-traversal session ids +/// before issuing any HTTP request — same defense-in-depth pattern as +/// `revoke_token`. +#[tokio::test] +async fn test_session_id_rejects_path_traversal() { + let server = MockServer::start().await; + let c = make_client(&server); + for bad in ["", "../foo", "..", "foo/bar", "foo?x=1", "foo#frag"] { + let err = c + .get_session(bad) + .await + .expect_err(&format!("get_session({bad:?}) should reject")); + assert!( + matches!(err, Error::Config(_)), + "{bad:?} produced wrong variant: {err:?}" + ); + } +}