mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 00:47:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acf2587e46 | ||
|
|
4583157b49 | ||
|
|
7185ea8808 | ||
|
|
4aa1508f54 | ||
|
|
5447bf7f1d | ||
|
|
b77ebfb897 | ||
|
|
2323cd5e67 | ||
|
|
df29e5e8b9 | ||
|
|
8b411c21c4 | ||
|
|
b00af5eddd | ||
|
|
36177425c4 | ||
|
|
6bed6c04ff | ||
|
|
4c496be02f | ||
|
|
7f7b071528 | ||
|
|
2d1fb8171c | ||
|
|
e683acc565 | ||
|
|
5396889ff1 | ||
|
|
c9e8d31571 | ||
|
|
d8ad91572e | ||
|
|
93b57bdd52 | ||
|
|
0227ff1790 | ||
|
|
525d7d844a |
Generated
+93
-90
@@ -139,7 +139,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -150,7 +150,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -893,7 +893,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1029,27 +1029,27 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-assembler-x64"
|
||||
version = "0.130.1"
|
||||
version = "0.130.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "046d4b584c3bb9b5eb500c8f29549bec36be11000f1ba2a927cef3d1a9875691"
|
||||
checksum = "adc822414b18d1f5b1b33ce1441534e311e62fef86ebb5b9d382af857d0272c9"
|
||||
dependencies = [
|
||||
"cranelift-assembler-x64-meta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-assembler-x64-meta"
|
||||
version = "0.130.1"
|
||||
version = "0.130.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9b194a7870becb1490366fc0ae392ccd188065ff35f8391e77ac659db6fb977"
|
||||
checksum = "8c646808b06f4532478d8d6057d74f15c3322f10d995d9486e7dcea405bf521a"
|
||||
dependencies = [
|
||||
"cranelift-srcgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-bforest"
|
||||
version = "0.130.1"
|
||||
version = "0.130.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb6a4ab44c6b371e661846b97dab687387a60ac4e2f864e2d4257284aad9e889"
|
||||
checksum = "7b5996f01a686b2349cdb379083ec5ad3e8cb8767fb2d495d3a4f2ee4163a18d"
|
||||
dependencies = [
|
||||
"cranelift-entity",
|
||||
"wasmtime-internal-core",
|
||||
@@ -1057,9 +1057,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-bitset"
|
||||
version = "0.130.1"
|
||||
version = "0.130.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8b7a44150c2f471a94023482bda1902710746e4bed9f9973d60c5a94319b06d"
|
||||
checksum = "523fea83273f6a985520f57788809a4de2165794d9ab00fb1254fceb4f5aa00c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_derive",
|
||||
@@ -1068,9 +1068,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-codegen"
|
||||
version = "0.130.1"
|
||||
version = "0.130.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01b06598133b1dd76758b8b95f8d6747c124124aade50cea96a3d88b962da9fa"
|
||||
checksum = "d73d1e372730b5f64ed1a2bd9f01fe4686c8ec14a28034e3084e530c8d951878"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"cranelift-assembler-x64",
|
||||
@@ -1096,9 +1096,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-codegen-meta"
|
||||
version = "0.130.1"
|
||||
version = "0.130.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6190e2e7bcf0a678da2f715363d34ed530fedf7a2f0ab75edaefef72a70465ff"
|
||||
checksum = "b0319c18165e93dc1ebf78946a8da0b1c341c95b4a39729a69574671639bdb5f"
|
||||
dependencies = [
|
||||
"cranelift-assembler-x64-meta",
|
||||
"cranelift-codegen-shared",
|
||||
@@ -1109,24 +1109,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-codegen-shared"
|
||||
version = "0.130.1"
|
||||
version = "0.130.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f583cf203d1aa8b79560e3b01f929bdacf9070b015eec4ea9c46e22a3f83e4a0"
|
||||
checksum = "9195cd8aeecb55e401aa96b2eaa55921636e8246c127ed7908f7ef7e0d40f270"
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-control"
|
||||
version = "0.130.1"
|
||||
version = "0.130.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "803159df35cc398ae54473c150b16d6c77e92ab2948be638488de126a3328fbc"
|
||||
checksum = "8976c2154b74136322befc74222ab5c7249edd7e2604f8cbef2b94975541ffb9"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-entity"
|
||||
version = "0.130.1"
|
||||
version = "0.130.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3109e417257082d88087f5bcce677525bdaa8322b88dd7f175ed1a1fd41d546c"
|
||||
checksum = "6038b3147c7982f4951150d5f96c7c06c1e7214b99d4b4a98607aadf8ded89d1"
|
||||
dependencies = [
|
||||
"cranelift-bitset",
|
||||
"serde",
|
||||
@@ -1136,9 +1136,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-frontend"
|
||||
version = "0.130.1"
|
||||
version = "0.130.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14db6b0e0e4994c581092df78d837be2072578f7cb2528f96a6cf895e56dee63"
|
||||
checksum = "4cbd294abe236e23cc3d907b0936226b6a8342db7636daa9c7c72be1e323420e"
|
||||
dependencies = [
|
||||
"cranelift-codegen",
|
||||
"log",
|
||||
@@ -1148,15 +1148,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-isle"
|
||||
version = "0.130.1"
|
||||
version = "0.130.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec66ea5025c7317383699778282ac98741d68444f956e3b1d7b62f12b7216e67"
|
||||
checksum = "b5a90b6ed3aba84189352a87badeb93b2126d3724225a42dc67fdce53d1b139c"
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-native"
|
||||
version = "0.130.1"
|
||||
version = "0.130.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "373ade56438e6232619d85678477d0a88a31b3581936e0503e61e96b546b0800"
|
||||
checksum = "c3ec0cc1a54e22925eacf4fc3dc815f907734d3b377899d19d52bec04863e853"
|
||||
dependencies = [
|
||||
"cranelift-codegen",
|
||||
"libc",
|
||||
@@ -1165,9 +1165,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-srcgen"
|
||||
version = "0.130.1"
|
||||
version = "0.130.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef53619d3cd5c78fd998c6d9420547af26b72e6456f94c2a8a2334cb76b42baa"
|
||||
checksum = "948865622f87f30907bb46fbb081b235ae63c1896a99a83c26a003305c1fa82d"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
@@ -1555,7 +1555,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1805,7 +1805,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3739,7 +3739,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3959,7 +3959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.6.7"
|
||||
version = "0.6.9"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
@@ -4001,7 +4001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.6.7"
|
||||
version = "0.6.9"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"async-trait",
|
||||
@@ -4040,7 +4040,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.6.7"
|
||||
version = "0.6.9"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -4068,7 +4068,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.6.7"
|
||||
version = "0.6.9"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -4094,7 +4094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.6.7"
|
||||
version = "0.6.9"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -4122,14 +4122,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.6.7"
|
||||
version = "0.6.9"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"dirs 6.0.0",
|
||||
"hex",
|
||||
"openfang-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio-test",
|
||||
@@ -4140,7 +4142,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.6.7"
|
||||
version = "0.6.9"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4165,6 +4167,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
@@ -4179,7 +4182,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.6.7"
|
||||
version = "0.6.9"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4199,7 +4202,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.6.7"
|
||||
version = "0.6.9"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -4218,7 +4221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.6.7"
|
||||
version = "0.6.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4254,7 +4257,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.6.7"
|
||||
version = "0.6.9"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"ed25519-dalek",
|
||||
@@ -4279,7 +4282,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.6.7"
|
||||
version = "0.6.9"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bitflags 2.11.0",
|
||||
@@ -4299,7 +4302,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.6.7"
|
||||
version = "0.6.9"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4396,7 +4399,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5021,7 +5024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
"itertools 0.13.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@@ -5029,9 +5032,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pulley-interpreter"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "010dec3755eb61b2f1051ecb3611b718460b7a74c131e474de2af20a845938af"
|
||||
checksum = "7ec12fe19a9588315a49fe5704502a9c02d6a198303314b0c7c86123b06d29e5"
|
||||
dependencies = [
|
||||
"cranelift-bitset",
|
||||
"log",
|
||||
@@ -5041,9 +5044,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pulley-macros"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad360c32e85ca4b083ac0e2b6856e8f11c3d5060dafa7d5dc57b370857fa3018"
|
||||
checksum = "36f7d5ef31ebf1b46cd7e722ffef934e670d7e462f49aa01cde07b9b76dca580"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5706,7 +5709,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5765,7 +5768,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5776,9 +5779,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.10"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -6311,7 +6314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7047,7 +7050,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7619,7 +7622,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8063,9 +8066,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce205cd643d661b5ba5ba4717e13730262e8cdbc8f2eacbc7b906d45c1a74026"
|
||||
checksum = "efb1ed5899dde98357cfdcf647a4614498798719793898245b4b34e663addabf"
|
||||
dependencies = [
|
||||
"addr2line",
|
||||
"async-trait",
|
||||
@@ -8116,9 +8119,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-environ"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b8b78abf3677d4a0a5db82e5015b4d085ff3a1b8b472cbb8c70d4b769f019ce"
|
||||
checksum = "4172382dcc785c31d0e862c6780a18f5dd437914d22c4691351f965ef751c821"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cpp_demangle",
|
||||
@@ -8147,9 +8150,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-cache"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e4fd4103ba413c0da2e636f73490c6c8e446d708cbde7573703941bc3d6a448"
|
||||
checksum = "4ed398988226d7aa0505ac6bb576e09532ad722d702ec4e66365d78ed695c95f"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"directories-next",
|
||||
@@ -8167,9 +8170,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-component-macro"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d3d6914f34be2f9d78d8ee9f422e834dfc204e71ccce697205fae95fed87892"
|
||||
checksum = "ae5ec9fff073ff13b81732d56a9515d761c245750bcda09093827f84130ebc25"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"proc-macro2",
|
||||
@@ -8182,15 +8185,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-component-util"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3751b0616b914fdd87fe1bf804694a078f321b000338e6476bc48a4d6e454f21"
|
||||
checksum = "935d9ab293ba27d1ec9aa7bc1b3a43993dbe961af2a8f23f90a11e1331b4c13f"
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-core"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22632b187e1b0716f1b9ac57ad29013bed33175fcb19e10bb6896126f82fac67"
|
||||
checksum = "9a3820b174f477d2a7083209d1ad5353fcdb11eaea434b2137b8681029460dd3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown 0.16.1",
|
||||
@@ -8200,9 +8203,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-cranelift"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b3ca07b3e0bb3429674b173b5800577719d600774dd81bff58f775c0aaa64ee"
|
||||
checksum = "d1679d205caf9766c6aa309d45bb3e7c634d7725e3164404df33824b9f7c4fb7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cranelift-codegen",
|
||||
@@ -8227,9 +8230,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-fiber"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20c8b2c9704eb1f33ead025ec16038277ccb63d0a14c31e99d5b765d7c36da55"
|
||||
checksum = "f1e505254058be5b0df458d670ee42d9eafe2349d04c1296e9dc01071dc20a85"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
@@ -8242,9 +8245,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-jit-debug"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d950310d07391d34369f62c48336ebb14eacbd4d6f772bb5f349c24e838e0664"
|
||||
checksum = "1c2e05b345f1773e59c20e6ad7298fd6857cdea245023d88bb659c96d8f0ea72"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"object",
|
||||
@@ -8254,9 +8257,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-jit-icache-coherence"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3606662c156962d096be3127b8b8ae8ee2f8be3f896dad29259ff01ddb64abfd"
|
||||
checksum = "b86701b234a4643e3f111869aa792b3a05a06e02d486ee9cb6c04dae16b52dab"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
@@ -8266,9 +8269,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-unwinder"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75eef0747e52dc545b075f64fd0e0cc237ae738e641266b1970e07e2d744bc32"
|
||||
checksum = "f63558d801beb83dde9b336eb4ae049019aee26627926edb32cd119d7e4c83cd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cranelift-codegen",
|
||||
@@ -8279,9 +8282,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-versioned-export-macros"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b0a5dab02a8fb527f547855ecc0e05f9fdc3d5bd57b8b080349408f9a6cece"
|
||||
checksum = "737c4d956fc3a848541a064afb683dd2771132a6b125be5baaf95c4379aa47df"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -8290,9 +8293,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-winch"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8007342bd12ff400293a817973f7ecd6f1d9a8549a53369a9c1af357166f1f1e"
|
||||
checksum = "f599b79545e3bba0b7913406055ebede5bb0dabee9ba2015ef25a9f4c9f47807"
|
||||
dependencies = [
|
||||
"cranelift-codegen",
|
||||
"gimli",
|
||||
@@ -8307,9 +8310,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-wit-bindgen"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7900c3e3c1d6e475bc225d73b02d6d5484815f260022e6964dca9558e50dd01a"
|
||||
checksum = "2192a77a00b9a67800c2b4e1c70fb6abca79d6b529e53a2ef9dcdcc36090330d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.0",
|
||||
@@ -8492,7 +8495,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8503,9 +8506,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "winch-codegen"
|
||||
version = "43.0.1"
|
||||
version = "43.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb9f45f7172a2628c8317766e427babc0a400f9d10b1c0f0b0617c5ed5b79de6"
|
||||
checksum = "52dbb0cf07b0dfe7b7a1ca8efb8f94ba98bd0fb144c411ea1665c78f0449e958"
|
||||
dependencies = [
|
||||
"cranelift-assembler-x64",
|
||||
"cranelift-codegen",
|
||||
@@ -9233,7 +9236,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.7"
|
||||
version = "0.6.9"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.7"
|
||||
version = "0.6.9"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/language-Rust-orange?style=flat-square" alt="Rust" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
|
||||
<img src="https://img.shields.io/badge/version-0.6.7-green?style=flat-square" alt="v0.6.7" />
|
||||
<img src="https://img.shields.io/badge/tests-2,657%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
|
||||
<img src="https://img.shields.io/badge/version-0.6.9-green?style=flat-square" alt="v0.6.9" />
|
||||
<img src="https://img.shields.io/badge/tests-2,696%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
|
||||
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
|
||||
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
|
||||
</p>
|
||||
|
||||
@@ -1166,11 +1166,12 @@ pub async fn start_channel_bridge_with_config(
|
||||
if let Some(ref tg_config) = config.telegram {
|
||||
if let Some(token) = read_token(&tg_config.bot_token_env, "Telegram") {
|
||||
let poll_interval = Duration::from_secs(tg_config.poll_interval_secs);
|
||||
let adapter = Arc::new(TelegramAdapter::new(
|
||||
let adapter = Arc::new(TelegramAdapter::with_thread_routes(
|
||||
token,
|
||||
tg_config.allowed_users.clone(),
|
||||
poll_interval,
|
||||
tg_config.api_url.clone(),
|
||||
tg_config.thread_routes.clone(),
|
||||
));
|
||||
adapters.push((adapter, tg_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1185,6 +1186,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
dc_config.allowed_users.clone(),
|
||||
dc_config.ignore_bots,
|
||||
dc_config.intents,
|
||||
dc_config.auto_thread.clone(),
|
||||
));
|
||||
adapters.push((adapter, dc_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1249,10 +1251,17 @@ pub async fn start_channel_bridge_with_config(
|
||||
// Matrix
|
||||
if let Some(ref mx_config) = config.matrix {
|
||||
if let Some(token) = read_token(&mx_config.access_token_env, "Matrix") {
|
||||
let adapter = Arc::new(MatrixAdapter::new(
|
||||
// MSC2918 refresh-token support: optional env var, when present the
|
||||
// adapter auto-recovers from M_UNKNOWN_TOKEN 401s.
|
||||
let refresh = mx_config
|
||||
.refresh_token_env
|
||||
.as_deref()
|
||||
.and_then(|env| read_token(env, "Matrix refresh"));
|
||||
let adapter = Arc::new(MatrixAdapter::with_refresh_token(
|
||||
mx_config.homeserver_url.clone(),
|
||||
mx_config.user_id.clone(),
|
||||
token,
|
||||
refresh,
|
||||
mx_config.allowed_rooms.clone(),
|
||||
mx_config.auto_accept_invites,
|
||||
));
|
||||
|
||||
@@ -1509,11 +1509,15 @@ pub async fn get_agent(
|
||||
"network": entry.manifest.capabilities.network,
|
||||
},
|
||||
"description": entry.manifest.description,
|
||||
"system_prompt": entry.manifest.model.system_prompt,
|
||||
"tags": entry.manifest.tags,
|
||||
"identity": {
|
||||
"emoji": entry.identity.emoji,
|
||||
"avatar_url": entry.identity.avatar_url,
|
||||
"color": entry.identity.color,
|
||||
"archetype": entry.identity.archetype,
|
||||
"vibe": entry.identity.vibe,
|
||||
"greeting_style": entry.identity.greeting_style,
|
||||
},
|
||||
"skills": entry.manifest.skills,
|
||||
"skills_mode": if entry.manifest.skills.is_empty() { "all" } else { "allowlist" },
|
||||
@@ -3699,7 +3703,14 @@ pub async fn install_skill(
|
||||
let config = openfang_skills::marketplace::MarketplaceConfig::default();
|
||||
let client = openfang_skills::marketplace::MarketplaceClient::new(config);
|
||||
|
||||
match client.install(&req.name, &skills_dir).await {
|
||||
let opts = openfang_skills::installer::InstallOptions {
|
||||
require_signed: req.require_signed,
|
||||
allowed_signer_keys: req.allowed_signer_keys.clone(),
|
||||
};
|
||||
match client
|
||||
.install_with_options(&req.name, &skills_dir, &opts)
|
||||
.await
|
||||
{
|
||||
Ok(version) => {
|
||||
// Hot-reload so agents see the new skill immediately
|
||||
state.kernel.reload_skills();
|
||||
@@ -3756,6 +3767,112 @@ pub async fn reload_skills(State(state): State<Arc<AppState>>) -> impl IntoRespo
|
||||
Json(serde_json::json!({"status": "reloaded"}))
|
||||
}
|
||||
|
||||
/// POST /api/audit/append — Append an entry to the Merkle hash chain audit
|
||||
/// trail on behalf of an external (instance-side) wrapper (issue #1174).
|
||||
///
|
||||
/// RBAC: gated by the same bearer-token middleware as POST /api/skills/install
|
||||
/// (see `middleware::auth_middleware`). When `api_key` is configured every
|
||||
/// caller must present `Authorization: Bearer <key>` — wrappers running in the
|
||||
/// same trust boundary as the daemon are expected to share that key.
|
||||
pub async fn audit_append(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<AuditAppendRequest>,
|
||||
) -> impl IntoResponse {
|
||||
use openfang_runtime::audit::AuditAction;
|
||||
|
||||
// SECURITY: bound input sizes so a wrapper cannot wedge the chain with
|
||||
// unbounded strings. The audit table stores TEXT columns and the chain
|
||||
// hash is computed over the same bytes — keep it sane.
|
||||
const MAX_FIELD: usize = 16 * 1024;
|
||||
if req.event_type.len() > MAX_FIELD
|
||||
|| req.agent_id.len() > MAX_FIELD
|
||||
|| req.detail.len() > MAX_FIELD
|
||||
|| req
|
||||
.outcome
|
||||
.as_ref()
|
||||
.map(|s| s.len() > MAX_FIELD)
|
||||
.unwrap_or(false)
|
||||
|| req
|
||||
.signing_context
|
||||
.as_ref()
|
||||
.map(|s| s.len() > MAX_FIELD)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(serde_json::json!({"error": "field exceeds 16KB limit"})),
|
||||
);
|
||||
}
|
||||
|
||||
// Map operator-supplied event_type → AuditAction (case-insensitive).
|
||||
let action = match req.event_type.trim().to_ascii_lowercase().as_str() {
|
||||
"toolinvoke" | "tool_invoke" | "tool" => AuditAction::ToolInvoke,
|
||||
"capabilitycheck" | "capability_check" | "capability" => AuditAction::CapabilityCheck,
|
||||
"agentspawn" | "agent_spawn" | "spawn" => AuditAction::AgentSpawn,
|
||||
"agentkill" | "agent_kill" | "kill" => AuditAction::AgentKill,
|
||||
"agentmessage" | "agent_message" | "message" => AuditAction::AgentMessage,
|
||||
"memoryaccess" | "memory_access" | "memory" => AuditAction::MemoryAccess,
|
||||
"fileaccess" | "file_access" | "file" => AuditAction::FileAccess,
|
||||
"networkaccess" | "network_access" | "network" => AuditAction::NetworkAccess,
|
||||
"shellexec" | "shell_exec" | "shell" => AuditAction::ShellExec,
|
||||
"authattempt" | "auth_attempt" | "auth" => AuditAction::AuthAttempt,
|
||||
"wireconnect" | "wire_connect" | "wire" => AuditAction::WireConnect,
|
||||
"configchange" | "config_change" | "config" => AuditAction::ConfigChange,
|
||||
other => {
|
||||
tracing::warn!(
|
||||
"audit_append: unknown event_type {other:?}, falling back to ToolInvoke"
|
||||
);
|
||||
AuditAction::ToolInvoke
|
||||
}
|
||||
};
|
||||
|
||||
// Compose a detail string that preserves the operator's free-form detail
|
||||
// plus optional signing context and structured payload, so wrappers can
|
||||
// attach context without changing the on-chain schema.
|
||||
let mut detail = req.detail.clone();
|
||||
if let Some(ctx) = req.signing_context.as_ref().filter(|s| !s.is_empty()) {
|
||||
if !detail.is_empty() {
|
||||
detail.push_str(" | ");
|
||||
}
|
||||
detail.push_str("signer=");
|
||||
detail.push_str(ctx);
|
||||
}
|
||||
if let Some(payload) = req.payload.as_ref() {
|
||||
let serialised = serde_json::to_string(payload)
|
||||
.unwrap_or_else(|_| String::from("<unserialisable payload>"));
|
||||
// Cap payload contribution so a huge JSON blob cannot blow the entry.
|
||||
let truncated: String = serialised.chars().take(8 * 1024).collect();
|
||||
if !detail.is_empty() {
|
||||
detail.push_str(" | ");
|
||||
}
|
||||
detail.push_str("payload=");
|
||||
detail.push_str(&truncated);
|
||||
}
|
||||
|
||||
let agent_id = if req.agent_id.trim().is_empty() {
|
||||
"external-wrapper".to_string()
|
||||
} else {
|
||||
req.agent_id.clone()
|
||||
};
|
||||
let outcome = req.outcome.clone().unwrap_or_else(|| "ok".to_string());
|
||||
|
||||
let hash = state
|
||||
.kernel
|
||||
.audit_log
|
||||
.record(agent_id, action, detail, outcome);
|
||||
let seq = state.kernel.audit_log.len().saturating_sub(1) as u64;
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"status": "appended",
|
||||
"seq": seq,
|
||||
"hash": hash,
|
||||
"tip": state.kernel.audit_log.tip_hash(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /api/marketplace/search — Search the FangHub marketplace.
|
||||
pub async fn marketplace_search(
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
@@ -9949,7 +10066,9 @@ pub async fn clone_agent(
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("Failed to serialize manifest: {e}")})),
|
||||
Json(
|
||||
serde_json::json!({"error": format!("Failed to serialize manifest: {e}")}),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -9969,8 +10088,10 @@ pub async fn clone_agent(
|
||||
|
||||
// new_name always wins over any name field in overrides.
|
||||
cloned_manifest.name = new_name.clone();
|
||||
// Let the kernel assign a fresh workspace path — never share with the template.
|
||||
// Let the kernel assign fresh workspace and state directory paths.
|
||||
// Never share private state with the template (see issue #868, #1097).
|
||||
cloned_manifest.workspace = None;
|
||||
cloned_manifest.state_dir = None;
|
||||
|
||||
// Spawn the cloned agent.
|
||||
let new_id = match state.kernel.spawn_agent(cloned_manifest) {
|
||||
@@ -9983,25 +10104,42 @@ pub async fn clone_agent(
|
||||
}
|
||||
};
|
||||
|
||||
// Copy non-memory workspace files from source to destination.
|
||||
// MEMORY.md and HEARTBEAT.md are intentionally skipped — the cloned agent
|
||||
// must start with independent memory (issue #868).
|
||||
// Copy non-memory identity files from source state_dir to destination
|
||||
// state_dir. MEMORY.md and HEARTBEAT.md are intentionally skipped — the
|
||||
// cloned agent must start with independent memory (issue #868). Identity
|
||||
// files live in state_dir per #1097; fall back to legacy workspace for
|
||||
// older agents.
|
||||
let new_entry = state.kernel.registry.get(new_id);
|
||||
let src_state = source
|
||||
.manifest
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.or(source.manifest.workspace.as_ref());
|
||||
let dst_state = new_entry.as_ref().and_then(|e| {
|
||||
e.manifest
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.or(e.manifest.workspace.as_ref())
|
||||
});
|
||||
if let (Some(src_ws), Some(dst_ws)) = (src_state, dst_state) {
|
||||
if let (Ok(src_can), Ok(dst_can)) = (src_ws.canonicalize(), dst_ws.canonicalize()) {
|
||||
for &fname in KNOWN_IDENTITY_FILES {
|
||||
if MEMORY_FILES.contains(&fname) {
|
||||
continue;
|
||||
}
|
||||
let src_file = src_can.join(fname);
|
||||
let dst_file = dst_can.join(fname);
|
||||
if src_file.exists() {
|
||||
let _ = std::fs::copy(&src_file, &dst_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Copy the user-facing `skills/` subdirectory so curated skills travel
|
||||
// with the template. These live in the workspace, not the state dir.
|
||||
if let (Some(ref src_ws), Some(ref new_entry)) = (&source.manifest.workspace, &new_entry) {
|
||||
if let Some(ref dst_ws) = new_entry.manifest.workspace {
|
||||
if let (Ok(src_can), Ok(dst_can)) = (src_ws.canonicalize(), dst_ws.canonicalize()) {
|
||||
for &fname in KNOWN_IDENTITY_FILES {
|
||||
if MEMORY_FILES.contains(&fname) {
|
||||
continue;
|
||||
}
|
||||
let src_file = src_can.join(fname);
|
||||
let dst_file = dst_can.join(fname);
|
||||
if src_file.exists() {
|
||||
let _ = std::fs::copy(&src_file, &dst_file);
|
||||
}
|
||||
}
|
||||
// Copy the `skills/` subdirectory if present so curated skills
|
||||
// travel with the template.
|
||||
let src_skills = src_can.join("skills");
|
||||
let dst_skills = dst_can.join("skills");
|
||||
if src_skills.is_dir() {
|
||||
@@ -10104,8 +10242,16 @@ pub async fn list_agent_files(
|
||||
}
|
||||
};
|
||||
|
||||
let workspace = match entry.manifest.workspace {
|
||||
Some(ref ws) => ws.clone(),
|
||||
// Identity files live in the agent's private state directory (see #1097).
|
||||
// Fall back to the legacy workspace location for agents created before the
|
||||
// split so existing on-disk files remain reachable.
|
||||
let workspace = match entry
|
||||
.manifest
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.or(entry.manifest.workspace.as_ref())
|
||||
{
|
||||
Some(ws) => ws.clone(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -10166,8 +10312,15 @@ pub async fn get_agent_file(
|
||||
}
|
||||
};
|
||||
|
||||
let workspace = match entry.manifest.workspace {
|
||||
Some(ref ws) => ws.clone(),
|
||||
// Identity files live in the agent's private state directory (see #1097).
|
||||
// Fall back to legacy workspace for agents created before the split.
|
||||
let workspace = match entry
|
||||
.manifest
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.or(entry.manifest.workspace.as_ref())
|
||||
{
|
||||
Some(ws) => ws.clone(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -10273,8 +10426,15 @@ pub async fn set_agent_file(
|
||||
}
|
||||
};
|
||||
|
||||
let workspace = match entry.manifest.workspace {
|
||||
Some(ref ws) => ws.clone(),
|
||||
// Identity files live in the agent's private state directory (see #1097).
|
||||
// Fall back to legacy workspace for agents created before the split.
|
||||
let workspace = match entry
|
||||
.manifest
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.or(entry.manifest.workspace.as_ref())
|
||||
{
|
||||
Some(ws) => ws.clone(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
|
||||
@@ -394,6 +394,11 @@ pub async fn build_router(
|
||||
"/api/skills/reload",
|
||||
axum::routing::post(routes::reload_skills),
|
||||
)
|
||||
// Audit trail (issue #1174 — instance-side wrapper integration)
|
||||
.route(
|
||||
"/api/audit/append",
|
||||
axum::routing::post(routes::audit_append),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/{id}/config",
|
||||
axum::routing::get(routes::get_skill_config).put(routes::put_skill_config),
|
||||
|
||||
@@ -65,6 +65,15 @@ pub struct MessageResponse {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SkillInstallRequest {
|
||||
pub name: String,
|
||||
/// When true, reject the install unless the bundle ships a valid
|
||||
/// Ed25519 SignedManifest envelope bound to the on-disk manifest.
|
||||
/// Maps to `InstallOptions::require_signed` (issue #1170).
|
||||
#[serde(default)]
|
||||
pub require_signed: bool,
|
||||
/// Optional hex-encoded allow-list of acceptable signer public keys.
|
||||
/// Empty = TOFU (any valid signature accepted).
|
||||
#[serde(default)]
|
||||
pub allowed_signer_keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// Request to uninstall a skill.
|
||||
@@ -115,3 +124,96 @@ pub struct CommandsQuery {
|
||||
#[serde(default)]
|
||||
pub surface: Option<String>,
|
||||
}
|
||||
|
||||
/// Request body for `POST /api/audit/append` (issue #1174).
|
||||
///
|
||||
/// Lets external (instance-side) wrappers append entries to the Merkle hash
|
||||
/// chain audit log. The handler maps `event_type` to an `AuditAction` and
|
||||
/// records the entry through `kernel.audit_log`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuditAppendRequest {
|
||||
/// Operator-supplied event category. Case-insensitive, matched against the
|
||||
/// `AuditAction` enum variants (e.g. `tool_invoke`, `ConfigChange`,
|
||||
/// `agent_message`). Unknown values fall back to `ToolInvoke`.
|
||||
pub event_type: String,
|
||||
/// Agent or wrapper identifier responsible for the event. When empty,
|
||||
/// recorded as `"external-wrapper"`.
|
||||
#[serde(default)]
|
||||
pub agent_id: String,
|
||||
/// Free-form detail string (e.g. tool name, URL, file path).
|
||||
#[serde(default)]
|
||||
pub detail: String,
|
||||
/// Optional arbitrary payload. When present it is serialised to JSON and
|
||||
/// appended onto the entry's detail so the wrapper retains structured
|
||||
/// context without changing the on-chain schema.
|
||||
#[serde(default)]
|
||||
pub payload: Option<serde_json::Value>,
|
||||
/// Optional outcome string (`"ok"`, `"denied"`, or an error). Defaults to
|
||||
/// `"ok"` when omitted.
|
||||
#[serde(default)]
|
||||
pub outcome: Option<String>,
|
||||
/// Optional operator-supplied signing context (e.g. wrapper identity, key
|
||||
/// fingerprint). Mixed into the detail when present so the chain captures
|
||||
/// who attested to the event.
|
||||
#[serde(default)]
|
||||
pub signing_context: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn skill_install_request_defaults_back_compat() {
|
||||
// Existing callers send `{"name": "..."}` only. New optional fields
|
||||
// must default cleanly (issue #1170).
|
||||
let req: SkillInstallRequest = serde_json::from_str(r#"{"name":"github-helper"}"#).unwrap();
|
||||
assert_eq!(req.name, "github-helper");
|
||||
assert!(!req.require_signed);
|
||||
assert!(req.allowed_signer_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_install_request_parses_require_signed() {
|
||||
let req: SkillInstallRequest = serde_json::from_str(
|
||||
r#"{"name":"x","require_signed":true,"allowed_signer_keys":["abc123"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(req.require_signed);
|
||||
assert_eq!(req.allowed_signer_keys, vec!["abc123".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_append_request_required_only() {
|
||||
// Only `event_type` is required; everything else must default.
|
||||
let req: AuditAppendRequest =
|
||||
serde_json::from_str(r#"{"event_type":"ToolInvoke"}"#).unwrap();
|
||||
assert_eq!(req.event_type, "ToolInvoke");
|
||||
assert!(req.agent_id.is_empty());
|
||||
assert!(req.detail.is_empty());
|
||||
assert!(req.payload.is_none());
|
||||
assert!(req.outcome.is_none());
|
||||
assert!(req.signing_context.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_append_request_full_payload() {
|
||||
let body = r#"{
|
||||
"event_type": "config_change",
|
||||
"agent_id": "wrapper-1",
|
||||
"detail": "rotated key",
|
||||
"payload": {"key_id": "k-42", "ts": 1700000000},
|
||||
"outcome": "ok",
|
||||
"signing_context": "ed25519:deadbeef"
|
||||
}"#;
|
||||
let req: AuditAppendRequest = serde_json::from_str(body).unwrap();
|
||||
assert_eq!(req.event_type, "config_change");
|
||||
assert_eq!(req.agent_id, "wrapper-1");
|
||||
assert_eq!(req.detail, "rotated key");
|
||||
assert_eq!(req.outcome.as_deref(), Some("ok"));
|
||||
assert_eq!(req.signing_context.as_deref(), Some("ed25519:deadbeef"));
|
||||
let payload = req.payload.expect("payload present");
|
||||
assert_eq!(payload["key_id"], "k-42");
|
||||
assert_eq!(payload["ts"], 1_700_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
+106
-14
@@ -223,18 +223,31 @@ pub(crate) struct WsAuthCtx<'a> {
|
||||
pub(crate) fn check_ws_auth(ctx: &WsAuthCtx<'_>) -> Result<(), axum::http::StatusCode> {
|
||||
use axum::http::StatusCode;
|
||||
|
||||
// No api_key configured: only allow loopback or explicit opt-in.
|
||||
// No api_key configured: behavior depends on whether dashboard auth is on.
|
||||
//
|
||||
// Issue #1189: previously this path allowed any loopback request through
|
||||
// when api_key was empty, EVEN IF dashboard auth was enabled. That diverged
|
||||
// from the HTTP middleware (which only opens the loopback no-auth path
|
||||
// when api_key is empty AND auth.enabled is false). A local attacker with
|
||||
// loopback access could chat with agents over WS even when the operator
|
||||
// had configured dashboard credentials. Now mirror HTTP exactly.
|
||||
if ctx.api_key.is_empty() {
|
||||
// A session cookie can still rescue non-loopback requests when
|
||||
// dashboard auth is enabled.
|
||||
if ctx.auth_enabled && !ctx.session_secret.is_empty() {
|
||||
if let Some(token) = crate::session_auth::extract_session_cookie(ctx.headers) {
|
||||
if crate::session_auth::verify_session_token(&token, ctx.session_secret).is_some()
|
||||
{
|
||||
return Ok(());
|
||||
// When dashboard auth is configured, require a valid session cookie
|
||||
// regardless of bind address. Loopback no longer bypasses login.
|
||||
if ctx.auth_enabled {
|
||||
if !ctx.session_secret.is_empty() {
|
||||
if let Some(token) = crate::session_auth::extract_session_cookie(ctx.headers) {
|
||||
if crate::session_auth::verify_session_token(&token, ctx.session_secret)
|
||||
.is_some()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
// No api_key AND dashboard auth disabled: keep the dev convenience
|
||||
// path (loopback or explicit OPENFANG_ALLOW_NO_AUTH=1).
|
||||
if ctx.is_loopback || ctx.allow_no_auth {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1035,7 +1048,9 @@ async fn handle_command(
|
||||
"command": cmd,
|
||||
"message": format!("Hand '{}' deactivated.", instance.hand_id),
|
||||
}),
|
||||
Err(e) => serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")}),
|
||||
Err(e) => {
|
||||
serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match state.kernel.stop_agent_run(agent_id) {
|
||||
@@ -1045,7 +1060,9 @@ async fn handle_command(
|
||||
Ok(false) => {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": "No active run to cancel."})
|
||||
}
|
||||
Err(e) => serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")}),
|
||||
Err(e) => {
|
||||
serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1799,10 +1816,7 @@ mod tests {
|
||||
// Cookie signed with the wrong secret must fail.
|
||||
let bad = crate::session_auth::create_session_token("alice", "other-secret", 1);
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
"cookie",
|
||||
format!("openfang_session={bad}").parse().unwrap(),
|
||||
);
|
||||
headers.insert("cookie", format!("openfang_session={bad}").parse().unwrap());
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "secret",
|
||||
@@ -1933,4 +1947,82 @@ mod tests {
|
||||
};
|
||||
assert!(check_ws_auth(&ctx).is_ok());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Issue #1189: WS auth must mirror HTTP middleware. When dashboard auth
|
||||
// is enabled, loopback + empty api_key + no cookie must NOT bypass.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ws_auth_dashboard_on_loopback_empty_key_no_cookie_rejected() {
|
||||
// Issue #1189 regression guard: previously this returned Ok(()) because
|
||||
// the empty-api_key branch allowed any loopback request through, even
|
||||
// when dashboard credentials were configured. HTTP middleware rejects
|
||||
// this path; WS must too.
|
||||
let secret = "password-hash-style-secret";
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: true,
|
||||
session_secret: secret,
|
||||
is_loopback: true,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert_eq!(
|
||||
check_ws_auth(&ctx).unwrap_err(),
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
"loopback must not bypass dashboard auth when api_key is empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_dashboard_on_loopback_valid_cookie_accepted() {
|
||||
// With dashboard auth on, a valid session cookie is the supported
|
||||
// credential and must upgrade successfully from loopback too.
|
||||
let secret = "password-hash-style-secret";
|
||||
let token = crate::session_auth::create_session_token("admin", secret, 1);
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
"cookie",
|
||||
format!("openfang_session={token}").parse().unwrap(),
|
||||
);
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: true,
|
||||
session_secret: secret,
|
||||
is_loopback: true,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(
|
||||
check_ws_auth(&ctx).is_ok(),
|
||||
"valid session cookie should authorize loopback WS upgrade"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_dashboard_off_loopback_empty_key_accepted() {
|
||||
// Preserve the development convenience path: when dashboard auth is
|
||||
// NOT configured AND api_key is empty, loopback still upgrades.
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: false,
|
||||
session_secret: "",
|
||||
is_loopback: true,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(
|
||||
check_ws_auth(&ctx).is_ok(),
|
||||
"loopback dev path must work when dashboard auth is disabled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,29 +337,38 @@ function agentsPage() {
|
||||
OpenFangAPI.wsDisconnect();
|
||||
},
|
||||
|
||||
buildConfigForm(agent) {
|
||||
var identity = (agent && agent.identity) || {};
|
||||
return {
|
||||
name: (agent && agent.name) || '',
|
||||
system_prompt: (agent && agent.system_prompt) || '',
|
||||
emoji: identity.emoji || '',
|
||||
color: identity.color || '#FF5C00',
|
||||
archetype: identity.archetype || '',
|
||||
vibe: identity.vibe || ''
|
||||
};
|
||||
},
|
||||
|
||||
async showDetail(agent) {
|
||||
this.detailAgent = agent;
|
||||
this.detailAgent._fallbacks = [];
|
||||
this.detailTab = 'info';
|
||||
this.agentFiles = [];
|
||||
this.editingFile = null;
|
||||
this.fileContent = '';
|
||||
this.editingFallback = false;
|
||||
this.newFallbackValue = '';
|
||||
this.configForm = {
|
||||
name: agent.name || '',
|
||||
system_prompt: agent.system_prompt || '',
|
||||
emoji: (agent.identity && agent.identity.emoji) || '',
|
||||
color: (agent.identity && agent.identity.color) || '#FF5C00',
|
||||
archetype: (agent.identity && agent.identity.archetype) || '',
|
||||
vibe: (agent.identity && agent.identity.vibe) || ''
|
||||
};
|
||||
this.showDetailModal = true;
|
||||
// Fetch full agent detail to get fallback_models
|
||||
// Load the full detail payload before opening the modal so editable
|
||||
// fields such as system_prompt and identity metadata are hydrated.
|
||||
var detail = agent;
|
||||
try {
|
||||
var full = await OpenFangAPI.get('/api/agents/' + agent.id);
|
||||
this.detailAgent._fallbacks = full.fallback_models || [];
|
||||
} catch(e) { /* ignore */ }
|
||||
detail = Object.assign({}, agent, full, {
|
||||
identity: Object.assign({}, (agent && agent.identity) || {}, (full && full.identity) || {})
|
||||
});
|
||||
} catch(e) { /* fall back to list payload */ }
|
||||
this.detailAgent = detail;
|
||||
this.detailAgent._fallbacks = detail.fallback_models || [];
|
||||
this.configForm = this.buildConfigForm(detail);
|
||||
this.showDetailModal = true;
|
||||
},
|
||||
|
||||
killAgent(agent) {
|
||||
|
||||
@@ -1660,10 +1660,7 @@ async fn test_clone_agent_happy_path() {
|
||||
assert_eq!(manifest["description"], "Cloned for user 1");
|
||||
assert_eq!(
|
||||
manifest["tags"].as_array().unwrap(),
|
||||
&vec![
|
||||
serde_json::json!("clone"),
|
||||
serde_json::json!("user-1"),
|
||||
]
|
||||
&vec![serde_json::json!("clone"), serde_json::json!("user-1"),]
|
||||
);
|
||||
// Inherited from template — the system_prompt should match.
|
||||
assert_eq!(
|
||||
@@ -1678,10 +1675,7 @@ async fn test_clone_agent_happy_path() {
|
||||
.await
|
||||
.unwrap();
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
let names: Vec<&str> = agents
|
||||
.iter()
|
||||
.map(|a| a["name"].as_str().unwrap())
|
||||
.collect();
|
||||
let names: Vec<&str> = agents.iter().map(|a| a["name"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"test-agent"));
|
||||
assert!(names.contains(&"cloned-user-1"));
|
||||
}
|
||||
@@ -1731,10 +1725,7 @@ async fn test_clone_agent_name_collision() {
|
||||
"duplicate name must return 409 Conflict"
|
||||
);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert!(body["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("already exists"));
|
||||
assert!(body["error"].as_str().unwrap().contains("already exists"));
|
||||
|
||||
// Cloning into the template's own name must also be rejected.
|
||||
let resp = client
|
||||
|
||||
@@ -773,12 +773,20 @@ async fn dispatch_message(
|
||||
.as_ref()
|
||||
.map(|o| o.lifecycle_reactions)
|
||||
.unwrap_or(true);
|
||||
let thread_id = if threading_enabled {
|
||||
message.thread_id.as_deref()
|
||||
|
||||
// --- Auto-thread: decide intent now, but create AFTER all policy guards ---
|
||||
let auto_thread_name = if !threading_enabled && message.thread_id.is_none() {
|
||||
adapter.should_auto_thread(message).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// thread_id is resolved later, after all guards pass.
|
||||
// Always propagate an existing thread_id (message arrived inside a thread),
|
||||
// regardless of threading_enabled — that flag controls explicit threading config,
|
||||
// not auto-detected thread context.
|
||||
let mut effective_thread_id: Option<String> = message.thread_id.clone();
|
||||
|
||||
// --- DM/Group policy check ---
|
||||
if let Some(ref ov) = overrides {
|
||||
if message.is_group {
|
||||
@@ -839,12 +847,42 @@ async fn dispatch_message(
|
||||
if let Err(msg) =
|
||||
rate_limiter.check(ct_str, sender_user_id(message), ov.rate_limit_per_user)
|
||||
{
|
||||
send_response(adapter, &message.sender, msg, thread_id, output_format).await;
|
||||
// Rate-limit rejection: don't create a thread, use existing thread if any
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
msg,
|
||||
message.thread_id.as_deref(),
|
||||
output_format,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Create auto-thread NOW (after all policy guards have passed) ---
|
||||
if let Some(ref thread_name) = auto_thread_name {
|
||||
match adapter
|
||||
.create_thread(&message.sender, &message.platform_message_id, thread_name)
|
||||
.await
|
||||
{
|
||||
Ok(new_thread_id) => {
|
||||
info!(
|
||||
"Created auto-thread {} for message {}",
|
||||
thread_name, message.platform_message_id
|
||||
);
|
||||
effective_thread_id = Some(new_thread_id);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to create auto-thread: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve final thread_id reference used by all downstream send_response calls
|
||||
let thread_id = effective_thread_id.as_deref();
|
||||
|
||||
// Handle commands first (early return)
|
||||
if let ChannelContent::Command { ref name, ref args } = message.content {
|
||||
let result = handle_command(
|
||||
@@ -1153,13 +1191,33 @@ async fn dispatch_message(
|
||||
// Use resolve_with_context so channel_id-scoped (and guild_id-scoped)
|
||||
// bindings can route per channel — see binding_context_for() for the
|
||||
// single-source-of-truth allowlist.
|
||||
//
|
||||
// Issue #780: when the adapter stamped a per-thread target agent in
|
||||
// metadata (e.g. Telegram forum-topic routing via `thread_routes`), prefer
|
||||
// it over the standard router so operators can scope topics to specific
|
||||
// agents from config.toml.
|
||||
let target_agent_name = message
|
||||
.metadata
|
||||
.get("target_agent_name")
|
||||
.and_then(|v| v.as_str());
|
||||
let routed_by_name = if let Some(name) = target_agent_name {
|
||||
match handle.find_agent_by_name(name).await {
|
||||
Ok(Some(id)) => Some(id),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let binding_ctx = binding_context_for(message);
|
||||
let agent_id = router.resolve_with_context(
|
||||
&message.channel,
|
||||
sender_user_id(message),
|
||||
message.sender.openfang_user.as_deref(),
|
||||
&binding_ctx,
|
||||
);
|
||||
let agent_id = routed_by_name.or_else(|| {
|
||||
router.resolve_with_context(
|
||||
&message.channel,
|
||||
sender_user_id(message),
|
||||
message.sender.openfang_user.as_deref(),
|
||||
&binding_ctx,
|
||||
)
|
||||
});
|
||||
|
||||
let agent_id = match agent_id {
|
||||
Some(id) => id,
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::types::{
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use futures::{SinkExt, Stream, StreamExt};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -22,6 +22,10 @@ const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(60);
|
||||
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
||||
const DISCORD_MSG_LIMIT: usize = 2000;
|
||||
/// Maximum number of seen message IDs kept in the dedup set.
|
||||
/// MESSAGE_UPDATE (embed resolution) events arrive within seconds of the
|
||||
/// original CREATE; entries older than this cap are safe to discard.
|
||||
const MAX_DEDUP_MSG_IDS: usize = 2_000;
|
||||
|
||||
/// Discord Gateway opcodes.
|
||||
mod opcode {
|
||||
@@ -56,6 +60,8 @@ pub struct DiscordAdapter {
|
||||
allowed_users: Vec<String>,
|
||||
ignore_bots: bool,
|
||||
intents: u64,
|
||||
/// Auto-thread behavior: "true", "false", or "smart"
|
||||
auto_thread: String,
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Bot's own user ID (populated after READY event).
|
||||
@@ -64,6 +70,13 @@ pub struct DiscordAdapter {
|
||||
session_id: Arc<RwLock<Option<String>>>,
|
||||
/// Resume gateway URL.
|
||||
resume_gateway_url: Arc<RwLock<Option<String>>>,
|
||||
/// Thread channel IDs created by this bot (thread_id → parent_channel_id).
|
||||
/// Used to detect when incoming messages are inside a bot-created thread.
|
||||
created_thread_ids: Arc<RwLock<HashMap<String, String>>>,
|
||||
/// Message IDs seen via MESSAGE_CREATE (used to drop duplicate MESSAGE_UPDATE events).
|
||||
/// Populated immediately when MESSAGE_CREATE is forwarded — before bridge processing —
|
||||
/// to eliminate the race window where MESSAGE_UPDATE arrives before thread creation completes.
|
||||
threaded_message_ids: Arc<RwLock<HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl DiscordAdapter {
|
||||
@@ -73,6 +86,7 @@ impl DiscordAdapter {
|
||||
allowed_users: Vec<String>,
|
||||
ignore_bots: bool,
|
||||
intents: u64,
|
||||
auto_thread: String,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
@@ -82,11 +96,14 @@ impl DiscordAdapter {
|
||||
allowed_users,
|
||||
ignore_bots,
|
||||
intents,
|
||||
auto_thread,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
bot_user_id: Arc::new(RwLock::new(None)),
|
||||
session_id: Arc::new(RwLock::new(None)),
|
||||
resume_gateway_url: Arc::new(RwLock::new(None)),
|
||||
created_thread_ids: Arc::new(RwLock::new(HashMap::new())),
|
||||
threaded_message_ids: Arc::new(RwLock::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +164,79 @@ impl DiscordAdapter {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a thread from a message in a Discord channel.
|
||||
async fn api_create_thread(
|
||||
&self,
|
||||
channel_id: &str,
|
||||
message_id: &str,
|
||||
name: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}/threads",
|
||||
channel_id = channel_id,
|
||||
message_id = message_id
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"auto_archive_duration": 1440 // 24 hours
|
||||
});
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bot {}", self.token.as_str()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Discord createThread failed: {}", body_text).into());
|
||||
}
|
||||
|
||||
let response: serde_json::Value = resp.json().await?;
|
||||
let thread_id = response["id"].as_str().unwrap_or("").to_string();
|
||||
|
||||
// Track thread_id → parent channel_id so we can recognise messages
|
||||
// that arrive inside this thread.
|
||||
if !thread_id.is_empty() {
|
||||
self.created_thread_ids
|
||||
.write()
|
||||
.await
|
||||
.insert(thread_id.clone(), channel_id.to_string());
|
||||
}
|
||||
|
||||
Ok(thread_id)
|
||||
}
|
||||
|
||||
/// Send a message to an existing thread.
|
||||
/// Discord threads are channels — post directly to channels/{thread_id}/messages.
|
||||
async fn api_send_thread_message(
|
||||
&self,
|
||||
_channel_id: &str,
|
||||
thread_id: &str,
|
||||
text: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!("{DISCORD_API_BASE}/channels/{thread_id}/messages");
|
||||
let chunks = split_message(text, DISCORD_MSG_LIMIT);
|
||||
|
||||
for chunk in chunks {
|
||||
let body = serde_json::json!({ "content": chunk });
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bot {}", self.token.as_str()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Discord sendThreadMessage failed: {body_text}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -159,6 +249,33 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
ChannelType::Discord
|
||||
}
|
||||
|
||||
async fn should_auto_thread(&self, message: &ChannelMessage) -> Option<String> {
|
||||
// Only auto-thread in group channels (servers), not DMs
|
||||
if !message.is_group {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check auto_thread mode
|
||||
match self.auto_thread.as_str() {
|
||||
"true" => Some(thread_name_from_message(message)),
|
||||
"false" => None,
|
||||
"smart" => {
|
||||
// Only create thread if bot was @mentioned
|
||||
let was_mentioned = message
|
||||
.metadata
|
||||
.get("was_mentioned")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if was_mentioned {
|
||||
Some(thread_name_from_message(message))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
@@ -176,6 +293,8 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
let bot_user_id = self.bot_user_id.clone();
|
||||
let session_id_store = self.session_id.clone();
|
||||
let resume_url_store = self.resume_gateway_url.clone();
|
||||
let created_thread_ids = self.created_thread_ids.clone();
|
||||
let threaded_message_ids = self.threaded_message_ids.clone();
|
||||
let mut shutdown = self.shutdown_rx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -414,19 +533,66 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
&allowed_guilds,
|
||||
&allowed_users,
|
||||
ignore_bots,
|
||||
&created_thread_ids,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// MESSAGE_UPDATE must be suppressed if we already
|
||||
// forwarded a MESSAGE_CREATE for this message ID.
|
||||
// The check uses `seen_message_ids` (tracked below)
|
||||
// which is populated the moment MESSAGE_CREATE is
|
||||
// forwarded — before the bridge even processes it.
|
||||
// This closes the race window where MESSAGE_UPDATE
|
||||
// arrives before adapter.create_thread() completes.
|
||||
if event_name == "MESSAGE_UPDATE"
|
||||
&& threaded_message_ids
|
||||
.read()
|
||||
.await
|
||||
.contains(&msg.platform_message_id)
|
||||
{
|
||||
debug!(
|
||||
"Discord MESSAGE_UPDATE skipped (already seen {})",
|
||||
msg.platform_message_id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Discord {event_name} from {}: {:?}",
|
||||
msg.sender.display_name, msg.content
|
||||
);
|
||||
|
||||
// Mark this message as seen immediately so any
|
||||
// concurrent or subsequent MESSAGE_UPDATE is dropped.
|
||||
if event_name == "MESSAGE_CREATE" {
|
||||
threaded_message_ids
|
||||
.write()
|
||||
.await
|
||||
.insert(msg.platform_message_id.clone());
|
||||
}
|
||||
|
||||
if tx.send(msg).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"THREAD_DELETE" | "CHANNEL_DELETE" => {
|
||||
// Clean up tracking when a thread is deleted so the
|
||||
// next message in the parent channel is treated fresh.
|
||||
if let Some(tid) = d["id"].as_str() {
|
||||
created_thread_ids.write().await.remove(tid);
|
||||
// Prune the dedup set to prevent unbounded growth.
|
||||
// Entries older than MAX_DEDUP_MSG_IDS are safe to
|
||||
// discard — embed UPDATE events arrive within seconds.
|
||||
let mut ids = threaded_message_ids.write().await;
|
||||
if ids.len() > MAX_DEDUP_MSG_IDS {
|
||||
ids.clear();
|
||||
}
|
||||
debug!("Discord thread/channel deleted: {tid}");
|
||||
}
|
||||
}
|
||||
|
||||
"RESUMED" => {
|
||||
info!("Discord session resumed successfully");
|
||||
}
|
||||
@@ -532,6 +698,46 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
self.api_send_typing(&user.platform_id).await
|
||||
}
|
||||
|
||||
async fn send_in_thread(
|
||||
&self,
|
||||
user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
thread_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let channel_id = &user.platform_id;
|
||||
match content {
|
||||
ChannelContent::Text(text) => {
|
||||
self.api_send_thread_message(channel_id, thread_id, &text)
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
self.api_send_thread_message(channel_id, thread_id, "(Unsupported content type)")
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_thread(
|
||||
&self,
|
||||
user: &ChannelUser,
|
||||
message_id: &str,
|
||||
thread_name: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let channel_id = &user.platform_id;
|
||||
let thread_id = self
|
||||
.api_create_thread(channel_id, message_id, thread_name)
|
||||
.await?;
|
||||
// Also ensure the message_id is marked as seen (belt-and-suspenders:
|
||||
// the gateway loop already inserts on MESSAGE_CREATE, but keep this
|
||||
// in case create_thread is ever called from another path).
|
||||
self.threaded_message_ids
|
||||
.write()
|
||||
.await
|
||||
.insert(message_id.to_string());
|
||||
Ok(thread_id)
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
Ok(())
|
||||
@@ -616,6 +822,7 @@ async fn parse_discord_message(
|
||||
allowed_guilds: &[String],
|
||||
allowed_users: &[String],
|
||||
ignore_bots: bool,
|
||||
created_thread_ids: &Arc<RwLock<HashMap<String, String>>>,
|
||||
) -> Option<ChannelMessage> {
|
||||
// Diagnostic: dump the raw Discord payload so we can ground attachment
|
||||
// parsing in real JSON. Gated by RUST_LOG; silent at default `info` level.
|
||||
@@ -655,6 +862,20 @@ async fn parse_discord_message(
|
||||
let content_text = d["content"].as_str().unwrap_or("");
|
||||
let channel_id = d["channel_id"].as_str()?;
|
||||
let message_id = d["id"].as_str().unwrap_or("0");
|
||||
|
||||
// Detect if this message is inside a bot-created thread.
|
||||
// In Discord, a thread is its own channel — channel_id will be the thread's ID.
|
||||
// If so, use the parent channel as platform_id and set thread_id so that:
|
||||
// (a) auto-thread logic is skipped (message.thread_id.is_some())
|
||||
// (b) responses are sent back into the same thread
|
||||
let (effective_channel_id, parsed_thread_id) = {
|
||||
let threads = created_thread_ids.read().await;
|
||||
if let Some(parent_channel_id) = threads.get(channel_id) {
|
||||
(parent_channel_id.clone(), Some(channel_id.to_string()))
|
||||
} else {
|
||||
(channel_id.to_string(), None)
|
||||
}
|
||||
};
|
||||
let username = author["username"].as_str().unwrap_or("Unknown");
|
||||
let discriminator = author["discriminator"].as_str().unwrap_or("0000");
|
||||
let display_name = if discriminator == "0" {
|
||||
@@ -760,7 +981,7 @@ async fn parse_discord_message(
|
||||
channel: ChannelType::Discord,
|
||||
platform_message_id: message_id.to_string(),
|
||||
sender: ChannelUser {
|
||||
platform_id: channel_id.to_string(),
|
||||
platform_id: effective_channel_id,
|
||||
display_name,
|
||||
openfang_user: None,
|
||||
},
|
||||
@@ -768,15 +989,50 @@ async fn parse_discord_message(
|
||||
target_agent: None,
|
||||
timestamp,
|
||||
is_group,
|
||||
thread_id: None,
|
||||
thread_id: parsed_thread_id,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a Discord thread name from the message content.
|
||||
/// Strips @mention prefixes (`<@...>`), trims whitespace, and truncates to
|
||||
/// Discord's 100-character thread name limit. Falls back to the sender's
|
||||
/// display name if the message has no usable text (e.g. image-only).
|
||||
fn thread_name_from_message(message: &ChannelMessage) -> String {
|
||||
let raw = match &message.content {
|
||||
ChannelContent::Text(t) => t.clone(),
|
||||
ChannelContent::Image { caption, .. } => caption.clone().unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
// Strip leading Discord mention tokens (<@id> / <@!id>)
|
||||
let stripped = regex_lite::Regex::new(r"^(<@!?\d+>\s*)+")
|
||||
.map(|re| re.replace(&raw, "").into_owned())
|
||||
.unwrap_or(raw);
|
||||
|
||||
let trimmed = stripped.trim().to_string();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return message.sender.display_name.clone();
|
||||
}
|
||||
|
||||
// Truncate to Discord's 100-char limit
|
||||
if trimmed.chars().count() <= 100 {
|
||||
trimmed
|
||||
} else {
|
||||
trimmed.chars().take(97).collect::<String>() + "…"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Convenience helper: empty thread-tracking map for tests that don't exercise threading.
|
||||
fn empty_threads() -> Arc<RwLock<HashMap<String, String>>> {
|
||||
Arc::new(RwLock::new(HashMap::new()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_discord_message_basic() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
@@ -793,7 +1049,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Discord);
|
||||
@@ -817,7 +1073,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -837,7 +1093,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -858,7 +1114,7 @@ mod tests {
|
||||
});
|
||||
|
||||
// With ignore_bots=false, other bots' messages should be allowed
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], false, &empty_threads()).await;
|
||||
assert!(msg.is_some());
|
||||
let msg = msg.unwrap();
|
||||
assert_eq!(msg.sender.display_name, "somebot");
|
||||
@@ -882,7 +1138,7 @@ mod tests {
|
||||
});
|
||||
|
||||
// Even with ignore_bots=false, the bot's own messages must still be filtered
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], false, &empty_threads()).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -903,12 +1159,20 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed guilds
|
||||
let msg =
|
||||
parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
|
||||
let msg = parse_discord_message(
|
||||
&d,
|
||||
&bot_id,
|
||||
&["111".into(), "222".into()],
|
||||
&[],
|
||||
true,
|
||||
&empty_threads(),
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed guilds
|
||||
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[], true).await;
|
||||
let msg =
|
||||
parse_discord_message(&d, &bot_id, &["999".into()], &[], true, &empty_threads()).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -927,7 +1191,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
@@ -954,7 +1218,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -973,7 +1237,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.sender.display_name, "alice#1234");
|
||||
@@ -997,7 +1261,7 @@ mod tests {
|
||||
});
|
||||
|
||||
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Discord);
|
||||
@@ -1028,16 +1292,25 @@ mod tests {
|
||||
&[],
|
||||
&["user111".into(), "user222".into()],
|
||||
true,
|
||||
&empty_threads(),
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed users
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()], true).await;
|
||||
let msg = parse_discord_message(
|
||||
&d,
|
||||
&bot_id,
|
||||
&[],
|
||||
&["user999".into()],
|
||||
true,
|
||||
&empty_threads(),
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_some());
|
||||
|
||||
// Empty allowed_users = allow all
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -1060,7 +1333,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(msg.is_group);
|
||||
@@ -1083,7 +1356,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true)
|
||||
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(msg2.is_group);
|
||||
@@ -1105,7 +1378,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!msg.is_group);
|
||||
@@ -1144,6 +1417,7 @@ mod tests {
|
||||
vec![],
|
||||
true,
|
||||
37376,
|
||||
"true".to_string(),
|
||||
);
|
||||
assert_eq!(adapter.name(), "discord");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Discord);
|
||||
@@ -1183,7 +1457,7 @@ mod tests {
|
||||
async fn test_parse_image_only_no_caption() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = payload_with("", vec![att("photo.png", Some("image/png"), 100_000)]);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
match msg.content {
|
||||
@@ -1206,7 +1480,7 @@ mod tests {
|
||||
"look at this",
|
||||
vec![att("photo.jpg", Some("image/jpeg"), 50_000)],
|
||||
);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
match msg.content {
|
||||
@@ -1238,7 +1512,7 @@ mod tests {
|
||||
att("b.png", Some("image/png"), 20_000),
|
||||
],
|
||||
);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
match msg.content {
|
||||
@@ -1262,7 +1536,7 @@ mod tests {
|
||||
att("b.png", Some("image/png"), 20_000),
|
||||
],
|
||||
);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
match msg.content {
|
||||
@@ -1281,7 +1555,7 @@ mod tests {
|
||||
async fn test_parse_heic_falls_to_file() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = payload_with("", vec![att("photo.heic", Some("image/heic"), 100_000)]);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
match msg.content {
|
||||
@@ -1301,7 +1575,7 @@ mod tests {
|
||||
"",
|
||||
vec![att("huge.png", Some("image/png"), 6 * 1024 * 1024)],
|
||||
);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
match msg.content {
|
||||
@@ -1326,7 +1600,7 @@ mod tests {
|
||||
"see attached",
|
||||
vec![att("doc.pdf", Some("application/pdf"), 200_000)],
|
||||
);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
match msg.content {
|
||||
@@ -1345,7 +1619,7 @@ mod tests {
|
||||
// we should fall back to the filename extension.
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = payload_with("", vec![att("pic.png", None, 50_000)]);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(msg.content, ChannelContent::Image { .. }));
|
||||
@@ -1355,7 +1629,7 @@ mod tests {
|
||||
async fn test_parse_empty_message_with_no_attachments_returns_none() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = payload_with("", vec![]);
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,20 @@ use zeroize::Zeroizing;
|
||||
const SYNC_TIMEOUT_MS: u64 = 30000;
|
||||
const MAX_MESSAGE_LEN: usize = 4096;
|
||||
|
||||
/// Shared access + refresh token pair. Tokens are zeroized on drop and rotated
|
||||
/// in place when MSC2918 refresh succeeds.
|
||||
type TokenPair = Arc<RwLock<(Zeroizing<String>, Option<Zeroizing<String>>)>>;
|
||||
|
||||
/// Matrix channel adapter using the Client-Server API.
|
||||
pub struct MatrixAdapter {
|
||||
/// Matrix homeserver URL (e.g., `"https://matrix.org"`).
|
||||
homeserver_url: String,
|
||||
/// Bot's user ID (e.g., "@openfang:matrix.org").
|
||||
user_id: String,
|
||||
/// SECURITY: Access token is zeroized on drop.
|
||||
access_token: Zeroizing<String>,
|
||||
/// SECURITY: Access + refresh tokens are zeroized on drop. Stored behind
|
||||
/// an RwLock so the sync loop and send paths see rotated tokens after a
|
||||
/// MSC2918 /refresh call (matrix.org/MAS rotates both tokens every refresh).
|
||||
tokens: TokenPair,
|
||||
/// HTTP client.
|
||||
client: reqwest::Client,
|
||||
/// Allowed room IDs (empty = all joined rooms).
|
||||
@@ -40,19 +46,47 @@ pub struct MatrixAdapter {
|
||||
}
|
||||
|
||||
impl MatrixAdapter {
|
||||
/// Create a new Matrix adapter.
|
||||
/// Create a new Matrix adapter without a refresh token.
|
||||
pub fn new(
|
||||
homeserver_url: String,
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
allowed_rooms: Vec<String>,
|
||||
auto_accept_invites: bool,
|
||||
) -> Self {
|
||||
Self::with_refresh_token(
|
||||
homeserver_url,
|
||||
user_id,
|
||||
access_token,
|
||||
None,
|
||||
allowed_rooms,
|
||||
auto_accept_invites,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a new Matrix adapter with an optional refresh token (MSC2918).
|
||||
///
|
||||
/// When `refresh_token` is `Some`, the adapter will automatically call
|
||||
/// `POST /_matrix/client/v3/refresh` on `401 M_UNKNOWN_TOKEN` responses
|
||||
/// and retry the failed request once. Both tokens rotate on each refresh
|
||||
/// under Matrix Authentication Service (MAS).
|
||||
pub fn with_refresh_token(
|
||||
homeserver_url: String,
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
allowed_rooms: Vec<String>,
|
||||
auto_accept_invites: bool,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let tokens: TokenPair = Arc::new(RwLock::new((
|
||||
Zeroizing::new(access_token),
|
||||
refresh_token.map(Zeroizing::new),
|
||||
)));
|
||||
Self {
|
||||
homeserver_url,
|
||||
user_id,
|
||||
access_token: Zeroizing::new(access_token),
|
||||
tokens,
|
||||
client: reqwest::Client::new(),
|
||||
allowed_rooms,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
@@ -62,6 +96,11 @@ impl MatrixAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the current access token (cloned).
|
||||
async fn current_access_token(&self) -> String {
|
||||
self.tokens.read().await.0.as_str().to_string()
|
||||
}
|
||||
|
||||
/// Send a text message to a Matrix room.
|
||||
async fn api_send_message(
|
||||
&self,
|
||||
@@ -81,18 +120,46 @@ impl MatrixAdapter {
|
||||
"body": chunk,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let token = self.current_access_token().await;
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
break;
|
||||
}
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Matrix API error {status}: {body}").into());
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
|
||||
// Try a single refresh+retry on M_UNKNOWN_TOKEN (MSC2918).
|
||||
if attempt == 1
|
||||
&& status == reqwest::StatusCode::UNAUTHORIZED
|
||||
&& is_unknown_token_body(&body_text)
|
||||
{
|
||||
match try_refresh_tokens(&self.client, &self.homeserver_url, &self.tokens).await
|
||||
{
|
||||
Ok(()) => {
|
||||
info!("Matrix: access token refreshed via MSC2918, retrying send");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!(
|
||||
"Matrix API error {status}: {body_text} (refresh failed: {e})"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Err(format!("Matrix API error {status}: {body_text}").into());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,21 +170,32 @@ impl MatrixAdapter {
|
||||
async fn validate(&self) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver_url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.send()
|
||||
.await?;
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let token = self.current_access_token().await;
|
||||
let resp = self.client.get(&url).bearer_auth(&token).send().await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
if resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let user_id = body["user_id"].as_str().unwrap_or("unknown").to_string();
|
||||
return Ok(user_id);
|
||||
}
|
||||
|
||||
let status = resp.status();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
if attempt == 1
|
||||
&& status == reqwest::StatusCode::UNAUTHORIZED
|
||||
&& is_unknown_token_body(&body_text)
|
||||
&& try_refresh_tokens(&self.client, &self.homeserver_url, &self.tokens)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
info!("Matrix: access token refreshed via MSC2918, retrying /whoami");
|
||||
continue;
|
||||
}
|
||||
return Err("Matrix authentication failed".into());
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let user_id = body["user_id"].as_str().unwrap_or("unknown").to_string();
|
||||
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -126,6 +204,87 @@ impl MatrixAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect `M_UNKNOWN_TOKEN` errors in a Matrix response body.
|
||||
///
|
||||
/// Matrix returns 401 for multiple reasons; we only want to refresh on
|
||||
/// `M_UNKNOWN_TOKEN` (the access token expired or was revoked). See
|
||||
/// <https://spec.matrix.org/latest/client-server-api/#soft-logout>.
|
||||
fn is_unknown_token_body(body: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("errcode").and_then(|c| c.as_str()).map(String::from))
|
||||
.map(|c| c == "M_UNKNOWN_TOKEN")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether a Matrix 401 body indicates a hard logout (operator must re-login).
|
||||
///
|
||||
/// `soft_logout: true` (or absent — default per spec) means the device is still
|
||||
/// known to the server and a refresh-token grant is valid. `soft_logout: false`
|
||||
/// means the device was invalidated and the operator must perform a new
|
||||
/// `m.login.password` flow.
|
||||
fn is_hard_logout(body: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("soft_logout").and_then(|s| s.as_bool()))
|
||||
.map(|soft| !soft)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Call `POST /_matrix/client/v3/refresh` (MSC2918) and rotate the stored tokens.
|
||||
///
|
||||
/// On success, replaces the access token and (if the server returned one) the
|
||||
/// refresh token. MAS (matrix.org since 2025-04-07) rotates the refresh token
|
||||
/// on every call, so callers must use the new value next time.
|
||||
async fn try_refresh_tokens(
|
||||
client: &reqwest::Client,
|
||||
homeserver: &str,
|
||||
tokens: &TokenPair,
|
||||
) -> Result<(), String> {
|
||||
let refresh_token = {
|
||||
let guard = tokens.read().await;
|
||||
match guard.1.as_ref() {
|
||||
Some(rt) => rt.as_str().to_string(),
|
||||
None => return Err("no refresh token configured".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
let url = format!("{homeserver}/_matrix/client/v3/refresh");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "refresh_token": refresh_token }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("refresh request failed: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("refresh returned {status}: {body}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("refresh response parse error: {e}"))?;
|
||||
|
||||
let new_access = body
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "refresh response missing access_token".to_string())?;
|
||||
let new_refresh = body
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let mut guard = tokens.write().await;
|
||||
guard.0 = Zeroizing::new(new_access.to_string());
|
||||
if let Some(rt) = new_refresh {
|
||||
guard.1 = Some(Zeroizing::new(rt));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accept a room invite by calling POST /_matrix/client/v3/rooms/{room_id}/join.
|
||||
async fn accept_invite(
|
||||
client: &reqwest::Client,
|
||||
@@ -218,7 +377,7 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
|
||||
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
let homeserver = self.homeserver_url.clone();
|
||||
let access_token = self.access_token.clone();
|
||||
let tokens = Arc::clone(&self.tokens);
|
||||
// Use the validated user ID from /whoami instead of the config value.
|
||||
// Matrix server delegation or casing differences can cause self.user_id
|
||||
// to not match the sender field in timeline events, making the bot
|
||||
@@ -232,9 +391,10 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
|
||||
// FIX #4: Do an initial sync to get the since token, skipping old messages.
|
||||
if since_token.read().await.is_none() {
|
||||
if let Some(token) = initial_sync(&client, &homeserver, access_token.as_str()).await {
|
||||
let token = self.current_access_token().await;
|
||||
if let Some(next) = initial_sync(&client, &homeserver, &token).await {
|
||||
info!("Matrix: initial sync complete, skipping old messages");
|
||||
*since_token.write().await = Some(token);
|
||||
*since_token.write().await = Some(next);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,12 +417,13 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
url.push_str(&format!("&since={token}"));
|
||||
}
|
||||
|
||||
let current_token = tokens.read().await.0.as_str().to_string();
|
||||
let resp = tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Matrix adapter shutting down");
|
||||
break;
|
||||
}
|
||||
result = client.get(&url).bearer_auth(access_token.as_str()).send() => {
|
||||
result = client.get(&url).bearer_auth(¤t_token).send() => {
|
||||
match result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -276,7 +437,38 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("Matrix sync returned {}", resp.status());
|
||||
let status = resp.status();
|
||||
// MSC2918: on 401 M_UNKNOWN_TOKEN with a refresh token configured,
|
||||
// try refreshing once and loop again immediately. Hard logout
|
||||
// (soft_logout:false) is unrecoverable here — the operator must
|
||||
// perform a fresh m.login.password.
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
if is_unknown_token_body(&body_text) {
|
||||
if is_hard_logout(&body_text) {
|
||||
warn!(
|
||||
"Matrix: hard logout (soft_logout=false), operator must re-login"
|
||||
);
|
||||
} else {
|
||||
match try_refresh_tokens(&client, &homeserver, &tokens).await {
|
||||
Ok(()) => {
|
||||
info!(
|
||||
"Matrix: access token refreshed via MSC2918, resuming /sync"
|
||||
);
|
||||
backoff = Duration::from_secs(1);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Matrix: token refresh failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Matrix sync returned {status}: {body_text}");
|
||||
}
|
||||
} else {
|
||||
warn!("Matrix sync returned {status}");
|
||||
}
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(Duration::from_secs(60));
|
||||
continue;
|
||||
@@ -309,8 +501,8 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
accept_invite(&client, &homeserver, access_token.as_str(), room_id)
|
||||
.await;
|
||||
let tok = tokens.read().await.0.as_str().to_string();
|
||||
accept_invite(&client, &homeserver, &tok, room_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -380,10 +572,11 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
}
|
||||
|
||||
// FIX #3: Determine if room is a DM (2 members) or group.
|
||||
let tok_for_count = tokens.read().await.0.as_str().to_string();
|
||||
let is_group = get_room_member_count(
|
||||
&client,
|
||||
&homeserver,
|
||||
access_token.as_str(),
|
||||
&tok_for_count,
|
||||
room_id,
|
||||
)
|
||||
.await
|
||||
@@ -409,10 +602,11 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
}
|
||||
|
||||
// FIX #3: Determine if room is a DM (2 members) or group.
|
||||
let tok_for_count = tokens.read().await.0.as_str().to_string();
|
||||
let is_group = get_room_member_count(
|
||||
&client,
|
||||
&homeserver,
|
||||
access_token.as_str(),
|
||||
&tok_for_count,
|
||||
room_id,
|
||||
)
|
||||
.await
|
||||
@@ -485,10 +679,11 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
"timeout": 5000,
|
||||
});
|
||||
|
||||
let token = self.current_access_token().await;
|
||||
let _ = self
|
||||
.client
|
||||
.put(&url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await;
|
||||
@@ -518,6 +713,79 @@ mod tests {
|
||||
assert_eq!(adapter.name(), "matrix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_unknown_token_body() {
|
||||
// Real matrix.org body for M_UNKNOWN_TOKEN under MAS.
|
||||
let body =
|
||||
r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Token is not active","soft_logout":true}"#;
|
||||
assert!(is_unknown_token_body(body));
|
||||
assert!(!is_hard_logout(body));
|
||||
|
||||
let hard = r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Invalidated","soft_logout":false}"#;
|
||||
assert!(is_unknown_token_body(hard));
|
||||
assert!(is_hard_logout(hard));
|
||||
|
||||
let other = r#"{"errcode":"M_FORBIDDEN","error":"You are not allowed"}"#;
|
||||
assert!(!is_unknown_token_body(other));
|
||||
assert!(!is_hard_logout(other));
|
||||
|
||||
// Empty / non-JSON must not trigger refresh.
|
||||
assert!(!is_unknown_token_body(""));
|
||||
assert!(!is_unknown_token_body("not json"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresh_tokens_rotates_pair() {
|
||||
// Spin up a tiny axum server that mimics MSC2918 /refresh: rotates both
|
||||
// access and refresh tokens and returns the new pair.
|
||||
use axum::{routing::post, Json, Router};
|
||||
|
||||
async fn refresh_handler(Json(body): Json<serde_json::Value>) -> Json<serde_json::Value> {
|
||||
let incoming = body
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
assert_eq!(incoming, "old_refresh");
|
||||
Json(serde_json::json!({
|
||||
"access_token": "new_access",
|
||||
"refresh_token": "new_refresh",
|
||||
"expires_in_ms": 3_600_000u64,
|
||||
}))
|
||||
}
|
||||
|
||||
let app = Router::new().route("/_matrix/client/v3/refresh", post(refresh_handler));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let homeserver = format!("http://{addr}");
|
||||
let tokens: TokenPair = Arc::new(RwLock::new((
|
||||
Zeroizing::new("old_access".to_string()),
|
||||
Some(Zeroizing::new("old_refresh".to_string())),
|
||||
)));
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
try_refresh_tokens(&client, &homeserver, &tokens)
|
||||
.await
|
||||
.expect("refresh succeeds");
|
||||
|
||||
let guard = tokens.read().await;
|
||||
assert_eq!(guard.0.as_str(), "new_access");
|
||||
assert_eq!(guard.1.as_ref().map(|s| s.as_str()), Some("new_refresh"));
|
||||
drop(guard);
|
||||
|
||||
// Refresh with no refresh token configured must fail cleanly.
|
||||
let no_refresh: TokenPair = Arc::new(RwLock::new((Zeroizing::new("a".to_string()), None)));
|
||||
let err = try_refresh_tokens(&client, &homeserver, &no_refresh)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("no refresh token"));
|
||||
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matrix_allowed_rooms() {
|
||||
let adapter = MatrixAdapter::new(
|
||||
|
||||
@@ -722,7 +722,8 @@ mod tests {
|
||||
assert_eq!(resolved, None);
|
||||
|
||||
// Missing channel_id on the wire — no match (the binding is restrictive).
|
||||
let resolved = router.resolve_with_channel_id(&ChannelType::Discord, "any-user", None, None);
|
||||
let resolved =
|
||||
router.resolve_with_channel_id(&ChannelType::Discord, "any-user", None, None);
|
||||
assert_eq!(resolved, None);
|
||||
}
|
||||
|
||||
|
||||
@@ -329,9 +329,7 @@ impl ChannelAdapter for SlackAdapter {
|
||||
// connection during the rotation overlap. Ack on
|
||||
// both, but only forward to the agent once.
|
||||
if is_duplicate_envelope(&seen_envelopes, envelope_id) {
|
||||
debug!(
|
||||
"Slack: skipping duplicate envelope_id {envelope_id}"
|
||||
);
|
||||
debug!("Slack: skipping duplicate envelope_id {envelope_id}");
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,11 @@ pub struct TelegramAdapter {
|
||||
poll_interval: Duration,
|
||||
/// Base URL for Telegram Bot API (supports proxies/mirrors).
|
||||
api_base_url: String,
|
||||
/// Maps Telegram forum topic `message_thread_id` to an agent name. When a
|
||||
/// message arrives inside a configured topic, the inbound parser stamps
|
||||
/// `target_agent` so the bridge dispatches to that agent instead of the
|
||||
/// default. Empty map = default routing for every thread. Issue #780.
|
||||
thread_routes: Arc<HashMap<i64, String>>,
|
||||
/// Bot username (without @), populated from `getMe` during `start()`.
|
||||
/// Used for @mention detection in group messages.
|
||||
bot_username: Arc<tokio::sync::RwLock<Option<String>>>,
|
||||
@@ -79,6 +84,19 @@ impl TelegramAdapter {
|
||||
allowed_users: Vec<String>,
|
||||
poll_interval: Duration,
|
||||
api_url: Option<String>,
|
||||
) -> Self {
|
||||
Self::with_thread_routes(token, allowed_users, poll_interval, api_url, HashMap::new())
|
||||
}
|
||||
|
||||
/// Same as [`new`] but accepts a `thread_routes` map for forum-topic
|
||||
/// routing (issue #780). Keys are Telegram `message_thread_id` values,
|
||||
/// values are agent names to dispatch matching messages to.
|
||||
pub fn with_thread_routes(
|
||||
token: String,
|
||||
allowed_users: Vec<String>,
|
||||
poll_interval: Duration,
|
||||
api_url: Option<String>,
|
||||
thread_routes: HashMap<i64, String>,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let api_base_url = api_url
|
||||
@@ -91,6 +109,7 @@ impl TelegramAdapter {
|
||||
allowed_users,
|
||||
poll_interval,
|
||||
api_base_url,
|
||||
thread_routes: Arc::new(thread_routes),
|
||||
bot_username: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
rejected_reactions: Arc::new(Mutex::new(HashSet::new())),
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
@@ -597,6 +616,7 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
let poll_interval = self.poll_interval;
|
||||
let api_base_url = self.api_base_url.clone();
|
||||
let bot_username = self.bot_username.clone();
|
||||
let thread_routes = self.thread_routes.clone();
|
||||
let mut shutdown = self.shutdown_rx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -717,6 +737,7 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
&client,
|
||||
&api_base_url,
|
||||
bot_uname.as_deref(),
|
||||
&thread_routes,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -826,6 +847,7 @@ async fn parse_telegram_update(
|
||||
client: &reqwest::Client,
|
||||
api_base_url: &str,
|
||||
bot_username: Option<&str>,
|
||||
thread_routes: &HashMap<i64, String>,
|
||||
) -> Option<ChannelMessage> {
|
||||
let update_id = update["update_id"].as_i64().unwrap_or(0);
|
||||
let message = match update
|
||||
@@ -1008,9 +1030,15 @@ async fn parse_telegram_update(
|
||||
|
||||
// Extract forum topic thread_id (Telegram sends this as `message_thread_id`
|
||||
// for messages inside forum topics / reply threads).
|
||||
let thread_id = message["message_thread_id"]
|
||||
.as_i64()
|
||||
.map(|tid| tid.to_string());
|
||||
let thread_id_raw = message["message_thread_id"].as_i64();
|
||||
let thread_id = thread_id_raw.map(|tid| tid.to_string());
|
||||
|
||||
// Forum topic routing (issue #780). If the message lives inside a
|
||||
// configured topic, stash the target agent name in metadata under
|
||||
// `target_agent_name` so the bridge dispatcher prefers it over the
|
||||
// channel default. Topics with no entry leave metadata untouched and
|
||||
// fall through to the adapter's default_agent.
|
||||
let target_agent_name = thread_id_raw.and_then(|tid| thread_routes.get(&tid).cloned());
|
||||
|
||||
// Detect @mention of the bot in entities / caption_entities for MentionOnly group policy.
|
||||
let mut metadata = HashMap::new();
|
||||
@@ -1043,6 +1071,16 @@ async fn parse_telegram_update(
|
||||
}
|
||||
}
|
||||
|
||||
// Stash the forum-topic route target so the bridge can dispatch to a
|
||||
// specific agent for this topic. The bridge resolves the name via
|
||||
// `find_agent_by_name` before the standard router fallback.
|
||||
if let Some(name) = target_agent_name {
|
||||
metadata.insert(
|
||||
"target_agent_name".to_string(),
|
||||
serde_json::Value::String(name),
|
||||
);
|
||||
}
|
||||
|
||||
Some(ChannelMessage {
|
||||
channel: ChannelType::Telegram,
|
||||
platform_message_id: message_id.to_string(),
|
||||
@@ -1196,9 +1234,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Telegram);
|
||||
assert_eq!(msg.sender.display_name, "Alice Smith");
|
||||
assert_eq!(msg.sender.platform_id, "111222333");
|
||||
@@ -1229,9 +1275,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The chat_id (used for replies) stays on sender.platform_id.
|
||||
assert_eq!(msg.sender.platform_id, "-1009876543210");
|
||||
@@ -1270,9 +1324,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tg_id = msg
|
||||
.metadata
|
||||
@@ -1307,9 +1369,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -1341,8 +1411,16 @@ mod tests {
|
||||
let client = test_client();
|
||||
|
||||
// Empty allowed_users = allow all
|
||||
let msg =
|
||||
parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await;
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_some());
|
||||
|
||||
// Non-matching allowed_users = filter out
|
||||
@@ -1354,6 +1432,7 @@ mod tests {
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_none());
|
||||
@@ -1367,6 +1446,7 @@ mod tests {
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_some());
|
||||
@@ -1394,9 +1474,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Telegram);
|
||||
assert_eq!(msg.sender.display_name, "Alice Smith");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message!"));
|
||||
@@ -1432,9 +1520,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agents");
|
||||
@@ -1458,9 +1554,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(msg.content, ChannelContent::Location { .. }));
|
||||
}
|
||||
|
||||
@@ -1484,9 +1588,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// With a fake token, getFile will fail, so we get a text fallback
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
@@ -1521,9 +1633,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert!(t.contains("Document received"));
|
||||
@@ -1554,9 +1674,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert!(t.contains("Voice message"));
|
||||
@@ -1587,9 +1715,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.thread_id, Some("42".to_string()));
|
||||
assert!(msg.is_group);
|
||||
}
|
||||
@@ -1609,9 +1745,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.thread_id, None);
|
||||
assert!(!msg.is_group);
|
||||
}
|
||||
@@ -1633,12 +1777,193 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.thread_id, Some("99".to_string()));
|
||||
}
|
||||
|
||||
// ---- Issue #780: forum topic -> agent routing ----
|
||||
|
||||
fn forum_topic_update(thread_id: i64, update_id: i64) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"update_id": update_id,
|
||||
"message": {
|
||||
"message_id": 1000 + update_id,
|
||||
"message_thread_id": thread_id,
|
||||
"from": { "id": 555, "first_name": "Operator" },
|
||||
"chat": { "id": -1009998887776_i64, "type": "supergroup" },
|
||||
"date": 1700000000,
|
||||
"text": "scoped message"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_thread_routes_dispatch_matching_topic_to_named_agent() {
|
||||
// A message inside a configured forum topic must route to that agent
|
||||
// via `target_agent`, overriding the channel default.
|
||||
let update = forum_topic_update(42, 1);
|
||||
let mut routes = HashMap::new();
|
||||
routes.insert(42_i64, "support-agent".to_string());
|
||||
routes.insert(99_i64, "ops-agent".to_string());
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&routes,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(msg.thread_id, Some("42".to_string()));
|
||||
assert_eq!(
|
||||
msg.metadata
|
||||
.get("target_agent_name")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("support-agent")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_thread_routes_unconfigured_topic_falls_through() {
|
||||
// A thread id NOT in the route map must leave target_agent = None so
|
||||
// the bridge applies the channel default_agent.
|
||||
let update = forum_topic_update(7, 2);
|
||||
let mut routes = HashMap::new();
|
||||
routes.insert(42_i64, "support-agent".to_string());
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&routes,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(msg.thread_id, Some("7".to_string()));
|
||||
assert!(!msg.metadata.contains_key("target_agent_name"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_thread_routes_ignored_for_non_topic_message() {
|
||||
// Messages with no message_thread_id (regular group / DM) must never
|
||||
// be matched against thread_routes, even if the map is non-empty.
|
||||
let update = serde_json::json!({
|
||||
"update_id": 3,
|
||||
"message": {
|
||||
"message_id": 1003,
|
||||
"from": { "id": 555, "first_name": "Operator" },
|
||||
"chat": { "id": -1009998887776_i64, "type": "supergroup" },
|
||||
"date": 1700000000,
|
||||
"text": "general group message"
|
||||
}
|
||||
});
|
||||
let mut routes = HashMap::new();
|
||||
routes.insert(42_i64, "support-agent".to_string());
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&routes,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(msg.thread_id, None);
|
||||
assert!(!msg.metadata.contains_key("target_agent_name"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_thread_routes_multiple_topics_route_independently() {
|
||||
// Two different topics in the same chat must dispatch to two different
|
||||
// agents.
|
||||
let mut routes = HashMap::new();
|
||||
routes.insert(10_i64, "alpha".to_string());
|
||||
routes.insert(20_i64, "beta".to_string());
|
||||
|
||||
let client = test_client();
|
||||
|
||||
let update_alpha = forum_topic_update(10, 4);
|
||||
let msg_alpha = parse_telegram_update(
|
||||
&update_alpha,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&routes,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
msg_alpha
|
||||
.metadata
|
||||
.get("target_agent_name")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("alpha")
|
||||
);
|
||||
|
||||
let update_beta = forum_topic_update(20, 5);
|
||||
let msg_beta = parse_telegram_update(
|
||||
&update_beta,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&routes,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
msg_beta
|
||||
.metadata
|
||||
.get("target_agent_name")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("beta")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_adapter_with_thread_routes_constructor_stores_map() {
|
||||
// Sanity check that the new constructor wires the route map onto the
|
||||
// adapter so the spawned polling task sees it.
|
||||
let mut routes = HashMap::new();
|
||||
routes.insert(1_i64, "first".to_string());
|
||||
let adapter = TelegramAdapter::with_thread_routes(
|
||||
"test:token".to_string(),
|
||||
vec![],
|
||||
Duration::from_millis(10),
|
||||
Some("https://example.test".to_string()),
|
||||
routes.clone(),
|
||||
);
|
||||
assert_eq!(adapter.thread_routes.as_ref(), &routes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_sender_chat_fallback() {
|
||||
// Messages sent on behalf of a channel have `sender_chat` instead of `from`.
|
||||
@@ -1658,9 +1983,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.sender.display_name, "My Channel");
|
||||
assert_eq!(msg.sender.platform_id, "-1001234567890");
|
||||
assert!(
|
||||
@@ -1682,8 +2015,16 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg =
|
||||
parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await;
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -1714,6 +2055,7 @@ mod tests {
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
Some("testbot"),
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1746,6 +2088,7 @@ mod tests {
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
Some("testbot"),
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1780,6 +2123,7 @@ mod tests {
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
Some("testbot"),
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1817,6 +2161,7 @@ mod tests {
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
Some("testbot"),
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1854,6 +2199,7 @@ mod tests {
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
Some("testbot"),
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1890,6 +2236,7 @@ mod tests {
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
Some("testbot"),
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1943,9 +2290,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert!(t.starts_with("[Replying to Bob: We should use Rust]\n\n"));
|
||||
@@ -1985,9 +2340,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert!(t.starts_with("[Replying to Carol: Sunset view]\n\n"));
|
||||
@@ -2026,9 +2389,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert_eq!(t, "What was that?");
|
||||
@@ -2064,9 +2435,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert!(t.starts_with("[Replying to Unknown: Anonymous message]\n\n"));
|
||||
@@ -2091,9 +2470,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert_eq!(t, "Just a normal message");
|
||||
|
||||
@@ -341,6 +341,24 @@ pub trait ChannelAdapter: Send + Sync {
|
||||
self.send(user, content).await
|
||||
}
|
||||
|
||||
/// Determine whether to auto-create a thread for an incoming message.
|
||||
/// Returns Some(thread_name) to create a thread, or None to reply directly.
|
||||
/// Default implementation returns None (no auto-threading).
|
||||
async fn should_auto_thread(&self, _message: &ChannelMessage) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Create a new thread (typically triggered after should_auto_thread returns Some).
|
||||
/// Returns the new thread ID on success.
|
||||
async fn create_thread(
|
||||
&self,
|
||||
_user: &ChannelUser,
|
||||
_message_id: &str,
|
||||
_thread_name: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
Err("Thread creation not supported for this adapter".into())
|
||||
}
|
||||
|
||||
/// Whether this adapter should suppress sending internal agent errors back to the user.
|
||||
///
|
||||
/// Returns `true` for public broadcast channels (e.g. Mastodon) where posting
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenFang",
|
||||
"version": "0.6.7",
|
||||
"version": "0.6.9",
|
||||
"identifier": "ai.openfang.desktop",
|
||||
"build": {},
|
||||
"app": {
|
||||
|
||||
@@ -16,6 +16,8 @@ uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -8,10 +8,27 @@ use crate::{
|
||||
use dashmap::DashMap;
|
||||
use openfang_types::agent::AgentId;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Callback signature invoked on every successful hand load / reload.
|
||||
///
|
||||
/// Arguments: `(hand_id, sha256_hex_of_hand_toml)`. The kernel wires this
|
||||
/// into the Merkle audit chain so reload events leave a tamper-evident
|
||||
/// record (issue #1172). The callback must be cheap and non-blocking; it
|
||||
/// runs inline on the loader thread.
|
||||
pub type HandAuditCallback = Arc<dyn Fn(&str, &str) + Send + Sync>;
|
||||
|
||||
/// Compute the SHA-256 hex digest of raw HAND.toml content.
|
||||
fn hand_toml_sha256(toml_content: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(toml_content.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
// ─── Settings availability types ────────────────────────────────────────────
|
||||
|
||||
/// Availability status of a single setting option.
|
||||
@@ -41,6 +58,10 @@ pub struct HandRegistry {
|
||||
definitions: DashMap<String, HandDefinition>,
|
||||
/// Active hand instances, keyed by instance UUID.
|
||||
instances: DashMap<Uuid, HandInstance>,
|
||||
/// Optional callback invoked on every successful HAND.toml load with
|
||||
/// the computed SHA-256 of the file content. Wired by the kernel into
|
||||
/// the Merkle audit chain (issue #1172).
|
||||
audit_callback: RwLock<Option<HandAuditCallback>>,
|
||||
}
|
||||
|
||||
impl HandRegistry {
|
||||
@@ -49,9 +70,37 @@ impl HandRegistry {
|
||||
Self {
|
||||
definitions: DashMap::new(),
|
||||
instances: DashMap::new(),
|
||||
audit_callback: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a callback invoked on every successful HAND.toml load /
|
||||
/// reload with the file's SHA-256. The kernel wires this to
|
||||
/// `AuditLog::record(AuditAction::ConfigChange, ...)` so reload events
|
||||
/// leave a tamper-evident audit record (issue #1172).
|
||||
pub fn set_audit_callback(&self, callback: HandAuditCallback) {
|
||||
let mut guard = self
|
||||
.audit_callback
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
*guard = Some(callback);
|
||||
}
|
||||
|
||||
/// Compute SHA-256 of the given HAND.toml content and invoke the audit
|
||||
/// callback if one is registered. Returns the hex digest for callers
|
||||
/// that want to log or compare it.
|
||||
fn emit_hand_loaded_audit(&self, hand_id: &str, toml_content: &str) -> String {
|
||||
let hash = hand_toml_sha256(toml_content);
|
||||
let guard = self
|
||||
.audit_callback
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(cb) = guard.as_ref() {
|
||||
cb(hand_id, &hash);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// Persist active hand state to disk so it survives restarts.
|
||||
pub fn persist_state(&self, path: &std::path::Path) -> HandResult<()> {
|
||||
let entries: Vec<serde_json::Value> = self
|
||||
@@ -112,7 +161,8 @@ impl HandRegistry {
|
||||
for (id, toml_content, skill_content) in bundled {
|
||||
match bundled::parse_bundled(id, toml_content, skill_content) {
|
||||
Ok(def) => {
|
||||
info!(hand = %def.id, name = %def.name, "Loaded bundled hand");
|
||||
let hash = self.emit_hand_loaded_audit(&def.id, toml_content);
|
||||
info!(hand = %def.id, name = %def.name, sha256 = %hash, "Loaded bundled hand");
|
||||
self.definitions.insert(def.id.clone(), def);
|
||||
count += 1;
|
||||
}
|
||||
@@ -173,7 +223,8 @@ impl HandRegistry {
|
||||
match bundled::parse_bundled("custom", &contents, &skill_content) {
|
||||
Ok(def) => {
|
||||
let hand_id = def.id.clone();
|
||||
info!(hand = %hand_id, path = %path.display(), "Loaded workspace hand");
|
||||
let hash = self.emit_hand_loaded_audit(&hand_id, &contents);
|
||||
info!(hand = %hand_id, path = %path.display(), sha256 = %hash, "Loaded workspace hand");
|
||||
self.definitions.insert(hand_id, def);
|
||||
count += 1;
|
||||
}
|
||||
@@ -204,7 +255,8 @@ impl HandRegistry {
|
||||
)));
|
||||
}
|
||||
|
||||
info!(hand = %def.id, name = %def.name, path = %path.display(), "Installed hand from path");
|
||||
let hash = self.emit_hand_loaded_audit(&def.id, &toml_content);
|
||||
info!(hand = %def.id, name = %def.name, path = %path.display(), sha256 = %hash, "Installed hand from path");
|
||||
self.definitions.insert(def.id.clone(), def.clone());
|
||||
|
||||
// Persist the hand to the user's data dir so it survives daemon
|
||||
@@ -251,7 +303,8 @@ impl HandRegistry {
|
||||
)));
|
||||
}
|
||||
|
||||
info!(hand = %def.id, name = %def.name, "Installed hand from content");
|
||||
let hash = self.emit_hand_loaded_audit(&def.id, toml_content);
|
||||
info!(hand = %def.id, name = %def.name, sha256 = %hash, "Installed hand from content");
|
||||
self.definitions.insert(def.id.clone(), def.clone());
|
||||
Ok(def)
|
||||
}
|
||||
@@ -269,7 +322,8 @@ impl HandRegistry {
|
||||
let def = bundled::parse_bundled("custom", toml_content, skill_content)?;
|
||||
let existed = self.definitions.contains_key(&def.id);
|
||||
let verb = if existed { "Updated" } else { "Installed" };
|
||||
info!(hand = %def.id, name = %def.name, "{verb} hand from content");
|
||||
let hash = self.emit_hand_loaded_audit(&def.id, toml_content);
|
||||
info!(hand = %def.id, name = %def.name, sha256 = %hash, "{verb} hand from content");
|
||||
self.definitions.insert(def.id.clone(), def.clone());
|
||||
Ok(def)
|
||||
}
|
||||
@@ -1145,6 +1199,114 @@ metrics = []
|
||||
assert!(matches!(err, HandError::AlreadyActive(_)));
|
||||
}
|
||||
|
||||
/// Issue #1172: HAND.toml SHA-256 must be emitted to the audit
|
||||
/// callback on every successful load / reload.
|
||||
#[test]
|
||||
fn audit_callback_records_hand_toml_hash_on_load() {
|
||||
use std::sync::Mutex;
|
||||
|
||||
let captured: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&captured);
|
||||
|
||||
let reg = HandRegistry::new();
|
||||
reg.set_audit_callback(Arc::new(move |hand_id: &str, hash: &str| {
|
||||
sink.lock()
|
||||
.unwrap()
|
||||
.push((hand_id.to_string(), hash.to_string()));
|
||||
}));
|
||||
|
||||
let toml_str = r#"
|
||||
id = "audit-hand"
|
||||
name = "Audit Hand"
|
||||
description = "Used to verify audit-trail wiring"
|
||||
category = "other"
|
||||
tools = []
|
||||
|
||||
[agent]
|
||||
name = "audit-agent"
|
||||
description = "audit"
|
||||
system_prompt = "audit."
|
||||
"#;
|
||||
// Precompute the expected hash so the test fails loudly if the
|
||||
// registry ever changes how it digests the TOML content.
|
||||
let expected_hash = {
|
||||
let mut h = Sha256::new();
|
||||
h.update(toml_str.as_bytes());
|
||||
hex::encode(h.finalize())
|
||||
};
|
||||
|
||||
let def = reg.install_from_content(toml_str, "").unwrap();
|
||||
assert_eq!(def.id, "audit-hand");
|
||||
|
||||
let events = captured.lock().unwrap().clone();
|
||||
assert_eq!(
|
||||
events.len(),
|
||||
1,
|
||||
"exactly one audit event should be emitted per load"
|
||||
);
|
||||
assert_eq!(events[0].0, "audit-hand", "hand id propagated to callback");
|
||||
assert_eq!(
|
||||
events[0].1, expected_hash,
|
||||
"callback received SHA-256 of the HAND.toml content"
|
||||
);
|
||||
assert_eq!(events[0].1.len(), 64, "SHA-256 hex is 64 chars");
|
||||
}
|
||||
|
||||
/// Issue #1172: reloading the same HAND.toml via upsert must emit a
|
||||
/// fresh audit event so the chain records when the swap took effect.
|
||||
/// A content change must surface a different hash.
|
||||
#[test]
|
||||
fn audit_callback_fires_on_reload_with_new_hash() {
|
||||
use std::sync::Mutex;
|
||||
|
||||
let captured: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&captured);
|
||||
|
||||
let reg = HandRegistry::new();
|
||||
reg.set_audit_callback(Arc::new(move |hand_id: &str, hash: &str| {
|
||||
sink.lock()
|
||||
.unwrap()
|
||||
.push((hand_id.to_string(), hash.to_string()));
|
||||
}));
|
||||
|
||||
let v1 = r#"
|
||||
id = "reload-hand"
|
||||
name = "Reload Hand v1"
|
||||
description = "v1"
|
||||
category = "other"
|
||||
tools = []
|
||||
|
||||
[agent]
|
||||
name = "reload-agent"
|
||||
description = "reload"
|
||||
system_prompt = "v1."
|
||||
"#;
|
||||
let v2 = r#"
|
||||
id = "reload-hand"
|
||||
name = "Reload Hand v2"
|
||||
description = "v2"
|
||||
category = "other"
|
||||
tools = []
|
||||
|
||||
[agent]
|
||||
name = "reload-agent"
|
||||
description = "reload"
|
||||
system_prompt = "v2 — schedule changed."
|
||||
"#;
|
||||
|
||||
reg.upsert_from_content(v1, "").unwrap();
|
||||
reg.upsert_from_content(v2, "").unwrap();
|
||||
|
||||
let events = captured.lock().unwrap().clone();
|
||||
assert_eq!(events.len(), 2, "one event per upsert (load + reload)");
|
||||
assert_eq!(events[0].0, "reload-hand");
|
||||
assert_eq!(events[1].0, "reload-hand");
|
||||
assert_ne!(
|
||||
events[0].1, events[1].1,
|
||||
"different HAND.toml content must yield different SHA-256"
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration test for issue #809: `hand config` round-trip.
|
||||
///
|
||||
/// Simulates what `openfang hand config <id> --set KEY=VAL` does against
|
||||
|
||||
@@ -32,6 +32,7 @@ futures = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
cron = "0.16"
|
||||
|
||||
@@ -339,6 +339,7 @@ mod tests {
|
||||
autonomous: None,
|
||||
pinned_model: None,
|
||||
workspace: None,
|
||||
state_dir: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
|
||||
@@ -288,9 +288,38 @@ impl DeliveryTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create workspace directory structure for an agent.
|
||||
/// Create the agent's private state directory layout. Holds identity files,
|
||||
/// AGENT.json, sessions/, memory/, and logs/. Lives under
|
||||
/// `~/.openfang/workspaces/{name}/` regardless of where the user pointed the
|
||||
/// user-facing workspace. See issue #1097.
|
||||
fn ensure_state_dir(state_dir: &Path, workspace: &Path) -> KernelResult<()> {
|
||||
for subdir in &["sessions", "logs", "memory"] {
|
||||
std::fs::create_dir_all(state_dir.join(subdir)).map_err(|e| {
|
||||
KernelError::OpenFang(OpenFangError::Internal(format!(
|
||||
"Failed to create state dir {}/{subdir}: {e}",
|
||||
state_dir.display()
|
||||
)))
|
||||
})?;
|
||||
}
|
||||
// Write agent metadata file (best-effort).
|
||||
let meta = serde_json::json!({
|
||||
"created_at": chrono::Utc::now().to_rfc3339(),
|
||||
"state_dir": state_dir.display().to_string(),
|
||||
"workspace": workspace.display().to_string(),
|
||||
});
|
||||
let _ = std::fs::write(
|
||||
state_dir.join("AGENT.json"),
|
||||
serde_json::to_string_pretty(&meta).unwrap_or_default(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create the user-facing workspace layout. Only `data/`, `output/`, and
|
||||
/// `skills/` are scaffolded here. When the user points the workspace at a
|
||||
/// pre-existing path like `~/Documents`, these subdirs are created lazily
|
||||
/// inside it without dumping identity files or sessions. See issue #1097.
|
||||
fn ensure_workspace(workspace: &Path) -> KernelResult<()> {
|
||||
for subdir in &["data", "output", "sessions", "skills", "logs", "memory"] {
|
||||
for subdir in &["data", "output", "skills"] {
|
||||
std::fs::create_dir_all(workspace.join(subdir)).map_err(|e| {
|
||||
KernelError::OpenFang(OpenFangError::Internal(format!(
|
||||
"Failed to create workspace dir {}/{subdir}: {e}",
|
||||
@@ -298,15 +327,6 @@ fn ensure_workspace(workspace: &Path) -> KernelResult<()> {
|
||||
)))
|
||||
})?;
|
||||
}
|
||||
// Write agent metadata file (best-effort)
|
||||
let meta = serde_json::json!({
|
||||
"created_at": chrono::Utc::now().to_rfc3339(),
|
||||
"workspace": workspace.display().to_string(),
|
||||
});
|
||||
let _ = std::fs::write(
|
||||
workspace.join("AGENT.json"),
|
||||
serde_json::to_string_pretty(&meta).unwrap_or_default(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -447,15 +467,18 @@ fn generate_identity_files(workspace: &Path, manifest: &AgentManifest) {
|
||||
}
|
||||
|
||||
/// Append an assistant response summary to the daily memory log (best-effort, append-only).
|
||||
/// Caps daily log at 1MB to prevent unbounded growth.
|
||||
fn append_daily_memory_log(workspace: &Path, response: &str) {
|
||||
/// Caps daily log at 1MB to prevent unbounded growth. Writes to the agent's
|
||||
/// private state directory (`state_dir/memory/`), never to the user-facing
|
||||
/// workspace, so pointing `workspace = "/home/me/Documents"` does not litter
|
||||
/// the user's folder with per-day markdown files. See issue #1097.
|
||||
fn append_daily_memory_log(state_dir: &Path, response: &str) {
|
||||
use std::io::Write;
|
||||
let trimmed = response.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||
let log_path = workspace.join("memory").join(format!("{today}.md"));
|
||||
let log_path = state_dir.join("memory").join(format!("{today}.md"));
|
||||
// Security: cap total daily log to 1MB
|
||||
if let Ok(metadata) = std::fs::metadata(&log_path) {
|
||||
if metadata.len() > 1_048_576 {
|
||||
@@ -474,16 +497,18 @@ fn append_daily_memory_log(workspace: &Path, response: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a workspace identity file with a size cap to prevent prompt stuffing.
|
||||
/// Returns None if the file doesn't exist or is empty.
|
||||
fn read_identity_file(workspace: &Path, filename: &str) -> Option<String> {
|
||||
/// Read an identity file from the agent's private state directory with a size
|
||||
/// cap to prevent prompt stuffing. Returns None if the file doesn't exist or
|
||||
/// is empty. Identity files live in `state_dir`, not in the user-facing
|
||||
/// workspace (see issue #1097), so this is called with the state directory.
|
||||
fn read_identity_file(state_dir: &Path, filename: &str) -> Option<String> {
|
||||
const MAX_IDENTITY_FILE_BYTES: usize = 32_768; // 32KB cap
|
||||
let path = workspace.join(filename);
|
||||
// Security: ensure path stays inside workspace
|
||||
let path = state_dir.join(filename);
|
||||
// Security: ensure path stays inside the state directory
|
||||
match path.canonicalize() {
|
||||
Ok(canonical) => {
|
||||
if let Ok(ws_canonical) = workspace.canonicalize() {
|
||||
if !canonical.starts_with(&ws_canonical) {
|
||||
if let Ok(sd_canonical) = state_dir.canonicalize() {
|
||||
if !canonical.starts_with(&sd_canonical) {
|
||||
return None; // path traversal attempt
|
||||
}
|
||||
}
|
||||
@@ -800,6 +825,9 @@ impl OpenFangKernel {
|
||||
// Initialize model catalog, detect provider auth, and apply URL overrides
|
||||
let mut model_catalog = openfang_runtime::model_catalog::ModelCatalog::new();
|
||||
model_catalog.detect_auth();
|
||||
// Env-var overrides for local providers (OLLAMA_HOST, LMSTUDIO_BASE_URL, etc.).
|
||||
// Applied before `provider_urls` so explicit config.toml entries win. See #1154.
|
||||
model_catalog.apply_local_env_overrides();
|
||||
if !config.provider_urls.is_empty() {
|
||||
model_catalog.apply_url_overrides(&config.provider_urls);
|
||||
info!(
|
||||
@@ -1058,11 +1086,10 @@ impl OpenFangKernel {
|
||||
openfang_runtime::media_understanding::MediaEngine::new(config.media.clone());
|
||||
// Closes #1051: thread MediaConfig URL overrides into the TTS engine
|
||||
// so local OpenAI/ElevenLabs-compatible services can be targeted.
|
||||
let tts_engine = openfang_runtime::tts::TtsEngine::new(config.tts.clone())
|
||||
.with_base_urls(
|
||||
config.media.tts_openai_base_url.clone(),
|
||||
config.media.tts_elevenlabs_base_url.clone(),
|
||||
);
|
||||
let tts_engine = openfang_runtime::tts::TtsEngine::new(config.tts.clone()).with_base_urls(
|
||||
config.media.tts_openai_base_url.clone(),
|
||||
config.media.tts_elevenlabs_base_url.clone(),
|
||||
);
|
||||
let mut pairing = crate::pairing::PairingManager::new(config.pairing.clone());
|
||||
|
||||
// Load paired devices from database and set up persistence callback
|
||||
@@ -1195,6 +1222,41 @@ impl OpenFangKernel {
|
||||
self_handle: OnceLock::new(),
|
||||
};
|
||||
|
||||
// Wire HAND.toml load events into the Merkle audit chain so reload
|
||||
// events (and future installs) leave a tamper-evident record of
|
||||
// which manifest hash was active at any point in time. Issue #1172.
|
||||
//
|
||||
// The bundled + workspace hands were loaded before the kernel struct
|
||||
// existed, so we backfill those hashes now and install a callback
|
||||
// for every subsequent install/upsert/reload.
|
||||
{
|
||||
let audit_log_initial = Arc::clone(&kernel.audit_log);
|
||||
for (hand_id, toml_content, _skill) in openfang_hands::bundled::bundled_hands() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(toml_content.as_bytes());
|
||||
let hash = hex::encode(hasher.finalize());
|
||||
audit_log_initial.record(
|
||||
"kernel",
|
||||
openfang_runtime::audit::AuditAction::ConfigChange,
|
||||
format!("HAND.toml load hand={hand_id} sha256={hash}"),
|
||||
"ok",
|
||||
);
|
||||
}
|
||||
|
||||
let audit_log_for_cb = Arc::clone(&kernel.audit_log);
|
||||
kernel
|
||||
.hand_registry
|
||||
.set_audit_callback(Arc::new(move |hand_id: &str, hash: &str| {
|
||||
audit_log_for_cb.record(
|
||||
"kernel",
|
||||
openfang_runtime::audit::AuditAction::ConfigChange,
|
||||
format!("HAND.toml reload hand={hand_id} sha256={hash}"),
|
||||
"ok",
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
// Restore persisted agents from SQLite
|
||||
match kernel.memory.load_all_agents() {
|
||||
Ok(agents) => {
|
||||
@@ -1478,9 +1540,7 @@ impl OpenFangKernel {
|
||||
}
|
||||
}
|
||||
if auto_spawned > 0 {
|
||||
info!(
|
||||
"Auto-spawned {auto_spawned} agent(s) from ~/.openfang/agents"
|
||||
);
|
||||
info!("Auto-spawned {auto_spawned} agent(s) from ~/.openfang/agents");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1631,15 +1691,27 @@ impl OpenFangKernel {
|
||||
// Apply global budget defaults to agent resource quotas
|
||||
apply_budget_defaults(&self.config.budget, &mut manifest.resources);
|
||||
|
||||
// Create workspace directory for the agent (name-based, so SOUL.md survives recreation)
|
||||
// Agent private state always lives under ~/.openfang/workspaces/{name}/.
|
||||
// This is name-based so SOUL.md and per-agent memory survive recreation
|
||||
// and never get dumped into a user-supplied workspace path. See #1097.
|
||||
let state_dir = manifest
|
||||
.state_dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.config.effective_workspaces_dir().join(&name));
|
||||
// The user-facing workspace defaults to the state_dir when the manifest
|
||||
// does not specify one. When the user sets `workspace = "/path"` in
|
||||
// agent.toml we leave that path alone — only data/, output/, skills/
|
||||
// get created lazily so private state never pollutes the target dir.
|
||||
let workspace_dir = manifest
|
||||
.workspace
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.config.effective_workspaces_dir().join(&name));
|
||||
.unwrap_or_else(|| state_dir.clone());
|
||||
ensure_state_dir(&state_dir, &workspace_dir)?;
|
||||
ensure_workspace(&workspace_dir)?;
|
||||
if manifest.generate_identity_files {
|
||||
generate_identity_files(&workspace_dir, &manifest);
|
||||
generate_identity_files(&state_dir, &manifest);
|
||||
}
|
||||
manifest.state_dir = Some(state_dir);
|
||||
manifest.workspace = Some(workspace_dir);
|
||||
|
||||
// Register capabilities
|
||||
@@ -2049,16 +2121,30 @@ impl OpenFangKernel {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<StreamEvent>(64);
|
||||
let mut manifest = entry.manifest.clone();
|
||||
|
||||
// Lazy backfill: create workspace for existing agents spawned before workspaces
|
||||
if manifest.workspace.is_none() {
|
||||
let workspace_dir = self.config.effective_workspaces_dir().join(&manifest.name);
|
||||
// Lazy backfill: create state_dir and workspace for existing agents
|
||||
// spawned before this field existed. Private state always lives under
|
||||
// ~/.openfang/workspaces/{name}/; the user-facing workspace defaults
|
||||
// to the same path unless the manifest already pinned one. See #1097.
|
||||
if manifest.state_dir.is_none() {
|
||||
let state_dir = self.config.effective_workspaces_dir().join(&manifest.name);
|
||||
let workspace_dir = manifest
|
||||
.workspace
|
||||
.clone()
|
||||
.unwrap_or_else(|| state_dir.clone());
|
||||
if let Err(e) = ensure_state_dir(&state_dir, &workspace_dir) {
|
||||
warn!(agent_id = %agent_id, "Failed to backfill state_dir (streaming): {e}");
|
||||
}
|
||||
if let Err(e) = ensure_workspace(&workspace_dir) {
|
||||
warn!(agent_id = %agent_id, "Failed to backfill workspace (streaming): {e}");
|
||||
} else {
|
||||
manifest.state_dir = Some(state_dir);
|
||||
manifest.workspace = Some(workspace_dir);
|
||||
let _ = self
|
||||
.registry
|
||||
.update_workspace(agent_id, manifest.workspace.clone());
|
||||
let _ = self
|
||||
.registry
|
||||
.update_state_dir(agent_id, manifest.state_dir.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2129,17 +2215,17 @@ impl OpenFangKernel {
|
||||
},
|
||||
workspace_path: manifest.workspace.as_ref().map(|p| p.display().to_string()),
|
||||
soul_md: manifest
|
||||
.workspace
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.and_then(|w| read_identity_file(w, "SOUL.md")),
|
||||
.and_then(|s| read_identity_file(s, "SOUL.md")),
|
||||
user_md: manifest
|
||||
.workspace
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.and_then(|w| read_identity_file(w, "USER.md")),
|
||||
.and_then(|s| read_identity_file(s, "USER.md")),
|
||||
memory_md: manifest
|
||||
.workspace
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.and_then(|w| read_identity_file(w, "MEMORY.md")),
|
||||
.and_then(|s| read_identity_file(s, "MEMORY.md")),
|
||||
canonical_context: self
|
||||
.memory
|
||||
.canonical_context(agent_id, None)
|
||||
@@ -2154,27 +2240,27 @@ impl OpenFangKernel {
|
||||
.unwrap_or(false),
|
||||
is_autonomous: manifest.autonomous.is_some(),
|
||||
agents_md: manifest
|
||||
.workspace
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.and_then(|w| read_identity_file(w, "AGENTS.md")),
|
||||
.and_then(|s| read_identity_file(s, "AGENTS.md")),
|
||||
bootstrap_md: manifest
|
||||
.workspace
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.and_then(|w| read_identity_file(w, "BOOTSTRAP.md")),
|
||||
.and_then(|s| read_identity_file(s, "BOOTSTRAP.md")),
|
||||
workspace_context: manifest.workspace.as_ref().map(|w| {
|
||||
let mut ws_ctx =
|
||||
openfang_runtime::workspace_context::WorkspaceContext::detect(w);
|
||||
ws_ctx.build_context_section()
|
||||
}),
|
||||
identity_md: manifest
|
||||
.workspace
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.and_then(|w| read_identity_file(w, "IDENTITY.md")),
|
||||
.and_then(|s| read_identity_file(s, "IDENTITY.md")),
|
||||
heartbeat_md: if manifest.autonomous.is_some() {
|
||||
manifest
|
||||
.workspace
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.and_then(|w| read_identity_file(w, "HEARTBEAT.md"))
|
||||
.and_then(|s| read_identity_file(s, "HEARTBEAT.md"))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
@@ -2315,15 +2401,17 @@ impl OpenFangKernel {
|
||||
}
|
||||
}
|
||||
|
||||
// Write JSONL session mirror to workspace
|
||||
if let Some(ref workspace) = manifest.workspace {
|
||||
// Write JSONL session mirror and daily memory log to the
|
||||
// agent's private state directory, not the user-facing
|
||||
// workspace. See issue #1097.
|
||||
if let Some(ref state_dir) = manifest.state_dir {
|
||||
if let Err(e) =
|
||||
memory.write_jsonl_mirror(&session, &workspace.join("sessions"))
|
||||
memory.write_jsonl_mirror(&session, &state_dir.join("sessions"))
|
||||
{
|
||||
warn!("Failed to write JSONL session mirror (streaming): {e}");
|
||||
}
|
||||
// Append daily memory log (best-effort)
|
||||
append_daily_memory_log(workspace, &result.response);
|
||||
append_daily_memory_log(state_dir, &result.response);
|
||||
}
|
||||
|
||||
kernel_clone
|
||||
@@ -2608,17 +2696,30 @@ impl OpenFangKernel {
|
||||
// Apply model routing if configured (disabled in Stable mode)
|
||||
let mut manifest = entry.manifest.clone();
|
||||
|
||||
// Lazy backfill: create workspace for existing agents spawned before workspaces
|
||||
if manifest.workspace.is_none() {
|
||||
let workspace_dir = self.config.effective_workspaces_dir().join(&manifest.name);
|
||||
// Lazy backfill: create state_dir and workspace for existing agents.
|
||||
// Private state lives under ~/.openfang/workspaces/{name}/. User-facing
|
||||
// workspace stays at whatever the manifest pinned (or defaults to the
|
||||
// state_dir). See issue #1097.
|
||||
if manifest.state_dir.is_none() {
|
||||
let state_dir = self.config.effective_workspaces_dir().join(&manifest.name);
|
||||
let workspace_dir = manifest
|
||||
.workspace
|
||||
.clone()
|
||||
.unwrap_or_else(|| state_dir.clone());
|
||||
if let Err(e) = ensure_state_dir(&state_dir, &workspace_dir) {
|
||||
warn!(agent_id = %agent_id, "Failed to backfill state_dir: {e}");
|
||||
}
|
||||
if let Err(e) = ensure_workspace(&workspace_dir) {
|
||||
warn!(agent_id = %agent_id, "Failed to backfill workspace: {e}");
|
||||
} else {
|
||||
manifest.state_dir = Some(state_dir);
|
||||
manifest.workspace = Some(workspace_dir);
|
||||
// Persist updated workspace in registry
|
||||
let _ = self
|
||||
.registry
|
||||
.update_workspace(agent_id, manifest.workspace.clone());
|
||||
let _ = self
|
||||
.registry
|
||||
.update_state_dir(agent_id, manifest.state_dir.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2697,17 +2798,17 @@ impl OpenFangKernel {
|
||||
},
|
||||
workspace_path: manifest.workspace.as_ref().map(|p| p.display().to_string()),
|
||||
soul_md: manifest
|
||||
.workspace
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.and_then(|w| read_identity_file(w, "SOUL.md")),
|
||||
.and_then(|s| read_identity_file(s, "SOUL.md")),
|
||||
user_md: manifest
|
||||
.workspace
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.and_then(|w| read_identity_file(w, "USER.md")),
|
||||
.and_then(|s| read_identity_file(s, "USER.md")),
|
||||
memory_md: manifest
|
||||
.workspace
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.and_then(|w| read_identity_file(w, "MEMORY.md")),
|
||||
.and_then(|s| read_identity_file(s, "MEMORY.md")),
|
||||
canonical_context: self
|
||||
.memory
|
||||
.canonical_context(agent_id, None)
|
||||
@@ -2722,27 +2823,27 @@ impl OpenFangKernel {
|
||||
.unwrap_or(false),
|
||||
is_autonomous: manifest.autonomous.is_some(),
|
||||
agents_md: manifest
|
||||
.workspace
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.and_then(|w| read_identity_file(w, "AGENTS.md")),
|
||||
.and_then(|s| read_identity_file(s, "AGENTS.md")),
|
||||
bootstrap_md: manifest
|
||||
.workspace
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.and_then(|w| read_identity_file(w, "BOOTSTRAP.md")),
|
||||
.and_then(|s| read_identity_file(s, "BOOTSTRAP.md")),
|
||||
workspace_context: manifest.workspace.as_ref().map(|w| {
|
||||
let mut ws_ctx =
|
||||
openfang_runtime::workspace_context::WorkspaceContext::detect(w);
|
||||
ws_ctx.build_context_section()
|
||||
}),
|
||||
identity_md: manifest
|
||||
.workspace
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.and_then(|w| read_identity_file(w, "IDENTITY.md")),
|
||||
.and_then(|s| read_identity_file(s, "IDENTITY.md")),
|
||||
heartbeat_md: if manifest.autonomous.is_some() {
|
||||
manifest
|
||||
.workspace
|
||||
.state_dir
|
||||
.as_ref()
|
||||
.and_then(|w| read_identity_file(w, "HEARTBEAT.md"))
|
||||
.and_then(|s| read_identity_file(s, "HEARTBEAT.md"))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
@@ -2880,16 +2981,17 @@ impl OpenFangKernel {
|
||||
}
|
||||
}
|
||||
|
||||
// Write JSONL session mirror to workspace
|
||||
if let Some(ref workspace) = manifest.workspace {
|
||||
// Write JSONL session mirror and daily memory log to the agent's
|
||||
// private state directory, not the user-facing workspace. See #1097.
|
||||
if let Some(ref state_dir) = manifest.state_dir {
|
||||
if let Err(e) = self
|
||||
.memory
|
||||
.write_jsonl_mirror(&session, &workspace.join("sessions"))
|
||||
.write_jsonl_mirror(&session, &state_dir.join("sessions"))
|
||||
{
|
||||
warn!("Failed to write JSONL session mirror: {e}");
|
||||
}
|
||||
// Append daily memory log (best-effort)
|
||||
append_daily_memory_log(workspace, &result.response);
|
||||
append_daily_memory_log(state_dir, &result.response);
|
||||
}
|
||||
|
||||
// Record usage in the metering engine (uses catalog pricing as single source of truth)
|
||||
@@ -5216,26 +5318,93 @@ impl OpenFangKernel {
|
||||
}
|
||||
|
||||
/// Collect every provider ID the operator has actually referenced in
|
||||
/// their effective config — the default model, every fallback chain
|
||||
/// entry, every `[provider_urls]` key, and every registered agent's
|
||||
/// manifest provider (including per-agent `fallback_models`). Used by
|
||||
/// the local provider probe loop so that we don't spam `WARN Local
|
||||
/// provider offline` for providers the user never asked about (#1031).
|
||||
/// their effective config. Walks the surfaces below so the local
|
||||
/// provider probe loop does not spam `WARN Local provider offline`
|
||||
/// for providers the user never asked about (#1031, #1188):
|
||||
///
|
||||
/// 1. Default model (boot config + hot-reload override).
|
||||
/// 2. Global fallback chain (boot config + hot-reload override).
|
||||
/// 3. Explicit `[provider_urls]` keys.
|
||||
/// 4. Every registered agent manifest provider + per-agent
|
||||
/// `fallback_models`.
|
||||
/// 5. Catalog-resolved aliases — model names on default/fallback/manifest
|
||||
/// that resolve to a different concrete provider via the catalog.
|
||||
/// 6. Per-channel `overrides.model` for every enabled channel adapter,
|
||||
/// resolved through the model catalog.
|
||||
/// 7. Bundled and user-installed skills — tags that match a known
|
||||
/// provider ID, and `config` variables whose `env` matches a known
|
||||
/// provider's `api_key_env`.
|
||||
/// 8. MCP server configs — `env` entries that match a known provider's
|
||||
/// `api_key_env`.
|
||||
fn referenced_providers(&self) -> std::collections::HashSet<String> {
|
||||
let mut set: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
// Snapshot catalog lookups up front so we don't keep the lock across
|
||||
// long iterations. Provider IDs are lowercased model-side already.
|
||||
let (provider_ids, env_to_provider) = {
|
||||
let catalog = self
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
|
||||
let ids: std::collections::HashSet<String> = catalog
|
||||
.list_providers()
|
||||
.iter()
|
||||
.map(|p| p.id.clone())
|
||||
.collect();
|
||||
// Multi-valued: several providers may share the same api_key_env
|
||||
// (e.g. both `openai` and `codex` use OPENAI_API_KEY). Using a
|
||||
// plain HashMap silently dropped earlier providers — broke #1188.
|
||||
let mut env_map: std::collections::HashMap<String, Vec<String>> =
|
||||
std::collections::HashMap::new();
|
||||
for p in catalog.list_providers() {
|
||||
if p.api_key_env.is_empty() {
|
||||
continue;
|
||||
}
|
||||
env_map
|
||||
.entry(p.api_key_env.to_ascii_uppercase())
|
||||
.or_default()
|
||||
.push(p.id.clone());
|
||||
}
|
||||
(ids, env_map)
|
||||
};
|
||||
|
||||
// Resolve a model name through the catalog and add the concrete
|
||||
// provider it lives on. Lets us catch alias-only references where
|
||||
// the surrounding `provider` field is "default" or empty (#1188).
|
||||
let add_model = |set: &mut std::collections::HashSet<String>, name: &str| {
|
||||
if name.is_empty() || name == "default" {
|
||||
return;
|
||||
}
|
||||
let catalog = self
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
|
||||
if let Some(entry) = catalog.find_model(name) {
|
||||
let p = &entry.provider;
|
||||
if !p.is_empty() && p != "default" {
|
||||
set.insert(p.clone());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Default model — respect hot-reloaded override.
|
||||
let override_guard = self
|
||||
.default_model_override
|
||||
.read()
|
||||
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
|
||||
let dm_provider = override_guard
|
||||
let (dm_provider, dm_model) = override_guard
|
||||
.as_ref()
|
||||
.map(|dm| dm.provider.clone())
|
||||
.unwrap_or_else(|| self.config.default_model.provider.clone());
|
||||
.map(|dm| (dm.provider.clone(), dm.model.clone()))
|
||||
.unwrap_or_else(|| {
|
||||
(
|
||||
self.config.default_model.provider.clone(),
|
||||
self.config.default_model.model.clone(),
|
||||
)
|
||||
});
|
||||
if !dm_provider.is_empty() && dm_provider != "default" {
|
||||
set.insert(dm_provider);
|
||||
}
|
||||
add_model(&mut set, &dm_model);
|
||||
drop(override_guard);
|
||||
|
||||
// Global fallback chain — respect hot-reloaded override.
|
||||
@@ -5250,6 +5419,7 @@ impl OpenFangKernel {
|
||||
if !fb.provider.is_empty() && fb.provider != "default" {
|
||||
set.insert(fb.provider.clone());
|
||||
}
|
||||
add_model(&mut set, &fb.model);
|
||||
}
|
||||
drop(fb_override);
|
||||
|
||||
@@ -5264,10 +5434,109 @@ impl OpenFangKernel {
|
||||
if !p.is_empty() && p != "default" {
|
||||
set.insert(p.clone());
|
||||
}
|
||||
add_model(&mut set, &entry.manifest.model.model);
|
||||
for fb in &entry.manifest.fallback_models {
|
||||
if !fb.provider.is_empty() && fb.provider != "default" {
|
||||
set.insert(fb.provider.clone());
|
||||
}
|
||||
add_model(&mut set, &fb.model);
|
||||
}
|
||||
}
|
||||
|
||||
// Channel adapters — each enabled channel may pin `overrides.model`
|
||||
// to a specific model, which resolves to a concrete provider through
|
||||
// the catalog. Skip when no override is set.
|
||||
let ch = &self.config.channels;
|
||||
let channel_overrides: [Option<&openfang_types::config::ChannelOverrides>; 43] = [
|
||||
ch.telegram.as_ref().map(|c| &c.overrides),
|
||||
ch.discord.as_ref().map(|c| &c.overrides),
|
||||
ch.slack.as_ref().map(|c| &c.overrides),
|
||||
ch.whatsapp.as_ref().map(|c| &c.overrides),
|
||||
ch.signal.as_ref().map(|c| &c.overrides),
|
||||
ch.matrix.as_ref().map(|c| &c.overrides),
|
||||
ch.email.as_ref().map(|c| &c.overrides),
|
||||
ch.teams.as_ref().map(|c| &c.overrides),
|
||||
ch.mattermost.as_ref().map(|c| &c.overrides),
|
||||
ch.irc.as_ref().map(|c| &c.overrides),
|
||||
ch.google_chat.as_ref().map(|c| &c.overrides),
|
||||
ch.twitch.as_ref().map(|c| &c.overrides),
|
||||
ch.rocketchat.as_ref().map(|c| &c.overrides),
|
||||
ch.zulip.as_ref().map(|c| &c.overrides),
|
||||
ch.xmpp.as_ref().map(|c| &c.overrides),
|
||||
ch.line.as_ref().map(|c| &c.overrides),
|
||||
ch.viber.as_ref().map(|c| &c.overrides),
|
||||
ch.messenger.as_ref().map(|c| &c.overrides),
|
||||
ch.reddit.as_ref().map(|c| &c.overrides),
|
||||
ch.mastodon.as_ref().map(|c| &c.overrides),
|
||||
ch.bluesky.as_ref().map(|c| &c.overrides),
|
||||
ch.feishu.as_ref().map(|c| &c.overrides),
|
||||
ch.revolt.as_ref().map(|c| &c.overrides),
|
||||
ch.nextcloud.as_ref().map(|c| &c.overrides),
|
||||
ch.guilded.as_ref().map(|c| &c.overrides),
|
||||
ch.keybase.as_ref().map(|c| &c.overrides),
|
||||
ch.threema.as_ref().map(|c| &c.overrides),
|
||||
ch.nostr.as_ref().map(|c| &c.overrides),
|
||||
ch.webex.as_ref().map(|c| &c.overrides),
|
||||
ch.pumble.as_ref().map(|c| &c.overrides),
|
||||
ch.flock.as_ref().map(|c| &c.overrides),
|
||||
ch.twist.as_ref().map(|c| &c.overrides),
|
||||
ch.mumble.as_ref().map(|c| &c.overrides),
|
||||
ch.dingtalk.as_ref().map(|c| &c.overrides),
|
||||
ch.dingtalk_stream.as_ref().map(|c| &c.overrides),
|
||||
ch.discourse.as_ref().map(|c| &c.overrides),
|
||||
ch.gitter.as_ref().map(|c| &c.overrides),
|
||||
ch.ntfy.as_ref().map(|c| &c.overrides),
|
||||
ch.gotify.as_ref().map(|c| &c.overrides),
|
||||
ch.webhook.as_ref().map(|c| &c.overrides),
|
||||
ch.linkedin.as_ref().map(|c| &c.overrides),
|
||||
ch.wecom.as_ref().map(|c| &c.overrides),
|
||||
ch.mqtt.as_ref().map(|c| &c.overrides),
|
||||
];
|
||||
for overrides in channel_overrides.iter().flatten() {
|
||||
if let Some(model) = overrides.model.as_deref() {
|
||||
add_model(&mut set, model);
|
||||
}
|
||||
}
|
||||
|
||||
// Skills — bundled + user-installed. Two indirect provider hints:
|
||||
// 1. Tag matching a known provider ID (e.g. tag "openai" on a
|
||||
// skill that drives the OpenAI API).
|
||||
// 2. A declared config variable whose `env` matches a known
|
||||
// provider's `api_key_env` (e.g. env = "OPENAI_API_KEY"
|
||||
// → openai).
|
||||
let skill_registry = self
|
||||
.skill_registry
|
||||
.read()
|
||||
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
|
||||
for skill in skill_registry.list() {
|
||||
for tag in &skill.manifest.skill.tags {
|
||||
let lower = tag.to_ascii_lowercase();
|
||||
if provider_ids.contains(&lower) {
|
||||
set.insert(lower);
|
||||
}
|
||||
}
|
||||
for var in skill.manifest.config.values() {
|
||||
if let Some(env_name) = var.env.as_deref() {
|
||||
if let Some(providers) = env_to_provider.get(&env_name.to_ascii_uppercase()) {
|
||||
for provider in providers {
|
||||
set.insert(provider.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(skill_registry);
|
||||
|
||||
// MCP server configs — each entry's `env` allowlist may include a
|
||||
// provider's API key env var, which is enough evidence the operator
|
||||
// wired that provider into their MCP server.
|
||||
for server in &self.config.mcp_servers {
|
||||
for env_name in &server.env {
|
||||
if let Some(providers) = env_to_provider.get(&env_name.to_ascii_uppercase()) {
|
||||
for provider in providers {
|
||||
set.insert(provider.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6155,7 +6424,16 @@ impl OpenFangKernel {
|
||||
summary.push_str(&format!("- {name}: {desc} [tools: {}]\n", tools.join(", ")));
|
||||
}
|
||||
}
|
||||
summary.push_str("Use these skill tools when they match the user's request.");
|
||||
// Issue #1038: skill directories (e.g. ~/.openfang/skills/) live OUTSIDE
|
||||
// the workspace sandbox. Tell the agent to use the dedicated skill_*
|
||||
// tools instead of falling back to file_read / shell_exec to inspect them.
|
||||
summary.push_str(
|
||||
"Use these skill tools when they match the user's request. \
|
||||
To inspect a skill's full instructions, call skill_describe with the skill name — \
|
||||
do NOT use file_read or shell_exec on the skills directory, those paths are \
|
||||
outside the agent workspace and will fail. \
|
||||
Use skill_list to enumerate skills and skill_execute to run a skill's tool.",
|
||||
);
|
||||
summary
|
||||
}
|
||||
|
||||
@@ -7685,6 +7963,7 @@ mod tests {
|
||||
autonomous: None,
|
||||
pinned_model: None,
|
||||
workspace: None,
|
||||
state_dir: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
@@ -7728,6 +8007,7 @@ mod tests {
|
||||
autonomous: None,
|
||||
pinned_model: None,
|
||||
workspace: Some(std::path::PathBuf::from("/var/lib/openfang/agents/demo")),
|
||||
state_dir: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: Some(ExecPolicy::default()),
|
||||
tool_allowlist: vec![],
|
||||
@@ -7744,8 +8024,7 @@ mod tests {
|
||||
|
||||
assert_eq!(merged.description, "new", "TOML edits must apply");
|
||||
assert_eq!(
|
||||
merged.workspace,
|
||||
entry.workspace,
|
||||
merged.workspace, entry.workspace,
|
||||
"kernel-assigned workspace must survive a TOML edit that omits it"
|
||||
);
|
||||
assert!(
|
||||
@@ -7779,6 +8058,7 @@ mod tests {
|
||||
autonomous: None,
|
||||
pinned_model: None,
|
||||
workspace: Some(std::path::PathBuf::from("/old")),
|
||||
state_dir: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
@@ -7835,6 +8115,7 @@ mod tests {
|
||||
autonomous: None,
|
||||
pinned_model: None,
|
||||
workspace: None,
|
||||
state_dir: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: Some(cached_policy.clone()),
|
||||
tool_allowlist: vec![],
|
||||
@@ -7948,6 +8229,7 @@ mod tests {
|
||||
autonomous: None,
|
||||
pinned_model: None,
|
||||
workspace: None,
|
||||
state_dir: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
@@ -8157,9 +8439,7 @@ mod tests {
|
||||
let first_instance_id = instance.instance_id;
|
||||
|
||||
// Sanity: hand is Active and re-activation is rejected.
|
||||
assert!(kernel
|
||||
.activate_hand("lead", HashMap::new(), None)
|
||||
.is_err());
|
||||
assert!(kernel.activate_hand("lead", HashMap::new(), None).is_err());
|
||||
|
||||
// Simulate what POST /api/agents/{id}/stop now does for a hand-owned
|
||||
// agent: look up the instance and deactivate the hand (which also
|
||||
@@ -8599,7 +8879,8 @@ mod tests {
|
||||
"fallback timeout edits must be hot-reloadable"
|
||||
);
|
||||
assert!(
|
||||
plan.hot_actions.contains(&HotAction::ReloadFallbackProviders),
|
||||
plan.hot_actions
|
||||
.contains(&HotAction::ReloadFallbackProviders),
|
||||
"ReloadFallbackProviders must be present in the plan"
|
||||
);
|
||||
|
||||
@@ -8791,6 +9072,185 @@ mod tests {
|
||||
kernel.shutdown();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Issue #1188: referenced_providers() must also walk MCP server configs,
|
||||
// skill manifests, channel adapters, and catalog aliases. Otherwise the
|
||||
// probe loop still spams "Local provider offline" for providers that are
|
||||
// referenced indirectly. Each block below pins one new surface so a
|
||||
// regression on any single surface fails its own assertion.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_1188_referenced_providers_resolves_alias_to_provider() {
|
||||
use openfang_types::config::DefaultModelConfig;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home_dir = tmp.path().join("openfang-1188-alias");
|
||||
std::fs::create_dir_all(&home_dir).unwrap();
|
||||
|
||||
// Operator sets provider = "default" and picks the model by its
|
||||
// builtin catalog alias "opus", which resolves to provider
|
||||
// "anthropic". The literal provider field is "default", so the
|
||||
// pre-fix walker would not have added anthropic.
|
||||
let config = KernelConfig {
|
||||
home_dir: home_dir.clone(),
|
||||
data_dir: home_dir.join("data"),
|
||||
default_model: DefaultModelConfig {
|
||||
provider: "default".to_string(),
|
||||
model: "opus".to_string(),
|
||||
api_key_env: "ANTHROPIC_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
|
||||
let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots");
|
||||
let referenced = kernel.referenced_providers();
|
||||
assert!(
|
||||
referenced.contains("anthropic"),
|
||||
"alias 'opus' must resolve to anthropic ({referenced:?})"
|
||||
);
|
||||
kernel.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_1188_referenced_providers_walks_channel_overrides() {
|
||||
use openfang_types::config::{
|
||||
ChannelOverrides, ChannelsConfig, DefaultModelConfig, TelegramConfig,
|
||||
};
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home_dir = tmp.path().join("openfang-1188-channel");
|
||||
std::fs::create_dir_all(&home_dir).unwrap();
|
||||
|
||||
let overrides = ChannelOverrides {
|
||||
model: Some("opus".to_string()),
|
||||
..ChannelOverrides::default()
|
||||
};
|
||||
|
||||
let config = KernelConfig {
|
||||
home_dir: home_dir.clone(),
|
||||
data_dir: home_dir.join("data"),
|
||||
default_model: DefaultModelConfig {
|
||||
provider: "groq".to_string(),
|
||||
model: "llama-3.1-70b".to_string(),
|
||||
api_key_env: "GROQ_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
channels: ChannelsConfig {
|
||||
telegram: Some(TelegramConfig {
|
||||
overrides,
|
||||
..TelegramConfig::default()
|
||||
}),
|
||||
..ChannelsConfig::default()
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
|
||||
let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots");
|
||||
let referenced = kernel.referenced_providers();
|
||||
assert!(
|
||||
referenced.contains("anthropic"),
|
||||
"channel override 'opus' must pull in anthropic ({referenced:?})"
|
||||
);
|
||||
kernel.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_1188_referenced_providers_walks_mcp_env() {
|
||||
use openfang_types::config::{DefaultModelConfig, McpServerConfigEntry, McpTransportEntry};
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home_dir = tmp.path().join("openfang-1188-mcp");
|
||||
std::fs::create_dir_all(&home_dir).unwrap();
|
||||
|
||||
// MCP server passes through OPENAI_API_KEY, which is the api_key_env
|
||||
// for the openai provider, so openai must be considered referenced.
|
||||
let config = KernelConfig {
|
||||
home_dir: home_dir.clone(),
|
||||
data_dir: home_dir.join("data"),
|
||||
default_model: DefaultModelConfig {
|
||||
provider: "groq".to_string(),
|
||||
model: "llama-3.1-70b".to_string(),
|
||||
api_key_env: "GROQ_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
mcp_servers: vec![McpServerConfigEntry {
|
||||
name: "openai-proxy".to_string(),
|
||||
transport: McpTransportEntry::Stdio {
|
||||
command: "node".to_string(),
|
||||
args: vec!["proxy.js".to_string()],
|
||||
},
|
||||
timeout_secs: 30,
|
||||
env: vec!["OPENAI_API_KEY".to_string()],
|
||||
headers: vec![],
|
||||
}],
|
||||
..KernelConfig::default()
|
||||
};
|
||||
|
||||
let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots");
|
||||
let referenced = kernel.referenced_providers();
|
||||
assert!(
|
||||
referenced.contains("openai"),
|
||||
"MCP env OPENAI_API_KEY must pull in openai ({referenced:?})"
|
||||
);
|
||||
kernel.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_1188_referenced_providers_walks_skill_tags() {
|
||||
use openfang_types::config::DefaultModelConfig;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home_dir = tmp.path().join("openfang-1188-skill");
|
||||
std::fs::create_dir_all(&home_dir).unwrap();
|
||||
|
||||
let config = KernelConfig {
|
||||
home_dir: home_dir.clone(),
|
||||
data_dir: home_dir.join("data"),
|
||||
default_model: DefaultModelConfig {
|
||||
provider: "groq".to_string(),
|
||||
model: "llama-3.1-70b".to_string(),
|
||||
api_key_env: "GROQ_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
|
||||
let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots");
|
||||
|
||||
// Drop a minimal skill manifest with a tag matching a known
|
||||
// provider ID, then load it through the kernel's registry.
|
||||
let skill_dir = home_dir.join("skills").join("openai-helper");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
let manifest_toml = r#"
|
||||
[skill]
|
||||
name = "openai-helper"
|
||||
version = "0.1.0"
|
||||
description = "test skill"
|
||||
tags = ["openai"]
|
||||
|
||||
[runtime]
|
||||
type = "promptonly"
|
||||
"#;
|
||||
std::fs::write(skill_dir.join("skill.toml"), manifest_toml).unwrap();
|
||||
{
|
||||
let mut reg = kernel.skill_registry.write().unwrap();
|
||||
reg.load_skill(&skill_dir).expect("skill loads");
|
||||
}
|
||||
|
||||
let referenced = kernel.referenced_providers();
|
||||
assert!(
|
||||
referenced.contains("openai"),
|
||||
"skill tag 'openai' must pull in openai ({referenced:?})"
|
||||
);
|
||||
kernel.shutdown();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Issue #1140: agents placed at ~/.openfang/agents/<name>/agent.toml
|
||||
// must auto-spawn on boot so they appear in the chat tab.
|
||||
@@ -8878,4 +9338,78 @@ system_prompt = "You are a test agent."
|
||||
|
||||
kernel2.shutdown();
|
||||
}
|
||||
|
||||
/// Regression for #1097: when a user points an agent's workspace at an
|
||||
/// existing directory like `~/Documents`, the runtime must NOT scaffold
|
||||
/// private state into that directory. Identity files (SOUL.md, AGENT.json,
|
||||
/// etc.) and `sessions/` / `memory/` / `logs/` must land in the agent's
|
||||
/// private state directory; only the lightweight user-facing layout
|
||||
/// (`data/`, `output/`, `skills/`) may appear in the workspace.
|
||||
#[test]
|
||||
fn test_workspace_outside_openfang_stays_clean() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let user_workspace = TempDir::new().expect("temp user workspace");
|
||||
let state_dir = TempDir::new().expect("temp state dir");
|
||||
|
||||
// Pre-populate the user workspace with an unrelated file to make sure
|
||||
// we don't trample existing contents either.
|
||||
std::fs::write(user_workspace.path().join("pre-existing.txt"), b"hello")
|
||||
.expect("write pre-existing file");
|
||||
|
||||
let manifest = AgentManifest {
|
||||
name: "ws-test".to_string(),
|
||||
description: "x".to_string(),
|
||||
..AgentManifest::default()
|
||||
};
|
||||
|
||||
// Simulate the spawn path: state dir gets the private layout, user
|
||||
// workspace only gets the lightweight subdirs.
|
||||
ensure_state_dir(state_dir.path(), user_workspace.path()).expect("ensure_state_dir");
|
||||
ensure_workspace(user_workspace.path()).expect("ensure_workspace");
|
||||
generate_identity_files(state_dir.path(), &manifest);
|
||||
|
||||
// Private state files must live in state_dir.
|
||||
for fname in &[
|
||||
"AGENT.json",
|
||||
"SOUL.md",
|
||||
"USER.md",
|
||||
"MEMORY.md",
|
||||
"AGENTS.md",
|
||||
"BOOTSTRAP.md",
|
||||
"IDENTITY.md",
|
||||
] {
|
||||
assert!(
|
||||
state_dir.path().join(fname).exists(),
|
||||
"{fname} should be created in state_dir"
|
||||
);
|
||||
assert!(
|
||||
!user_workspace.path().join(fname).exists(),
|
||||
"{fname} must NOT pollute the user-facing workspace (issue #1097)"
|
||||
);
|
||||
}
|
||||
for subdir in &["sessions", "memory", "logs"] {
|
||||
assert!(
|
||||
state_dir.path().join(subdir).is_dir(),
|
||||
"{subdir}/ should be created in state_dir"
|
||||
);
|
||||
assert!(
|
||||
!user_workspace.path().join(subdir).exists(),
|
||||
"{subdir}/ must NOT pollute the user-facing workspace (issue #1097)"
|
||||
);
|
||||
}
|
||||
|
||||
// The user-facing workspace gets only the lightweight layout.
|
||||
for subdir in &["data", "output", "skills"] {
|
||||
assert!(
|
||||
user_workspace.path().join(subdir).is_dir(),
|
||||
"{subdir}/ should be created in workspace"
|
||||
);
|
||||
}
|
||||
|
||||
// The pre-existing file must still be intact.
|
||||
let contents = std::fs::read_to_string(user_workspace.path().join("pre-existing.txt"))
|
||||
.expect("read pre-existing");
|
||||
assert_eq!(contents, "hello", "must not overwrite user files");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,12 @@ pub struct BudgetStatus {
|
||||
/// Order matters: more specific patterns must come before generic ones
|
||||
/// (e.g. "gpt-4o-mini" before "gpt-4o", "gpt-4.1-mini" before "gpt-4.1").
|
||||
fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
// ── Requesty (issue #995) ──────────────────────────────────
|
||||
// Router-style gateway. IDs are `requesty/<upstream>/<model>` and
|
||||
// resolve via substring match on the upstream model name below
|
||||
// (e.g. "sonnet", "gpt-4o", "gemini", "deepseek", "llama").
|
||||
// No early-return here — fall through to upstream patterns.
|
||||
|
||||
// ── Anthropic ──────────────────────────────────────────────
|
||||
if model.contains("haiku") {
|
||||
return (0.25, 1.25);
|
||||
|
||||
@@ -134,6 +134,23 @@ impl AgentRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's private state directory path. The state directory
|
||||
/// holds identity files, sessions, and per-agent memory and is always
|
||||
/// kept separate from the user-facing workspace. See issue #1097.
|
||||
pub fn update_state_dir(
|
||||
&self,
|
||||
id: AgentId,
|
||||
state_dir: Option<std::path::PathBuf>,
|
||||
) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
.agents
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
|
||||
entry.manifest.state_dir = state_dir;
|
||||
entry.last_active = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's visual identity (emoji, avatar, color).
|
||||
pub fn update_identity(
|
||||
&self,
|
||||
@@ -391,6 +408,7 @@ mod tests {
|
||||
autonomous: None,
|
||||
pinned_model: None,
|
||||
workspace: None,
|
||||
state_dir: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
|
||||
@@ -176,6 +176,7 @@ impl SetupWizard {
|
||||
autonomous: None,
|
||||
pinned_model: None,
|
||||
workspace: None,
|
||||
state_dir: None,
|
||||
generate_identity_files: true,
|
||||
profile: None,
|
||||
fallback_models: vec![],
|
||||
|
||||
@@ -69,8 +69,7 @@ fn env_timeout_secs(var: &str) -> Option<u64> {
|
||||
fn tool_timeout_for(tool_name: &str) -> Option<Duration> {
|
||||
let secs = match tool_name {
|
||||
"agent_send" | "agent_spawn" => {
|
||||
env_timeout_secs("OPENFANG_AGENT_TOOL_TIMEOUT_SECS")
|
||||
.unwrap_or(AGENT_TOOL_TIMEOUT_SECS)
|
||||
env_timeout_secs("OPENFANG_AGENT_TOOL_TIMEOUT_SECS").unwrap_or(AGENT_TOOL_TIMEOUT_SECS)
|
||||
}
|
||||
_ => env_timeout_secs("OPENFANG_TOOL_TIMEOUT_SECS").unwrap_or(TOOL_TIMEOUT_SECS),
|
||||
};
|
||||
@@ -158,22 +157,30 @@ fn build_assistant_message_preserving_thinking(
|
||||
response_blocks: &[ContentBlock],
|
||||
final_text: &str,
|
||||
) -> Message {
|
||||
let has_thinking = response_blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::Thinking { .. }));
|
||||
if !has_thinking {
|
||||
// Key on either Thinking or RedactedThinking — Anthropic/Bedrock both
|
||||
// reject extended-thinking history that drops the redacted variant, so a
|
||||
// turn that contains only RedactedThinking must still be preserved.
|
||||
let has_reasoning = response_blocks.iter().any(|b| {
|
||||
matches!(
|
||||
b,
|
||||
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. }
|
||||
)
|
||||
});
|
||||
if !has_reasoning {
|
||||
return Message::assistant(final_text.to_string());
|
||||
}
|
||||
|
||||
// Preserve order: Thinking blocks first (in original order), then a
|
||||
// single Text block carrying `final_text`. Tool blocks aren't expected
|
||||
// here (StopReason::EndTurn path), but copy them through if present so
|
||||
// we don't drop information.
|
||||
// Preserve order: Thinking / RedactedThinking blocks first (in original
|
||||
// order), then a single Text block carrying `final_text`. Tool blocks
|
||||
// aren't expected here (StopReason::EndTurn path), but copy them through
|
||||
// if present so we don't drop information.
|
||||
let mut blocks: Vec<ContentBlock> = Vec::with_capacity(response_blocks.len() + 1);
|
||||
let mut emitted_text = false;
|
||||
for b in response_blocks {
|
||||
match b {
|
||||
ContentBlock::Thinking { .. } => blocks.push(b.clone()),
|
||||
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {
|
||||
blocks.push(b.clone())
|
||||
}
|
||||
ContentBlock::Text { .. } if !emitted_text => {
|
||||
blocks.push(ContentBlock::Text {
|
||||
text: final_text.to_string(),
|
||||
@@ -3263,7 +3270,9 @@ mod tests {
|
||||
assert_eq!(blocks.len(), 2, "must preserve thinking + text");
|
||||
match &blocks[0] {
|
||||
ContentBlock::Thinking {
|
||||
thinking, signature, ..
|
||||
thinking,
|
||||
signature,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(thinking, "Let me reason carefully...");
|
||||
assert_eq!(signature.as_deref(), Some("sig_anthropic_xyz"));
|
||||
@@ -3359,7 +3368,10 @@ mod tests {
|
||||
let has_redacted = blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::RedactedThinking { .. }));
|
||||
assert!(has_thinking, "Thinking block must be preserved on MaxTokens");
|
||||
assert!(
|
||||
has_thinking,
|
||||
"Thinking block must be preserved on MaxTokens"
|
||||
);
|
||||
assert!(
|
||||
has_redacted,
|
||||
"RedactedThinking block must be preserved on MaxTokens"
|
||||
@@ -3380,6 +3392,36 @@ mod tests {
|
||||
assert_eq!(saved_text, Some(final_text));
|
||||
}
|
||||
|
||||
/// Issue #1187 — a turn that contains only `RedactedThinking` (no
|
||||
/// `Thinking` block) must still trigger the block-preserving path. The
|
||||
/// previous gate keyed solely on `Thinking`, so redacted-only turns were
|
||||
/// downgraded to plain text and the encrypted blob was lost on the next
|
||||
/// request, which Anthropic/Bedrock reject.
|
||||
#[test]
|
||||
fn test_build_assistant_message_preserves_redacted_only() {
|
||||
let response_blocks = vec![
|
||||
ContentBlock::RedactedThinking {
|
||||
data: "encrypted_only".to_string(),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "Answer".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
];
|
||||
let msg = build_assistant_message_preserving_thinking(&response_blocks, "Answer");
|
||||
let blocks = match &msg.content {
|
||||
MessageContent::Blocks(b) => b,
|
||||
other => panic!("expected Blocks content for redacted-only turn, got {other:?}"),
|
||||
};
|
||||
let has_redacted = blocks.iter().any(
|
||||
|b| matches!(b, ContentBlock::RedactedThinking { data } if data == "encrypted_only"),
|
||||
);
|
||||
assert!(
|
||||
has_redacted,
|
||||
"RedactedThinking-only turn must be preserved as Blocks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_constants() {
|
||||
assert_eq!(MAX_RETRIES, 3);
|
||||
|
||||
@@ -189,7 +189,9 @@ enum ContentBlockAccum {
|
||||
/// Redacted (encrypted) thinking block streamed from Anthropic.
|
||||
/// The opaque `data` blob arrives on `content_block_start` and must be
|
||||
/// persisted so the next turn can echo it back verbatim.
|
||||
RedactedThinking { data: String },
|
||||
RedactedThinking {
|
||||
data: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -454,10 +456,8 @@ impl LlmDriver for AnthropicDriver {
|
||||
"thinking" => {
|
||||
// Some API versions ship the signature on
|
||||
// content_block_start instead of as a delta.
|
||||
let initial_sig = block["signature"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let initial_sig =
|
||||
block["signature"].as_str().unwrap_or("").to_string();
|
||||
blocks.push(ContentBlockAccum::Thinking {
|
||||
thinking: String::new(),
|
||||
signature: initial_sig,
|
||||
@@ -470,10 +470,7 @@ impl LlmDriver for AnthropicDriver {
|
||||
// verbatim so we can echo it back on the
|
||||
// next request — API rejects history
|
||||
// that strips redacted_thinking blocks.
|
||||
let data = block["data"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let data = block["data"].as_str().unwrap_or("").to_string();
|
||||
blocks.push(ContentBlockAccum::RedactedThinking { data });
|
||||
}
|
||||
_ => {}
|
||||
@@ -751,9 +748,7 @@ fn convert_message(msg: &Message) -> ApiMessage {
|
||||
if data.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ApiContentBlock::RedactedThinking {
|
||||
data: data.clone(),
|
||||
})
|
||||
Some(ApiContentBlock::RedactedThinking { data: data.clone() })
|
||||
}
|
||||
}
|
||||
ContentBlock::Unknown => None,
|
||||
|
||||
@@ -92,6 +92,20 @@ enum BedrockContentBlock {
|
||||
#[serde(rename = "toolResult")]
|
||||
tool_result: BedrockToolResult,
|
||||
},
|
||||
// Bedrock Converse representation of Anthropic's `redacted_thinking`.
|
||||
// The encrypted blob is echoed back verbatim under
|
||||
// reasoningContent.redactedContent so Claude extended-thinking history
|
||||
// is not rejected on resubmission.
|
||||
ReasoningContent {
|
||||
#[serde(rename = "reasoningContent")]
|
||||
reasoning_content: BedrockReasoningContent,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BedrockReasoningContent {
|
||||
#[serde(rename = "redactedContent")]
|
||||
redacted_content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -299,11 +313,22 @@ fn convert_content_block(block: &ContentBlock) -> Option<BedrockContentBlock> {
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Image, Thinking, RedactedThinking, and Unknown are not supported — silently drop
|
||||
ContentBlock::Image { .. }
|
||||
| ContentBlock::Thinking { .. }
|
||||
| ContentBlock::RedactedThinking { .. }
|
||||
| ContentBlock::Unknown => None,
|
||||
// Echo redacted_thinking verbatim. Bedrock Converse rejects history
|
||||
// that drops these blocks on Claude extended-thinking models, mirroring
|
||||
// the anthropic.rs path. Drop empty blobs (e.g. interrupted stream).
|
||||
ContentBlock::RedactedThinking { data } => {
|
||||
if data.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(BedrockContentBlock::ReasoningContent {
|
||||
reasoning_content: BedrockReasoningContent {
|
||||
redacted_content: data.clone(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
// Image, Thinking, and Unknown are not supported — silently drop
|
||||
ContentBlock::Image { .. } | ContentBlock::Thinking { .. } | ContentBlock::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1126,6 +1151,53 @@ mod tests {
|
||||
assert!(text_at_3 >= 1);
|
||||
}
|
||||
|
||||
/// Issue #1187 — Bedrock Converse history must preserve
|
||||
/// `redacted_thinking` blocks on Claude extended-thinking models.
|
||||
/// A message containing only RedactedThinking must round-trip through
|
||||
/// `convert_content_block` without being silently dropped, and the wire
|
||||
/// format must use `reasoningContent.redactedContent`.
|
||||
#[test]
|
||||
fn test_bedrock_redacted_thinking_round_trip() {
|
||||
let msg = Message::assistant_with_blocks(vec![ContentBlock::RedactedThinking {
|
||||
data: "encrypted-blob-abc123".to_string(),
|
||||
}]);
|
||||
|
||||
let bedrock_blocks = convert_message_content(&msg.content);
|
||||
assert_eq!(
|
||||
bedrock_blocks.len(),
|
||||
1,
|
||||
"RedactedThinking must survive convert_content_block"
|
||||
);
|
||||
match &bedrock_blocks[0] {
|
||||
BedrockContentBlock::ReasoningContent { reasoning_content } => {
|
||||
assert_eq!(reasoning_content.redacted_content, "encrypted-blob-abc123");
|
||||
}
|
||||
other => panic!("expected ReasoningContent block, got {other:?}"),
|
||||
}
|
||||
|
||||
// Wire format check: serialized JSON must carry
|
||||
// reasoningContent.redactedContent so Bedrock accepts the history.
|
||||
let json = serde_json::to_value(&bedrock_blocks[0]).unwrap();
|
||||
assert_eq!(
|
||||
json["reasoningContent"]["redactedContent"],
|
||||
"encrypted-blob-abc123"
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty RedactedThinking blobs (interrupted stream) must be dropped on
|
||||
/// outbound, matching the anthropic.rs behavior.
|
||||
#[test]
|
||||
fn test_bedrock_redacted_thinking_empty_dropped() {
|
||||
let msg = Message::assistant_with_blocks(vec![ContentBlock::RedactedThinking {
|
||||
data: String::new(),
|
||||
}]);
|
||||
let bedrock_blocks = convert_message_content(&msg.content);
|
||||
assert!(
|
||||
bedrock_blocks.is_empty(),
|
||||
"empty redacted_thinking must be dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_tool_pairing_noop_on_correct() {
|
||||
// already correct 2-for-2 → no change
|
||||
|
||||
@@ -21,9 +21,9 @@ use openfang_types::model_catalog::{
|
||||
HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL,
|
||||
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NOVITA_BASE_URL, NVIDIA_NIM_BASE_URL,
|
||||
OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL,
|
||||
QWEN_BASE_URL, REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL,
|
||||
VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
|
||||
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
QWEN_BASE_URL, REPLICATE_BASE_URL, REQUESTY_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL,
|
||||
VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL,
|
||||
ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -35,6 +35,64 @@ struct ProviderDefaults {
|
||||
key_required: bool,
|
||||
}
|
||||
|
||||
/// Resolve an OpenAI-compatible base URL for a local/self-hosted provider from
|
||||
/// well-known environment variables. Returns `None` if no override is set.
|
||||
///
|
||||
/// This lets users point Ollama / LM Studio / vLLM / Lemonade at a remote host
|
||||
/// (VPS, LXC, another box on the LAN) without editing `~/.openfang/config.toml`.
|
||||
///
|
||||
/// Recognised variables:
|
||||
/// - `ollama` → `OLLAMA_BASE_URL`, then `OLLAMA_HOST` (Ollama CLI convention)
|
||||
/// - `lmstudio` → `LMSTUDIO_BASE_URL`, then `LMSTUDIO_HOST`
|
||||
/// - `vllm` → `VLLM_BASE_URL`, then `VLLM_HOST`
|
||||
/// - `lemonade` → `LEMONADE_BASE_URL`, then `LEMONADE_HOST`
|
||||
///
|
||||
/// `*_HOST` values may omit the scheme and the `/v1` suffix
|
||||
/// (e.g. `OLLAMA_HOST=192.168.1.50:11434`); both are normalised.
|
||||
pub fn local_provider_url_from_env(provider: &str) -> Option<String> {
|
||||
fn read(var: &str) -> Option<String> {
|
||||
std::env::var(var)
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Normalise a host-style value into a full OpenAI-compatible base URL.
|
||||
/// - Adds `http://` if no scheme is present.
|
||||
/// - Appends `/v1` if not already present in the path.
|
||||
fn normalize(raw: &str) -> String {
|
||||
let mut url = if raw.contains("://") {
|
||||
raw.trim_end_matches('/').to_string()
|
||||
} else {
|
||||
format!("http://{}", raw.trim_end_matches('/'))
|
||||
};
|
||||
// Add /v1 suffix if missing (OpenAI-compatible endpoints expect it).
|
||||
// Be lenient: accept either `/v1` or `/v1/` already in place, and also
|
||||
// `/openai/v1` style proxies.
|
||||
let lower = url.to_lowercase();
|
||||
if !lower.ends_with("/v1") && !lower.contains("/v1/") {
|
||||
url.push_str("/v1");
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
let (primary, host_fallback) = match provider {
|
||||
"ollama" => ("OLLAMA_BASE_URL", "OLLAMA_HOST"),
|
||||
"lmstudio" => ("LMSTUDIO_BASE_URL", "LMSTUDIO_HOST"),
|
||||
"vllm" => ("VLLM_BASE_URL", "VLLM_HOST"),
|
||||
"lemonade" => ("LEMONADE_BASE_URL", "LEMONADE_HOST"),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
if let Some(v) = read(primary) {
|
||||
return Some(normalize(&v));
|
||||
}
|
||||
if let Some(v) = read(host_fallback) {
|
||||
return Some(normalize(&v));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get defaults for known providers.
|
||||
fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
match provider {
|
||||
@@ -48,6 +106,11 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
api_key_env: "OPENROUTER_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"requesty" => Some(ProviderDefaults {
|
||||
base_url: REQUESTY_BASE_URL,
|
||||
api_key_env: "REQUESTY_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"deepseek" => Some(ProviderDefaults {
|
||||
base_url: DEEPSEEK_BASE_URL,
|
||||
api_key_env: "DEEPSEEK_API_KEY",
|
||||
@@ -473,9 +536,14 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
|
||||
)));
|
||||
}
|
||||
|
||||
// Precedence for the base URL:
|
||||
// 1. Explicit `DriverConfig.base_url` (from config.toml or `[provider_urls]`)
|
||||
// 2. Well-known env vars for local providers (`OLLAMA_HOST`, etc.) — issue #1154
|
||||
// 3. Hard-coded provider default (localhost for ollama/lmstudio/vllm/lemonade)
|
||||
let base_url = config
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| local_provider_url_from_env(provider))
|
||||
.unwrap_or_else(|| defaults.base_url.to_string());
|
||||
|
||||
return Ok(Arc::new(openai::OpenAIDriver::new(api_key, base_url)));
|
||||
@@ -629,13 +697,24 @@ pub fn known_providers() -> &'static [&'static str] {
|
||||
]
|
||||
}
|
||||
|
||||
/// Cross-module env-var serialisation lock for tests that mutate process env.
|
||||
///
|
||||
/// Several tests in this crate (drivers, model_catalog) set/unset the same
|
||||
/// `OLLAMA_*` / `LMSTUDIO_*` env vars and would race under cargo's parallel
|
||||
/// test runner. Anything that mutates those vars must hold this lock.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn env_lock_for_tests() -> &'static std::sync::Mutex<()> {
|
||||
use std::ops::Deref;
|
||||
tests::ENV_LOCK.deref()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
pub(super) static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
@@ -1118,4 +1197,114 @@ mod tests {
|
||||
"unparseable env override should fall through to config field"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Issue #1154: env-var URL overrides for local providers ──
|
||||
|
||||
#[test]
|
||||
fn test_local_url_env_ollama_host_normalised() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _g1 = EnvVarGuard::remove("OLLAMA_BASE_URL");
|
||||
let _g2 = EnvVarGuard::set("OLLAMA_HOST", "192.168.1.50:11434");
|
||||
let url = local_provider_url_from_env("ollama").expect("env should resolve");
|
||||
assert_eq!(url, "http://192.168.1.50:11434/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_url_env_ollama_base_url_wins() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _g1 = EnvVarGuard::set("OLLAMA_BASE_URL", "https://llm.example.com/v1");
|
||||
let _g2 = EnvVarGuard::set("OLLAMA_HOST", "should-be-ignored:11434");
|
||||
let url = local_provider_url_from_env("ollama").expect("env should resolve");
|
||||
assert_eq!(url, "https://llm.example.com/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_url_env_lmstudio() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _g1 = EnvVarGuard::remove("LMSTUDIO_BASE_URL");
|
||||
let _g2 = EnvVarGuard::set("LMSTUDIO_HOST", "http://10.0.0.5:1234");
|
||||
let url = local_provider_url_from_env("lmstudio").expect("env should resolve");
|
||||
assert_eq!(url, "http://10.0.0.5:1234/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_url_env_vllm() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _g1 = EnvVarGuard::remove("VLLM_BASE_URL");
|
||||
let _g2 = EnvVarGuard::set("VLLM_HOST", "vps.internal:8000");
|
||||
let url = local_provider_url_from_env("vllm").expect("env should resolve");
|
||||
assert_eq!(url, "http://vps.internal:8000/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_url_env_unset_returns_none() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _g1 = EnvVarGuard::remove("OLLAMA_BASE_URL");
|
||||
let _g2 = EnvVarGuard::remove("OLLAMA_HOST");
|
||||
assert!(local_provider_url_from_env("ollama").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_url_env_only_for_local_providers() {
|
||||
// Cloud providers should never resolve via these helpers — they have
|
||||
// their own *_API_KEY conventions and a fixed cloud base URL.
|
||||
assert!(local_provider_url_from_env("openai").is_none());
|
||||
assert!(local_provider_url_from_env("anthropic").is_none());
|
||||
assert!(local_provider_url_from_env("groq").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_url_env_preserves_existing_v1_suffix() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _g1 = EnvVarGuard::set("OLLAMA_BASE_URL", "http://1.2.3.4:11434/v1");
|
||||
let _g2 = EnvVarGuard::remove("OLLAMA_HOST");
|
||||
let url = local_provider_url_from_env("ollama").expect("env should resolve");
|
||||
assert_eq!(url, "http://1.2.3.4:11434/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_driver_ollama_uses_env_host() {
|
||||
// End-to-end: when no explicit base_url and no OLLAMA_API_KEY, the
|
||||
// driver should be constructed pointed at the env-supplied host.
|
||||
// (We can't introspect the OpenAIDriver's base_url directly, but
|
||||
// construction succeeds — separate unit covers URL resolution.)
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _g1 = EnvVarGuard::remove("OLLAMA_BASE_URL");
|
||||
let _g2 = EnvVarGuard::set("OLLAMA_HOST", "10.20.30.40:11434");
|
||||
let _g3 = EnvVarGuard::remove("OLLAMA_API_KEY");
|
||||
|
||||
let config = DriverConfig {
|
||||
provider: "ollama".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(
|
||||
driver.is_ok(),
|
||||
"ollama with OLLAMA_HOST set and no API key should construct: {:?}",
|
||||
driver.err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_driver_lmstudio_no_key_no_env_still_works() {
|
||||
// Pre-#1154 regression guard: lmstudio with no env vars and no API key
|
||||
// should still construct (falls back to localhost default).
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _g1 = EnvVarGuard::remove("LMSTUDIO_BASE_URL");
|
||||
let _g2 = EnvVarGuard::remove("LMSTUDIO_HOST");
|
||||
let _g3 = EnvVarGuard::remove("LMSTUDIO_API_KEY");
|
||||
|
||||
let config = DriverConfig {
|
||||
provider: "lmstudio".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
skip_permissions: true,
|
||||
subprocess_timeout_secs: None,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(driver.is_ok(), "lmstudio default should construct");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,8 +460,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Convert messages
|
||||
@@ -474,8 +474,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
(Role::User, MessageContent::Text(text)) => {
|
||||
oai_messages.push(OaiMessage {
|
||||
@@ -484,8 +484,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
(Role::Assistant, MessageContent::Text(text)) => {
|
||||
oai_messages.push(OaiMessage {
|
||||
@@ -494,8 +494,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
(Role::User, MessageContent::Blocks(blocks)) => {
|
||||
// Handle tool results and images in user messages
|
||||
@@ -519,8 +519,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_use_id.clone()),
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
ContentBlock::Text { text, .. } => {
|
||||
parts.push(OaiContentPart::Text { text: text.clone() });
|
||||
@@ -543,8 +543,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
(Role::Assistant, MessageContent::Blocks(blocks)) => {
|
||||
@@ -915,8 +915,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
|
||||
for msg in &request.messages {
|
||||
@@ -928,8 +928,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
(Role::User, MessageContent::Text(text)) => {
|
||||
oai_messages.push(OaiMessage {
|
||||
@@ -938,8 +938,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
(Role::Assistant, MessageContent::Text(text)) => {
|
||||
oai_messages.push(OaiMessage {
|
||||
@@ -948,8 +948,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
(Role::User, MessageContent::Blocks(blocks)) => {
|
||||
for block in blocks {
|
||||
@@ -969,8 +969,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_use_id.clone()),
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
});
|
||||
reasoning: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1492,7 +1492,9 @@ impl LlmDriver for OpenAIDriver {
|
||||
// non-zero output_tokens so the agent loop doesn't misclassify
|
||||
// this as a "silent failure" and loop unnecessarily.
|
||||
if !content.is_empty() && usage.input_tokens == 0 && usage.output_tokens == 0 {
|
||||
debug!("Stream has content but no usage stats — setting synthetic output_tokens=1");
|
||||
debug!(
|
||||
"Stream has content but no usage stats — setting synthetic output_tokens=1"
|
||||
);
|
||||
usage.output_tokens = 1;
|
||||
}
|
||||
|
||||
@@ -1956,10 +1958,7 @@ mod tests {
|
||||
/// the upstream server is pre- or post-vLLM 0.19.
|
||||
#[test]
|
||||
fn test_assemble_emits_both_reasoning_fields_for_vllm_compat() {
|
||||
let driver = OpenAIDriver::new(
|
||||
"test".to_string(),
|
||||
"http://localhost:8000/v1".to_string(),
|
||||
);
|
||||
let driver = OpenAIDriver::new("test".to_string(), "http://localhost:8000/v1".to_string());
|
||||
let blocks = vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "MARKER-vllm-019".to_string(),
|
||||
@@ -1994,8 +1993,7 @@ mod tests {
|
||||
/// change in #1157.
|
||||
#[test]
|
||||
fn test_assemble_no_reasoning_fields_for_plain_model() {
|
||||
let driver =
|
||||
OpenAIDriver::new("test".to_string(), "https://api.openai.com/v1".to_string());
|
||||
let driver = OpenAIDriver::new("test".to_string(), "https://api.openai.com/v1".to_string());
|
||||
let blocks = vec![ContentBlock::Text {
|
||||
text: "hi".to_string(),
|
||||
provider_metadata: None,
|
||||
@@ -2014,18 +2012,14 @@ mod tests {
|
||||
/// understand the new name.
|
||||
#[test]
|
||||
fn test_assemble_moonshot_keeps_legacy_field_only() {
|
||||
let driver = OpenAIDriver::new(
|
||||
"test".to_string(),
|
||||
"https://api.moonshot.cn/v1".to_string(),
|
||||
);
|
||||
let blocks = vec![
|
||||
ContentBlock::ToolUse {
|
||||
id: "call_1".to_string(),
|
||||
name: "search".to_string(),
|
||||
input: serde_json::json!({"q": "x"}),
|
||||
provider_metadata: None,
|
||||
},
|
||||
];
|
||||
let driver =
|
||||
OpenAIDriver::new("test".to_string(), "https://api.moonshot.cn/v1".to_string());
|
||||
let blocks = vec![ContentBlock::ToolUse {
|
||||
id: "call_1".to_string(),
|
||||
name: "search".to_string(),
|
||||
input: serde_json::json!({"q": "x"}),
|
||||
provider_metadata: None,
|
||||
}];
|
||||
let msg = assemble_assistant_message(&blocks, "kimi-k2", &driver);
|
||||
assert_eq!(
|
||||
msg.reasoning_content.as_deref(),
|
||||
@@ -2174,7 +2168,10 @@ mod tests {
|
||||
Some(OaiMessageContent::Text(t)) => t,
|
||||
_ => panic!("expected text content"),
|
||||
};
|
||||
assert_eq!(content, "answer", "visible content must not include <think>");
|
||||
assert_eq!(
|
||||
content, "answer",
|
||||
"visible content must not include <think>"
|
||||
);
|
||||
assert_eq!(
|
||||
msg.reasoning_content.as_deref(),
|
||||
Some("internal chain-of-thought"),
|
||||
@@ -2186,8 +2183,7 @@ mod tests {
|
||||
/// assistant message — preserve the legacy shape.
|
||||
#[test]
|
||||
fn test_assemble_assistant_no_thinking_is_plain() {
|
||||
let driver =
|
||||
OpenAIDriver::new("test".to_string(), "https://api.openai.com/v1".to_string());
|
||||
let driver = OpenAIDriver::new("test".to_string(), "https://api.openai.com/v1".to_string());
|
||||
let blocks = vec![ContentBlock::Text {
|
||||
text: "Hi.".to_string(),
|
||||
provider_metadata: None,
|
||||
|
||||
@@ -258,7 +258,6 @@ fn host_net_fetch(state: &GuestState, params: &serde_json::Value) -> serde_json:
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell (capability-checked)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -561,7 +560,10 @@ mod tests {
|
||||
assert!(web_fetch::check_ssrf("http://127.0.0.1:8080/secret", &no_allow).is_err());
|
||||
assert!(web_fetch::check_ssrf("http://localhost:3000/api", &no_allow).is_err());
|
||||
assert!(web_fetch::check_ssrf("http://169.254.169.254/metadata", &no_allow).is_err());
|
||||
assert!(web_fetch::check_ssrf("http://metadata.google.internal/v1/instance", &no_allow).is_err());
|
||||
assert!(
|
||||
web_fetch::check_ssrf("http://metadata.google.internal/v1/instance", &no_allow)
|
||||
.is_err()
|
||||
);
|
||||
// These were previously missing from host_functions — now covered:
|
||||
assert!(web_fetch::check_ssrf("http://[::1]:8080/secret", &no_allow).is_err());
|
||||
assert!(web_fetch::check_ssrf("http://100.100.100.200/metadata", &no_allow).is_err());
|
||||
|
||||
@@ -10,8 +10,8 @@ use openfang_types::model_catalog::{
|
||||
HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL,
|
||||
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL,
|
||||
OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
|
||||
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
|
||||
REPLICATE_BASE_URL, REQUESTY_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL,
|
||||
VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
|
||||
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
@@ -339,6 +339,27 @@ impl ModelCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply environment-variable URL overrides for local providers.
|
||||
///
|
||||
/// Honours the same env vars the drivers respect (see
|
||||
/// `drivers::local_provider_url_from_env`): `OLLAMA_HOST` / `OLLAMA_BASE_URL`,
|
||||
/// `LMSTUDIO_HOST` / `LMSTUDIO_BASE_URL`, `VLLM_HOST` / `VLLM_BASE_URL`,
|
||||
/// `LEMONADE_HOST` / `LEMONADE_BASE_URL`. This keeps the dashboard's
|
||||
/// "Providers" view in sync with what the driver actually connects to,
|
||||
/// without requiring users to edit `config.toml` for remote local-LLM hosts
|
||||
/// (VPS, LXC, LAN). See issue #1154.
|
||||
pub fn apply_local_env_overrides(&mut self) {
|
||||
for provider in ["ollama", "lmstudio", "vllm", "lemonade"] {
|
||||
if let Some(url) = crate::drivers::local_provider_url_from_env(provider) {
|
||||
if let Some(p) = self.providers.iter_mut().find(|p| p.id == provider) {
|
||||
p.base_url = url;
|
||||
// A custom host indicates intentional setup, surface it as configured.
|
||||
p.auth_status = AuthStatus::Configured;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a batch of provider URL overrides from config.
|
||||
///
|
||||
/// Each entry maps a provider ID to a custom base URL.
|
||||
@@ -604,6 +625,15 @@ fn builtin_providers() -> Vec<ProviderInfo> {
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
},
|
||||
ProviderInfo {
|
||||
id: "requesty".into(),
|
||||
display_name: "Requesty".into(),
|
||||
api_key_env: "REQUESTY_API_KEY".into(),
|
||||
base_url: REQUESTY_BASE_URL.into(),
|
||||
key_required: true,
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
},
|
||||
ProviderInfo {
|
||||
id: "mistral".into(),
|
||||
display_name: "Mistral AI".into(),
|
||||
@@ -1022,10 +1052,7 @@ fn builtin_aliases() -> HashMap<String, String> {
|
||||
),
|
||||
("free", "openrouter/meta-llama/llama-3.3-70b-instruct:free"),
|
||||
("free-reasoning", "openrouter/deepseek/deepseek-r1:free"),
|
||||
(
|
||||
"openrouter/free-coder",
|
||||
"openrouter/qwen/qwen3-coder:free",
|
||||
),
|
||||
("openrouter/free-coder", "openrouter/qwen/qwen3-coder:free"),
|
||||
(
|
||||
"openrouter/free-large",
|
||||
"openrouter/openai/gpt-oss-120b:free",
|
||||
@@ -2053,6 +2080,80 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec!["hunter-alpha".into()],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Requesty (5) — router-style OpenAI-compatible gateway (issue #995)
|
||||
// Hundreds of upstream models accessible via https://router.requesty.ai/v1
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "requesty/anthropic/claude-sonnet-4".into(),
|
||||
display_name: "Claude Sonnet 4 (Requesty)".into(),
|
||||
provider: "requesty".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 64_000,
|
||||
input_cost_per_m: 3.0,
|
||||
output_cost_per_m: 15.0,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "requesty/openai/gpt-4o".into(),
|
||||
display_name: "GPT-4o (Requesty)".into(),
|
||||
provider: "requesty".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 16_384,
|
||||
input_cost_per_m: 2.5,
|
||||
output_cost_per_m: 10.0,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "requesty/google/gemini-2.5-flash".into(),
|
||||
display_name: "Gemini 2.5 Flash (Requesty)".into(),
|
||||
provider: "requesty".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 1_048_576,
|
||||
max_output_tokens: 65_536,
|
||||
input_cost_per_m: 0.15,
|
||||
output_cost_per_m: 0.60,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "requesty/deepseek/deepseek-chat".into(),
|
||||
display_name: "DeepSeek V3 (Requesty)".into(),
|
||||
provider: "requesty".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 32_768,
|
||||
input_cost_per_m: 0.14,
|
||||
output_cost_per_m: 0.28,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "requesty/meta-llama/llama-3.3-70b-instruct".into(),
|
||||
display_name: "Llama 3.3 70B (Requesty)".into(),
|
||||
provider: "requesty".into(),
|
||||
tier: ModelTier::Balanced,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 32_768,
|
||||
input_cost_per_m: 0.39,
|
||||
output_cost_per_m: 0.39,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Mistral (6)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
@@ -3982,7 +4083,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_catalog_has_providers() {
|
||||
let catalog = ModelCatalog::new();
|
||||
assert_eq!(catalog.list_providers().len(), 41);
|
||||
assert_eq!(catalog.list_providers().len(), 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4611,9 +4712,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_openrouter_free_alias_supports_tools() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let entry = catalog.find_model("openrouter/free").expect(
|
||||
"openrouter/free alias must resolve to a known model",
|
||||
);
|
||||
let entry = catalog
|
||||
.find_model("openrouter/free")
|
||||
.expect("openrouter/free alias must resolve to a known model");
|
||||
assert_eq!(entry.provider, "openrouter");
|
||||
assert!(
|
||||
entry.supports_tools,
|
||||
@@ -4626,9 +4727,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_openrouter_free_short_alias_supports_tools() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let entry = catalog
|
||||
.find_model("free")
|
||||
.expect("free alias must resolve");
|
||||
let entry = catalog.find_model("free").expect("free alias must resolve");
|
||||
assert_eq!(entry.provider, "openrouter");
|
||||
assert!(
|
||||
entry.supports_tools,
|
||||
@@ -4702,4 +4801,66 @@ mod tests {
|
||||
"qwen-2.5-7b-instruct:free has no tool-supporting free endpoint"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Requesty provider (issue #995) ────────────────────────────────────
|
||||
|
||||
/// Requesty must be registered as a provider with the correct base URL
|
||||
/// and env var, and at least one of its catalog models must resolve.
|
||||
#[test]
|
||||
fn test_requesty_provider_and_models_present() {
|
||||
let catalog = ModelCatalog::new();
|
||||
|
||||
let provider = catalog
|
||||
.list_providers()
|
||||
.iter()
|
||||
.find(|p| p.id == "requesty")
|
||||
.expect("requesty provider must be registered");
|
||||
assert_eq!(provider.display_name, "Requesty");
|
||||
assert_eq!(provider.api_key_env, "REQUESTY_API_KEY");
|
||||
assert_eq!(provider.base_url, "https://router.requesty.ai/v1");
|
||||
assert!(provider.key_required);
|
||||
assert!(
|
||||
provider.model_count >= 1,
|
||||
"requesty must have at least one model in catalog"
|
||||
);
|
||||
|
||||
let entry = catalog
|
||||
.find_model("requesty/anthropic/claude-sonnet-4")
|
||||
.expect("requesty/anthropic/claude-sonnet-4 must resolve");
|
||||
assert_eq!(entry.provider, "requesty");
|
||||
assert!(entry.supports_tools);
|
||||
}
|
||||
|
||||
// ── Issue #1154: env-var overrides for local provider URLs ──
|
||||
|
||||
/// Local guard so this catalog test doesn't clash with the driver tests
|
||||
/// that touch the same env vars. We acquire the cross-module lock from
|
||||
/// the drivers module to serialise.
|
||||
#[test]
|
||||
fn test_apply_local_env_overrides_ollama() {
|
||||
// Serialise with driver-side env tests that touch OLLAMA_*.
|
||||
let _lock = crate::drivers::env_lock_for_tests()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let prev_base = std::env::var_os("OLLAMA_BASE_URL");
|
||||
let prev_host = std::env::var_os("OLLAMA_HOST");
|
||||
std::env::remove_var("OLLAMA_BASE_URL");
|
||||
std::env::set_var("OLLAMA_HOST", "172.16.0.10:11434");
|
||||
|
||||
let mut catalog = ModelCatalog::new();
|
||||
catalog.apply_local_env_overrides();
|
||||
let ollama = catalog.get_provider("ollama").unwrap();
|
||||
assert_eq!(ollama.base_url, "http://172.16.0.10:11434/v1");
|
||||
assert_eq!(ollama.auth_status, AuthStatus::Configured);
|
||||
|
||||
// Restore env
|
||||
if let Some(v) = prev_base {
|
||||
std::env::set_var("OLLAMA_BASE_URL", v);
|
||||
}
|
||||
if let Some(v) = prev_host {
|
||||
std::env::set_var("OLLAMA_HOST", v);
|
||||
} else {
|
||||
std::env::remove_var("OLLAMA_HOST");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,6 +478,11 @@ pub async fn execute_tool(
|
||||
}
|
||||
},
|
||||
|
||||
// Skill introspection tools (issue #1038)
|
||||
"skill_list" => tool_skill_list(skill_registry),
|
||||
"skill_describe" => tool_skill_describe(input, skill_registry),
|
||||
"skill_execute" => tool_skill_execute(input, skill_registry).await,
|
||||
|
||||
// Canvas / A2UI tool
|
||||
"canvas_present" => tool_canvas_present(input, workspace_root).await,
|
||||
|
||||
@@ -1308,6 +1313,42 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
|
||||
"required": ["html"]
|
||||
}),
|
||||
},
|
||||
// --- Skill introspection tools (issue #1038) ---
|
||||
// These let the agent discover and read installed skills without
|
||||
// touching the filesystem. Global skills live at ~/.openfang/skills/
|
||||
// which is outside the workspace sandbox — file_read cannot reach them.
|
||||
ToolDefinition {
|
||||
name: "skill_list".to_string(),
|
||||
description: "List all installed skills available to this agent. Returns name, version, description, runtime type, and provided tool names. Use this instead of file_list on the skills directory.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}),
|
||||
},
|
||||
ToolDefinition {
|
||||
name: "skill_describe".to_string(),
|
||||
description: "Read the full description (SKILL.md body / prompt context) of an installed skill by name. Use this instead of file_read on a skill's SKILL.md file — global skills live outside the workspace sandbox and cannot be read with file_read.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "The skill name (as returned by skill_list)" }
|
||||
},
|
||||
"required": ["name"]
|
||||
}),
|
||||
},
|
||||
ToolDefinition {
|
||||
name: "skill_execute".to_string(),
|
||||
description: "Execute a tool provided by an installed skill. For code-runtime skills (Python/Node/Shell) this invokes the underlying script. For prompt-only skills this returns the skill's instruction body so the agent can follow it using built-in tools.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill": { "type": "string", "description": "The skill name (as returned by skill_list)" },
|
||||
"tool": { "type": "string", "description": "Optional name of a tool the skill provides. Omit to invoke the skill's default behavior (returns SKILL.md body for prompt-only skills)." },
|
||||
"input": { "type": "object", "description": "Optional JSON input for the skill tool" }
|
||||
},
|
||||
"required": ["skill"]
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3078,8 +3119,7 @@ async fn tool_image_generate(
|
||||
|
||||
// Closes #1051: route to a local OpenAI-compatible image generation
|
||||
// service when `media.image_gen_base_url` is set.
|
||||
let base_url_override = media_engine
|
||||
.and_then(|e| e.config().image_gen_base_url.as_deref());
|
||||
let base_url_override = media_engine.and_then(|e| e.config().image_gen_base_url.as_deref());
|
||||
let result = crate::image_gen::generate_image(&request, base_url_override).await?;
|
||||
|
||||
// Save images to workspace if available
|
||||
@@ -3537,6 +3577,165 @@ async fn tool_canvas_present(
|
||||
serde_json::to_string_pretty(&response).map_err(|e| format!("Serialize error: {e}"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill introspection tools (issue #1038)
|
||||
//
|
||||
// Global skills live at ~/.openfang/skills/ which is outside the agent
|
||||
// workspace sandbox. Without these tools the LLM falls back to file_read /
|
||||
// shell_exec to inspect SKILL.md files — which fail with path-resolution
|
||||
// errors because file_read is workspace-scoped. These tools surface the
|
||||
// already-loaded skill registry directly to the agent.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List all skills available to this agent, with their provided tool names.
|
||||
fn tool_skill_list(skill_registry: Option<&SkillRegistry>) -> Result<String, String> {
|
||||
let registry = match skill_registry {
|
||||
Some(r) => r,
|
||||
None => return Ok("No skill registry available.".to_string()),
|
||||
};
|
||||
let skills = registry.list();
|
||||
if skills.is_empty() {
|
||||
return Ok("No skills installed. Install skills via the dashboard or `openfang skill install <name>`.".to_string());
|
||||
}
|
||||
let entries: Vec<serde_json::Value> = skills
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let tool_names: Vec<String> = s
|
||||
.manifest
|
||||
.tools
|
||||
.provided
|
||||
.iter()
|
||||
.map(|t| t.name.clone())
|
||||
.collect();
|
||||
serde_json::json!({
|
||||
"name": s.manifest.skill.name,
|
||||
"version": s.manifest.skill.version,
|
||||
"description": s.manifest.skill.description,
|
||||
"runtime": format!("{:?}", s.manifest.runtime.runtime_type),
|
||||
"enabled": s.enabled,
|
||||
"tools": tool_names,
|
||||
"has_prompt_context": s.manifest.prompt_context.as_ref().is_some_and(|c| !c.is_empty()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"count": entries.len(),
|
||||
"skills": entries,
|
||||
}))
|
||||
.map_err(|e| format!("Serialize error: {e}"))
|
||||
}
|
||||
|
||||
/// Return the full description (SKILL.md body) of a named skill.
|
||||
fn tool_skill_describe(
|
||||
input: &serde_json::Value,
|
||||
skill_registry: Option<&SkillRegistry>,
|
||||
) -> Result<String, String> {
|
||||
let name = input["name"]
|
||||
.as_str()
|
||||
.ok_or("Missing 'name' parameter")?
|
||||
.trim();
|
||||
let registry = skill_registry.ok_or("No skill registry available")?;
|
||||
let skill = registry.get(name).ok_or_else(|| {
|
||||
format!("Skill '{name}' not found. Use skill_list to see installed skills.")
|
||||
})?;
|
||||
let body = skill
|
||||
.manifest
|
||||
.prompt_context
|
||||
.clone()
|
||||
.unwrap_or_else(|| "(No prompt context body — this skill provides executable tools only. Use skill_execute or call its tools directly.)".to_string());
|
||||
let tool_names: Vec<String> = skill
|
||||
.manifest
|
||||
.tools
|
||||
.provided
|
||||
.iter()
|
||||
.map(|t| t.name.clone())
|
||||
.collect();
|
||||
let response = serde_json::json!({
|
||||
"name": skill.manifest.skill.name,
|
||||
"version": skill.manifest.skill.version,
|
||||
"description": skill.manifest.skill.description,
|
||||
"runtime": format!("{:?}", skill.manifest.runtime.runtime_type),
|
||||
"tools": tool_names,
|
||||
"body": body,
|
||||
});
|
||||
serde_json::to_string_pretty(&response).map_err(|e| format!("Serialize error: {e}"))
|
||||
}
|
||||
|
||||
/// Execute a skill's tool, or for prompt-only skills return the description body.
|
||||
async fn tool_skill_execute(
|
||||
input: &serde_json::Value,
|
||||
skill_registry: Option<&SkillRegistry>,
|
||||
) -> Result<String, String> {
|
||||
let skill_name = input["skill"]
|
||||
.as_str()
|
||||
.ok_or("Missing 'skill' parameter")?
|
||||
.trim();
|
||||
let registry = skill_registry.ok_or("No skill registry available")?;
|
||||
let skill = registry.get(skill_name).ok_or_else(|| {
|
||||
format!("Skill '{skill_name}' not found. Use skill_list to see installed skills.")
|
||||
})?;
|
||||
|
||||
// If no tool name was given, default behavior depends on runtime.
|
||||
// For prompt-only skills, return the SKILL.md body (most useful response
|
||||
// for issue #1038's daily-journal style skills).
|
||||
let tool_name = input["tool"].as_str().map(|s| s.trim());
|
||||
let tool_input = input.get("input").cloned().unwrap_or(serde_json::json!({}));
|
||||
|
||||
let resolved_tool = match tool_name {
|
||||
Some(t) if !t.is_empty() => t.to_string(),
|
||||
_ => {
|
||||
// No tool specified — return SKILL.md body so the agent can act on it.
|
||||
if let Some(ref body) = skill.manifest.prompt_context {
|
||||
if !body.is_empty() {
|
||||
let response = serde_json::json!({
|
||||
"skill": skill.manifest.skill.name,
|
||||
"mode": "prompt_context",
|
||||
"body": body,
|
||||
"note": "This is a prompt-only skill. Follow the instructions in 'body' using your built-in tools.",
|
||||
});
|
||||
return serde_json::to_string_pretty(&response)
|
||||
.map_err(|e| format!("Serialize error: {e}"));
|
||||
}
|
||||
}
|
||||
// Fall through: pick the first provided tool if any
|
||||
skill
|
||||
.manifest
|
||||
.tools
|
||||
.provided
|
||||
.first()
|
||||
.map(|t| t.name.clone())
|
||||
.ok_or_else(|| {
|
||||
format!("Skill '{skill_name}' provides no tools and has no prompt body.")
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
match openfang_skills::loader::execute_skill_tool(
|
||||
&skill.manifest,
|
||||
&skill.path,
|
||||
&resolved_tool,
|
||||
&tool_input,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
let content = serde_json::to_string_pretty(&serde_json::json!({
|
||||
"skill": skill.manifest.skill.name,
|
||||
"tool": resolved_tool,
|
||||
"output": result.output,
|
||||
"is_error": result.is_error,
|
||||
}))
|
||||
.unwrap_or_else(|_| result.output.to_string());
|
||||
if result.is_error {
|
||||
Err(content)
|
||||
} else {
|
||||
Ok(content)
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("Skill execution failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -3610,6 +3809,67 @@ mod tests {
|
||||
assert!(names.contains(&"docker_exec"));
|
||||
// Canvas tool
|
||||
assert!(names.contains(&"canvas_present"));
|
||||
// 3 skill introspection tools (issue #1038)
|
||||
assert!(names.contains(&"skill_list"));
|
||||
assert!(names.contains(&"skill_describe"));
|
||||
assert!(names.contains(&"skill_execute"));
|
||||
}
|
||||
|
||||
/// Issue #1038: skill_list, skill_describe, skill_execute work without
|
||||
/// touching the filesystem so global skills (outside the workspace
|
||||
/// sandbox) are reachable by the agent.
|
||||
#[tokio::test]
|
||||
async fn test_skill_tools_no_filesystem_access() {
|
||||
use openfang_skills::registry::SkillRegistry;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// Build a skills directory containing one prompt-only SKILL.md skill
|
||||
// (mirroring the user's daily-journal scenario from #1038).
|
||||
let global_dir = TempDir::new().unwrap();
|
||||
let skill_dir = global_dir.path().join("daily-journal");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: daily-journal\ndescription: Keep a daily journal\n---\n\
|
||||
# Daily Journal\n\nWrite one paragraph per day about what you learned.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut registry = SkillRegistry::new(global_dir.path().to_path_buf());
|
||||
registry.load_all().unwrap();
|
||||
assert_eq!(registry.count(), 1);
|
||||
|
||||
// skill_list returns the global skill without any filesystem call
|
||||
let list_out = tool_skill_list(Some(®istry)).unwrap();
|
||||
assert!(list_out.contains("daily-journal"));
|
||||
assert!(list_out.contains("Keep a daily journal"));
|
||||
|
||||
// skill_describe returns the SKILL.md body — no file_read needed
|
||||
let desc_out = tool_skill_describe(
|
||||
&serde_json::json!({ "name": "daily-journal" }),
|
||||
Some(®istry),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(desc_out.contains("Daily Journal"));
|
||||
assert!(desc_out.contains("Write one paragraph"));
|
||||
|
||||
// skill_execute on a prompt-only skill returns the body in 'prompt_context' mode
|
||||
let exec_out = tool_skill_execute(
|
||||
&serde_json::json!({ "skill": "daily-journal" }),
|
||||
Some(®istry),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(exec_out.contains("prompt_context"));
|
||||
assert!(exec_out.contains("Daily Journal"));
|
||||
|
||||
// skill_describe on a missing skill returns a helpful error
|
||||
let missing = tool_skill_describe(
|
||||
&serde_json::json!({ "name": "no-such-skill" }),
|
||||
Some(®istry),
|
||||
);
|
||||
assert!(missing.is_err());
|
||||
assert!(missing.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3756,14 +4016,14 @@ mod tests {
|
||||
async fn test_create_directory_creates_nested() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
let result = tool_create_directory(
|
||||
&serde_json::json!({"path": "a/b/c"}),
|
||||
Some(root),
|
||||
)
|
||||
.await;
|
||||
let result = tool_create_directory(&serde_json::json!({"path": "a/b/c"}), Some(root)).await;
|
||||
assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
|
||||
let expected = root.join("a").join("b").join("c");
|
||||
assert!(expected.is_dir(), "Expected directory to exist: {}", expected.display());
|
||||
assert!(
|
||||
expected.is_dir(),
|
||||
"Expected directory to exist: {}",
|
||||
expected.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3771,18 +4031,10 @@ mod tests {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
// First create
|
||||
let r1 = tool_create_directory(
|
||||
&serde_json::json!({"path": "data/logs"}),
|
||||
Some(root),
|
||||
)
|
||||
.await;
|
||||
let r1 = tool_create_directory(&serde_json::json!({"path": "data/logs"}), Some(root)).await;
|
||||
assert!(r1.is_ok());
|
||||
// Second create on existing dir should also succeed
|
||||
let r2 = tool_create_directory(
|
||||
&serde_json::json!({"path": "data/logs"}),
|
||||
Some(root),
|
||||
)
|
||||
.await;
|
||||
let r2 = tool_create_directory(&serde_json::json!({"path": "data/logs"}), Some(root)).await;
|
||||
assert!(r2.is_ok(), "Expected idempotent success, got: {:?}", r2);
|
||||
}
|
||||
|
||||
@@ -3802,23 +4054,27 @@ mod tests {
|
||||
"test-id",
|
||||
"create_directory",
|
||||
&serde_json::json!({"path": "nested/folder"}),
|
||||
None, // kernel
|
||||
None, // allowed_tools
|
||||
None, // caller_agent_id
|
||||
None, // skill_registry
|
||||
None, // mcp_connections
|
||||
None, // web_ctx
|
||||
None, // browser_ctx
|
||||
None, // allowed_env_vars
|
||||
None, // kernel
|
||||
None, // allowed_tools
|
||||
None, // caller_agent_id
|
||||
None, // skill_registry
|
||||
None, // mcp_connections
|
||||
None, // web_ctx
|
||||
None, // browser_ctx
|
||||
None, // allowed_env_vars
|
||||
Some(root.as_path()), // workspace_root
|
||||
None, // media_engine
|
||||
None, // exec_policy
|
||||
None, // tts_engine
|
||||
None, // docker_config
|
||||
None, // process_manager
|
||||
None, // media_engine
|
||||
None, // exec_policy
|
||||
None, // tts_engine
|
||||
None, // docker_config
|
||||
None, // process_manager
|
||||
)
|
||||
.await;
|
||||
assert!(!result.is_error, "Expected success, got: {}", result.content);
|
||||
assert!(
|
||||
!result.is_error,
|
||||
"Expected success, got: {}",
|
||||
result.content
|
||||
);
|
||||
assert!(root.join("nested").join("folder").is_dir());
|
||||
}
|
||||
|
||||
|
||||
@@ -547,9 +547,24 @@ impl ClawHubClient {
|
||||
};
|
||||
info!(slug, sha256 = %sha256, "Downloaded skill");
|
||||
|
||||
// Create skill directory
|
||||
let skill_dir = target_dir.join(slug);
|
||||
std::fs::create_dir_all(&skill_dir)?;
|
||||
// Codex Finding 1 (#1170 followup): extract into a private staging
|
||||
// directory and atomically rename into the final location only after
|
||||
// enforce_require_signed passes. Without this, a local writer with
|
||||
// access to target_dir/<slug>/ can swap files between extraction
|
||||
// and the binding check (the TOCTOU window).
|
||||
let final_dir = target_dir.join(slug);
|
||||
let staging_dir = target_dir.join(format!(
|
||||
".staging-{}-{}-{}",
|
||||
slug,
|
||||
std::process::id(),
|
||||
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
|
||||
));
|
||||
// Pre-clean any leftover staging dir from a prior crashed install.
|
||||
let _ = std::fs::remove_dir_all(&staging_dir);
|
||||
std::fs::create_dir_all(&staging_dir)?;
|
||||
// Use the staging path for all extraction/conversion work.
|
||||
// After enforcement passes the function renames it to final_dir.
|
||||
let skill_dir = staging_dir.clone();
|
||||
|
||||
// Detect content type and extract accordingly
|
||||
let content_str = String::from_utf8_lossy(&bytes);
|
||||
@@ -665,6 +680,33 @@ impl ClawHubClient {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Step 9: Atomic rename staging -> final. Closes the TOCTOU window
|
||||
// between extraction and enforcement (Codex Finding 1). If the final
|
||||
// location already exists from a prior install, replace it
|
||||
// atomically (where the filesystem allows) or fall back to
|
||||
// remove-then-rename.
|
||||
if final_dir.exists() {
|
||||
if let Err(e) = std::fs::remove_dir_all(&final_dir) {
|
||||
let _ = std::fs::remove_dir_all(&skill_dir);
|
||||
return Err(SkillError::SecurityBlocked(format!(
|
||||
"install: failed to clear existing {} before atomic rename: {e}",
|
||||
final_dir.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&skill_dir, &final_dir) {
|
||||
let _ = std::fs::remove_dir_all(&skill_dir);
|
||||
return Err(SkillError::SecurityBlocked(format!(
|
||||
"install: atomic rename {} -> {} failed: {e}",
|
||||
skill_dir.display(),
|
||||
final_dir.display()
|
||||
)));
|
||||
}
|
||||
// From this point on the skill lives at final_dir, not staging.
|
||||
// (Variable kept for clarity even though unread; future tracing
|
||||
// additions reference it.)
|
||||
let _skill_dir = final_dir;
|
||||
|
||||
let result = ClawHubInstallResult {
|
||||
skill_name: manifest.skill.name.clone(),
|
||||
version: manifest.skill.version.clone(),
|
||||
|
||||
@@ -60,11 +60,76 @@ impl InstallOptions {
|
||||
|
||||
/// Well-known filenames the installer searches for a detached signature
|
||||
/// envelope, in priority order.
|
||||
const SIGNATURE_CANDIDATES: &[&str] = &[
|
||||
"signature.json",
|
||||
"skill.toml.sig.json",
|
||||
"SKILL.md.sig.json",
|
||||
];
|
||||
const SIGNATURE_CANDIDATES: &[&str] =
|
||||
&["signature.json", "skill.toml.sig.json", "SKILL.md.sig.json"];
|
||||
|
||||
/// Normalize a manifest text for content-binding comparison.
|
||||
///
|
||||
/// Strips a UTF-8 BOM if present and converts CRLF to LF. Without this,
|
||||
/// a Windows checkout with git autocrlf would write `\r\n` line endings
|
||||
/// to disk while the signed envelope captured `\n`, and the literal
|
||||
/// byte-equality check would reject an otherwise-valid signed manifest.
|
||||
/// Applied symmetrically to both sides of the binding compare.
|
||||
fn normalize_manifest_text(text: &str) -> String {
|
||||
let trimmed = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
trimmed.replace("\r\n", "\n")
|
||||
}
|
||||
|
||||
/// Verify that a path resolves to a regular file *inside* `dir`, not a
|
||||
/// symlink and not an escape via `..` or canonicalization. Returns
|
||||
/// `Ok(true)` if the entry is safe to open, `Ok(false)` if it doesn't
|
||||
/// exist, and `Err` if the entry exists but fails the safety check.
|
||||
///
|
||||
/// Without this, an attacker shipping a crafted archive could place
|
||||
/// `signature.json` or `skill.toml` as a symlink pointing outside the
|
||||
/// skill directory and redirect manifest/envelope reads — bypassing the
|
||||
/// intent of the binding enforcement.
|
||||
fn safe_regular_file_in(dir: &Path, name: &str) -> Result<bool, SkillError> {
|
||||
let path = dir.join(name);
|
||||
let md = match std::fs::symlink_metadata(&path) {
|
||||
Ok(md) => md,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(e) => {
|
||||
return Err(SkillError::SecurityBlocked(format!(
|
||||
"require_signed: failed to stat {} for safety check: {e}",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
};
|
||||
if md.file_type().is_symlink() {
|
||||
return Err(SkillError::SecurityBlocked(format!(
|
||||
"require_signed: refusing to follow symlink at {} \
|
||||
(skill bundles must ship regular files only)",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
if !md.is_file() {
|
||||
return Err(SkillError::SecurityBlocked(format!(
|
||||
"require_signed: {} is not a regular file",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let canon_dir = std::fs::canonicalize(dir).map_err(|e| {
|
||||
SkillError::SecurityBlocked(format!(
|
||||
"require_signed: cannot canonicalize {}: {e}",
|
||||
dir.display()
|
||||
))
|
||||
})?;
|
||||
let canon_path = std::fs::canonicalize(&path).map_err(|e| {
|
||||
SkillError::SecurityBlocked(format!(
|
||||
"require_signed: cannot canonicalize {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
if !canon_path.starts_with(&canon_dir) {
|
||||
return Err(SkillError::SecurityBlocked(format!(
|
||||
"require_signed: {} escapes skill directory {}",
|
||||
canon_path.display(),
|
||||
canon_dir.display()
|
||||
)));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Locate a `SignedManifest` envelope inside `skill_dir`, if any.
|
||||
///
|
||||
@@ -73,10 +138,10 @@ const SIGNATURE_CANDIDATES: &[&str] = &[
|
||||
/// malformed envelope is a stronger signal than an absent one.
|
||||
pub fn load_signature(skill_dir: &Path) -> Result<Option<SignedManifest>, SkillError> {
|
||||
for name in SIGNATURE_CANDIDATES {
|
||||
let path = skill_dir.join(name);
|
||||
if !path.exists() {
|
||||
if !safe_regular_file_in(skill_dir, name)? {
|
||||
continue;
|
||||
}
|
||||
let path = skill_dir.join(name);
|
||||
let raw = std::fs::read_to_string(&path)?;
|
||||
let envelope: SignedManifest = serde_json::from_str(&raw).map_err(|e| {
|
||||
SkillError::InvalidManifest(format!(
|
||||
@@ -99,10 +164,7 @@ pub fn load_signature(skill_dir: &Path) -> Result<Option<SignedManifest>, SkillE
|
||||
/// Returns `SkillError::SecurityBlocked` when enforcement is on and the
|
||||
/// skill fails any of those checks. On failure the caller is expected to
|
||||
/// remove `skill_dir` to keep the skills directory clean.
|
||||
pub fn enforce_require_signed(
|
||||
skill_dir: &Path,
|
||||
opts: &InstallOptions,
|
||||
) -> Result<(), SkillError> {
|
||||
pub fn enforce_require_signed(skill_dir: &Path, opts: &InstallOptions) -> Result<(), SkillError> {
|
||||
if !opts.require_signed {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -124,6 +186,60 @@ pub fn enforce_require_signed(
|
||||
)));
|
||||
}
|
||||
|
||||
// Bind the signature to the installed bytes.
|
||||
//
|
||||
// envelope.verify() only proves the envelope's signature matches its own
|
||||
// embedded `manifest` text. Without comparing that text to the actual
|
||||
// skill.toml / SKILL.md / package.json on disk, an attacker could ship
|
||||
// a benign signed envelope alongside malicious skill files and pass the
|
||||
// check.
|
||||
//
|
||||
// Defense layers (Codex Findings 1-4 from audit of 4c496be):
|
||||
// - Symlink + canonicalization checks: redirect-via-symlink closed
|
||||
// - CRLF/BOM normalization: Windows false-reject closed
|
||||
// - package.json in candidate list: OpenClaw gap closed
|
||||
// - clawhub::install_with_options now uses staging-then-atomic-rename
|
||||
// so this function runs inside a private dir not yet visible at the
|
||||
// final install path. That closes the TOCTOU window where a local
|
||||
// writer could swap files between extract and enforce.
|
||||
//
|
||||
// Read every candidate manifest file in the installed dir and require
|
||||
// that at least one byte-matches envelope.manifest after BOM strip +
|
||||
// CRLF→LF normalization (applied symmetrically to both sides).
|
||||
// `package.json` is included because openclaw_compat treats it as a
|
||||
// valid SKILL manifest source.
|
||||
const MANIFEST_CANDIDATES: &[&str] = &["skill.toml", "SKILL.md", "skill.md", "package.json"];
|
||||
let normalized_envelope = normalize_manifest_text(&envelope.manifest);
|
||||
let mut bound = false;
|
||||
for name in MANIFEST_CANDIDATES {
|
||||
if !safe_regular_file_in(skill_dir, name)? {
|
||||
continue;
|
||||
}
|
||||
let path = skill_dir.join(name);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(actual) => {
|
||||
if normalize_manifest_text(&actual) == normalized_envelope {
|
||||
bound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(SkillError::SecurityBlocked(format!(
|
||||
"require_signed: failed to read {} for binding check: {e}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !bound {
|
||||
return Err(SkillError::SecurityBlocked(format!(
|
||||
"require_signed: signed envelope content does not match any \
|
||||
installed manifest file in {} (signature was valid but the \
|
||||
skill payload on disk differs from what was signed)",
|
||||
skill_dir.display()
|
||||
)));
|
||||
}
|
||||
|
||||
if !opts.allowed_signer_keys.is_empty() {
|
||||
let actual = hex::encode(&envelope.signer_public_key);
|
||||
let actual_lower = actual.to_lowercase();
|
||||
@@ -278,6 +394,51 @@ entry = "main.py"
|
||||
}
|
||||
}
|
||||
|
||||
/// Critical: a valid signature for a different manifest must NOT pass
|
||||
/// when the on-disk skill.toml differs. Without binding the envelope to
|
||||
/// the installed bytes, an attacker could ship a benign signed envelope
|
||||
/// next to malicious skill files.
|
||||
#[test]
|
||||
fn require_signed_on_rejects_signature_unbound_to_disk() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Sign a BENIGN manifest body but never write that text to disk.
|
||||
let benign_toml = r#"name = "benign"
|
||||
version = "0.1.0"
|
||||
description = "Looks fine."
|
||||
|
||||
[runtime]
|
||||
type = "python"
|
||||
entry = "main.py"
|
||||
"#;
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let envelope =
|
||||
SignedManifest::sign(benign_toml.to_string(), &signing_key, "trusted-signer");
|
||||
write_signature(dir.path(), &envelope, "signature.json");
|
||||
|
||||
// Write a DIFFERENT (malicious) skill.toml on disk.
|
||||
let evil_toml = r#"name = "evil"
|
||||
version = "0.1.0"
|
||||
description = "Backdoor."
|
||||
|
||||
[runtime]
|
||||
type = "python"
|
||||
entry = "rm-rf.py"
|
||||
"#;
|
||||
std::fs::write(dir.path().join("skill.toml"), evil_toml).unwrap();
|
||||
|
||||
let opts = InstallOptions::require_signed();
|
||||
let err = enforce_require_signed(dir.path(), &opts).unwrap_err();
|
||||
match err {
|
||||
SkillError::SecurityBlocked(msg) => {
|
||||
assert!(
|
||||
msg.contains("does not match any") || msg.contains("payload on disk differs"),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected SecurityBlocked, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_signature_returns_none_when_absent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -296,4 +457,84 @@ entry = "main.py"
|
||||
let loaded = load_signature(dir.path()).unwrap().unwrap();
|
||||
assert_eq!(loaded.signer_id, "alt-name-signer");
|
||||
}
|
||||
|
||||
// Codex audit Finding 2: CRLF/BOM normalization. A Windows checkout
|
||||
// may write \r\n line endings to disk; the signed envelope captured
|
||||
// \n. Literal byte equality would false-reject. Both sides are
|
||||
// normalize_manifest_text-ed before compare.
|
||||
#[test]
|
||||
fn require_signed_accepts_crlf_disk_when_envelope_is_lf() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let lf_toml = "[skill]\nname = \"x\"\nversion = \"0.1\"\n[runtime]\ntype = \"python\"\nentry = \"main.py\"\n";
|
||||
let crlf_toml = lf_toml.replace('\n', "\r\n");
|
||||
std::fs::write(dir.path().join("skill.toml"), &crlf_toml).unwrap();
|
||||
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let envelope = SignedManifest::sign(lf_toml, &signing_key, "lf-signer");
|
||||
write_signature(dir.path(), &envelope, "signature.json");
|
||||
|
||||
let opts = InstallOptions::require_signed();
|
||||
assert!(enforce_require_signed(dir.path(), &opts).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_signed_accepts_utf8_bom_disk_when_envelope_is_clean() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let clean = "[skill]\nname = \"x\"\nversion = \"0.1\"\n[runtime]\ntype = \"python\"\nentry = \"main.py\"\n";
|
||||
let with_bom = format!("\u{feff}{clean}");
|
||||
std::fs::write(dir.path().join("skill.toml"), &with_bom).unwrap();
|
||||
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let envelope = SignedManifest::sign(clean, &signing_key, "bom-signer");
|
||||
write_signature(dir.path(), &envelope, "signature.json");
|
||||
|
||||
let opts = InstallOptions::require_signed();
|
||||
assert!(enforce_require_signed(dir.path(), &opts).is_ok());
|
||||
}
|
||||
|
||||
// Codex audit Finding 3: package.json must be a valid binding candidate
|
||||
// because openclaw_compat treats it as a SKILL manifest source.
|
||||
#[test]
|
||||
fn require_signed_binds_to_package_json() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let pkg = r#"{"name":"x","version":"0.1.0","openfang":{"skill":"x"}}"#;
|
||||
std::fs::write(dir.path().join("package.json"), pkg).unwrap();
|
||||
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let envelope = SignedManifest::sign(pkg, &signing_key, "pkg-signer");
|
||||
write_signature(dir.path(), &envelope, "signature.json");
|
||||
|
||||
let opts = InstallOptions::require_signed();
|
||||
assert!(enforce_require_signed(dir.path(), &opts).is_ok());
|
||||
}
|
||||
|
||||
// Codex audit Finding 4: symlinks for signature.json or manifest must
|
||||
// be rejected even when they point to a valid file, to prevent crafted
|
||||
// bundles from redirecting reads outside the skill directory.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn require_signed_rejects_symlink_signature() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let toml = write_skill_toml(dir.path());
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let envelope = SignedManifest::sign(toml, &signing_key, "symlink-signer");
|
||||
let outside_sig = outside.path().join("real-sig.json");
|
||||
std::fs::write(
|
||||
&outside_sig,
|
||||
serde_json::to_string_pretty(&envelope).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
symlink(&outside_sig, dir.path().join("signature.json")).unwrap();
|
||||
|
||||
let opts = InstallOptions::require_signed();
|
||||
let err = enforce_require_signed(dir.path(), &opts).unwrap_err();
|
||||
match err {
|
||||
SkillError::SecurityBlocked(msg) => {
|
||||
assert!(msg.contains("symlink"), "got: {msg}");
|
||||
}
|
||||
other => panic!("expected SecurityBlocked, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,11 +474,23 @@ pub struct AgentManifest {
|
||||
/// Pinned model override (used in Stable mode).
|
||||
#[serde(default)]
|
||||
pub pinned_model: Option<String>,
|
||||
/// Agent workspace directory. Auto-created on spawn.
|
||||
/// Default: `{workspaces_dir}/{agent_name}-{agent_id_prefix}/`
|
||||
/// Agent workspace directory. User-facing working area for output, data,
|
||||
/// skills, context.md, and uploads. When the user sets this in agent.toml
|
||||
/// (e.g. `workspace = "/home/me/Documents"`) the runtime treats it as the
|
||||
/// agent's working directory and will NOT scaffold private state files
|
||||
/// there. Multiple agents can share the same workspace to collaborate on
|
||||
/// files. See issue #1097.
|
||||
/// Default: `{workspaces_dir}/{agent_name}/` (same as state_dir).
|
||||
#[serde(default)]
|
||||
pub workspace: Option<PathBuf>,
|
||||
/// Whether to generate workspace identity files (SOUL.md, USER.md, etc.) on creation.
|
||||
/// Agent private state directory. Stores identity files (SOUL.md, etc.),
|
||||
/// AGENT.json, sessions/, and the daily memory log. Always lives under
|
||||
/// `~/.openfang/workspaces/{name}/` regardless of where the user-facing
|
||||
/// workspace points. Auto-derived on spawn. See issue #1097.
|
||||
#[serde(default)]
|
||||
pub state_dir: Option<PathBuf>,
|
||||
/// Whether to generate identity files (SOUL.md, USER.md, etc.) in
|
||||
/// `state_dir` on creation.
|
||||
#[serde(default = "default_true")]
|
||||
pub generate_identity_files: bool,
|
||||
/// Per-agent exec policy override. If None, uses global exec_policy.
|
||||
@@ -550,6 +562,7 @@ impl Default for AgentManifest {
|
||||
autonomous: None,
|
||||
pinned_model: None,
|
||||
workspace: None,
|
||||
state_dir: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: Vec::new(),
|
||||
@@ -809,6 +822,7 @@ mod tests {
|
||||
autonomous: None,
|
||||
pinned_model: None,
|
||||
workspace: None,
|
||||
state_dir: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: Vec::new(),
|
||||
|
||||
@@ -1481,6 +1481,10 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_auto_thread() -> String {
|
||||
"false".to_string()
|
||||
}
|
||||
|
||||
fn default_thread_ttl() -> u64 {
|
||||
24
|
||||
}
|
||||
@@ -1929,6 +1933,13 @@ pub struct TelegramConfig {
|
||||
/// Allows channel_send(channel="telegram", message="...") without a recipient.
|
||||
#[serde(default)]
|
||||
pub default_chat_id: Option<String>,
|
||||
/// Forum topic routing: maps `message_thread_id` to an agent name.
|
||||
/// When a message arrives inside a Telegram forum topic whose thread id is
|
||||
/// listed here, the bridge dispatches it to the named agent instead of the
|
||||
/// default. Threads not listed fall back to `default_agent`.
|
||||
/// Issue #780.
|
||||
#[serde(default)]
|
||||
pub thread_routes: HashMap<i64, String>,
|
||||
/// Per-channel behavior overrides.
|
||||
#[serde(default)]
|
||||
pub overrides: ChannelOverrides,
|
||||
@@ -1943,6 +1954,7 @@ impl Default for TelegramConfig {
|
||||
poll_interval_secs: 1,
|
||||
api_url: None,
|
||||
default_chat_id: None,
|
||||
thread_routes: HashMap::new(),
|
||||
overrides: ChannelOverrides::default(),
|
||||
}
|
||||
}
|
||||
@@ -1976,6 +1988,10 @@ pub struct DiscordConfig {
|
||||
/// In these channels, the bot responds to all group messages without needing to be mentioned.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub free_response_channels: Vec<String>,
|
||||
/// Auto-thread behavior: "true" (always create thread), "false" (never), "smart" (only when @mentioned).
|
||||
/// Default: "false"
|
||||
#[serde(default = "default_auto_thread")]
|
||||
pub auto_thread: String,
|
||||
/// Per-channel behavior overrides.
|
||||
#[serde(default)]
|
||||
pub overrides: ChannelOverrides,
|
||||
@@ -1992,6 +2008,7 @@ impl Default for DiscordConfig {
|
||||
ignore_bots: true,
|
||||
default_channel_id: None,
|
||||
free_response_channels: vec![],
|
||||
auto_thread: "false".to_string(),
|
||||
overrides: ChannelOverrides::default(),
|
||||
}
|
||||
}
|
||||
@@ -2119,6 +2136,13 @@ pub struct MatrixConfig {
|
||||
pub user_id: String,
|
||||
/// Env var name holding the access token.
|
||||
pub access_token_env: String,
|
||||
/// Env var name holding the MSC2918 refresh token (optional).
|
||||
///
|
||||
/// When set, the adapter auto-recovers from `M_UNKNOWN_TOKEN` 401 responses
|
||||
/// by calling `POST /_matrix/client/v3/refresh`. Required for matrix.org
|
||||
/// since the 2025-04-07 migration to MAS (Matrix Authentication Service).
|
||||
#[serde(default)]
|
||||
pub refresh_token_env: Option<String>,
|
||||
/// Room IDs to listen in (empty = all joined rooms).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_rooms: Vec<String>,
|
||||
@@ -2138,6 +2162,7 @@ impl Default for MatrixConfig {
|
||||
homeserver_url: "https://matrix.org".to_string(),
|
||||
user_id: String::new(),
|
||||
access_token_env: "MATRIX_ACCESS_TOKEN".to_string(),
|
||||
refresh_token_env: None,
|
||||
allowed_rooms: vec![],
|
||||
default_agent: None,
|
||||
auto_accept_invites: false,
|
||||
@@ -4146,6 +4171,9 @@ mod tests {
|
||||
let mx = MatrixConfig::default();
|
||||
assert_eq!(mx.homeserver_url, "https://matrix.org");
|
||||
assert_eq!(mx.access_token_env, "MATRIX_ACCESS_TOKEN");
|
||||
// MSC2918 refresh token env defaults to None; operators opt in via
|
||||
// `refresh_token_env = "MATRIX_REFRESH_TOKEN"` in config.toml.
|
||||
assert!(mx.refresh_token_env.is_none());
|
||||
assert!(mx.allowed_rooms.is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -434,7 +434,9 @@ mod tests {
|
||||
let restored: ContentBlock = serde_json::from_str(&serialized).unwrap();
|
||||
match restored {
|
||||
ContentBlock::Thinking {
|
||||
thinking, signature, ..
|
||||
thinking,
|
||||
signature,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(thinking, "Let me reason about this carefully...");
|
||||
assert_eq!(
|
||||
@@ -517,7 +519,9 @@ mod tests {
|
||||
assert_eq!(blocks.len(), 2);
|
||||
match &blocks[0] {
|
||||
ContentBlock::Thinking {
|
||||
thinking, signature, ..
|
||||
thinking,
|
||||
signature,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(thinking, "Internal reasoning");
|
||||
assert_eq!(signature.as_deref(), Some("sig_xyz"));
|
||||
@@ -547,8 +551,7 @@ mod tests {
|
||||
fn test_provider_msg_id_is_recorded_but_not_primary() {
|
||||
// The LLM-supplied identifier is preserved for debugging only — the
|
||||
// server-generated `msg_id` is unchanged and remains the primary key.
|
||||
let msg =
|
||||
Message::assistant("hi").with_provider_msg_id("msg_abc123_anthropic_collidable");
|
||||
let msg = Message::assistant("hi").with_provider_msg_id("msg_abc123_anthropic_collidable");
|
||||
assert_eq!(
|
||||
msg.provider_msg_id.as_deref(),
|
||||
Some("msg_abc123_anthropic_collidable")
|
||||
|
||||
@@ -14,6 +14,7 @@ pub const GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com";
|
||||
pub const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1";
|
||||
pub const GROQ_BASE_URL: &str = "https://api.groq.com/openai/v1";
|
||||
pub const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
|
||||
pub const REQUESTY_BASE_URL: &str = "https://router.requesty.ai/v1";
|
||||
pub const MISTRAL_BASE_URL: &str = "https://api.mistral.ai/v1";
|
||||
pub const TOGETHER_BASE_URL: &str = "https://api.together.xyz/v1";
|
||||
pub const FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
|
||||
|
||||
Reference in New Issue
Block a user