diff --git a/Cargo.lock b/Cargo.lock index 16d86d0206f..c0fea3ffdd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,7 +117,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -128,7 +128,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -209,6 +209,45 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -321,6 +360,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-task" version = "4.7.1" @@ -562,6 +623,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base16ct" version = "1.0.0" @@ -892,10 +959,14 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "bytes", "dirs", + "futures-util", "getrandom 0.4.3", "hex", + "http", "nix 0.31.3", + "opensecret", "reqwest 0.13.4", "rmcp", "serde", @@ -1230,7 +1301,7 @@ dependencies = [ "metrics-exporter-prometheus", "minicbor", "nostr 0.44.7", - "p256", + "p256 0.14.0", "proptest", "rand 0.10.1", "reqwest 0.13.4", @@ -1619,6 +1690,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1712,7 +1810,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1795,6 +1893,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -2068,6 +2172,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-bigint" version = "0.7.5" @@ -2333,7 +2449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2402,17 +2518,42 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + [[package]] name = "der" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2510,6 +2651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -2521,7 +2663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -2544,7 +2686,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2617,18 +2759,32 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + [[package]] name = "ecdsa" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ - "der", + "der 0.8.0", "digest 0.11.3", - "elliptic-curve", - "rfc6979", - "signature", - "spki", + "elliptic-curve 0.14.1", + "rfc6979 0.6.0", + "signature 3.0.0", + "spki 0.8.0", "zeroize", ] @@ -2638,9 +2794,9 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8", + "pkcs8 0.11.0", "serdect", - "signature", + "signature 3.0.0", ] [[package]] @@ -2654,7 +2810,7 @@ dependencies = [ "rand_core 0.10.1", "serde", "sha2 0.11.0", - "signature", + "signature 3.0.0", "subtle", "zeroize", ] @@ -2668,23 +2824,44 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff 0.13.1", + "generic-array", + "group 0.13.0", + "hkdf 0.12.4", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1 0.7.3", + "subtle", + "zeroize", +] + [[package]] name = "elliptic-curve" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ - "base16ct", - "crypto-bigint", + "base16ct 1.0.0", + "crypto-bigint 0.7.5", "crypto-common 0.2.2", "digest 0.11.3", - "ff", - "group", + "ff 0.14.0", + "group 0.14.0", "hybrid-array", - "pem-rfc7468", - "pkcs8", + "pem-rfc7468 1.0.0", + "pkcs8 0.11.0", "rand_core 0.10.1", - "sec1", + "sec1 0.8.1", "subtle", "zeroize", ] @@ -2767,7 +2944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2822,6 +2999,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + [[package]] name = "evmap" version = "11.0.0" @@ -2891,6 +3079,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "ff" version = "0.14.0" @@ -3183,7 +3381,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", + "windows-link 0.2.1", "windows-result 0.4.1", ] @@ -3325,13 +3523,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff 0.13.1", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "group" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ - "ff", + "ff 0.14.0", "rand_core 0.10.1", "subtle", ] @@ -3355,6 +3564,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if 1.0.4", + "crunchy", + "zerocopy", +] + [[package]] name = "hash32" version = "0.3.1" @@ -6028,7 +6248,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6274,6 +6494,15 @@ dependencies = [ "objc2-security", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -6325,6 +6554,43 @@ dependencies = [ "tracing", ] +[[package]] +name = "opensecret" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86d9a35e5dd1ee761d3449d9e2db722eb1ebbab0eef02d3bb8601fd6b23d6bd9" +dependencies = [ + "aes-gcm", + "anyhow", + "async-stream", + "async-trait", + "base64 0.22.1", + "bytes", + "chacha20poly1305", + "chrono", + "ciborium", + "eventsource-stream", + "futures", + "hex", + "hkdf 0.12.4", + "http", + "p256 0.13.2", + "percent-encoding", + "pin-project", + "reqwest 0.12.28", + "ring", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", + "x25519-dalek", + "x509-parser", + "yasna", +] + [[package]] name = "openssl" version = "0.10.80" @@ -6595,16 +6861,28 @@ dependencies = [ "memchr", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + [[package]] name = "p256" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ - "ecdsa", - "elliptic-curve", + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", "primefield", - "primeorder", + "primeorder 0.14.0", "sha2 0.11.0", ] @@ -6720,6 +6998,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -6897,14 +7184,24 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der 0.8.0", + "spki 0.8.0", ] [[package]] @@ -7098,21 +7395,30 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ - "crypto-bigint", + "crypto-bigint 0.7.5", "crypto-common 0.2.2", - "ff", + "ff 0.14.0", "rand_core 0.10.1", "subtle", "zeroize", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve 0.13.8", +] + [[package]] name = "primeorder" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.14.1", "once_cell", "primefield", "serdect", @@ -7501,7 +7807,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -8022,13 +8328,23 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "rfc6979" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" dependencies = [ - "crypto-bigint", + "crypto-bigint 0.7.5", "hmac 0.13.0", ] @@ -8162,6 +8478,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.38.44" @@ -8172,7 +8497,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8185,7 +8510,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8244,7 +8569,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8391,15 +8716,29 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "sec1" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ - "base16ct", + "base16ct 1.0.0", "ctutils", - "der", + "der 0.8.0", "hybrid-array", "subtle", "zeroize", @@ -8527,7 +8866,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8690,7 +9029,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ - "base16ct", + "base16ct 1.0.0", "serde", ] @@ -8823,6 +9162,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -9024,7 +9373,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9059,6 +9408,16 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -9066,7 +9425,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.0", ] [[package]] @@ -9652,7 +10011,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9665,7 +10024,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10435,7 +10794,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11019,7 +11378,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -11514,8 +11873,8 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" dependencies = [ - "ff", - "group", + "ff 0.14.0", + "group 0.14.0", "hybrid-array", ] @@ -11544,6 +11903,35 @@ dependencies = [ "web-sys", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -11726,6 +12114,12 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" + [[package]] name = "yoke" version = "0.8.2" diff --git a/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index fabf75754e1..3fad755a7a4 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -31,6 +31,13 @@ serde_json = { workspace = true } serde_yaml = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls", "form"] } rmcp = { version = "1", default-features = false, features = ["client", "transport-child-process"] } +# Maple (OpenSecret) confidential inference: attested, end-to-end encrypted +# transport to TEE-hosted models. `http`, `bytes`, and `futures-util` are for +# the SDK's request/response types and are already in its dependency tree. +opensecret = "3" +http = "1" +bytes = "1" +futures-util = { workspace = true } arc-swap = "1" getrandom = "0.4" tracing = { workspace = true } diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index 0bc03db7813..7b54ec3c83d 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -61,6 +61,12 @@ BUZZ_AGENT_PROVIDER=databricks \ DATABRICKS_HOST=https://dbc-...cloud.databricks.com \ DATABRICKS_MODEL=goose-claude-4-6-sonnet \ ./target/release/buzz-agent + +# Or Maple (OpenSecret) confidential inference, attested and end-to-end encrypted +BUZZ_AGENT_PROVIDER=maple \ +MAPLE_API_KEY=... \ +MAPLE_MODEL=llama3-3-70b \ + ./target/release/buzz-agent ``` That's the whole setup. The agent reads JSON-RPC frames from stdin, writes them to stdout, and logs to stderr. @@ -135,7 +141,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro | Variable | Default | Notes | |---|---|---| -| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `openrouter`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. | +| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `openrouter`, `maple`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. | | `ANTHROPIC_API_KEY` | — | Required when provider=anthropic. | | `ANTHROPIC_MODEL` | — | Required when provider=anthropic. | | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | | @@ -147,6 +153,10 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `OPENROUTER_API_KEY` | — | Required when provider=openrouter. | | `OPENROUTER_MODEL` | — | Required when provider=openrouter. Use OpenRouter's `vendor/model` id, e.g. `anthropic/claude-sonnet-4.5`. | | `OPENROUTER_BASE_URL` | `https://openrouter.ai/api/v1` | | +| `MAPLE_API_KEY` | — | Required when provider=maple. | +| `MAPLE_MODEL` | — | Required when provider=maple. | +| `MAPLE_BASE_URL` | `https://enclave.trymaple.ai` | The enclave endpoint the opensecret SDK attests against. | +| `MAPLE_PCR0_ENVIRONMENT` | `production` | `production` \| `development`. Which PCR0 trust roots verify the enclave's attestation document. | | `DATABRICKS_HOST` | — | Required when provider=databricks or provider=databricks_v2. | | `DATABRICKS_MODEL` | — | Required when provider=databricks or provider=databricks_v2. | | `DATABRICKS_TOKEN` | — | Optional static bearer escape hatch. If unset, Databricks uses browser OAuth + refresh cache. | @@ -240,10 +250,11 @@ lifecycle hook — see [MCP_DRIVEN_HOOKS.md](../../docs/MCP_DRIVEN_HOOKS.md). | Ollama | `openai` | `POST {base}/chat/completions` | llama3.1, qwen2.5-coder | | Block Gateway | `openai` | `POST {base}/chat/completions` | gpt-5, claude | | OpenRouter | `openrouter` | `POST {base}/chat/completions` | anything they route (extended-thinking replay, provider-agnostic tool calling) | +| Maple (OpenSecret) | `maple` | `POST {base}/v1/chat/completions` (attested + encrypted) | TEE-hosted open models (llama, deepseek, qwen, …) | | Databricks | `databricks` | `POST {host}/serving-endpoints/{model}/invocations` | goose-claude-4-6-sonnet | | Databricks AI Gateway v2 | `databricks_v2` | `POST {host}/ai-gateway/{provider}/v1/...` | databricks-gpt-5-5, databricks-claude-opus-4-7 | -If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, or `BUZZ_AGENT_PROVIDER=openrouter` is selected without `OPENROUTER_API_KEY`, the agent returns an error — there is no implicit fallback to another provider. +If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, `BUZZ_AGENT_PROVIDER=openrouter` is selected without `OPENROUTER_API_KEY`, or `BUZZ_AGENT_PROVIDER=maple` is selected without `MAPLE_API_KEY`, the agent returns an error — there is no implicit fallback to another provider. `provider=openai` speaks two HTTP dialects: the [Responses API](https://platform.openai.com/docs/api-reference/responses) (`/v1/responses`, required for GPT-5 / o-series tool-calling on OpenAI's own service) and the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) (`/chat/completions`, the broadly-supported OpenAI-compatible wire format). @@ -256,8 +267,22 @@ By default (`OPENAI_COMPAT_API=auto`) the agent picks **Responses** when `OPENAI - `anthropic/*` models get Anthropic-style `cache_control` breakpoints injected on the system message and the last two user messages. - Retryable statuses (429 and typed `provider_overloaded` 503) honor the documented `Retry-After` header (clamped to a small ceiling — see `RETRY_AFTER_CAP_SECS` in `llm.rs` — since the sleep happens outside `BUZZ_AGENT_LLM_TIMEOUT_SECS`); 502 and untyped 503 retry with jittered backoff instead. `401` is treated as an expired/invalid key and refreshed once, while `402` (no credits) and `403` (guardrail/moderation/permission) fail immediately without retry. +`provider=maple` speaks OpenAI's Chat Completions wire format, but every request travels through the [opensecret](https://crates.io/crates/opensecret) SDK instead of a plain HTTPS POST: the SDK verifies the enclave's AWS Nitro attestation document against pinned PCR0 trust roots, performs an X25519 key exchange with the attested enclave, and end-to-end encrypts the request and response bodies. The handshake runs lazily on the first request and re-runs transparently when the enclave session goes stale. One divergence from the shared OpenAI path: a `401`/`403` fails immediately without a refresh retry, since a static Maple API key cannot mint a different token. + `Provider` is a Rust `enum` with one `match` in `Llm::complete`. There is no trait, no `Box`, no async-trait. Adding a provider is a `match` arm and one `body`/`parse` pair in `llm.rs`. +### Model discovery + +`buzz-agent models` prints the provider's live model catalog as a JSON array of `{"id","name"}` objects on stdout. It reads the same provider env vars as the ACP server but does **not** require a model. Providers without a live agent-side catalog (Anthropic, OpenAI, OpenRouter; frontends list those over plain HTTP) print `[]`. Failures exit non-zero with the error on stderr. + +For Maple, `MAPLE_API_KEY` is optional here: the enclave lists models for any attested session, so a picker can fill in before the user has a key. An enclave that still requires a credential answers a keyless call with `config: MAPLE_API_KEY required`. + +```bash +BUZZ_AGENT_PROVIDER=databricks_v2 DATABRICKS_HOST=https://dbc-...cloud.databricks.com buzz-agent models +``` + +The per-provider dispatch lives in `catalog.rs` (`discover_models`) and also backs the `availableModels` list in the ACP `session/new` response, so a provider added there reaches every frontend, including Buzz Desktop's model picker. + ## MCP Servers The client passes MCP server specs in `session/new`. The agent spawns each one as a stdio subprocess, calls `tools/list`, and merges everything into a single tool catalog the LLM sees. Tool names are namespaced as `server__tool` (double underscore separator). Bare tool names containing `__` are rejected at registration. diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs index 69714b145c5..aaa5eede402 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-agent/src/catalog.rs @@ -1,16 +1,18 @@ -//! Databricks model catalog discovery. +//! Live model-catalog discovery, per provider. //! -//! Exposes [`discover_databricks_models`] — an async helper that lists -//! available models for the `databricks` and `databricks_v2` providers -//! without triggering a browser OAuth flow. Auth is acquired in-process via -//! [`build_token_source`](crate::llm::build_token_source): +//! [`discover_models`] is the dispatch point behind ACP `session/new` and +//! the `buzz-agent models` subcommand. +//! +//! Databricks ([`discover_databricks_models`]) lists endpoints for the +//! `databricks` and `databricks_v2` providers without opening a browser. +//! Auth comes from [`build_token_source`](crate::llm::build_token_source): //! //! - Static bearer (`DATABRICKS_TOKEN`): returned immediately. -//! - PKCE cache hit: returned from disk without a network round-trip. -//! - PKCE cache empty / no token: returns `Err(AgentError::LlmAuth)`. +//! - PKCE cache hit: read from disk, no network round-trip. +//! - PKCE cache empty, no token: `Err(AgentError::LlmAuth)`. //! -//! This helper never opens a browser. Callers choose whether to reject, degrade, -//! or start a separate interactive authentication flow. +//! Maple's catalog is only reachable through the opensecret SDK's attested +//! transport, so its discovery lives here too. use std::sync::Arc; @@ -401,6 +403,113 @@ pub(crate) fn parse_v2_endpoints_page( Ok((models, next_page_token)) } +// --------------------------------------------------------------------------- +// Provider-generic dispatch +// --------------------------------------------------------------------------- + +/// Discover the live model catalog for `cfg.provider`. +/// +/// `Ok(Some(models))` is a non-empty catalog. `Ok(None)` means the provider +/// has no agent-side catalog: the frontend lists its models itself over +/// plain OpenAI-compatible HTTP, or the configured model is the only option. +pub async fn discover_models(cfg: &Config) -> Result>, AgentError> { + match cfg.provider { + Provider::Databricks | Provider::DatabricksV2 => { + discover_databricks_models(cfg).await.map(Some) + } + Provider::Maple => discover_maple_models(cfg).await.map(Some), + Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter => Ok(None), + } +} + +// --------------------------------------------------------------------------- +// Maple (OpenSecret) +// --------------------------------------------------------------------------- + +/// Cap on one Maple catalog exchange, attestation handshake included. The +/// SDK's HTTP client sets no timeout, and this call sits on the `session/new` +/// path where a hang would block agent startup. +const MAPLE_DISCOVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// Discover the Maple (OpenSecret) model catalog: `GET /v1/models` through +/// the opensecret SDK. +/// +/// The catalog is unreachable over plain HTTPS, so the desktop's picker +/// cannot probe it directly; it calls this via `buzz-agent models` or ACP +/// `session/new`. +/// +/// Auth is the static `MAPLE_API_KEY`. An empty key is allowed: the enclave +/// lists models for any attested session, so the picker can fill in before +/// the user enters a key. An older enclave that still requires a credential +/// answers a keyless call with `Err(AgentError::LlmAuth)`. +async fn discover_maple_models(cfg: &Config) -> Result, AgentError> { + let client = crate::llm::build_maple_client(cfg)?.ok_or_else(|| { + AgentError::InvalidParams("discover_maple_models called for non-Maple provider".into()) + })?; + let keyless = cfg.api_key.trim().is_empty(); + let response = tokio::time::timeout(MAPLE_DISCOVERY_TIMEOUT, client.get_models()) + .await + .map_err(|_| { + AgentError::Llm(format!( + "Maple model discovery timed out after {MAPLE_DISCOVERY_TIMEOUT:?}" + )) + })? + .map_err(|error| maple_discovery_error(error, keyless))?; + let models = filter_maple_models(response.data.into_iter().map(|m| m.id)); + if models.is_empty() { + return Err(AgentError::Llm( + "Maple model discovery returned no chat-capable models".into(), + )); + } + Ok(models) +} + +/// Maple's raw model ids as picker entries, dropping ids that cannot serve +/// chat traffic. The catalog has no display-name or task field, so the id is +/// also the label and the name is the only capability signal, as in +/// [`is_chat_capable_endpoint`]. +fn filter_maple_models(ids: impl Iterator) -> Vec { + ids.filter(|id| is_maple_chat_model(id)) + .map(|id| ModelEntry { + name: id.clone(), + id, + }) + .collect() +} + +fn is_maple_chat_model(id: &str) -> bool { + let lower = id.to_ascii_lowercase(); + if !is_chat_capable_endpoint(id) { + return false; + } + !["embed", "whisper", "tts", "transcribe", "speech"] + .iter() + .any(|needle| lower.contains(needle)) +} + +/// Map an opensecret SDK discovery failure onto `AgentError`. 401/403 become +/// `LlmAuth` so `session/new` rejects with the credential error instead of +/// silently degrading to the configured model. +/// +/// A keyless call rejected for auth means this enclave does not list models +/// without a credential. Word that as the standard `config: MAPLE_API_KEY +/// required` error so the frontend shows its usual key prompt. +fn maple_discovery_error(error: opensecret::Error, keyless: bool) -> AgentError { + match error { + opensecret::Error::Api { + status: status @ (401 | 403), + message, + } if keyless => AgentError::LlmAuth(format!( + "config: MAPLE_API_KEY required — this enclave does not list models without a credential (HTTP {status}: {message})" + )), + opensecret::Error::Api { + status: status @ (401 | 403), + message, + } => AgentError::LlmAuth(format!("Maple model discovery HTTP {status}: {message}")), + other => AgentError::Llm(format!("Maple model discovery failed: {other}")), + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -728,4 +837,73 @@ mod tests { assert!(!is_chat_capable_endpoint("databricks-gte-large-en")); assert!(!is_chat_capable_endpoint("databricks-qwen3-embedding-0-6b")); } + + /// Providers without an agent-side catalog resolve to `Ok(None)`; the + /// dispatch must not error for them and must not return an empty `Some`. + #[tokio::test] + async fn discover_models_returns_none_for_providers_without_live_catalog() { + for provider in [Provider::Anthropic, Provider::OpenAi, Provider::OpenRouter] { + let cfg = + Config::for_discovery(provider, "key".into(), "https://example.invalid".into()); + let discovered = discover_models(&cfg) + .await + .expect("no-catalog providers must not error"); + assert!(discovered.is_none(), "{provider:?} has no live catalog"); + } + } + + /// A keyless listing rejected for auth is reported as the standard + /// missing-key config error so the desktop shows its "enter an API key" + /// prompt; the same rejection with a key present names the HTTP status. + #[test] + fn maple_discovery_auth_rejection_wording_depends_on_keyless() { + let rejected = || opensecret::Error::Api { + status: 401, + message: "Invalid JWT".into(), + }; + match maple_discovery_error(rejected(), true) { + AgentError::LlmAuth(s) => assert!(s.contains("config: MAPLE_API_KEY required"), "{s}"), + other => panic!("expected LlmAuth, got {other:?}"), + } + match maple_discovery_error(rejected(), false) { + AgentError::LlmAuth(s) => { + assert!(s.contains("HTTP 401"), "{s}"); + assert!(!s.contains("MAPLE_API_KEY required"), "{s}"); + } + other => panic!("expected LlmAuth, got {other:?}"), + } + // Non-auth failures are plain errors regardless of key presence. + assert!(matches!( + maple_discovery_error(opensecret::Error::Session("stale".into()), true), + AgentError::Llm(_) + )); + } + + /// The catalog is dynamic, so this pins the filter rules, not a + /// snapshot: audio, TTS, and embedding ids drop; everything else stays, + /// including audio-capable chat models. Order is preserved and the id is + /// also the label, since Maple has no name field. + #[test] + fn filter_maple_models_drops_non_chat_families() { + let kept = [ + "llama3-3-70b", + "deepseek-v4-flash", + "gpt-oss-120b", + "voxtral-small-24b", + "some-future-model", + ]; + let dropped = [ + "whisper-large-v3", + "voxtral-tts", + "nomic-embed-text", + "qwen3-embedding-0-6b", + "kokoro-speech", + "parakeet-transcribe", + ]; + let models = + filter_maple_models(kept.iter().chain(dropped.iter()).map(|id| id.to_string())); + let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, kept); + assert!(models.iter().all(|m| m.id == m.name)); + } } diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 67d7c593b56..09539c577b7 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -427,6 +427,11 @@ pub enum Provider { DatabricksV2, /// OpenRouter multi-provider gateway. Routes to `{base_url}/chat/completions` with bearer auth. Wire format is OpenAI-chat-compatible. OpenRouter, + /// Maple (OpenSecret) confidential inference. Routes to + /// `{base_url}/v1/chat/completions` through the opensecret SDK's attested, + /// encrypted enclave session. Wire format inside the envelope is + /// OpenAI-chat-compatible. + Maple, } /// Which OpenAI-family HTTP API to call. Set via `OPENAI_COMPAT_API` @@ -513,6 +518,10 @@ pub struct Config { /// Set via `BUZZ_AGENT_THINKING_SUMMARY`. Ignored on Anthropic, Chat /// Completions, and OpenRouter routes. pub thinking_summary: ThinkingSummary, + /// Verify Maple's attestation against the development PCR0 trust roots + /// instead of production. Only read when `provider = Maple`. Set via + /// `MAPLE_PCR0_ENVIRONMENT=development`. + pub maple_pcr0_development: bool, /// Emit Anthropic `cache_control` breakpoints on the stable prefix /// (tools + system prompt) and the rolling conversation tail. Default on; /// disable with `BUZZ_AGENT_PROMPT_CACHING=0`. Consulted on every route that @@ -525,66 +534,23 @@ pub struct Config { impl Config { pub fn from_env() -> Result { - let databricks_host = env("DATABRICKS_HOST"); - let databricks_model = env("DATABRICKS_MODEL"); - let provider = resolve_provider( - env("BUZZ_AGENT_PROVIDER").as_deref(), - env("ANTHROPIC_API_KEY").as_deref(), - env("OPENAI_COMPAT_API_KEY").as_deref(), - env("OPENROUTER_API_KEY").as_deref(), - )?; + let provider = resolve_provider_from_env()?; + let conn = provider_connection_from_env(provider, ConnectionPurpose::Inference)?; // Universal model override — takes priority over provider-specific model // env vars (ANTHROPIC_MODEL, OPENAI_COMPAT_MODEL, DATABRICKS_MODEL) when // present. Set by the desktop from the persona/record to express explicit // user intent; provider-specific vars serve as defaults for CLI/standalone use. let buzz_agent_model = env("BUZZ_AGENT_MODEL"); - - // OPENAI_COMPAT_API is only read when provider=openai, so a stray - // bad value can't break an Anthropic-only deployment. - // - // Databricks borrows api_key as the *optional* `DATABRICKS_TOKEN` escape - // hatch — empty means "use OAuth PKCE." Legacy Databricks encodes the - // model in the URL path; Databricks v2 keeps it in the request body. - let (api_key, model, base_url, openai_api) = match provider { - Provider::Anthropic => ( - req("ANTHROPIC_API_KEY")?, - resolve_model( - buzz_agent_model.as_deref(), - env("ANTHROPIC_MODEL").as_deref(), - ) - .ok_or_else(|| "config: ANTHROPIC_MODEL required".to_string())?, - env_or("ANTHROPIC_BASE_URL", "https://api.anthropic.com"), - OpenAiApi::Auto, // unused for Anthropic - ), - Provider::OpenAi => ( - req("OPENAI_COMPAT_API_KEY")?, - resolve_model( - buzz_agent_model.as_deref(), - env("OPENAI_COMPAT_MODEL").as_deref(), - ) - .ok_or_else(|| "config: OPENAI_COMPAT_MODEL required".to_string())?, - env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"), - parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?, - ), - Provider::Databricks | Provider::DatabricksV2 => ( - env("DATABRICKS_TOKEN").unwrap_or_default(), - resolve_model(buzz_agent_model.as_deref(), databricks_model.as_deref()) - .ok_or_else(|| "config: DATABRICKS_MODEL required".to_string())?, - databricks_host.ok_or_else(|| "config: DATABRICKS_HOST required".to_string())?, - OpenAiApi::Chat, // only read by OpenAI/legacy Databricks dispatch - ), - Provider::OpenRouter => ( - req("OPENROUTER_API_KEY")?, - resolve_model( - buzz_agent_model.as_deref(), - env("OPENROUTER_MODEL").as_deref(), - ) - .ok_or_else(|| "config: OPENROUTER_MODEL required".to_string())?, - env_or("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"), - OpenAiApi::Chat, // OpenRouter uses Chat Completions only - ), - }; + let model = resolve_model(buzz_agent_model.as_deref(), conn.provider_model.as_deref()) + .ok_or_else(|| format!("config: {} required", conn.model_env_var))?; + let ProviderConnection { + api_key, + base_url, + openai_api, + maple_pcr0_development, + .. + } = conn; let system_prompt = match (env("BUZZ_AGENT_SYSTEM_PROMPT"), env("BUZZ_AGENT_SYSTEM_PROMPT_FILE")) { (Some(_), Some(_)) => return Err( "config: BUZZ_AGENT_SYSTEM_PROMPT and BUZZ_AGENT_SYSTEM_PROMPT_FILE are mutually exclusive".into()), @@ -600,6 +566,7 @@ impl Config { base_url, anthropic_api_version: env_or("ANTHROPIC_API_VERSION", "2023-06-01"), openai_api, + maple_pcr0_development, max_rounds: parse_env("BUZZ_AGENT_MAX_ROUNDS", 0)?, max_output_tokens: parse_env("BUZZ_AGENT_MAX_OUTPUT_TOKENS", 65_536)?, max_token_recoveries: parse_env("BUZZ_AGENT_MAX_TOKEN_RECOVERIES", 3u32)?, @@ -637,12 +604,25 @@ impl Config { Ok(cfg) } + /// Same env vars as [`Config::from_env`], but no model is required: + /// `buzz-agent models` runs before a model has been chosen. The ACP + /// server path keeps using `from_env`, since a running agent always has + /// a model. + pub fn discovery_from_env() -> Result { + let provider = resolve_provider_from_env()?; + let conn = provider_connection_from_env(provider, ConnectionPurpose::Discovery)?; + let mut cfg = Self::for_discovery(provider, conn.api_key, conn.base_url); + cfg.openai_api = conn.openai_api; + cfg.maple_pcr0_development = conn.maple_pcr0_development; + Ok(cfg) + } + /// Construct a minimal `Config` for model-catalog discovery. /// /// Only the fields used by [`build_token_source`](crate::llm::build_token_source) /// and the catalog HTTP helpers are meaningful; all others are set to - /// inert defaults. Never call `from_env` for discovery — it requires - /// `DATABRICKS_MODEL` and other fields that are irrelevant here. + /// inert defaults. Never call `from_env` for discovery; use + /// [`Config::discovery_from_env`] instead. pub fn for_discovery(provider: Provider, api_key: String, base_url: String) -> Self { Self { provider, @@ -652,6 +632,7 @@ impl Config { system_prompt: String::new(), anthropic_api_version: "2023-06-01".into(), openai_api: OpenAiApi::Chat, + maple_pcr0_development: false, max_rounds: 0, max_output_tokens: 1, max_token_recoveries: 0, @@ -814,6 +795,10 @@ fn resolve_provider( "databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2), "openrouter" if present_nonempty(openrouter_key) => Ok(Provider::OpenRouter), "openrouter" => Err("config: OPENROUTER_API_KEY required".into()), + // No key check here: Maple may be resolved for keyless model + // discovery. `provider_connection_from_env` enforces the key + // when the purpose is inference. + "maple" => Ok(Provider::Maple), _ => Err(format!( "config: BUZZ_AGENT_PROVIDER={raw} not supported" )), @@ -825,6 +810,121 @@ fn resolve_provider( } } +/// Resolve the provider from the standard env vars (`BUZZ_AGENT_PROVIDER` +/// plus each provider's key). Shared by `from_env` and `discovery_from_env`. +fn resolve_provider_from_env() -> Result { + resolve_provider( + env("BUZZ_AGENT_PROVIDER").as_deref(), + env("ANTHROPIC_API_KEY").as_deref(), + env("OPENAI_COMPAT_API_KEY").as_deref(), + env("OPENROUTER_API_KEY").as_deref(), + ) +} + +/// Per-provider connection settings from env: everything `from_env` needs +/// except the model. Split out so [`Config::discovery_from_env`] can build a +/// connection without one. +struct ProviderConnection { + api_key: String, + base_url: String, + openai_api: OpenAiApi, + /// Development PCR0 trust roots. Only read for `Provider::Maple`. + maple_pcr0_development: bool, + /// Provider-specific model env var (e.g. `DATABRICKS_MODEL`), the + /// default when `BUZZ_AGENT_MODEL` is absent. + provider_model: Option, + /// Name of that env var, for the "required" error message. + model_env_var: &'static str, +} + +/// Inference always needs a credential. Discovery may run before the user +/// has one: Maple's enclave lists models for any attested session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConnectionPurpose { + Inference, + Discovery, +} + +/// Resolve one provider's connection settings from env. +/// +/// `OPENAI_COMPAT_API` is only read when provider=openai, so a stray bad +/// value can't break an Anthropic-only deployment. Databricks borrows +/// `api_key` as the optional `DATABRICKS_TOKEN` escape hatch; empty means +/// "use OAuth PKCE." Legacy Databricks encodes the model in the URL path, +/// Databricks v2 in the request body. +fn provider_connection_from_env( + provider: Provider, + purpose: ConnectionPurpose, +) -> Result { + Ok(match provider { + Provider::Anthropic => ProviderConnection { + api_key: req("ANTHROPIC_API_KEY")?, + base_url: env_or("ANTHROPIC_BASE_URL", "https://api.anthropic.com"), + openai_api: OpenAiApi::Auto, // unused for Anthropic + maple_pcr0_development: false, + provider_model: env("ANTHROPIC_MODEL"), + model_env_var: "ANTHROPIC_MODEL", + }, + Provider::OpenAi => ProviderConnection { + api_key: req("OPENAI_COMPAT_API_KEY")?, + base_url: env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"), + openai_api: parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?, + maple_pcr0_development: false, + provider_model: env("OPENAI_COMPAT_MODEL"), + model_env_var: "OPENAI_COMPAT_MODEL", + }, + Provider::Databricks | Provider::DatabricksV2 => ProviderConnection { + api_key: env("DATABRICKS_TOKEN").unwrap_or_default(), + base_url: env("DATABRICKS_HOST") + .ok_or_else(|| "config: DATABRICKS_HOST required".to_string())?, + openai_api: OpenAiApi::Chat, // only read by OpenAI/legacy Databricks dispatch + maple_pcr0_development: false, + provider_model: env("DATABRICKS_MODEL"), + model_env_var: "DATABRICKS_MODEL", + }, + Provider::OpenRouter => ProviderConnection { + api_key: req("OPENROUTER_API_KEY")?, + base_url: env_or("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"), + openai_api: OpenAiApi::Chat, // OpenRouter uses Chat Completions only + maple_pcr0_development: false, + provider_model: env("OPENROUTER_MODEL"), + model_env_var: "OPENROUTER_MODEL", + }, + Provider::Maple => ProviderConnection { + api_key: match purpose { + ConnectionPurpose::Inference => req("MAPLE_API_KEY")?, + // Empty means "attested session, no credential"; see + // `build_maple_client`. + ConnectionPurpose::Discovery => env("MAPLE_API_KEY").unwrap_or_default(), + }, + base_url: env_or("MAPLE_BASE_URL", "https://enclave.trymaple.ai"), + openai_api: OpenAiApi::Chat, // Maple uses Chat Completions only + maple_pcr0_development: parse_maple_pcr0_environment( + env("MAPLE_PCR0_ENVIRONMENT").as_deref(), + )?, + provider_model: env("MAPLE_MODEL"), + model_env_var: "MAPLE_MODEL", + }, + }) +} + +/// Parse `MAPLE_PCR0_ENVIRONMENT`. Returns `true` for the development trust +/// roots. Pure (env-free) for testability; the caller hands in the raw value. +fn parse_maple_pcr0_environment(raw: Option<&str>) -> Result { + match raw + .unwrap_or("production") + .trim() + .to_ascii_lowercase() + .as_str() + { + "production" | "" => Ok(false), + "development" => Ok(true), + other => Err(format!( + "config: MAPLE_PCR0_ENVIRONMENT={other} not supported (use production|development)" + )), + } +} + /// Parse `OPENAI_COMPAT_API`. Pure (env-free) for testability; the /// caller hands in the raw value. fn parse_openai_api(raw: Option<&str>) -> Result { @@ -2160,6 +2260,37 @@ mod tests { assert!(err.contains("OPENROUTER_API_KEY")); } + /// Maple resolves without a key; the inference key requirement lives in + /// `provider_connection_from_env`, not here. + #[test] + fn resolve_provider_maple_does_not_gate_on_key() { + assert_eq!( + resolve_provider(Some("maple"), None, None, None).unwrap(), + Provider::Maple + ); + assert_eq!( + resolve_provider(Some("MAPLE"), None, None, None).unwrap(), + Provider::Maple + ); + } + + #[test] + fn parse_maple_pcr0_environment_values() { + // Absent and explicit production select the production trust roots. + assert_eq!(parse_maple_pcr0_environment(None), Ok(false)); + assert_eq!(parse_maple_pcr0_environment(Some("production")), Ok(false)); + assert_eq!(parse_maple_pcr0_environment(Some("")), Ok(false)); + // Development is case-insensitive and whitespace-tolerant. + assert_eq!(parse_maple_pcr0_environment(Some("development")), Ok(true)); + assert_eq!( + parse_maple_pcr0_environment(Some(" Development ")), + Ok(true) + ); + // Anything else is a hard startup error, matching provider handling. + let err = parse_maple_pcr0_environment(Some("staging")).unwrap_err(); + assert!(err.contains("MAPLE_PCR0_ENVIRONMENT=staging"), "{err}"); + } + // ── pricing_authority: canonical URL → bare-host registry token ────────── #[test] diff --git a/crates/buzz-agent/src/handoff.rs b/crates/buzz-agent/src/handoff.rs index 869fbe06664..956062f3dac 100644 --- a/crates/buzz-agent/src/handoff.rs +++ b/crates/buzz-agent/src/handoff.rs @@ -571,6 +571,7 @@ mod tests { Provider::OpenAi, Provider::Databricks, Provider::DatabricksV2, + Provider::Maple, ] { assert_eq!( summary_completion_cap(provider, HANDOFF_MAX_OUTPUT_TOKENS), diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 98fa99ca5bf..4cc2e4e2255 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -12,7 +12,7 @@ pub mod model_capabilities; pub mod types; mod wire; -pub use catalog::{discover_databricks_models, ModelEntry}; +pub use catalog::{discover_databricks_models, discover_models, ModelEntry}; pub use config::Provider; pub use types::AgentError; @@ -141,6 +141,12 @@ pub fn run() -> Result<(), Box> { .build()? .block_on(auth_subcommand(&args[2..])); } + if matches!(args.get(1).map(String::as_str), Some("models")) { + return tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(models_subcommand()); + } tokio::runtime::Builder::new_multi_thread() .enable_all() .build()? @@ -154,6 +160,27 @@ pub async fn authenticate_databricks(host: &str) -> Result<(), AgentError> { .await } +/// `buzz-agent models`: live model discovery for frontends. The desktop's +/// model picker runs it for draft configs. +/// +/// Reads the same provider env vars as the ACP server but needs no model. +/// Prints a JSON array of `{"id","name"}` objects on stdout; providers with +/// no live catalog print `[]` and the caller falls back to its own options. +/// Failures exit non-zero with the error on stderr. +async fn models_subcommand() -> Result<(), Box> { + let cfg = Config::discovery_from_env()?; + let models = catalog::discover_models(&cfg) + .await + .map_err(|error| error.to_string())? + .unwrap_or_default(); + let out: Vec = models + .iter() + .map(|m| json!({ "id": m.id, "name": m.name })) + .collect(); + println!("{}", Value::Array(out)); + Ok(()) +} + /// `buzz-agent auth ` — run the interactive auth flow for a /// provider and persist the result, then exit. Today this supports Databricks /// OAuth 2.0 PKCE. Reads `DATABRICKS_HOST` from env; needs a browser on the @@ -336,13 +363,13 @@ async fn resolve_models_catalog( cache.get_or_try_init(|| discover).await.cloned() } -/// Return the configured model as a one-entry catalog for this response. +/// Return the configured model as a one-entry catalog. /// -/// This value is never written to `models_cache`; failed discovery must be retried by -/// the next session rather than pinning degraded state for the process lifetime. -/// -/// Only reached from the Databricks provider arm below, so the curated label is -/// looked up from the Databricks manifest; `id` stays the raw configured value. +/// Used as the cached result when `discover_models` returns `Ok(None)` +/// (the configured model is process-constant, so caching it is fine), and +/// as the uncached fallback when live discovery fails, so the next session +/// retries instead of keeping degraded state. The label lookup only hits +/// for Databricks ids; other providers keep the raw model as `name`. fn configured_model_fallback(model: &str) -> Vec { let model = model.trim().to_string(); let name = crate::model_capabilities::databricks_registry_label(&model) @@ -418,42 +445,45 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen // failures and other catalog failures use only the configured model for this // response, without caching, so session/prompt can run the existing PKCE flow. let available_models: Vec = { - use crate::config::Provider; - match app.cfg.provider { - Provider::Databricks | Provider::DatabricksV2 => { - let models = match resolve_models_catalog( - &app.models_cache, - discover_databricks_models(&app.cfg), - ) - .await - { - Ok(models) => models, - Err(error @ AgentError::LlmAuth(_)) if !app.cfg.api_key.is_empty() => { - return reject(wire_tx, id, error.json_rpc_code(), &error.to_string()) - .await; - } - Err(error @ AgentError::LlmAuth(_)) => { - tracing::warn!( - error = %error, - "Databricks OAuth model catalog unavailable; using configured model" - ); - configured_model_fallback(&app.cfg.model) - } - Err(error) => { - tracing::warn!( - error = %error, - "Databricks model catalog unavailable; using configured model" - ); - configured_model_fallback(&app.cfg.model) - } - }; - models - .iter() - .map(|m| json!({ "modelId": m.id, "name": m.name })) - .collect() + // catalog::discover_models owns the per-provider dispatch; + // no-catalog providers resolve to the configured model without + // touching the cache. + let models = match resolve_models_catalog(&app.models_cache, async { + catalog::discover_models(&app.cfg).await.map(|discovered| { + discovered.unwrap_or_else(|| configured_model_fallback(&app.cfg.model)) + }) + }) + .await + { + Ok(models) => models, + // A static credential cannot recover interactively; reject so + // the frontend shows the credential error. + Err(error @ AgentError::LlmAuth(_)) if !app.cfg.api_key.is_empty() => { + return reject(wire_tx, id, error.json_rpc_code(), &error.to_string()).await; } - _ => vec![json!({ "modelId": app.cfg.model, "name": app.cfg.model })], - } + // OAuth-recoverable (Databricks PKCE): degrade to the configured + // model; session/prompt can run the interactive flow later. + Err(error @ AgentError::LlmAuth(_)) => { + tracing::warn!( + error = %error, + provider = ?app.cfg.provider, + "model catalog auth unavailable; using configured model" + ); + configured_model_fallback(&app.cfg.model) + } + Err(error) => { + tracing::warn!( + error = %error, + provider = ?app.cfg.provider, + "model catalog unavailable; using configured model" + ); + configured_model_fallback(&app.cfg.model) + } + }; + models + .iter() + .map(|m| json!({ "modelId": m.id, "name": m.name })) + .collect() }; let mcp = match McpRegistry::spawn_all(&app.cfg, &p.mcp_servers, &p.cwd).await { diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 83f642c1239..1ca6e4ae8b6 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -2,6 +2,9 @@ use std::collections::BTreeSet; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use bytes::Bytes; +use futures_util::StreamExt; +use opensecret::{OpenSecretClient, Pcr0Environment}; use reqwest::Client; use serde_json::{json, Map, Value}; @@ -48,6 +51,10 @@ pub struct Llm { /// Databricks otherwise. Anthropic doesn't use this — it always /// reads `cfg.api_key` directly because the API expects `x-api-key`. auth: Arc, + /// OpenSecret client for `Provider::Maple`, `None` for other providers. + /// The SDK owns the enclave session and re-attests on expiry inside + /// `send_inference_request`, so no session state lives here. + maple: Option, } /// Connect-phase timeout applied to every outgoing LLM HTTP request. @@ -68,10 +75,12 @@ impl Llm { .build() .map_err(|e| AgentError::Llm(format!("http: {e}")))?; let auth = build_token_source(cfg)?; + let maple = build_maple_client(cfg)?; Ok(Self { http, auto_upgraded: AtomicBool::new(false), auth, + maple, }) } @@ -114,6 +123,15 @@ impl Llm { .await .and_then(parse_openai_with_reasoning_details) } + Provider::Maple => { + // OpenAI Chat wire format inside the encrypted envelope. + // "maple" has no manifest records yet, so effort + // normalization falls to the `_default` provider fallback. + let e = + effort.map(|ef| normalize_effort_for_provider("maple", effective_model, ef)); + let body = openai_body(cfg, system_prompt, history, tools, effective_model, e); + self.post_maple(cfg, &body).await.and_then(parse_openai) + } Provider::OpenAi | Provider::Databricks => { let provider_str = match cfg.provider { Provider::OpenAi => "openai", @@ -270,6 +288,18 @@ impl Llm { let v = self.post_openrouter(cfg, &body).await?; Ok(parse_openai(v)?.text) } + Provider::Maple => { + let body = json!({ + "model": effective_model, + "stream": false, + "max_completion_tokens": max_output_tokens, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": user_prompt }, + ], + }); + Ok(parse_openai(self.post_maple(cfg, &body).await?)?.text) + } Provider::OpenAi | Provider::Databricks => { let r = self .openai_request(cfg, effective_model, |use_responses, request_model| { @@ -541,6 +571,154 @@ impl Llm { } } + /// POST one Chat Completions body through the attested Maple transport. + /// + /// Retries follow `post()`: 429/5xx and transport failures retry with + /// jittered backoff, a timeout raises the next attempt's budget, + /// malformed 2xx bodies retry, everything else is terminal. Auth + /// rejections are terminal immediately: the SDK already retried once + /// through its own credential recovery, and a static API key cannot + /// produce a different token, so a retry would only duplicate the + /// request. + async fn post_maple(&self, cfg: &Config, body: &Value) -> Result { + let client = self.maple.as_ref().ok_or_else(|| { + AgentError::Llm("maple: client not initialized for this provider".into()) + })?; + let body_bytes = + serde_json::to_vec(body).map_err(|e| AgentError::Llm(format!("serialize: {e}")))?; + let call_start = std::time::Instant::now(); + let mut timeout_failures: u32 = 0; + for attempt in 0..MAX_RETRIES { + let per_request_timeout = escalated_timeout(cfg.llm_timeout, timeout_failures); + let last_attempt = attempt + 1 >= MAX_RETRIES; + // The SDK's HTTP client sets no timeout, so the whole + // exchange, attestation handshake included, runs under one + // per-attempt budget. + let outcome = tokio::time::timeout( + per_request_timeout, + maple_chat_completion(client, &body_bytes), + ) + .await; + let (status, response_body) = match outcome { + Err(_elapsed) => { + timeout_failures += 1; + if !last_attempt { + tracing::warn!( + attempt = attempt + 1, + max_attempts = MAX_RETRIES, + timeout_failures, + "llm: maple request timeout, retrying with escalated budget" + ); + backoff_with_jitter(attempt).await; + continue; + } + return Err(terminal_llm_error( + call_start.elapsed(), + attempt + 1, + &timeout_message(false, per_request_timeout, TimeoutPhase::BodyRead), + )); + } + Ok(Err(MapleCallError::Terminal(message))) => { + return Err(AgentError::Llm(message)); + } + Ok(Err(MapleCallError::Sdk(error))) => { + if !last_attempt && is_retryable_maple_sdk_error(&error) { + if matches!(&error, opensecret::Error::Http(e) if e.is_timeout()) { + timeout_failures += 1; + } + tracing::warn!( + attempt = attempt + 1, + max_attempts = MAX_RETRIES, + error = %error, + "llm: maple transport error, retrying" + ); + backoff_with_jitter(attempt).await; + continue; + } + return Err(maple_sdk_terminal_error( + error, + call_start.elapsed(), + attempt + 1, + )); + } + Ok(Ok(exchange)) => exchange, + }; + let code = status.as_u16(); + if code == 401 || code == 403 { + return Err(AgentError::LlmAuth(format!( + "{status}: {} — update key in agent settings", + String::from_utf8_lossy(&response_body) + ))); + } + if status.is_server_error() || code == 429 || code == 499 { + let body_text = String::from_utf8_lossy(&response_body); + if !last_attempt { + tracing::warn!( + attempt = attempt + 1, + max_attempts = MAX_RETRIES, + %status, + "llm: maple retryable status, retrying" + ); + backoff_with_jitter(attempt).await; + continue; + } + return Err(terminal_llm_error( + call_start.elapsed(), + attempt + 1, + &format!("exhausted retries: {status}: {body_text}"), + )); + } + if code == 404 { + return Err(AgentError::LlmModelNotFound(format!( + "{status}: {}", + String::from_utf8_lossy(&response_body) + ))); + } + if !status.is_success() { + let body_text = String::from_utf8_lossy(&response_body).into_owned(); + if code == 400 && is_context_length_error(&body_text) { + return Err(AgentError::LlmContextExceeded(format!( + "{status}: {body_text}" + ))); + } + if code == 400 && is_unsupported_image_input_error(&body_text) { + return Err(AgentError::UnsupportedImageInput(body_text)); + } + return Err(AgentError::Llm(format!("{status}: {body_text}"))); + } + match serde_json::from_slice(&response_body) { + Ok(value) => return Ok(value), + Err(e) => { + // As in `post()`: a 2xx body that fails to parse is + // treated as transient and re-sent; no tool call was + // extracted from it. + if !last_attempt { + tracing::warn!( + attempt = attempt + 1, + max_attempts = MAX_RETRIES, + error = %e, + "llm: maple malformed response body, retrying" + ); + backoff_with_jitter(attempt).await; + continue; + } + return Err(terminal_llm_error( + call_start.elapsed(), + attempt + 1, + &format!("json: {e}"), + )); + } + } + } + // Unreachable in practice: every iteration returns or continues. + // See the matching tail in `post()`. + Err(terminal_llm_error( + call_start.elapsed(), + MAX_RETRIES, + "exhausted retries", + )) + } + /// If `err` names `/v1/responses` / "use the Responses API", latch a /// sticky upgrade so subsequent OpenAI calls hit Responses. Logged once. fn try_upgrade(&self, err: &AgentError) -> bool { @@ -2071,13 +2249,16 @@ pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig { /// never read for Anthropic requests (those go through `post_anthropic` with /// `x-api-key`), but Llm holds one to keep the field non-`Option`. /// - `Provider::OpenAi`: a static source over `OPENAI_COMPAT_API_KEY`. +/// - `Provider::Maple`: a static source over `MAPLE_API_KEY`. Never read for +/// Maple requests (the opensecret client carries the key); held to keep +/// the field non-`Option`, as for Anthropic. /// - `Provider::Databricks`: if `DATABRICKS_TOKEN` is set, a static source. /// Otherwise a `PkceOAuthTokenSource` pointed at the workspace's OIDC /// discovery URL. First request without a cached token triggers a browser /// flow; subsequent requests use the cache + refresh transparently. pub(crate) fn build_token_source(cfg: &Config) -> Result, AgentError> { match cfg.provider { - Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter => { + Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter | Provider::Maple => { Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone()))) } Provider::Databricks | Provider::DatabricksV2 => { @@ -2103,9 +2284,11 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result, A pub(crate) fn summary_completion_cap(provider: Provider, max_output_tokens: u32) -> u32 { match provider { Provider::OpenRouter => max_output_tokens.saturating_mul(2), - Provider::Anthropic | Provider::OpenAi | Provider::Databricks | Provider::DatabricksV2 => { - max_output_tokens - } + Provider::Anthropic + | Provider::OpenAi + | Provider::Databricks + | Provider::DatabricksV2 + | Provider::Maple => max_output_tokens, } } @@ -2475,6 +2658,137 @@ async fn openrouter_post( )) } +/// Build the attested OpenSecret client when the provider is Maple. +/// +/// Construction validates the base URL and picks the PCR0 trust roots; no +/// network I/O. Localhost base URLs switch the SDK to mock attestation (its +/// own rule); anything else requires HTTPS and a verified Nitro attestation +/// document before any request body leaves the process. +/// +/// An empty `api_key` builds a keyless client. The enclave lists models for +/// an attested session without a credential, so discovery can run before the +/// user enters a key. Inference configs always carry a key; an empty one +/// must not become a bogus `Bearer ` header. +pub(crate) fn build_maple_client(cfg: &Config) -> Result, AgentError> { + if cfg.provider != Provider::Maple { + return Ok(None); + } + let environment = if cfg.maple_pcr0_development { + Pcr0Environment::Development + } else { + Pcr0Environment::Production + }; + let client = if cfg.api_key.trim().is_empty() { + OpenSecretClient::new_with_pcr0_environment(cfg.base_url.clone(), environment) + } else { + OpenSecretClient::new_with_api_key_and_pcr0_environment( + cfg.base_url.clone(), + cfg.api_key.clone(), + environment, + ) + }; + client + .map(Some) + .map_err(|e| AgentError::Llm(format!("maple: {e}"))) +} + +/// Failure modes of one Maple exchange: SDK faults (classified by +/// `is_retryable_maple_sdk_error`) versus local hard stops like the response +/// size cap. +enum MapleCallError { + Sdk(opensecret::Error), + Terminal(String), +} + +/// One encrypted Chat Completions exchange through the opensecret SDK. +/// +/// Returns the decrypted status and body bytes; the caller classifies +/// non-2xx statuses and parses the JSON. Success bodies are capped at +/// `MAX_LLM_RESPONSE_BYTES` (a hard error, as in `post()`); error bodies are +/// diagnostics, truncated at `MAX_LLM_ERROR_BODY_BYTES`. +async fn maple_chat_completion( + client: &OpenSecretClient, + body_bytes: &[u8], +) -> Result<(http::StatusCode, Vec), MapleCallError> { + let request = http::Request::builder() + .method(http::Method::POST) + .uri("/v1/chat/completions") + .body(Bytes::copy_from_slice(body_bytes)) + .map_err(|e| MapleCallError::Terminal(format!("maple: build request: {e}")))?; + let response = client + .send_inference_request(request) + .await + .map_err(MapleCallError::Sdk)?; + let status = response.status(); + let mut buf: Vec = Vec::new(); + let mut body = response.into_body(); + while let Some(chunk) = body.next().await { + let chunk = chunk.map_err(MapleCallError::Sdk)?; + if status.is_success() { + if buf.len() + chunk.len() > MAX_LLM_RESPONSE_BYTES { + return Err(MapleCallError::Terminal(format!( + "response exceeded {MAX_LLM_RESPONSE_BYTES} bytes" + ))); + } + buf.extend_from_slice(&chunk); + } else { + let room = MAX_LLM_ERROR_BODY_BYTES.saturating_sub(buf.len()); + let take = chunk.len().min(room); + buf.extend_from_slice(&chunk[..take]); + if take < chunk.len() { + break; + } + } + } + Ok((status, buf)) +} + +/// `true` when an identical retry may succeed: transport failures under the +/// SDK, and 429/5xx `Api` errors from the attestation-handshake endpoints. +/// Attestation, session, and crypto failures are terminal; the SDK already +/// retried one re-attestation before surfacing them. +fn is_retryable_maple_sdk_error(error: &opensecret::Error) -> bool { + match error { + // The SDK pins its own reqwest version, so its error type differs + // from the workspace's and cannot flow through + // `is_retryable_transport_error`. + opensecret::Error::Http(e) => e.is_timeout() || e.is_connect() || e.is_request(), + opensecret::Error::Api { status, .. } => { + *status == 429 || *status == 499 || (500..600).contains(status) + } + _ => false, + } +} + +/// Map a terminal opensecret SDK error onto `AgentError`. Attestation +/// failures say the request was never sent. 401/403 map to `LlmAuth` so the +/// desktop shows its credential prompt. +fn maple_sdk_terminal_error( + error: opensecret::Error, + elapsed: std::time::Duration, + attempts: u32, +) -> AgentError { + match error { + opensecret::Error::AttestationVerificationFailed(message) => AgentError::Llm(format!( + "maple: enclave attestation verification failed — request not sent: {message}" + )), + opensecret::Error::Api { + status: status @ (401 | 403), + message, + } => AgentError::LlmAuth(format!("{status}: {message}")), + // The SDK's reqwest error differs from the workspace's type, so + // `classify_transport_error` cannot be reused. Timeouts never + // originate here; the per-attempt budget in `post_maple` fires. + opensecret::Error::Http(e) => { + terminal_llm_error(elapsed, attempts, &format!("transport: {e}")) + } + opensecret::Error::Api { status, message } => { + terminal_llm_error(elapsed, attempts, &format!("maple: {status}: {message}")) + } + other => AgentError::Llm(format!("maple: {other}")), + } +} + fn apply_openrouter_mutations( body: &mut Value, effort: Option, @@ -2618,6 +2932,7 @@ mod tests { base_url: "http://example.invalid".into(), anthropic_api_version: "2023-06-01".into(), openai_api: OpenAiApi::Chat, + maple_pcr0_development: false, hints_enabled: true, thinking_effort: None, thinking_summary: ThinkingSummary::Auto, @@ -5779,6 +6094,7 @@ mod tests { .unwrap(), auto_upgraded: std::sync::atomic::AtomicBool::new(false), auth, + maple: None, } } @@ -6232,6 +6548,100 @@ mod tests { assert!(body.get("max_tokens").is_none()); } + // ---- Maple (OpenSecret) ---- + + #[test] + fn maple_client_is_built_only_for_the_maple_provider() { + assert!(build_maple_client(&cfg(Provider::OpenAi)) + .expect("non-Maple providers skip construction") + .is_none()); + + let mut maple_cfg = cfg(Provider::Maple); + maple_cfg.base_url = "https://enclave.trymaple.ai".into(); + assert!(build_maple_client(&maple_cfg) + .expect("valid HTTPS base URL constructs") + .is_some()); + + // Keyless discovery: an attested but unauthenticated client, not an + // empty bearer. + maple_cfg.api_key = String::new(); + assert!(build_maple_client(&maple_cfg) + .expect("keyless client constructs") + .is_some()); + + // The SDK requires HTTPS for non-loopback hosts; a plain-HTTP remote + // base URL is a startup error, not a first-request surprise. + maple_cfg.base_url = "http://enclave.trymaple.ai".into(); + match build_maple_client(&maple_cfg) { + Err(AgentError::Llm(s)) => assert!(s.starts_with("maple: "), "{s}"), + Err(other) => panic!("expected AgentError::Llm, got: {other:?}"), + Ok(_) => panic!("plain-HTTP remote base URL must be rejected"), + } + } + + /// 429/5xx from the handshake endpoints retry; attestation and crypto + /// failures are terminal. + #[test] + fn maple_sdk_error_retryability() { + for status in [429u16, 499, 500, 503] { + assert!( + is_retryable_maple_sdk_error(&opensecret::Error::Api { + status, + message: "busy".into() + }), + "{status} should be retryable" + ); + } + for error in [ + opensecret::Error::Api { + status: 400, + message: "bad".into(), + }, + opensecret::Error::AttestationVerificationFailed("pcr0 mismatch".into()), + opensecret::Error::Session("stale".into()), + opensecret::Error::Decryption("bad tag".into()), + ] { + assert!(!is_retryable_maple_sdk_error(&error), "{error} is terminal"); + } + } + + #[test] + fn maple_terminal_errors_map_onto_the_agent_taxonomy() { + let auth = maple_sdk_terminal_error( + opensecret::Error::Api { + status: 401, + message: "invalid api key".into(), + }, + Duration::from_secs(1), + 1, + ); + assert!( + matches!(auth, AgentError::LlmAuth(ref s) if s.contains("invalid api key")), + "{auth:?}" + ); + + let attestation = maple_sdk_terminal_error( + opensecret::Error::AttestationVerificationFailed("pcr0 mismatch".into()), + Duration::from_secs(1), + 1, + ); + assert!( + matches!( + attestation, + AgentError::Llm(ref s) + if s.contains("attestation verification failed") && s.contains("pcr0 mismatch") + ), + "{attestation:?}" + ); + } + + /// Maple's summarize path requests exactly the caller's budget, with no + /// separate reasoning allowance like OpenRouter. + #[test] + fn maple_summary_completion_cap_is_the_callers_budget() { + assert_eq!(summary_completion_cap(Provider::Maple, 2048), 2048); + } + // ---- A5: error-inside-200 ---- #[test] diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index ff8a0e7703b..425ea20bbe9 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -56,6 +56,7 @@ export default defineConfig({ "**/voice-settings.spec.ts", "**/agent-readiness-screenshots.spec.ts", "**/agent-error-state-screenshots.spec.ts", + "**/maple-provider-ui.spec.ts", "**/edit-agent.spec.ts", "**/doctor-cta-screenshots.spec.ts", "**/pubkey-display-screenshots.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ac982df6f20..e23fe6dc07b 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -302,6 +302,45 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -416,6 +455,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "async-task" version = "4.7.1" @@ -721,6 +782,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base16ct" version = "1.0.0" @@ -1040,10 +1107,14 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "bytes", "dirs", + "futures-util", "getrandom 0.4.3", "hex", + "http", "nix 0.31.3", + "opensecret", "reqwest 0.13.4", "rmcp", "serde", @@ -1527,6 +1598,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1701,6 +1799,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -2002,6 +2106,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -2351,17 +2467,42 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + [[package]] name = "der" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2449,6 +2590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -2460,7 +2602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", "zeroize", @@ -2629,15 +2771,29 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7984231f8b4c72eb3b88c70040dc1e4ff6803fa9169e93c0ac465942d74fa36a" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki 0.7.3", +] + [[package]] name = "ed25519" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8", + "pkcs8 0.11.0", "serdect", - "signature", + "signature 3.0.0", ] [[package]] @@ -2651,7 +2807,7 @@ dependencies = [ "rand_core 0.10.1", "serde", "sha2 0.11.0", - "signature", + "signature 3.0.0", "subtle", "zeroize", ] @@ -2662,6 +2818,27 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.11" @@ -2810,6 +2987,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom 7.1.3", + "pin-project-lite", +] + [[package]] name = "extended" version = "0.1.0" @@ -2886,6 +3074,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -3526,6 +3724,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -6858,6 +7067,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -6920,6 +7138,43 @@ dependencies = [ "tracing", ] +[[package]] +name = "opensecret" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86d9a35e5dd1ee761d3449d9e2db722eb1ebbab0eef02d3bb8601fd6b23d6bd9" +dependencies = [ + "aes-gcm", + "anyhow", + "async-stream", + "async-trait", + "base64 0.22.1", + "bytes", + "chacha20poly1305", + "chrono", + "ciborium", + "eventsource-stream", + "futures", + "hex", + "hkdf", + "http", + "p256", + "percent-encoding", + "pin-project", + "reqwest 0.12.28", + "ring", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", + "x25519-dalek", + "x509-parser", + "yasna", +] + [[package]] name = "openssl" version = "0.10.81" @@ -7143,6 +7398,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "palette" version = "0.7.6" @@ -7280,6 +7547,15 @@ dependencies = [ "hmac 0.13.0", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -7510,14 +7786,24 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der 0.8.1", + "spki 0.8.0", ] [[package]] @@ -7760,6 +8046,15 @@ dependencies = [ "num-integer", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -8622,6 +8917,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "rfd" version = "0.16.0" @@ -8851,6 +9156,15 @@ dependencies = [ "transpose", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "0.38.44" @@ -9118,6 +9432,20 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "secp256k1" version = "0.29.1" @@ -9440,7 +9768,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ - "base16ct", + "base16ct 1.0.0", "serde", ] @@ -9651,6 +9979,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -9913,6 +10251,16 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -9920,7 +10268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.1", ] [[package]] @@ -13312,6 +13660,35 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -13498,6 +13875,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" + [[package]] name = "yoke" version = "0.8.3" diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs index 998edeca27d..4a310fe8f06 100644 --- a/desktop/src-tauri/src/commands/agent_model_process.rs +++ b/desktop/src-tauri/src/commands/agent_model_process.rs @@ -2,11 +2,101 @@ use std::{collections::BTreeMap, path::PathBuf}; use crate::managed_agents::{ build_buzz_agent_provider_defaults, default_agent_workdir, known_acp_runtime, - redact_env_values_in, AgentModelsResponse, + redact_env_values_in, AgentModelInfo, AgentModelsResponse, }; use super::agent_models::normalize_agent_models; +/// Live discovery through the agent binary: `buzz-agent models` dispatches +/// on `BUZZ_AGENT_PROVIDER` inside the agent, which owns every provider +/// transport, including ones the desktop cannot probe over plain HTTP. It +/// needs no configured model, so it also serves draft configs in the +/// create/edit dialog. +/// +/// Returns `Ok(None)` when the runtime is not buzz-agent or the catalog is +/// empty, and callers fall through to their next discovery step. Errors +/// propagate so credential failures reach the dialog. +pub(super) async fn run_buzz_agent_native_models( + agent_command: &str, + resolved_agent: &str, + merged_env: BTreeMap, + persisted_model: Option, +) -> Result, String> { + if known_acp_runtime(agent_command).map(|meta| meta.id) != Some("buzz-agent") { + return Ok(None); + } + let env_for_redaction = merged_env.clone(); + let resolved_agent = resolved_agent.to_string(); + let output = tokio::task::spawn_blocking(move || { + let mut cmd = std::process::Command::new(&resolved_agent); + if let Some(home) = default_agent_workdir() { + cmd.current_dir(home); + } + cmd.arg("models"); + // Mirror runtime spawn: internal builds may bake provider/model + // defaults. User-provided env below still wins. + build_buzz_agent_provider_defaults(&mut cmd); + for (k, v) in &merged_env { + cmd.env(k, v); + } + crate::util::configure_no_window(&mut cmd); + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|e| format!("failed to spawn buzz-agent models: {e}")) + }) + .await + .map_err(|e| format!("model discovery task failed: {e}"))? + .map_err(|e: String| e)?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Scrub user-supplied env values (API keys) before surfacing stderr. + let stderr_redacted = redact_env_values_in(stderr.as_ref(), &env_for_redaction); + return Err(format!( + "buzz-agent models failed (exit {}): {stderr_redacted}", + output.status.code().unwrap_or(-1) + )); + } + + parse_native_models_output(&output.stdout, persisted_model) +} + +/// Parse `buzz-agent models` stdout: a JSON array of `{"id","name"}` +/// objects. An empty array means the provider has no live catalog, returned +/// as `Ok(None)` so the caller falls through to its next discovery step. +pub(super) fn parse_native_models_output( + stdout: &[u8], + persisted_model: Option, +) -> Result, String> { + #[derive(serde::Deserialize)] + struct NativeModel { + id: String, + name: Option, + } + let raw: Vec = serde_json::from_slice(stdout) + .map_err(|e| format!("failed to parse buzz-agent models JSON: {e}"))?; + if raw.is_empty() { + return Ok(None); + } + Ok(Some(AgentModelsResponse { + agent_name: "buzz-agent".to_string(), + agent_version: "native-models".to_string(), + models: raw + .into_iter() + .map(|m| AgentModelInfo { + id: m.id, + name: m.name, + description: None, + }) + .collect(), + agent_default_model: None, + selected_model: persisted_model, + supports_switching: true, + })) +} + pub(super) async fn run_agent_models_command( resolved_acp: PathBuf, agent_command: String, diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index cb809b6c04a..7b0ac5291f5 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -4,7 +4,7 @@ use nostr::Keys; use serde::Deserialize; use tauri::{AppHandle, State}; -use super::agent_model_process::run_agent_models_command; +use super::agent_model_process::{run_agent_models_command, run_buzz_agent_native_models}; use super::managed_agent_definition::apply_model_provider_prompt_update; // The map-only lookup is reached solely from the base-URL helpers that exist for // their unit tests; discovery itself always goes through the process-env variant. @@ -92,7 +92,7 @@ pub async fn get_agent_models( provider: saved_provider, provider_env_var, env: merged_env, - command: _, + command: runtime_command, } = discovery; let merged_env = discovery_env_with_baked_floor(merged_env); @@ -145,6 +145,19 @@ pub async fn get_agent_models( return Ok(models); } + // The agent binary owns every provider transport; providers without a + // desktop HTTP helper discover here. + if let Some(models) = run_buzz_agent_native_models( + &runtime_command, + &agent_command, + merged_env.clone(), + persisted_model.clone(), + ) + .await? + { + return Ok(models); + } + run_agent_models_command( resolved_acp, agent_command, @@ -321,6 +334,16 @@ pub async fn discover_agent_models( return Ok(models); } + // `buzz-agent models` needs no configured model, so it serves the draft + // dialog. The ACP path below requires one and would fail with + // "config: _MODEL required". + if let Some(models) = + run_buzz_agent_native_models(agent_command, &resolved_agent, merged_env.clone(), None) + .await? + { + return Ok(models); + } + run_agent_models_command(resolved_acp, resolved_agent, agent_args, None, merged_env).await } diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index df3849de4a4..a71f04455c8 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -962,3 +962,38 @@ fn databricks_static_token_error_redacts_echoed_token() { "error lost its remediation: {error}" ); } + +// ── buzz-agent native model discovery (`buzz-agent models`) ────────────────── + +use crate::commands::agent_model_process::parse_native_models_output; + +#[test] +fn parse_native_models_output_builds_response() { + let stdout = + br#"[{"id":"llama3-3-70b","name":"llama3-3-70b"},{"id":"kimi-k3","name":"kimi-k3"}]"#; + let response = parse_native_models_output(stdout, Some("kimi-k3".to_string())) + .expect("valid JSON parses") + .expect("non-empty catalog yields a response"); + assert_eq!(response.agent_name, "buzz-agent"); + assert!(response.supports_switching); + assert_eq!(response.selected_model.as_deref(), Some("kimi-k3")); + let ids: Vec<&str> = response.models.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, ["llama3-3-70b", "kimi-k3"]); +} + +#[test] +fn parse_native_models_output_empty_catalog_falls_through() { + // `[]` means no live catalog; callers fall through to the ACP + // subprocess instead of an empty dropdown. + let response = parse_native_models_output(b"[]", None).expect("empty array is valid"); + assert!(response.is_none()); +} + +#[test] +fn parse_native_models_output_rejects_malformed_json() { + let error = parse_native_models_output(b"not json", None).unwrap_err(); + assert!( + error.contains("failed to parse buzz-agent models JSON"), + "{error}" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index f7f5d5c5d0e..3044dc16673 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -482,6 +482,7 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { Some("anthropic") => Some("ANTHROPIC_MODEL"), Some("openai") | Some("openai-compat") => Some("OPENAI_COMPAT_MODEL"), Some("openrouter") => Some("OPENROUTER_MODEL"), + Some("maple") => Some("MAPLE_MODEL"), _ => None, }; let model_present = effective @@ -530,6 +531,12 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { key: "OPENROUTER_API_KEY".to_string(), }); } + Some("maple") + if env_key_missing("MAPLE_API_KEY") => { + missing.push(Requirement::EnvKey { + key: "MAPLE_API_KEY".to_string(), + }); + } _ => { // Unknown provider or no provider yet — only the NormalizedField // requirement above captures this gap. @@ -1679,58 +1686,6 @@ mod tests { field: "model".to_string() })); } - - // ── OpenRouter readiness ───────────────────────────────────────────── - - #[test] - fn buzz_agent_openrouter_with_all_fields_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), - ("OPENROUTER_API_KEY", "sk-or-test-key"), - ]), - ); - let result = agent_readiness(&env); - assert!( - result.is_ready(), - "openrouter with all fields should be ready" - ); - } - - #[test] - fn buzz_agent_openrouter_missing_key_returns_not_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), - ]), - ); - let result = agent_readiness(&env); - assert!(!result.is_ready()); - assert!(result.requirements().contains(&Requirement::EnvKey { - key: "OPENROUTER_API_KEY".to_string() - })); - } - - #[test] - fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), - ("OPENROUTER_API_KEY", "sk-or-test-key"), - ]), - ); - let result = agent_readiness(&env); - assert!( - result.is_ready(), - "OPENROUTER_MODEL fallback should satisfy model requirement" - ); - } } // Goose file-config-aware requirement tests live in a sibling file so this @@ -1738,3 +1693,8 @@ mod tests { #[cfg(test)] #[path = "readiness_goose_file_config_tests.rs"] mod goose_file_config_tests; + +// Per-provider readiness tests (OpenRouter, Maple), same convention. +#[cfg(test)] +#[path = "readiness_provider_tests.rs"] +mod provider_tests; diff --git a/desktop/src-tauri/src/managed_agents/readiness_provider_tests.rs b/desktop/src-tauri/src/managed_agents/readiness_provider_tests.rs new file mode 100644 index 00000000000..09a06301a29 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness_provider_tests.rs @@ -0,0 +1,128 @@ +//! Per-provider readiness tests for buzz-agent (OpenRouter, Maple). +//! +//! Included from `readiness.rs` via `#[path]`, so `super::*` resolves against +//! that module. Sibling file, like `readiness_goose_file_config_tests.rs`, +//! to keep `readiness.rs` under the file-size limit. + +use std::collections::BTreeMap; + +use super::*; +use crate::managed_agents::discovery::known_acp_runtime_exact; + +/// Build a minimal `EffectiveAgentEnv` with the given env map and command. +fn make_env(command: &str, env: BTreeMap) -> EffectiveAgentEnv { + let runtime = known_acp_runtime_exact(command); + EffectiveAgentEnv { + env, + config_file_path: runtime.and_then(|r| r.config_file_path), + effective_command: command.to_string(), + } +} + +fn env_with(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +// ── OpenRouter readiness ───────────────────────────────────────────────── + +#[test] +fn buzz_agent_openrouter_with_all_fields_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "openrouter with all fields should be ready" + ); +} + +#[test] +fn buzz_agent_openrouter_missing_key_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "OPENROUTER_API_KEY".to_string() + })); +} + +#[test] +fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "OPENROUTER_MODEL fallback should satisfy model requirement" + ); +} + +// ── Maple readiness ────────────────────────────────────────────────────── + +#[test] +fn buzz_agent_maple_with_all_fields_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "maple"), + ("BUZZ_AGENT_MODEL", "llama3-3-70b"), + ("MAPLE_API_KEY", "maple-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!(result.is_ready(), "maple with all fields should be ready"); +} + +#[test] +fn buzz_agent_maple_missing_key_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "maple"), + ("BUZZ_AGENT_MODEL", "llama3-3-70b"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "MAPLE_API_KEY".to_string() + })); +} + +#[test] +fn buzz_agent_maple_with_provider_model_fallback_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "maple"), + ("MAPLE_MODEL", "llama3-3-70b"), + ("MAPLE_API_KEY", "maple-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "MAPLE_MODEL fallback should satisfy model requirement" + ); +} diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 5c515a05073..9fb739c7859 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -46,6 +46,7 @@ const KNOWN_LLM_PROVIDER_IDS = [ "anthropic", "databricks", "databricks_v2", + "maple", "openai", "openai-compat", "openrouter", @@ -135,6 +136,11 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial< secretEnvVar: "OPENROUTER_API_KEY", apiKeyLabel: "OpenRouter API Key", }, + maple: { + requiredEnvKeys: ["MAPLE_API_KEY"], + secretEnvVar: "MAPLE_API_KEY", + apiKeyLabel: "Maple API Key", + }, }; const DEFAULT_MODEL_OPTION: PersonaModelOption = { @@ -147,6 +153,7 @@ export const PERSONA_LLM_PROVIDER_OPTIONS: readonly PersonaModelOption[] = [ { id: "openai", label: "OpenAI" }, { id: "openai-compat", label: "OpenAI-compatible" }, { id: "openrouter", label: "OpenRouter" }, + { id: "maple", label: "Maple (encrypted)" }, { id: "relay-mesh", label: "Buzz shared compute" }, { id: "databricks", label: "Databricks" }, { id: "databricks_v2", label: "Databricks v2" }, diff --git a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs index dfa086738fb..8e96738bdc5 100644 --- a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs +++ b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs @@ -26,6 +26,21 @@ test("model discovery status names missing OpenAI-compatible credentials", () => assert.match(status?.message ?? "", /OpenAI models/); }); +test("model discovery status handles any provider's missing API key generically", () => { + // A provider without branded copy falls to the generic + // `config: _API_KEY required` matcher. + const status = formatModelDiscoveryErrorStatus( + new Error( + "buzz-agent models failed (exit 1): config: ACME_API_KEY required", + ), + "acme", + ); + + assert.equal(status?.tone, "warning"); + assert.match(status?.message ?? "", /ACME_API_KEY/); + assert.match(status?.message ?? "", /API key/); +}); + test("Buzz shared compute names the empty state and next action", () => { const status = formatModelDiscoveryErrorStatus( new Error("no Buzz shared compute serving members are available"), diff --git a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts index 0d895264253..6f51ce0e40e 100644 --- a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts +++ b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts @@ -116,6 +116,17 @@ export function formatModelDiscoveryErrorStatus( }; } + // Generic provider-key gate: buzz-agent reports a missing credential as + // `config: _API_KEY required`. The branded Anthropic/OpenAI + // cases above stay because their copy names the vendor. + const requiredKey = message.match(/config: ([A-Z0-9_]+_API_KEY) required/); + if (requiredKey) { + return { + message: `Enter an API key (${requiredKey[1]}) to load live models.`, + tone: "warning", + }; + } + if ( message.includes("DATABRICKS_HOST required") || message.includes("DATABRICKS_MODEL required") || diff --git a/desktop/tests/e2e/maple-provider-ui.spec.ts b/desktop/tests/e2e/maple-provider-ui.spec.ts new file mode 100644 index 00000000000..15ce3584e13 --- /dev/null +++ b/desktop/tests/e2e/maple-provider-ui.spec.ts @@ -0,0 +1,126 @@ +/** + * Screenshot spec for the Maple (OpenSecret) provider entry in the persona + * provider picker. + * + * 01 – Provider dropdown open, "Maple (encrypted)" listed between + * OpenRouter and Buzz shared compute. + * 02 – Maple selected: the dialog shows the "Maple API Key" credential + * field and the model picker (models arrive through the + * provider-generic "buzz-agent models" discovery path). + */ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; + +const SHOTS = "test-results/screenshots-maple"; + +async function openPersonaEditDialog(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("agents-library-personas")).toBeVisible({ + timeout: 10_000, + }); + + const actionsBtn = page.getByRole("button", { + name: "Open actions for Encrypted Agent", + }); + await expect(actionsBtn).toBeVisible({ timeout: 8_000 }); + await actionsBtn.click(); + await page.getByRole("menuitem", { name: "Edit" }).click(); + + const dialog = page.getByTestId("persona-dialog"); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + await dialog.getByRole("tab", { name: "Customize for this agent" }).click(); + return dialog; +} + +test.describe("maple provider UI screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test("01-maple-provider-option", async ({ page }) => { + await installMockBridge(page, { + personas: [ + { + displayName: "Encrypted Agent", + systemPrompt: "An agent for capturing the provider picker.", + }, + ], + }); + const dialog = await openPersonaEditDialog(page); + + const providerSelect = dialog.locator("#persona-llm-provider"); + await expect(providerSelect).toBeVisible({ timeout: 8_000 }); + await providerSelect.click(); + + const mapleOption = page.getByRole("menuitemradio", { + name: "Maple (encrypted)", + }); + await expect(mapleOption).toBeVisible({ timeout: 5_000 }); + await expect(mapleOption).toHaveText("Maple (encrypted)"); + + await waitForAnimations(page); + + // The option popover is portaled outside the dialog, so the clip must + // cover the union of the dialog and the popover rectangles. + const dialogBox = await dialog.boundingBox(); + const optionBox = await mapleOption.boundingBox(); + const x = Math.min(dialogBox.x, optionBox.x) - 16; + const y = Math.min(dialogBox.y, optionBox.y) - 16; + await page.screenshot({ + path: `${SHOTS}/01-maple-provider-option.png`, + clip: { + x: Math.max(0, x), + y: Math.max(0, y), + width: + Math.max( + dialogBox.x + dialogBox.width, + optionBox.x + optionBox.width, + ) - + Math.max(0, x) + + 16, + height: + Math.max( + dialogBox.y + dialogBox.height, + optionBox.y + optionBox.height, + ) - + Math.max(0, y) + + 16, + }, + }); + }); + + test("02-maple-api-key-field", async ({ page }) => { + await installMockBridge(page, { + personas: [ + { + displayName: "Encrypted Agent", + systemPrompt: "An agent for capturing the provider picker.", + }, + ], + }); + const dialog = await openPersonaEditDialog(page); + + const providerSelect = dialog.locator("#persona-llm-provider"); + await expect(providerSelect).toBeVisible({ timeout: 8_000 }); + await providerSelect.click(); + + await page + .getByRole("menuitemradio", { name: "Maple (encrypted)" }) + .click(); + await expect(providerSelect).toContainText("Maple (encrypted)", { + timeout: 5_000, + }); + + // Selecting Maple swaps the credential field to the Maple API Key input. + await expect(dialog.getByText("Maple API Key")).toBeVisible({ + timeout: 5_000, + }); + + // Close any popover so the shot captures the dialog's resting state. + await page.keyboard.press("Escape"); + await waitForAnimations(page); + + await dialog.screenshot({ path: `${SHOTS}/02-maple-api-key-field.png` }); + }); +});