v0.2 scaffold: vendor clawdforge SDK + forge module + Whisper plan

The Rust SDK already existed at Sulkta-Coop/clawdforge clients/rust/ — async,
reqwest-based, bearer-auth, exposes Client::run() + Session for multi-turn.
Vendoring it into vendor/clawdforge so skald is self-contained: no
git-submodule + no needing the clawdforge repo cloned next to skald.
Trade-off accepted: updates require manual re-copy until both sides
stabilize and we publish to a private cargo registry.

What landed:

- vendor/clawdforge/ — full SDK source from Sulkta-Coop/clawdforge HEAD.
  Pinned in skald-core/Cargo.toml as a path dep.
- skald-core/src/forge.rs — three-pass orchestration shell. Forge wraps
  clawdforge::Client; generate() / cleanup() / audit() each build a
  RunRequest with the right system prompt + model alias (always opus),
  call client.run(), return a PassOutput.
  Prompt templates are TODO stubs (SYSTEM_GEN_TODO etc) — filling in the
  actual prose-craft prompts is its own deep session.
- skald-core/src/config.rs — ForgeConfig { base_url, app_token, model }.
  Resolved by the binary from env (CLAWDFORGE_URL + CLAWDFORGE_TOKEN);
  lib stays env-agnostic.
- skald-core::AuditFinding + AuditResponse — parse shape for what the
  third-Opus canon audit returns, ready to map onto audit_findings rows.
- docs/tts-pipeline.md — full plan for v0.2 narration + post-TTS audit
  chain. Whisper-large-v3 STT does text-to-text verification on every
  render; an optional Gemini Flash audio pass catches subjective issues
  (prosody, tone) Whisper can't see. Reroll loop on crit findings.

What's still stubbed:

- Prompt templates in forge.rs (gen / cleanup / audit) — placeholders
  that describe the role but don't constrain output shape yet.
- context.rs (assemble the LLM context blob from DB rows) — entire module
  TBD.
- No CLI subcommand yet for invoking forge — that comes after context.rs.

Naming note: in Rust 2024 'gen' is a reserved keyword (for generators),
so the method is Forge::generate(), not Forge::gen().
This commit is contained in:
Kayos 2026-05-13 10:18:56 -07:00
parent 4a91e0738d
commit f71b533e52
17 changed files with 3340 additions and 19 deletions

528
Cargo.lock generated
View file

@ -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"

View file

@ -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

146
docs/tts-pipeline.md Normal file
View file

@ -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

View file

@ -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"] }

33
skald-core/src/config.rs Normal file
View file

@ -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(),
}
}
}

203
skald-core/src/forge.rs Normal file
View file

@ -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<Self> {
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<PassOutput> {
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<PassOutput> {
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<AuditFinding>`.
pub async fn audit(&self, parent_prose: &str, sequel_prose: &str, bible: &str) -> anyhow::Result<PassOutput> {
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<AuditFinding>,
}

View file

@ -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;

30
vendor/clawdforge/Cargo.toml vendored Normal file
View file

@ -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"

270
vendor/clawdforge/README.md vendored Normal file
View file

@ -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::<T>()` 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 <pin>
```
Or pin manually in `Cargo.toml`:
```toml
[dependencies]
clawdforge = { git = "https://gitea.sulkta.com/Sulkta-Coop/clawdforge", rev = "<pin>" }
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<dyn std::error::Error>> {
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::<T>().
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, Error>`. |
### `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.

77
vendor/clawdforge/examples/basic.rs vendored Normal file
View file

@ -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<dyn std::error::Error>> {
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::<Hello>() {
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(())
}

515
vendor/clawdforge/src/client.rs vendored Normal file
View file

@ -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<String>,
/// Admin bootstrap token (used for `/admin/*`). Optional.
admin_token: Option<String>,
/// Optional cap on `upload_file` size in bytes — `None` = no cap.
max_upload_bytes: Option<u64>,
}
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(|_| "<redacted>"))
.field(
"admin_token",
&self.admin_token.as_ref().map(|_| "<redacted>"),
)
.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<String>,
app_token: Option<String>,
admin_token: Option<String>,
timeout: Option<Duration>,
user_agent: Option<String>,
danger_accept_invalid_certs: bool,
max_upload_bytes: Option<u64>,
}
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(|_| "<redacted>"))
.field(
"admin_token",
&self.admin_token.as_ref().map(|_| "<redacted>"),
)
.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<Healthz, Error> {
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<RunResult, Error> {
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<Path>,
ttl_secs: Option<u32>,
) -> Result<FileToken, Error> {
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<AppToken, Error> {
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<TokenList, Error> {
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<Session, Error> {
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<SessionList, Error> {
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<SessionState, Error> {
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::<SessionCloseResponse>(&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<B: serde::Serialize>(
&self,
id: &str,
body: &B,
) -> Result<TurnResult, Error> {
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<Url, Error> {
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<String>) -> 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<String>) -> 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<String>) -> 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/<crate-version>`.
pub fn user_agent(mut self, ua: impl Into<String>) -> 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<Client, Error> {
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<T: DeserializeOwned>(resp: Response) -> Result<T, Error> {
let status = resp.status();
if status.is_success() {
let bytes = resp.bytes().await?;
return Ok(serde_json::from_slice::<T>(&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])
}
}

68
vendor/clawdforge/src/error.rs vendored Normal file
View file

@ -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<reqwest::Error> 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<String>) -> Self {
Self::Api {
status,
body: body.into(),
}
}
}

58
vendor/clawdforge/src/lib.rs vendored Normal file
View file

@ -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<dyn std::error::Error>> {
//! 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,
};

281
vendor/clawdforge/src/session.rs vendored Normal file
View file

@ -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<String>,
/// Free-form metadata stored alongside the session ledger row.
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
/// 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<String>,
/// Tool name for `"tool_call"` events.
#[serde(default)]
pub name: Option<String>,
/// Tool arguments for `"tool_call"` events.
#[serde(default)]
pub args: Option<serde_json::Value>,
/// Tool result for `"tool_call"` events.
#[serde(default)]
pub result: Option<serde_json::Value>,
}
/// 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<TurnEvent>,
/// 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<i64>,
/// Number of turns dispatched.
pub turn_count: i32,
/// Unix epoch seconds when closed (or `None` if still open).
#[serde(default)]
pub closed_at: Option<i64>,
}
/// 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<bool>,
}
/// Reply body from `GET /sessions`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SessionList {
/// All sessions visible to the calling token.
pub sessions: Vec<SessionState>,
}
/// 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<String>) -> Result<TurnResult, Error> {
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<String>,
files: &[String],
) -> Result<TurnResult, Error> {
self.dispatch_turn(prompt.into(), Some(files)).await
}
async fn dispatch_turn(
&mut self,
prompt: String,
files: Option<&[String]>,
) -> Result<TurnResult, Error> {
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"
);
}
});
}
}

186
vendor/clawdforge/src/types.rs vendored Normal file
View file

@ -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<String>,
}
/// 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<String>,
/// System prompt appended via `claude -p --append-system-prompt`.
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<String>,
/// 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<Vec<String>>,
/// Subprocess timeout in seconds. Server clamps to `5..=600`.
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u32>,
}
/// 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<String>,
}
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<T: DeserializeOwned>(&self) -> Result<T, Error> {
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::<RunFailure>(&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<String>,
/// Last 4 KB of `claude` stderr, when available.
pub stderr: Option<String>,
/// Wall-clock duration of the (failed) subprocess.
pub duration_ms: u64,
/// `claude` stop reason if the failure produced one.
pub stop_reason: Option<String>,
}
/// 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<String>,
}
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", &"<redacted>")
.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<String>,
}
/// 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<String>,
/// Unix epoch seconds (server-controlled field shape — extra fields are
/// captured by `extra`).
#[serde(default)]
pub created_at: Option<i64>,
/// Catch-all for any future fields the server adds.
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
/// 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<AppTokenInfo>,
}

517
vendor/clawdforge/tests/client.rs vendored Normal file
View file

@ -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<serde_json::Map<String, serde_json::Value>, _> = 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("<redacted>"), "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("<redacted>"), "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("<redacted>"), "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:?}");
}

425
vendor/clawdforge/tests/sessions.rs vendored Normal file
View file

@ -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:?}"
);
}
}